Rename directory: ext -> extension
5
extension/.eslintrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true
|
||||
}
|
||||
}
|
||||
180
extension/bin/build.js
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
107
extension/bin/lib/copyFilesPlugin.js
Normal 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
27
extension/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
355
extension/src/_locales/de/messages.json
Normal 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."
|
||||
}
|
||||
}
|
||||
638
extension/src/_locales/en/messages.json
Executable 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."
|
||||
}
|
||||
}
|
||||
370
extension/src/_locales/es/messages.json
Normal 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."
|
||||
}
|
||||
}
|
||||
689
extension/src/_locales/id/messages.json
Normal 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" }
|
||||
}
|
||||
283
extension/src/_locales/it/messages.json
Normal 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."
|
||||
}
|
||||
}
|
||||
324
extension/src/_locales/nl/messages.json
Executable 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."
|
||||
}
|
||||
}
|
||||
359
extension/src/_locales/no/messages.json
Normal 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."
|
||||
}
|
||||
}
|
||||
297
extension/src/background/ReceiverSelector.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
60
extension/src/background/action.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
147
extension/src/background/background.ts
Executable 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();
|
||||
1122
extension/src/background/castManager.ts
Normal file
271
extension/src/background/deviceManager.ts
Normal 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);
|
||||
};
|
||||
})();
|
||||
347
extension/src/background/menus.ts
Normal 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();
|
||||
}
|
||||
268
extension/src/background/whitelist.ts
Normal 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);
|
||||
}
|
||||
48
extension/src/cast/content.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Cast Sender SDK page script loaded in place of remote cast_sender
|
||||
* script. Handles API object creation and initializes sender apps.
|
||||
*/
|
||||
|
||||
import logger from "../lib/logger";
|
||||
import { loadScript } from "../lib/utils";
|
||||
|
||||
import pageMessaging from "./pageMessaging";
|
||||
import CastSDK from "./sdk";
|
||||
import { CAST_FRAMEWORK_SCRIPT_URL } from "./urls";
|
||||
|
||||
// Create page-accessible API object
|
||||
window.chrome.cast = new CastSDK();
|
||||
|
||||
let frameworkScriptPromise: Promise<HTMLScriptElement> | undefined;
|
||||
|
||||
// Load remote CAF script if requested in script URL params.
|
||||
if (document.currentScript) {
|
||||
const currentScript = document.currentScript as HTMLScriptElement;
|
||||
const currentScriptParams = new URLSearchParams(
|
||||
new URL(currentScript.src).search
|
||||
);
|
||||
|
||||
if (currentScriptParams.get("loadCastFramework") === "1") {
|
||||
frameworkScriptPromise = loadScript(CAST_FRAMEWORK_SCRIPT_URL);
|
||||
frameworkScriptPromise.catch(() => {
|
||||
logger.error("Failed to load CAF script!");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pageMessaging.page.addListener(async message => {
|
||||
switch (message.subject) {
|
||||
case "cast:instanceCreated": {
|
||||
// If framework API is loading, wait until completed
|
||||
await frameworkScriptPromise;
|
||||
|
||||
// Call page script/framework API script's init function
|
||||
const initFn = window.__onGCastApiAvailable;
|
||||
if (initFn && typeof initFn === "function") {
|
||||
initFn(message.data.isAvailable);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
19
extension/src/cast/contentBridge.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import messaging, { Message } from "../messaging";
|
||||
import pageMessaging from "./pageMessaging";
|
||||
|
||||
// Message port to cast manager in background script
|
||||
const managerPort = messaging.connect({ name: "cast" });
|
||||
|
||||
const forwardToPage = (message: Message) => {
|
||||
pageMessaging.extension.sendMessage(message);
|
||||
};
|
||||
const forwardToMain = (message: Message) => {
|
||||
managerPort.postMessage(message);
|
||||
};
|
||||
|
||||
managerPort.onMessage.addListener(forwardToPage);
|
||||
pageMessaging.extension.addListener(forwardToMain);
|
||||
|
||||
managerPort.onDisconnect.addListener(() => {
|
||||
pageMessaging.extension.close();
|
||||
});
|
||||
57
extension/src/cast/contentInitial.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Content script loaded on whitelisted URLs. Sets some window
|
||||
* properties to help with Chrome compatibility and handles dynamic
|
||||
* chrome-extension:// cast script loads.
|
||||
*/
|
||||
|
||||
import { CAST_LOADER_SCRIPT_URL, CAST_SCRIPT_URLS } from "./urls";
|
||||
|
||||
declare global {
|
||||
interface Object {
|
||||
wrappedJSObject: this;
|
||||
}
|
||||
|
||||
interface Window {
|
||||
wrappedJSObject: Window;
|
||||
chrome: {
|
||||
cast?: object;
|
||||
};
|
||||
__onGCastApiAvailable: (isAvailable: boolean) => void;
|
||||
}
|
||||
interface Navigator {
|
||||
presentation: object;
|
||||
}
|
||||
}
|
||||
|
||||
window.wrappedJSObject.chrome = cloneInto({}, window);
|
||||
|
||||
/**
|
||||
* YouTube won't load the cast SDK unless it thinks the presentation API
|
||||
* exists.
|
||||
*/
|
||||
if (window.location.host === "www.youtube.com") {
|
||||
window.wrappedJSObject.navigator.presentation = cloneInto({}, window);
|
||||
}
|
||||
|
||||
const srcPropDesc = Reflect.getOwnPropertyDescriptor(
|
||||
HTMLScriptElement.prototype.wrappedJSObject,
|
||||
"src"
|
||||
);
|
||||
/**
|
||||
* Intercept script element src attribute changes and rewrite cast
|
||||
* script URLs to the remote loader script URL to be redirected by the
|
||||
* extension's webRequest handlers in the background script.
|
||||
*/
|
||||
Reflect.defineProperty(HTMLScriptElement.prototype.wrappedJSObject, "src", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
get: srcPropDesc?.get,
|
||||
|
||||
set: exportFunction(function (this: HTMLScriptElement, value: string) {
|
||||
if (CAST_SCRIPT_URLS.includes(value)) {
|
||||
return srcPropDesc?.set?.call(this, CAST_LOADER_SCRIPT_URL);
|
||||
}
|
||||
|
||||
return srcPropDesc?.set?.call(this, value);
|
||||
}, window)
|
||||
});
|
||||
154
extension/src/cast/export.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { TypedMessagePort } from "../lib/TypedMessagePort";
|
||||
import messaging, { Message } from "../messaging";
|
||||
import type { ReceiverDevice } from "../types";
|
||||
|
||||
import pageMessaging from "./pageMessaging";
|
||||
|
||||
// Ensure extension-side is initialized first
|
||||
void pageMessaging.extension;
|
||||
|
||||
import CastSDK from "./sdk";
|
||||
|
||||
export type CastPort = TypedMessagePort<Message>;
|
||||
|
||||
let existingPort: CastPort;
|
||||
let existingInstance = new CastSDK();
|
||||
|
||||
export default existingInstance;
|
||||
|
||||
interface EnsureInitOpts {
|
||||
contextTabId?: number;
|
||||
/** Skip receiver selection. */
|
||||
receiverDevice?: ReceiverDevice;
|
||||
}
|
||||
|
||||
/**
|
||||
* To support exporting the API from a module, we need to retain the
|
||||
* MessageChannel-based pageMessaging layer despite not crossing any
|
||||
* context boundaries.
|
||||
*
|
||||
* The ensureInit function creates a messaging connection to the
|
||||
* cast manager, hooks it up to the pageMessaging layer and also
|
||||
* provides a messaging port so consumers of this module can communicate
|
||||
* with the cast manager.
|
||||
*/
|
||||
export function ensureInit(opts: EnsureInitOpts): Promise<CastPort> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// If already initialized
|
||||
if (existingPort) {
|
||||
existingPort.close();
|
||||
existingInstance = new CastSDK();
|
||||
}
|
||||
|
||||
/**
|
||||
* If imported into a background script context, the location
|
||||
* will be the internal extension URL, whereas in a content
|
||||
* script, it will be the content page URL.
|
||||
*/
|
||||
if (
|
||||
window.location.protocol === "moz-extension:" &&
|
||||
window.location.pathname === "_generated_background_page.html"
|
||||
) {
|
||||
const { default: castManager } = await import(
|
||||
"../background/castManager"
|
||||
);
|
||||
|
||||
/**
|
||||
* port1 will handle cast manager messages.
|
||||
* port2 will handle cast instance messages.
|
||||
*/
|
||||
const { port1: managerPort, port2: instancePort } =
|
||||
new MessageChannel();
|
||||
|
||||
/**
|
||||
* Provide cast manager with a port to send messages to
|
||||
* cast instance.
|
||||
*/
|
||||
if (opts.contextTabId) {
|
||||
await castManager.createInstance(instancePort, {
|
||||
tabId: opts.contextTabId,
|
||||
frameId: 0
|
||||
});
|
||||
} else {
|
||||
await castManager.createInstance(instancePort);
|
||||
}
|
||||
|
||||
// cast manager -> cast instance
|
||||
managerPort.addEventListener("message", ev => {
|
||||
const message = ev.data as Message;
|
||||
if (message.subject === "cast:instanceCreated") {
|
||||
if (message.data.isAvailable) {
|
||||
resolve(existingPort);
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
}
|
||||
|
||||
pageMessaging.extension.sendMessage(message);
|
||||
});
|
||||
managerPort.start();
|
||||
|
||||
// Cast instance -> cast manager
|
||||
pageMessaging.extension.addListener(message => {
|
||||
// Skip receiver selection
|
||||
if (opts.receiverDevice) {
|
||||
message = rewriteTrustedRequestSession(
|
||||
message,
|
||||
opts.receiverDevice
|
||||
);
|
||||
}
|
||||
|
||||
managerPort.postMessage(message);
|
||||
});
|
||||
} else {
|
||||
const managerPort = messaging.connect({ name: "trusted-cast" });
|
||||
|
||||
// Cast manager -> cast instance
|
||||
managerPort.onMessage.addListener(message => {
|
||||
if (message.subject === "cast:instanceCreated") {
|
||||
if (message.data.isAvailable) {
|
||||
resolve(pageMessaging.page.messagePort);
|
||||
} else {
|
||||
reject();
|
||||
}
|
||||
}
|
||||
|
||||
pageMessaging.extension.sendMessage(message);
|
||||
});
|
||||
|
||||
// Cast instance -> cast manager
|
||||
pageMessaging.extension.addListener(message => {
|
||||
// Skip receiver selection
|
||||
if (opts.receiverDevice) {
|
||||
message = rewriteTrustedRequestSession(
|
||||
message,
|
||||
opts.receiverDevice
|
||||
);
|
||||
}
|
||||
|
||||
managerPort.postMessage(message);
|
||||
});
|
||||
|
||||
managerPort.onDisconnect.addListener(() => {
|
||||
pageMessaging.extension.close();
|
||||
});
|
||||
|
||||
existingPort = pageMessaging.page.messagePort;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* If a receiver device was passed to `ensureInit`, messages to the cast
|
||||
* manager will be passed through this function and the receiver device
|
||||
* will be added to the message payload. This tells the cast manager to
|
||||
* skip receiver selection when requesting a session.
|
||||
*/
|
||||
function rewriteTrustedRequestSession(
|
||||
message: Message,
|
||||
receiverDevice: ReceiverDevice
|
||||
) {
|
||||
if (message.subject !== "main:requestSession") return message;
|
||||
message.data.receiverDevice = receiverDevice;
|
||||
return message;
|
||||
}
|
||||
40
extension/src/cast/knownApps.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
const _ = browser.i18n.getMessage;
|
||||
|
||||
export interface KnownApp {
|
||||
name: string;
|
||||
matches?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Just keep a list of IDs and cache names from the Google API:
|
||||
* https://clients3.google.com/cast/chromecast/device/app?a=[appId]
|
||||
*
|
||||
* Also, localization since the API supports it.
|
||||
*/
|
||||
export default {
|
||||
// Web-supported
|
||||
"CA5E8412": { name: "Netflix", matches: "https://www.netflix.com/*" },
|
||||
"233637DE": { name: "YouTube", matches: "https://www.youtube.com/*" },
|
||||
"2DB7CC49": {
|
||||
name: "YouTube Music",
|
||||
matches: "https://music.youtube.com/*"
|
||||
},
|
||||
"CC32E753": { name: "Spotify", matches: "https://open.spotify.com/*" },
|
||||
"2BA92214": {
|
||||
name: "BBC iPlayer",
|
||||
matches: "https://www.bbc.co.uk/iplayer*"
|
||||
},
|
||||
"B3DCF968": { name: "Twitch", matches: "https://www.twitch.tv/*" },
|
||||
"B88B034A": {
|
||||
name: "Dailymotion",
|
||||
matches: "https://www.dailymotion.com/*"
|
||||
},
|
||||
"C3DE6BC2": { name: "Disney+", matches: "https://www.disneyplus.com/*" },
|
||||
"B143C57E": { name: "SoundCloud", matches: "https://soundcloud.com/*" },
|
||||
"10AAD887": { name: "All 4", matches: "https://www.channel4.com/*" },
|
||||
|
||||
// Misc
|
||||
"9AC194DC": { name: "Plex" },
|
||||
|
||||
"CC1AD845": { name: _("popupMediaTypeAppMedia") }
|
||||
} as Record<string, KnownApp>;
|
||||
140
extension/src/cast/pageMessaging.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { TypedMessagePort } from "../lib/TypedMessagePort";
|
||||
import type { Message } from "../messaging";
|
||||
|
||||
const INIT_MESSAGE = "__pageMessenger_init__";
|
||||
|
||||
/** Strip anything non-serializable for message channel. */
|
||||
function simplify(input: any) {
|
||||
return JSON.parse(JSON.stringify(input));
|
||||
}
|
||||
|
||||
type MessengerListener = (message: Message) => void;
|
||||
|
||||
/**
|
||||
* Abstract messenger class for cross-context messages via
|
||||
* MessageChannel.
|
||||
*
|
||||
* Facilitates a message channel between page scripts running in the
|
||||
* page script context and the extension scripts running in the
|
||||
* sandboxed content script context.
|
||||
*/
|
||||
abstract class Messenger {
|
||||
private listeners = new Set<MessengerListener>();
|
||||
|
||||
protected onMessage = (ev: MessageEvent<Message>) => {
|
||||
for (const listener of this.listeners) {
|
||||
listener(simplify(ev.data));
|
||||
}
|
||||
};
|
||||
|
||||
addListener(listener: MessengerListener) {
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
removeListener(listener: MessengerListener) {
|
||||
this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
/** Sends a message across the */
|
||||
abstract sendMessage(message: Message): void;
|
||||
|
||||
close() {
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Page-side of page script messaging.
|
||||
*
|
||||
* Creates a message channel, then sends an INIT_MESSAGE window message
|
||||
* with a port that is handled by an ExtensionScriptMessenger in the
|
||||
* content script.
|
||||
*/
|
||||
export class PageScriptMessenger extends Messenger {
|
||||
private port: TypedMessagePort<Message>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Create message channel and send port2 to
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
window.postMessage(INIT_MESSAGE, window.location.href, [port2]);
|
||||
|
||||
this.port = port1;
|
||||
this.port.addEventListener("message", this.onMessage);
|
||||
this.port.start();
|
||||
}
|
||||
|
||||
sendMessage(message: Message) {
|
||||
this.port.postMessage(simplify(message));
|
||||
}
|
||||
get messagePort() {
|
||||
return this.port;
|
||||
}
|
||||
|
||||
close() {
|
||||
super.close();
|
||||
this.port.removeEventListener("message", this.onMessage);
|
||||
this.port.close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension-side of page script messaging.
|
||||
*
|
||||
* Listens for a INIT_MESSAGE window message from a PageScriptMessenger
|
||||
* running in a page script and establishes a message channel connection
|
||||
* once received.
|
||||
*/
|
||||
export class ExtensionScriptMessenger extends Messenger {
|
||||
private port?: TypedMessagePort<Message>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
window.addEventListener("message", this.onWindowMessage);
|
||||
}
|
||||
|
||||
/** Handles init message from window and stores transferred port. */
|
||||
private onWindowMessage = (ev: MessageEvent<any>) => {
|
||||
if (ev.source !== window || ev.data !== INIT_MESSAGE) return;
|
||||
|
||||
window.removeEventListener("message", this.onWindowMessage);
|
||||
|
||||
this.port = ev.ports[0];
|
||||
this.port.addEventListener("message", ev => this.onMessage(ev));
|
||||
this.port.start();
|
||||
};
|
||||
|
||||
sendMessage(message: Message) {
|
||||
this.port?.postMessage(simplify(message));
|
||||
}
|
||||
|
||||
close() {
|
||||
super.close();
|
||||
|
||||
window.removeEventListener("message", this.onWindowMessage);
|
||||
this.port?.removeEventListener("message", this.onMessage);
|
||||
this.port?.close();
|
||||
}
|
||||
}
|
||||
|
||||
let pageMessenger: Nullable<PageScriptMessenger> = null;
|
||||
let extensionMessenger: Nullable<ExtensionScriptMessenger> = null;
|
||||
|
||||
export default {
|
||||
/** Messenger for page scripts. */
|
||||
get page() {
|
||||
if (!pageMessenger) {
|
||||
pageMessenger = new PageScriptMessenger();
|
||||
}
|
||||
|
||||
return pageMessenger;
|
||||
},
|
||||
|
||||
/** Messenger for extension scripts. */
|
||||
get extension() {
|
||||
if (!extensionMessenger) {
|
||||
extensionMessenger = new ExtensionScriptMessenger();
|
||||
}
|
||||
return extensionMessenger;
|
||||
}
|
||||
};
|
||||
454
extension/src/cast/sdk/Session.ts
Normal file
@@ -0,0 +1,454 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { Logger } from "../../lib/logger";
|
||||
|
||||
import pageMessaging from "../pageMessaging";
|
||||
import { convertSupportedMediaCommandsFlags } from "../utils";
|
||||
|
||||
import type {
|
||||
MediaStatus,
|
||||
ReceiverMediaMessage,
|
||||
SenderMediaMessage,
|
||||
SenderMessage
|
||||
} from "./types";
|
||||
|
||||
import { ErrorCode, SessionStatus } from "./enums";
|
||||
import {
|
||||
Error as CastError,
|
||||
Image,
|
||||
Receiver,
|
||||
SenderApplication
|
||||
} from "./classes";
|
||||
|
||||
import { PlayerState } from "./media/enums";
|
||||
import type { LoadRequest, QueueLoadRequest, QueueItem } from "./media/classes";
|
||||
import Media, {
|
||||
createMedia,
|
||||
mediaLastUpdateTimes,
|
||||
mediaUpdateListeners,
|
||||
NS_MEDIA
|
||||
} from "./media/Media";
|
||||
|
||||
const logger = new Logger("fx_cast [sdk :: cast.Session]");
|
||||
|
||||
/**
|
||||
* Takes a media object and a media status object and merges the status
|
||||
* with the existing media object, updating it with new properties.
|
||||
*/
|
||||
export function updateMedia(media: Media, status: MediaStatus) {
|
||||
media.currentItemId = null;
|
||||
media.loadingItemId = null;
|
||||
media.preloadedItemId = null;
|
||||
|
||||
// Copy status properties to media
|
||||
for (const prop in status) {
|
||||
if (prop === "items") continue;
|
||||
|
||||
switch (prop) {
|
||||
case "volume":
|
||||
media.volume.level = status.volume.level;
|
||||
media.volume.muted = status.volume.muted;
|
||||
break;
|
||||
case "supportedMediaCommands":
|
||||
media.supportedMediaCommands =
|
||||
convertSupportedMediaCommandsFlags(
|
||||
status.supportedMediaCommands
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
(media as any)[prop] = (status as any)[prop];
|
||||
}
|
||||
}
|
||||
|
||||
if (!("idleReason" in status)) {
|
||||
media.idleReason = null;
|
||||
}
|
||||
if (!("extendedStatus" in status)) {
|
||||
// FIXME: Add extendedStatus types
|
||||
(media as any).extendedStatus = null;
|
||||
}
|
||||
|
||||
// Set last update time on currentTime change
|
||||
if ("currentTime" in status) {
|
||||
mediaLastUpdateTimes.set(media, Date.now());
|
||||
}
|
||||
|
||||
if (
|
||||
media.playerState === PlayerState.IDLE &&
|
||||
media.loadingItemId === null
|
||||
) {
|
||||
media.currentItemId = null;
|
||||
media.loadingItemId = null;
|
||||
media.preloadedItemId = null;
|
||||
media.items = null;
|
||||
} else if (status.items) {
|
||||
const newItems: QueueItem[] = [];
|
||||
|
||||
for (const newItem of status.items) {
|
||||
if (!newItem.media) {
|
||||
// Existing queue item with the same ID
|
||||
const existingItem = media.items?.find(
|
||||
item => item.itemId === newItem.itemId
|
||||
);
|
||||
|
||||
/**
|
||||
* Use existing queue item's media info if available
|
||||
* otherwise, if the current queue item, use the main
|
||||
* media item.
|
||||
*/
|
||||
if (existingItem?.media) {
|
||||
newItem.media = existingItem.media;
|
||||
} else if (
|
||||
media.media &&
|
||||
newItem.itemId === media.currentItemId
|
||||
) {
|
||||
newItem.media = media.media;
|
||||
}
|
||||
}
|
||||
|
||||
newItems.push(newItem);
|
||||
}
|
||||
|
||||
media.items = newItems;
|
||||
}
|
||||
}
|
||||
|
||||
export const sessionMessageListeners = new WeakMap<
|
||||
Session,
|
||||
Map<string, Set<MessageListener>>
|
||||
>();
|
||||
export const sessionUpdateListeners = new WeakMap<
|
||||
Session,
|
||||
Set<UpdateListener>
|
||||
>();
|
||||
export const sessionSendMessageCallbacks = new WeakMap<
|
||||
Session,
|
||||
Map<string, SendMessageCallback>
|
||||
>();
|
||||
|
||||
export const sessionLeaveSuccessCallback = new WeakMap<
|
||||
Session,
|
||||
Optional<() => void>
|
||||
>();
|
||||
|
||||
type SendMediaMessage = (
|
||||
message: DistributiveOmit<SenderMediaMessage, "requestId">
|
||||
) => Promise<void>;
|
||||
export const sessionSendMediaMessage = new WeakMap<Session, SendMediaMessage>();
|
||||
|
||||
interface MediaRequest {
|
||||
successCallback: () => void;
|
||||
errorCallback: (error: CastError) => void;
|
||||
message: SenderMediaMessage;
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
const sessionMediaRequests = new WeakMap<Session, Map<number, MediaRequest>>();
|
||||
|
||||
/** Creates a Session object and initializes private data. */
|
||||
export function createSession(
|
||||
sessionArgs: ConstructorParameters<typeof Session>
|
||||
) {
|
||||
const session = new Session(...sessionArgs);
|
||||
sessionUpdateListeners.set(session, new Set());
|
||||
sessionSendMessageCallbacks.set(session, new Map());
|
||||
|
||||
// Record of pending media requests
|
||||
// FIXME: Handle request timeouts
|
||||
const mediaRequests = new Map<number, MediaRequest>();
|
||||
sessionMediaRequests.set(session, mediaRequests);
|
||||
|
||||
// Current media request ID
|
||||
let mediaRequestId = 1;
|
||||
|
||||
/**
|
||||
* Stores callbacks for request response, then adds current request
|
||||
* ID to the message and sends it.
|
||||
*/
|
||||
sessionSendMediaMessage.set(session, message => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const requestId = mediaRequestId++;
|
||||
const request: MediaRequest = {
|
||||
successCallback: () => {
|
||||
mediaRequests.delete(requestId);
|
||||
resolve();
|
||||
},
|
||||
errorCallback: () => {
|
||||
mediaRequests.delete(requestId);
|
||||
reject();
|
||||
},
|
||||
message: { ...message, requestId },
|
||||
requestId
|
||||
};
|
||||
|
||||
mediaRequests.set(request.requestId, request);
|
||||
session.sendMessage(NS_MEDIA, request.message, undefined, () => {
|
||||
mediaRequests.delete(requestId);
|
||||
reject();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
type MessageListener = (namespace: string, message: string) => void;
|
||||
type UpdateListener = (isAlive: boolean) => void;
|
||||
type SendMessageCallback = [(() => void)?, ((err: CastError) => void)?];
|
||||
|
||||
export default class Session {
|
||||
#loadMediaRequest?: LoadRequest;
|
||||
#loadMediaSuccessCallback?: (media: Media) => void;
|
||||
#loadMediaErrorCallback?: (err: CastError) => void;
|
||||
|
||||
get #messageListeners() {
|
||||
const messageListeners = sessionMessageListeners.get(this);
|
||||
if (!messageListeners)
|
||||
throw logger.error("Missing session message listeners!");
|
||||
return messageListeners;
|
||||
}
|
||||
get #updateListeners() {
|
||||
const updateListeners = sessionUpdateListeners.get(this);
|
||||
if (!updateListeners)
|
||||
throw logger.error("Missing session update listeners!");
|
||||
return updateListeners;
|
||||
}
|
||||
get #sendMessageCallbacks() {
|
||||
const sendMessageCallback = sessionSendMessageCallbacks.get(this);
|
||||
if (!sendMessageCallback)
|
||||
throw logger.error("Missing session sendMessage callback!");
|
||||
return sendMessageCallback;
|
||||
}
|
||||
|
||||
get #sendMediaMessage() {
|
||||
const sendMediaMessage = sessionSendMediaMessage.get(this);
|
||||
if (!sendMediaMessage)
|
||||
throw logger.error("Missing send media message function!");
|
||||
return sendMediaMessage;
|
||||
}
|
||||
|
||||
get #mediaRequests() {
|
||||
const mediaRequests = sessionMediaRequests.get(this);
|
||||
if (!mediaRequests)
|
||||
throw logger.error("Missing session media requests!");
|
||||
return mediaRequests;
|
||||
}
|
||||
|
||||
get #leaveSuccessCallback() {
|
||||
return sessionLeaveSuccessCallback.get(this);
|
||||
}
|
||||
set #leaveSuccessCallback(successCallback: Optional<() => void>) {
|
||||
sessionLeaveSuccessCallback.set(this, successCallback);
|
||||
}
|
||||
|
||||
media: Media[] = [];
|
||||
namespaces: Array<{ name: string }> = [];
|
||||
senderApps: SenderApplication[] = [];
|
||||
status = SessionStatus.CONNECTED;
|
||||
statusText: Nullable<string> = null;
|
||||
transportId: string;
|
||||
|
||||
constructor(
|
||||
public sessionId: string,
|
||||
public appId: string,
|
||||
public displayName: string,
|
||||
public appImages: Image[],
|
||||
public receiver: Receiver
|
||||
) {
|
||||
this.transportId = sessionId || "";
|
||||
|
||||
sessionMessageListeners.set(this, new Map());
|
||||
this.addMessageListener(NS_MEDIA, this.#mediaMessageListener);
|
||||
}
|
||||
|
||||
#mediaMessageListener = (namespace: string, messageString: string) => {
|
||||
if (namespace !== NS_MEDIA) return;
|
||||
const message: ReceiverMediaMessage = JSON.parse(messageString);
|
||||
if (message.type !== "MEDIA_STATUS") return;
|
||||
|
||||
for (const status of message.status) {
|
||||
let media = this.media.find(
|
||||
media => media.mediaSessionId === status.mediaSessionId
|
||||
);
|
||||
|
||||
if (!media) {
|
||||
media = createMedia(
|
||||
[this.sessionId, status.mediaSessionId],
|
||||
this.#sendMediaMessage
|
||||
);
|
||||
this.media.push(media);
|
||||
updateMedia(media, status);
|
||||
} else {
|
||||
updateMedia(media, status);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle media request responses
|
||||
const mediaRequest = this.#mediaRequests.get(message.requestId);
|
||||
if (mediaRequest) {
|
||||
mediaRequest.successCallback();
|
||||
}
|
||||
|
||||
for (const status of message.status) {
|
||||
const media = this.media.find(
|
||||
media => media.mediaSessionId === status.mediaSessionId
|
||||
);
|
||||
if (!media) continue;
|
||||
|
||||
const updateListeners = mediaUpdateListeners.get(media);
|
||||
if (updateListeners) {
|
||||
for (const listener of updateListeners) {
|
||||
listener(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#sendReceiverMessage = (
|
||||
message: DistributiveOmit<SenderMessage, "requestId">
|
||||
) => {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const messageId = uuid();
|
||||
|
||||
pageMessaging.page.sendMessage({
|
||||
subject: "bridge:sendCastReceiverMessage",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
messageData: message as SenderMessage,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
this.#sendMessageCallbacks.set(messageId, [resolve, reject]);
|
||||
});
|
||||
};
|
||||
|
||||
addMediaListener(_mediaListener: (media: Media) => void) {
|
||||
logger.info("STUB :: Session#addMediaListener");
|
||||
}
|
||||
removeMediaListener(_mediaListener: (media: Media) => void) {
|
||||
logger.info("STUB :: Session#removeMediaListener");
|
||||
}
|
||||
|
||||
addMessageListener(namespace: string, listener: MessageListener) {
|
||||
if (!this.#messageListeners.has(namespace)) {
|
||||
this.#messageListeners.set(namespace, new Set());
|
||||
}
|
||||
|
||||
this.#messageListeners.get(namespace)?.add(listener);
|
||||
}
|
||||
removeMessageListener(namespace: string, listener: MessageListener) {
|
||||
this.#messageListeners.get(namespace)?.delete(listener);
|
||||
}
|
||||
|
||||
addUpdateListener(listener: UpdateListener) {
|
||||
this.#updateListeners.add(listener);
|
||||
}
|
||||
removeUpdateListener(listener: UpdateListener) {
|
||||
this.#updateListeners.delete(listener);
|
||||
}
|
||||
|
||||
leave(
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
if (!this.sessionId) {
|
||||
errorCallback?.(
|
||||
new CastError(ErrorCode.INVALID_PARAMETER, "Session not active")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#leaveSuccessCallback = successCallback;
|
||||
|
||||
pageMessaging.page.sendMessage({
|
||||
subject: "main:leaveSession"
|
||||
});
|
||||
}
|
||||
|
||||
loadMedia(
|
||||
loadRequest: LoadRequest,
|
||||
successCallback?: (media: Media) => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
if (!loadRequest) {
|
||||
errorCallback?.(new CastError(ErrorCode.INVALID_PARAMETER));
|
||||
return;
|
||||
}
|
||||
|
||||
this.#loadMediaSuccessCallback = successCallback;
|
||||
this.#loadMediaErrorCallback = errorCallback;
|
||||
|
||||
loadRequest.sessionId = this.sessionId;
|
||||
this.#sendMediaMessage(loadRequest)
|
||||
.then(() => {
|
||||
successCallback?.(this.media[this.media.length - 1]);
|
||||
})
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queueLoad(
|
||||
_queueLoadRequest: QueueLoadRequest,
|
||||
_successCallback?: (media: Media) => void,
|
||||
_errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
logger.info("STUB :: Session#queueLoad");
|
||||
}
|
||||
|
||||
sendMessage(
|
||||
namespace: string,
|
||||
message: object | string,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
const messageId = uuid();
|
||||
|
||||
pageMessaging.page.sendMessage({
|
||||
subject: "bridge:sendCastSessionMessage",
|
||||
data: {
|
||||
sessionId: this.sessionId,
|
||||
namespace,
|
||||
messageData: message,
|
||||
messageId
|
||||
}
|
||||
});
|
||||
|
||||
this.#sendMessageCallbacks.set(messageId, [
|
||||
successCallback,
|
||||
errorCallback
|
||||
]);
|
||||
}
|
||||
|
||||
setReceiverMuted(
|
||||
muted: boolean,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#sendReceiverMessage({ type: "SET_VOLUME", volume: { muted } })
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
setReceiverVolumeLevel(
|
||||
newLevel: number,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#sendReceiverMessage({
|
||||
type: "SET_VOLUME",
|
||||
volume: { level: newLevel }
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
stop(
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#sendReceiverMessage({ type: "STOP", sessionId: this.sessionId })
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
}
|
||||
104
extension/src/cast/sdk/classes.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import type Session from "./Session";
|
||||
|
||||
import {
|
||||
AutoJoinPolicy,
|
||||
Capability,
|
||||
DefaultActionPolicy,
|
||||
ErrorCode,
|
||||
ReceiverAvailability,
|
||||
ReceiverType,
|
||||
VolumeControlType
|
||||
} from "./enums";
|
||||
|
||||
export class ApiConfig {
|
||||
constructor(
|
||||
public sessionRequest: SessionRequest,
|
||||
public sessionListener: (session: Session) => void,
|
||||
public receiverListener: (availability: ReceiverAvailability) => void,
|
||||
|
||||
public autoJoinPolicy: string = AutoJoinPolicy.TAB_AND_ORIGIN_SCOPED,
|
||||
public defaultActionPolicy: string = DefaultActionPolicy.CREATE_SESSION
|
||||
) {}
|
||||
}
|
||||
|
||||
export class CredentialsData {
|
||||
constructor(public credentials: string, public credentialsData: string) {}
|
||||
}
|
||||
|
||||
export class DialRequest {
|
||||
constructor(
|
||||
public appName: string,
|
||||
public launchParameter: Nullable<string> = null
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Error {
|
||||
constructor(
|
||||
public code: ErrorCode,
|
||||
public description: Nullable<string> = null,
|
||||
public details: unknown = null
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Image {
|
||||
width: Nullable<number> = null;
|
||||
height: Nullable<number> = null;
|
||||
|
||||
constructor(public url: string) {}
|
||||
}
|
||||
|
||||
export class Receiver {
|
||||
displayStatus: Nullable<ReceiverDisplayStatus> = null;
|
||||
isActiveInput: Nullable<boolean> = null;
|
||||
receiverType = ReceiverType.CAST;
|
||||
|
||||
constructor(
|
||||
public label: string,
|
||||
public friendlyName: string,
|
||||
public capabilities: Capability[] = [],
|
||||
public volume: Nullable<Volume> = null
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ReceiverDisplayStatus {
|
||||
showStop: Nullable<boolean> = null;
|
||||
|
||||
constructor(public statusText: string, public appImages: Image[]) {}
|
||||
}
|
||||
|
||||
export class SenderApplication {
|
||||
packageId: Nullable<string> = null;
|
||||
url: Nullable<string> = null;
|
||||
|
||||
constructor(public platform: string) {}
|
||||
}
|
||||
|
||||
export class SessionRequest {
|
||||
language: Nullable<string> = null;
|
||||
|
||||
constructor(
|
||||
public appId: string,
|
||||
public capabilities: Capability[] = [],
|
||||
public requestSessionTimeout = new Timeout().requestSession,
|
||||
public androidReceiverCompatible = false,
|
||||
public credentialsData: Nullable<CredentialsData> = null
|
||||
) {}
|
||||
}
|
||||
|
||||
export class Timeout {
|
||||
leaveSession = 3000;
|
||||
requestSession = 60000;
|
||||
sendCustomMessage = 3000;
|
||||
setReceiverVolume = 3000;
|
||||
stopSession = 3000;
|
||||
}
|
||||
|
||||
export class Volume {
|
||||
controlType?: VolumeControlType;
|
||||
stepInterval?: number;
|
||||
|
||||
constructor(
|
||||
public level: Nullable<number> = null,
|
||||
public muted: Nullable<boolean> = null
|
||||
) {}
|
||||
}
|
||||
73
extension/src/cast/sdk/enums.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
export enum AutoJoinPolicy {
|
||||
TAB_AND_ORIGIN_SCOPED = "tab_and_origin_scoped",
|
||||
ORIGIN_SCOPED = "origin_scoped",
|
||||
PAGE_SCOPED = "page_scoped",
|
||||
CUSTOM_CONTROLLER_SCOPED = "custom_controller_scoped"
|
||||
}
|
||||
|
||||
export enum Capability {
|
||||
VIDEO_OUT = "video_out",
|
||||
AUDIO_OUT = "audio_out",
|
||||
VIDEO_IN = "video_in",
|
||||
AUDIO_IN = "audio_in",
|
||||
MULTIZONE_GROUP = "multizone_group"
|
||||
}
|
||||
|
||||
export enum DefaultActionPolicy {
|
||||
CREATE_SESSION = "create_session",
|
||||
CAST_THIS_TAB = "cast_this_tab"
|
||||
}
|
||||
|
||||
export enum DialAppState {
|
||||
RUNNING = "running",
|
||||
STOPPED = "stopped",
|
||||
ERROR = "error"
|
||||
}
|
||||
|
||||
export enum ErrorCode {
|
||||
CANCEL = "cancel",
|
||||
TIMEOUT = "timeout",
|
||||
API_NOT_INITIALIZED = "api_not_initialized",
|
||||
INVALID_PARAMETER = "invalid_parameter",
|
||||
EXTENSION_NOT_COMPATIBLE = "extension_not_compatible",
|
||||
EXTENSION_MISSING = "extension_missing",
|
||||
RECEIVER_UNAVAILABLE = "receiver_unavailable",
|
||||
SESSION_ERROR = "session_error",
|
||||
CHANNEL_ERROR = "channel_error",
|
||||
LOAD_MEDIA_FAILED = "load_media_failed"
|
||||
}
|
||||
|
||||
export enum ReceiverAction {
|
||||
CAST = "cast",
|
||||
STOP = "stop"
|
||||
}
|
||||
|
||||
export enum ReceiverAvailability {
|
||||
AVAILABLE = "available",
|
||||
UNAVAILABLE = "unavailable"
|
||||
}
|
||||
|
||||
export enum ReceiverType {
|
||||
CAST = "cast",
|
||||
DIAL = "dial",
|
||||
HANGOUT = "hangout",
|
||||
CUSTOM = "custom"
|
||||
}
|
||||
|
||||
export enum SenderPlatform {
|
||||
CHROME = "chrome",
|
||||
IOS = "ios",
|
||||
ANDROID = "android"
|
||||
}
|
||||
|
||||
export enum SessionStatus {
|
||||
CONNECTED = "connected",
|
||||
DISCONNECTED = "disconnected",
|
||||
STOPPED = "stopped"
|
||||
}
|
||||
|
||||
export enum VolumeControlType {
|
||||
ATTENUATION = "attenuation",
|
||||
FIXED = "fixed",
|
||||
MASTER = "master"
|
||||
}
|
||||
426
extension/src/cast/sdk/index.ts
Normal file
@@ -0,0 +1,426 @@
|
||||
import { Logger } from "../../lib/logger";
|
||||
|
||||
import type { Message } from "../../messaging";
|
||||
import pageMessaging from "../pageMessaging";
|
||||
|
||||
import {
|
||||
AutoJoinPolicy,
|
||||
Capability,
|
||||
DefaultActionPolicy,
|
||||
DialAppState,
|
||||
ErrorCode,
|
||||
ReceiverAction,
|
||||
ReceiverAvailability,
|
||||
ReceiverType,
|
||||
SenderPlatform,
|
||||
SessionStatus,
|
||||
VolumeControlType
|
||||
} from "./enums";
|
||||
|
||||
import {
|
||||
ApiConfig,
|
||||
CredentialsData,
|
||||
DialRequest,
|
||||
Error as CastError,
|
||||
Image,
|
||||
Receiver,
|
||||
ReceiverDisplayStatus,
|
||||
SenderApplication,
|
||||
SessionRequest,
|
||||
Timeout,
|
||||
Volume
|
||||
} from "./classes";
|
||||
|
||||
import Session, {
|
||||
createSession,
|
||||
sessionLeaveSuccessCallback,
|
||||
sessionMessageListeners,
|
||||
sessionSendMediaMessage,
|
||||
sessionSendMessageCallbacks,
|
||||
sessionUpdateListeners,
|
||||
updateMedia
|
||||
} from "./Session";
|
||||
|
||||
import * as media from "./media";
|
||||
import { createMedia } from "./media/Media";
|
||||
|
||||
const logger = new Logger("fx_cast [sdk]");
|
||||
|
||||
type ReceiverActionListener = (
|
||||
receiver: Receiver,
|
||||
receiverAction: string
|
||||
) => void;
|
||||
|
||||
type RequestSessionSuccessCallback = (session: Session) => void;
|
||||
|
||||
/** Cast SDK root class */
|
||||
export default class {
|
||||
#apiConfig?: ApiConfig;
|
||||
#sessionRequest?: SessionRequest;
|
||||
|
||||
#isInitialized = false;
|
||||
|
||||
/** Current receiver availability. */
|
||||
#receiverAvailability?: ReceiverAvailability;
|
||||
|
||||
#initializeSuccessCallback?: () => void;
|
||||
|
||||
#requestSessionSuccessCallback?: RequestSessionSuccessCallback;
|
||||
#requestSessionErrorCallback?: (err: CastError) => void;
|
||||
|
||||
#receiverActionListeners = new Set<ReceiverActionListener>();
|
||||
|
||||
#sessions = new Map<string, Session>();
|
||||
|
||||
// Enums
|
||||
AutoJoinPolicy = AutoJoinPolicy;
|
||||
Capability = Capability;
|
||||
DefaultActionPolicy = DefaultActionPolicy;
|
||||
DialAppState = DialAppState;
|
||||
ErrorCode = ErrorCode;
|
||||
ReceiverAction = ReceiverAction;
|
||||
ReceiverAvailability = ReceiverAvailability;
|
||||
ReceiverType = ReceiverType;
|
||||
SenderPlatform = SenderPlatform;
|
||||
SessionStatus = SessionStatus;
|
||||
VolumeControlType = VolumeControlType;
|
||||
|
||||
// Classes
|
||||
ApiConfig = ApiConfig;
|
||||
CredentialsData = CredentialsData;
|
||||
DialRequest = DialRequest;
|
||||
Error = CastError;
|
||||
Image = Image;
|
||||
Receiver = Receiver;
|
||||
ReceiverDisplayStatus = ReceiverDisplayStatus;
|
||||
SenderApplication = SenderApplication;
|
||||
SessionRequest = SessionRequest;
|
||||
Timeout = Timeout;
|
||||
Volume = Volume;
|
||||
Session = Session;
|
||||
|
||||
media = { ...media };
|
||||
|
||||
VERSION = [1, 2];
|
||||
isAvailable = false;
|
||||
timeout = new Timeout();
|
||||
|
||||
constructor() {
|
||||
pageMessaging.page.addListener(this.#onMessage.bind(this));
|
||||
}
|
||||
|
||||
#onMessage(message: Message) {
|
||||
switch (message.subject) {
|
||||
case "cast:instanceCreated":
|
||||
this.isAvailable = true;
|
||||
break;
|
||||
|
||||
case "cast:receiverAvailabilityUpdated": {
|
||||
/**
|
||||
* The first availability update happens after
|
||||
* initialize is called.
|
||||
*/
|
||||
if (!this.#isInitialized) {
|
||||
this.#isInitialized = true;
|
||||
this.#initializeSuccessCallback?.();
|
||||
}
|
||||
|
||||
const availability = message.data.isAvailable
|
||||
? ReceiverAvailability.AVAILABLE
|
||||
: ReceiverAvailability.UNAVAILABLE;
|
||||
|
||||
// If availability has changed, call receiver listeners
|
||||
if (availability !== this.#receiverAvailability) {
|
||||
this.#receiverAvailability = availability;
|
||||
this.#apiConfig?.receiverListener(availability);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "cast:receiverAction":
|
||||
for (const actionListener of this.#receiverActionListeners) {
|
||||
actionListener(message.data.receiver, message.data.action);
|
||||
}
|
||||
break;
|
||||
|
||||
// Popup closed before session established
|
||||
case "cast:sessionRequestCancelled":
|
||||
if (this.#sessionRequest) {
|
||||
this.#sessionRequest = undefined;
|
||||
|
||||
this.#requestSessionErrorCallback?.(
|
||||
new CastError(ErrorCode.CANCEL)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
/**
|
||||
* Once the bridge detects a session creation, session info
|
||||
* and data needed to create cast API objects is sent.
|
||||
*/
|
||||
case "cast:sessionCreated": {
|
||||
this.#sessionRequest = undefined;
|
||||
const status = message.data;
|
||||
|
||||
status.receiver.volume = status.volume;
|
||||
status.receiver.displayStatus = new ReceiverDisplayStatus(
|
||||
status.statusText,
|
||||
status.appImages
|
||||
);
|
||||
|
||||
const session = createSession([
|
||||
status.sessionId,
|
||||
status.appId,
|
||||
status.displayName,
|
||||
status.appImages,
|
||||
status.receiver
|
||||
]);
|
||||
|
||||
session.namespaces = status.namespaces;
|
||||
session.senderApps = status.senderApps;
|
||||
session.statusText = status.statusText;
|
||||
session.transportId = status.transportId;
|
||||
|
||||
if (status.media) {
|
||||
const media = createMedia(
|
||||
[status.sessionId, status.media.mediaSessionId],
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
sessionSendMediaMessage.get(session)!
|
||||
);
|
||||
updateMedia(media, status.media);
|
||||
session.media = [media];
|
||||
}
|
||||
|
||||
this.#sessions.set(session.sessionId, session);
|
||||
|
||||
/**
|
||||
* If session created via requestSession, the success
|
||||
* callback will be set, otherwise the session was
|
||||
* created by the extension and the session listener
|
||||
* should be called instead.
|
||||
*/
|
||||
if (this.#requestSessionSuccessCallback) {
|
||||
this.#requestSessionSuccessCallback(session);
|
||||
this.#requestSessionSuccessCallback = undefined;
|
||||
this.#requestSessionErrorCallback = undefined;
|
||||
} else {
|
||||
this.#apiConfig?.sessionListener(session);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "cast:sessionUpdated": {
|
||||
const status = message.data;
|
||||
const session = this.#sessions.get(status.sessionId);
|
||||
if (!session) {
|
||||
logger.error(`Session not found (${status.sessionId})`);
|
||||
break;
|
||||
}
|
||||
|
||||
session.statusText = status.statusText;
|
||||
session.namespaces = status.namespaces;
|
||||
session.receiver.volume = status.volume;
|
||||
|
||||
const updateListeners = sessionUpdateListeners.get(session);
|
||||
if (updateListeners) {
|
||||
for (const listener of updateListeners) {
|
||||
listener(session.status !== SessionStatus.STOPPED);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "cast:sessionStopped": {
|
||||
const session = this.#sessions.get(message.data.sessionId);
|
||||
if (session?.status === SessionStatus.CONNECTED) {
|
||||
session.status = SessionStatus.STOPPED;
|
||||
|
||||
const updateListeners = sessionUpdateListeners.get(session);
|
||||
if (updateListeners) {
|
||||
for (const listener of updateListeners) {
|
||||
listener(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "cast:sessionDisconnected": {
|
||||
const session = this.#sessions.get(message.data.sessionId);
|
||||
if (session?.status === SessionStatus.CONNECTED) {
|
||||
session.status = SessionStatus.DISCONNECTED;
|
||||
|
||||
sessionLeaveSuccessCallback.get(session)?.();
|
||||
|
||||
const updateListeners = sessionUpdateListeners.get(session);
|
||||
if (updateListeners) {
|
||||
for (const listener of updateListeners) {
|
||||
listener(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "cast:sessionMessageReceived": {
|
||||
const { sessionId, namespace, messageData } = message.data;
|
||||
const session = this.#sessions.get(sessionId);
|
||||
if (session) {
|
||||
const listeners = sessionMessageListeners
|
||||
.get(session)
|
||||
?.get(namespace);
|
||||
if (listeners) {
|
||||
for (const listener of listeners) {
|
||||
listener(namespace, messageData);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "cast:impl_sendMessage": {
|
||||
const { sessionId, messageId, error } = message.data;
|
||||
|
||||
const session = this.#sessions.get(sessionId);
|
||||
if (!session) {
|
||||
break;
|
||||
}
|
||||
|
||||
const sendMessageCallback = sessionSendMessageCallbacks
|
||||
.get(session)
|
||||
?.get(messageId);
|
||||
if (sendMessageCallback) {
|
||||
const [successCallback, errorCallback] =
|
||||
sendMessageCallback;
|
||||
|
||||
if (error) {
|
||||
errorCallback?.(
|
||||
new CastError(ErrorCode.CHANNEL_ERROR, error)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
successCallback?.();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initialize(
|
||||
apiConfig: ApiConfig,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
logger.info("cast.initialize");
|
||||
|
||||
// Already initialized
|
||||
if (this.#apiConfig) {
|
||||
errorCallback?.(new CastError(ErrorCode.INVALID_PARAMETER));
|
||||
return;
|
||||
}
|
||||
|
||||
this.#apiConfig = apiConfig;
|
||||
|
||||
if (successCallback) {
|
||||
this.#initializeSuccessCallback = successCallback;
|
||||
}
|
||||
|
||||
pageMessaging.page.sendMessage({
|
||||
subject: "main:initializeCastSdk",
|
||||
data: { apiConfig: this.#apiConfig }
|
||||
});
|
||||
}
|
||||
|
||||
requestSession(
|
||||
successCallback: RequestSessionSuccessCallback,
|
||||
errorCallback: (err: CastError) => void,
|
||||
newSessionRequest?: SessionRequest
|
||||
) {
|
||||
logger.info("cast.requestSession");
|
||||
|
||||
// Not yet initialized
|
||||
if (!this.#apiConfig) {
|
||||
errorCallback?.(new CastError(ErrorCode.API_NOT_INITIALIZED));
|
||||
return;
|
||||
}
|
||||
|
||||
// Already requesting session
|
||||
if (this.#sessionRequest) {
|
||||
errorCallback?.(
|
||||
new CastError(
|
||||
ErrorCode.INVALID_PARAMETER,
|
||||
"Session request already in progress."
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.#receiverAvailability === ReceiverAvailability.UNAVAILABLE) {
|
||||
errorCallback?.(new CastError(ErrorCode.RECEIVER_UNAVAILABLE));
|
||||
return;
|
||||
}
|
||||
|
||||
// Store used session request
|
||||
this.#sessionRequest =
|
||||
newSessionRequest ?? this.#apiConfig.sessionRequest;
|
||||
|
||||
this.#requestSessionSuccessCallback = successCallback;
|
||||
this.#requestSessionErrorCallback = errorCallback;
|
||||
|
||||
// Open receiver selector UI
|
||||
pageMessaging.page.sendMessage({
|
||||
subject: "main:requestSession",
|
||||
data: { sessionRequest: this.#sessionRequest }
|
||||
});
|
||||
}
|
||||
|
||||
requestSessionById(sessionId: string) {
|
||||
pageMessaging.page.sendMessage({
|
||||
subject: "main:requestSessionById",
|
||||
data: { sessionId }
|
||||
});
|
||||
}
|
||||
|
||||
setCustomReceivers(
|
||||
_receivers: Receiver[],
|
||||
_successCallback?: () => void,
|
||||
_errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
logger.info("STUB :: cast.setCustomReceivers");
|
||||
}
|
||||
|
||||
setPageContext(_win: Window) {
|
||||
logger.info("STUB :: cast.setPageContext");
|
||||
}
|
||||
|
||||
setReceiverDisplayStatus(_sessionId: string) {
|
||||
logger.info("STUB :: cast.setReceiverDisplayStatus");
|
||||
}
|
||||
|
||||
unescape(escaped: string): string {
|
||||
return window.decodeURI(escaped);
|
||||
}
|
||||
|
||||
addReceiverActionListener(listener: ReceiverActionListener) {
|
||||
this.#receiverActionListeners.add(listener);
|
||||
}
|
||||
removeReceiverActionListener(listener: ReceiverActionListener) {
|
||||
this.#receiverActionListeners.delete(listener);
|
||||
}
|
||||
|
||||
logMessage(message: string) {
|
||||
logger.info("(logMessage)", message);
|
||||
}
|
||||
|
||||
precache(_data: string) {
|
||||
logger.info("STUB :: cast.precache");
|
||||
}
|
||||
}
|
||||
487
extension/src/cast/sdk/media/Media.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { Logger } from "../../../lib/logger";
|
||||
import { getEstimatedTime } from "../../utils";
|
||||
import type { SenderMediaMessage } from "../types";
|
||||
|
||||
import { Volume, Error as CastError } from "../classes";
|
||||
import { ErrorCode } from "../enums";
|
||||
|
||||
import {
|
||||
BreakStatus,
|
||||
EditTracksInfoRequest,
|
||||
GetStatusRequest,
|
||||
LiveSeekableRange,
|
||||
MediaInfo,
|
||||
PauseRequest,
|
||||
PlayRequest,
|
||||
QueueData,
|
||||
QueueJumpRequest,
|
||||
QueueInsertItemsRequest,
|
||||
QueueItem,
|
||||
QueueSetPropertiesRequest,
|
||||
QueueRemoveItemsRequest,
|
||||
QueueReorderItemsRequest,
|
||||
QueueUpdateItemsRequest,
|
||||
SeekRequest,
|
||||
StopRequest,
|
||||
VideoInformation,
|
||||
VolumeRequest
|
||||
} from "./classes";
|
||||
import { PlayerState, RepeatMode } from "./enums";
|
||||
|
||||
const logger = new Logger("fx_cast [sdk :: cast.Media]");
|
||||
|
||||
export const NS_MEDIA = "urn:x-cast:com.google.cast.media";
|
||||
|
||||
type MediaMessageCallback = (
|
||||
message: DistributiveOmit<SenderMediaMessage, "requestId">
|
||||
) => Promise<void>;
|
||||
|
||||
const mediaMessageCallbacks = new WeakMap<Media, MediaMessageCallback>();
|
||||
export const mediaUpdateListeners = new WeakMap<Media, Set<UpdateListener>>();
|
||||
export const mediaLastUpdateTimes = new WeakMap<Media, number>();
|
||||
|
||||
/** Creates a Media object and initializes private data. */
|
||||
export function createMedia(
|
||||
mediaArgs: ConstructorParameters<typeof Media>,
|
||||
mediaMessageCallback: MediaMessageCallback
|
||||
) {
|
||||
const media = new Media(...mediaArgs);
|
||||
mediaMessageCallbacks.set(media, mediaMessageCallback);
|
||||
mediaUpdateListeners.set(media, new Set());
|
||||
mediaLastUpdateTimes.set(media, 0);
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
type UpdateListener = (isAlive: boolean) => void;
|
||||
|
||||
export default class Media {
|
||||
#id = uuid();
|
||||
|
||||
get #updateListeners() {
|
||||
const updateListeners = mediaUpdateListeners.get(this);
|
||||
if (!updateListeners)
|
||||
throw logger.error("Missing media update listeners!");
|
||||
return updateListeners;
|
||||
}
|
||||
get #mediaMessageCallback() {
|
||||
const callback = mediaMessageCallbacks.get(this);
|
||||
if (!callback) throw logger.error("Missing media message callback!");
|
||||
return callback;
|
||||
}
|
||||
get #lastUpdateTime() {
|
||||
const lastUpdateTime = mediaLastUpdateTimes.get(this);
|
||||
if (lastUpdateTime === undefined)
|
||||
throw logger.error("Missing last update time!");
|
||||
return lastUpdateTime;
|
||||
}
|
||||
|
||||
activeTrackIds: Nullable<number[]> = null;
|
||||
breakStatus?: BreakStatus;
|
||||
currentTime = 0;
|
||||
customData: unknown = null;
|
||||
idleReason: Nullable<string> = null;
|
||||
liveSeekableRange?: LiveSeekableRange;
|
||||
media: Nullable<MediaInfo> = null;
|
||||
playbackRate = 1;
|
||||
playerState = PlayerState.IDLE;
|
||||
repeatMode = RepeatMode.OFF;
|
||||
supportedMediaCommands: string[] = [];
|
||||
videoInfo?: VideoInformation;
|
||||
volume: Volume = new Volume();
|
||||
|
||||
// Queues
|
||||
items: Nullable<QueueItem[]> = null;
|
||||
currentItemId: Nullable<number> = null;
|
||||
loadingItemId: Nullable<number> = null;
|
||||
preloadedItemId: Nullable<number> = null;
|
||||
queueData?: QueueData;
|
||||
|
||||
constructor(public sessionId: string, public mediaSessionId: number) {}
|
||||
|
||||
addUpdateListener(listener: UpdateListener) {
|
||||
this.#updateListeners?.add(listener);
|
||||
}
|
||||
removeUpdateListener(listener: UpdateListener) {
|
||||
this.#updateListeners?.delete(listener);
|
||||
}
|
||||
|
||||
editTracksInfo(
|
||||
editTracksInfoRequest: EditTracksInfoRequest,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...editTracksInfoRequest,
|
||||
type: "EDIT_TRACKS_INFO",
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the current break clip position based on the last
|
||||
* information reported by the receiver.
|
||||
*/
|
||||
getEstimatedBreakClipTime() {
|
||||
if (this.breakStatus?.currentBreakClipTime === undefined) return;
|
||||
if (this.playerState === PlayerState.PLAYING) {
|
||||
return getEstimatedTime({
|
||||
currentTime: this.breakStatus.currentBreakClipTime,
|
||||
lastUpdateTime: this.#lastUpdateTime
|
||||
});
|
||||
}
|
||||
|
||||
return this.breakStatus.currentBreakClipTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the current break position based on the last
|
||||
* information reported by the receiver.
|
||||
*/
|
||||
getEstimatedBreakTime() {
|
||||
if (this.breakStatus?.currentBreakTime === undefined) return;
|
||||
if (this.playerState === PlayerState.PLAYING) {
|
||||
return getEstimatedTime({
|
||||
currentTime: this.breakStatus.currentBreakTime,
|
||||
lastUpdateTime: this.#lastUpdateTime
|
||||
});
|
||||
}
|
||||
|
||||
return this.breakStatus.currentBreakTime;
|
||||
}
|
||||
|
||||
getEstimatedLiveSeekableRange() {
|
||||
logger.info("STUB :: Media#getEstimatedLiveSeekableRange");
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimates the current playback position based on the last
|
||||
* information reported by the receiver.
|
||||
*/
|
||||
getEstimatedTime(): number {
|
||||
if (this.playerState === PlayerState.PLAYING) {
|
||||
return getEstimatedTime({
|
||||
currentTime: this.currentTime,
|
||||
lastUpdateTime: this.#lastUpdateTime,
|
||||
playbackRate: this.playbackRate,
|
||||
duration: this.media?.duration
|
||||
});
|
||||
}
|
||||
|
||||
return this.currentTime;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request media status from the receiver application. This will
|
||||
* also trigger any added media update listeners.
|
||||
*/
|
||||
getStatus(
|
||||
getStatusRequest = new GetStatusRequest(),
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...getStatusRequest,
|
||||
type: "MEDIA_GET_STATUS",
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
pause(
|
||||
pauseRequest = new PauseRequest(),
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...pauseRequest,
|
||||
type: "PAUSE",
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
play(
|
||||
playRequest = new PlayRequest(),
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...playRequest,
|
||||
type: "PLAY",
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queueAppendItem(
|
||||
item: QueueItem,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...new QueueInsertItemsRequest([item]),
|
||||
type: "QUEUE_INSERT",
|
||||
sessionId: this.sessionId,
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queueInsertItems(
|
||||
queueInsertItemsRequest: QueueInsertItemsRequest,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...queueInsertItemsRequest,
|
||||
type: "QUEUE_INSERT",
|
||||
sessionId: this.sessionId,
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queueJumpToItem(
|
||||
itemId: number,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
if (this.items?.find(item => item.itemId === itemId)) {
|
||||
const jumpRequest = new QueueJumpRequest();
|
||||
jumpRequest.currentItemId = itemId;
|
||||
|
||||
this.#mediaMessageCallback?.({
|
||||
...jumpRequest,
|
||||
type: "QUEUE_UPDATE",
|
||||
sessionId: this.sessionId,
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
}
|
||||
|
||||
queueMoveItemToNewIndex(
|
||||
itemId: number,
|
||||
newIndex: number,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
// Return early if not in queue
|
||||
if (!this.items) {
|
||||
return;
|
||||
}
|
||||
|
||||
const itemIndex = this.items.findIndex(item => item.itemId === itemId);
|
||||
|
||||
if (itemIndex !== -1) {
|
||||
// New index must not be negative
|
||||
if (newIndex < 0) {
|
||||
if (errorCallback) {
|
||||
errorCallback(new CastError(ErrorCode.INVALID_PARAMETER));
|
||||
}
|
||||
} else if (newIndex == itemIndex) {
|
||||
if (successCallback) {
|
||||
successCallback();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (newIndex > itemIndex) {
|
||||
newIndex++;
|
||||
}
|
||||
|
||||
const reorderItemsRequest = new QueueReorderItemsRequest([itemId]);
|
||||
if (newIndex < this.items.length) {
|
||||
const existingItem = this.items[newIndex];
|
||||
reorderItemsRequest.insertBefore = existingItem.itemId;
|
||||
}
|
||||
|
||||
this.#mediaMessageCallback?.({
|
||||
...reorderItemsRequest,
|
||||
type: "QUEUE_REORDER",
|
||||
sessionId: this.sessionId,
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
}
|
||||
|
||||
queueNext(
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
const jumpRequest = new QueueJumpRequest();
|
||||
jumpRequest.jump = 1;
|
||||
|
||||
this.#mediaMessageCallback?.({
|
||||
...jumpRequest,
|
||||
type: "QUEUE_UPDATE",
|
||||
sessionId: this.sessionId,
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queuePrev(
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
const jumpRequest = new QueueJumpRequest();
|
||||
jumpRequest.jump = -1;
|
||||
|
||||
this.#mediaMessageCallback?.({
|
||||
...jumpRequest,
|
||||
type: "QUEUE_UPDATE",
|
||||
sessionId: this.sessionId,
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queueRemoveItem(
|
||||
itemId: number,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
const item = this.items?.find(item => item.itemId === itemId);
|
||||
if (item) {
|
||||
this.queueRemoveItems(
|
||||
new QueueRemoveItemsRequest([itemId]),
|
||||
successCallback,
|
||||
errorCallback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
queueRemoveItems(
|
||||
queueRemoveItemsRequest: QueueRemoveItemsRequest,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...queueRemoveItemsRequest,
|
||||
|
||||
mediaSessionId: this.mediaSessionId,
|
||||
type: "QUEUE_REMOVE",
|
||||
sessionId: this.sessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queueReorderItems(
|
||||
queueReorderItemsRequest: QueueReorderItemsRequest,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...queueReorderItemsRequest,
|
||||
|
||||
mediaSessionId: this.mediaSessionId,
|
||||
type: "QUEUE_REORDER",
|
||||
sessionId: this.sessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queueSetRepeatMode(
|
||||
repeatMode: string,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
const setPropertiesRequest = new QueueSetPropertiesRequest();
|
||||
setPropertiesRequest.repeatMode = repeatMode;
|
||||
|
||||
this.#mediaMessageCallback?.({
|
||||
...setPropertiesRequest,
|
||||
type: "QUEUE_UPDATE",
|
||||
sessionId: this.sessionId,
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
queueUpdateItems(
|
||||
queueUpdateItemsRequest: QueueUpdateItemsRequest,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...queueUpdateItemsRequest,
|
||||
type: "QUEUE_UPDATE",
|
||||
sessionId: this.sessionId,
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
seek(
|
||||
seekRequest: SeekRequest,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...seekRequest,
|
||||
type: "SEEK",
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
setVolume(
|
||||
volumeRequest: VolumeRequest,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
this.#mediaMessageCallback?.({
|
||||
...volumeRequest,
|
||||
type: "MEDIA_SET_VOLUME",
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(successCallback)
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
stop(
|
||||
stopRequest?: StopRequest,
|
||||
successCallback?: () => void,
|
||||
errorCallback?: (err: CastError) => void
|
||||
) {
|
||||
if (!stopRequest) {
|
||||
stopRequest = new StopRequest();
|
||||
}
|
||||
|
||||
this.#mediaMessageCallback?.({
|
||||
...stopRequest,
|
||||
type: "STOP",
|
||||
mediaSessionId: this.mediaSessionId
|
||||
})
|
||||
.then(() => {
|
||||
if (successCallback) {
|
||||
successCallback();
|
||||
}
|
||||
})
|
||||
.catch(errorCallback);
|
||||
}
|
||||
|
||||
supportsCommand(command: string): boolean {
|
||||
return this.supportedMediaCommands.includes(command);
|
||||
}
|
||||
}
|
||||
392
extension/src/cast/sdk/media/classes.ts
Normal file
@@ -0,0 +1,392 @@
|
||||
import type { Image, Volume } from "../classes";
|
||||
|
||||
import {
|
||||
ContainerType,
|
||||
HdrType,
|
||||
HlsSegmentFormat,
|
||||
HlsVideoSegmentFormat,
|
||||
MetadataType,
|
||||
RepeatMode,
|
||||
ResumeState,
|
||||
StreamType,
|
||||
TrackType,
|
||||
UserAction
|
||||
} from "./enums";
|
||||
|
||||
export class AudiobookContainerMetadata {
|
||||
authors?: string[];
|
||||
narrators?: string[];
|
||||
publisher?: string;
|
||||
releaseDate?: string;
|
||||
}
|
||||
|
||||
export class Break {
|
||||
duration?: number;
|
||||
isEmbedded?: boolean;
|
||||
isWatched = false;
|
||||
|
||||
constructor(
|
||||
public id: string,
|
||||
public breakClipIds: string[],
|
||||
public position: number
|
||||
) {}
|
||||
}
|
||||
|
||||
export class BreakClip {
|
||||
clickThroughUrl?: string;
|
||||
contentId?: string;
|
||||
contentType?: string;
|
||||
contentUrl?: string;
|
||||
customData?: unknown;
|
||||
duration?: number;
|
||||
hlsSegmentFormat?: HlsSegmentFormat;
|
||||
posterUrl?: string;
|
||||
title?: string;
|
||||
vastAdsRequest?: VastAdsRequest;
|
||||
whenSkippable?: number;
|
||||
|
||||
constructor(public id: string) {}
|
||||
}
|
||||
|
||||
export class BreakStatus {
|
||||
breakClipId?: string;
|
||||
breakId?: string;
|
||||
currentBreakClipTime?: number;
|
||||
currentBreakTime?: number;
|
||||
whenSkippable?: number;
|
||||
}
|
||||
|
||||
export class ContainerMetadata {
|
||||
containerDuration?: number;
|
||||
containerImages?: Image[];
|
||||
sections?: Metadata[];
|
||||
title?: string;
|
||||
|
||||
constructor(
|
||||
public containerType: ContainerType = ContainerType.GENERIC_CONTAINER
|
||||
) {}
|
||||
}
|
||||
|
||||
export class EditTracksInfoRequest {
|
||||
requestId = 0;
|
||||
|
||||
constructor(
|
||||
public activeTrackIds: Nullable<number[]> = null,
|
||||
public textTrackStyle: Nullable<string> = null
|
||||
) {}
|
||||
}
|
||||
|
||||
export class GetStatusRequest {
|
||||
customData: unknown = null;
|
||||
}
|
||||
|
||||
export class LiveSeekableRange {
|
||||
constructor(
|
||||
public start?: number,
|
||||
public end?: number,
|
||||
public isMovingWindow?: boolean,
|
||||
public isLiveDone?: boolean
|
||||
) {}
|
||||
}
|
||||
|
||||
export class LoadRequest {
|
||||
activeTrackIds: Nullable<number[]> = null;
|
||||
atvCredentials?: string;
|
||||
atvCredentialsType?: string;
|
||||
autoplay: Nullable<boolean> = true;
|
||||
currentTime: Nullable<number> = null;
|
||||
customData: unknown = null;
|
||||
media: MediaInfo;
|
||||
requestId = 0;
|
||||
sessionId: Nullable<string> = null;
|
||||
type: "LOAD" = "LOAD";
|
||||
|
||||
constructor(mediaInfo: MediaInfo) {
|
||||
this.media = mediaInfo;
|
||||
}
|
||||
}
|
||||
|
||||
export type Metadata =
|
||||
| AudiobookChapterMediaMetadata
|
||||
| GenericMediaMetadata
|
||||
| MovieMediaMetadata
|
||||
| MusicTrackMediaMetadata
|
||||
| PhotoMediaMetadata
|
||||
| TvShowMediaMetadata;
|
||||
|
||||
export class MediaInfo {
|
||||
atvEntity?: string;
|
||||
breakClips?: BreakClip[];
|
||||
breaks?: Break[];
|
||||
customData: unknown = null;
|
||||
contentUrl?: string;
|
||||
duration: Nullable<number> = null;
|
||||
entity?: string;
|
||||
hlsSegmentFormat?: HlsSegmentFormat;
|
||||
hlsVideoSegmentFormat?: HlsVideoSegmentFormat;
|
||||
metadata: Nullable<Metadata> = null;
|
||||
startAbsoluteTime?: number;
|
||||
streamType: string = StreamType.BUFFERED;
|
||||
textTrackStyle: Nullable<TextTrackStyle> = null;
|
||||
tracks: Nullable<Track[]> = null;
|
||||
userActionStates?: UserActionState[];
|
||||
vmapAdsRequest?: VastAdsRequest;
|
||||
|
||||
constructor(public contentId: string, public contentType: string) {}
|
||||
}
|
||||
|
||||
export abstract class MediaMetadata<T extends MetadataType> {
|
||||
queueItemId?: number;
|
||||
sectionDuration?: number;
|
||||
sectionStartAbsoluteTime?: number;
|
||||
sectionStartTimeInContainer?: number;
|
||||
sectionStartTimeInMedia?: number;
|
||||
type: T;
|
||||
metadataType: T;
|
||||
|
||||
constructor(type: T) {
|
||||
this.type = type;
|
||||
this.metadataType = type;
|
||||
}
|
||||
}
|
||||
|
||||
export class AudiobookChapterMediaMetadata extends MediaMetadata<MetadataType.AUDIOBOOK_CHAPTER> {
|
||||
bookTitle?: string;
|
||||
chapterNumber?: number;
|
||||
chapterTitle?: string;
|
||||
images?: Image[];
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
|
||||
constructor() {
|
||||
super(MetadataType.AUDIOBOOK_CHAPTER);
|
||||
}
|
||||
}
|
||||
|
||||
export class GenericMediaMetadata extends MediaMetadata<MetadataType.GENERIC> {
|
||||
images?: Image[];
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
|
||||
constructor() {
|
||||
super(MetadataType.GENERIC);
|
||||
}
|
||||
}
|
||||
|
||||
export class MovieMediaMetadata extends MediaMetadata<MetadataType.MOVIE> {
|
||||
images?: Image[];
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
studio?: string;
|
||||
subtitle?: string;
|
||||
title?: string;
|
||||
|
||||
constructor() {
|
||||
super(MetadataType.MOVIE);
|
||||
}
|
||||
}
|
||||
|
||||
export class MusicTrackMediaMetadata extends MediaMetadata<MetadataType.MUSIC_TRACK> {
|
||||
albumArtist?: string;
|
||||
albumName?: string;
|
||||
artist?: string;
|
||||
artistName?: string;
|
||||
composer?: string;
|
||||
discNumber?: number;
|
||||
images?: Image[];
|
||||
releaseDate?: string;
|
||||
releaseYear?: number;
|
||||
songName?: string;
|
||||
title?: string;
|
||||
trackNumber?: number;
|
||||
|
||||
constructor() {
|
||||
super(MetadataType.MUSIC_TRACK);
|
||||
}
|
||||
}
|
||||
|
||||
export class PhotoMediaMetadata extends MediaMetadata<MetadataType.PHOTO> {
|
||||
artist?: string;
|
||||
creationDateTime?: string;
|
||||
height?: number;
|
||||
images?: Image[];
|
||||
latitude?: number;
|
||||
location?: string;
|
||||
longitude?: number;
|
||||
title?: string;
|
||||
width?: number;
|
||||
|
||||
constructor() {
|
||||
super(MetadataType.PHOTO);
|
||||
}
|
||||
}
|
||||
|
||||
export class TvShowMediaMetadata extends MediaMetadata<MetadataType.TV_SHOW> {
|
||||
episode?: number;
|
||||
episodeNumber?: number;
|
||||
episodeTitle?: string;
|
||||
images?: Image[];
|
||||
originalAirdate?: string;
|
||||
releaseYear?: number;
|
||||
season?: number;
|
||||
seasonNumber?: number;
|
||||
seriesTitle?: string;
|
||||
title?: string;
|
||||
|
||||
constructor() {
|
||||
super(MetadataType.TV_SHOW);
|
||||
}
|
||||
}
|
||||
|
||||
export class PauseRequest {
|
||||
customData: unknown = null;
|
||||
}
|
||||
|
||||
export class PlayRequest {
|
||||
customData: unknown = null;
|
||||
}
|
||||
|
||||
export class QueueData {
|
||||
shuffle = false;
|
||||
|
||||
constructor(
|
||||
public id?: string,
|
||||
public name?: string,
|
||||
public description?: string,
|
||||
public repeatMode?: RepeatMode,
|
||||
public items?: QueueItem[],
|
||||
public startIndex?: number,
|
||||
public startTime?: number
|
||||
) {}
|
||||
}
|
||||
|
||||
export class QueueInsertItemsRequest {
|
||||
customData: unknown = null;
|
||||
insertBefore: Nullable<number> = null;
|
||||
requestId: Nullable<number> = null;
|
||||
sessionId: Nullable<string> = null;
|
||||
type = "QUEUE_INSERT";
|
||||
|
||||
constructor(public items: QueueItem[]) {}
|
||||
}
|
||||
|
||||
export class QueueItem {
|
||||
activeTrackIds: Nullable<number[]> = null;
|
||||
autoplay = true;
|
||||
customData: unknown = null;
|
||||
itemId: Nullable<number> = null;
|
||||
media: MediaInfo;
|
||||
playbackDuration: Nullable<number> = null;
|
||||
preloadTime = 0;
|
||||
startTime = 0;
|
||||
|
||||
constructor(mediaInfo: MediaInfo) {
|
||||
this.media = mediaInfo;
|
||||
}
|
||||
}
|
||||
|
||||
export class QueueJumpRequest {
|
||||
type = "QUEUE_UPDATE";
|
||||
jump: Nullable<number> = null;
|
||||
currentItemId: Nullable<number> = null;
|
||||
}
|
||||
|
||||
export class QueueLoadRequest {
|
||||
type = "QUEUE_LOAD";
|
||||
customData: unknown = null;
|
||||
repeatMode: string = RepeatMode.OFF;
|
||||
startIndex = 0;
|
||||
|
||||
constructor(public items: QueueItem[]) {}
|
||||
}
|
||||
|
||||
export class QueueRemoveItemsRequest {
|
||||
type = "QUEUE_REMOVE";
|
||||
customData: unknown = null;
|
||||
|
||||
constructor(public itemIds: number[]) {}
|
||||
}
|
||||
|
||||
export class QueueReorderItemsRequest {
|
||||
customData: unknown = null;
|
||||
insertBefore: Nullable<number> = null;
|
||||
type = "QUEUE_REORDER";
|
||||
|
||||
constructor(public itemIds: number[]) {}
|
||||
}
|
||||
|
||||
export class QueueSetPropertiesRequest {
|
||||
type = "QUEUE_UPDATE";
|
||||
customData: unknown = null;
|
||||
repeatMode: Nullable<string> = null;
|
||||
}
|
||||
|
||||
export class QueueUpdateItemsRequest {
|
||||
type = "QUEUE_UPDATE";
|
||||
customData: unknown = null;
|
||||
|
||||
constructor(public items: QueueItem[]) {}
|
||||
}
|
||||
|
||||
export class SeekRequest {
|
||||
currentTime: Nullable<number> = null;
|
||||
customData: unknown = null;
|
||||
resumeState: Nullable<ResumeState> = null;
|
||||
}
|
||||
|
||||
export class StopRequest {
|
||||
customData: unknown = null;
|
||||
}
|
||||
|
||||
export class TextTrackStyle {
|
||||
backgroundColor: Nullable<string> = null;
|
||||
customData: unknown = null;
|
||||
edgeColor: Nullable<string> = null;
|
||||
edgeType: Nullable<string> = null;
|
||||
fontFamily: Nullable<string> = null;
|
||||
fontGenericFamily: Nullable<string> = null;
|
||||
fontScale: Nullable<number> = null;
|
||||
fontStyle: Nullable<string> = null;
|
||||
foregroundColor: Nullable<string> = null;
|
||||
windowColor: Nullable<string> = null;
|
||||
windowRoundedCornerRadius: Nullable<number> = null;
|
||||
windowType: Nullable<string> = null;
|
||||
}
|
||||
|
||||
export class Track {
|
||||
customData: unknown = null;
|
||||
language: Nullable<string> = null;
|
||||
name: Nullable<string> = null;
|
||||
subtype: Nullable<string> = null;
|
||||
trackContentId: Nullable<string> = null;
|
||||
trackContentType: Nullable<string> = null;
|
||||
|
||||
constructor(public trackId: number, public type: TrackType) {}
|
||||
}
|
||||
|
||||
export class UserActionState {
|
||||
customData: unknown = null;
|
||||
|
||||
constructor(public userAction: UserAction) {}
|
||||
}
|
||||
|
||||
export class VastAdsRequest {
|
||||
adsResponse?: string;
|
||||
adTagUrl?: string;
|
||||
}
|
||||
|
||||
export class VideoInformation {
|
||||
constructor(
|
||||
public width: number,
|
||||
public height: number,
|
||||
public hdrType: HdrType
|
||||
) {}
|
||||
}
|
||||
|
||||
export class VolumeRequest {
|
||||
customData: unknown = null;
|
||||
|
||||
constructor(public volume: Volume) {}
|
||||
}
|
||||
137
extension/src/cast/sdk/media/enums.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
export enum ContainerType {
|
||||
GENERIC_CONTAINER,
|
||||
AUDIOBOOK_CONTAINER
|
||||
}
|
||||
|
||||
export enum HdrType {
|
||||
SDR = "sdr",
|
||||
HDR = "hdr",
|
||||
DV = "dv"
|
||||
}
|
||||
|
||||
export enum HlsSegmentFormat {
|
||||
AAC = "aac",
|
||||
AC3 = "ac3",
|
||||
MP3 = "mp3",
|
||||
TS = "ts",
|
||||
TS_AAC = "ts_aac",
|
||||
E_AC3 = "e_ac3",
|
||||
FMP4 = "fmp4"
|
||||
}
|
||||
|
||||
export enum HlsVideoSegmentFormat {
|
||||
MPEG2_TS = "mpeg2_ts",
|
||||
FMP4 = "fmp4"
|
||||
}
|
||||
|
||||
export enum IdleReason {
|
||||
CANCELLED = "CANCELLED",
|
||||
INTERRUPTED = "INTERRUPTED",
|
||||
FINISHED = "FINISHED",
|
||||
ERROR = "ERROR"
|
||||
}
|
||||
|
||||
export enum MediaCommand {
|
||||
PAUSE = "pause",
|
||||
SEEK = "seek",
|
||||
STREAM_VOLUME = "stream_volume",
|
||||
STREAM_MUTE = "stream_mute"
|
||||
}
|
||||
|
||||
export enum MetadataType {
|
||||
GENERIC,
|
||||
MOVIE,
|
||||
TV_SHOW,
|
||||
MUSIC_TRACK,
|
||||
PHOTO,
|
||||
AUDIOBOOK_CHAPTER
|
||||
}
|
||||
|
||||
export enum PlayerState {
|
||||
IDLE = "IDLE",
|
||||
PLAYING = "PLAYING",
|
||||
PAUSED = "PAUSED",
|
||||
BUFFERING = "BUFFERING"
|
||||
}
|
||||
|
||||
export enum QueueType {
|
||||
ALBUM = "ALBUM",
|
||||
PLAYLIST = "PLAYLIST",
|
||||
AUDIOBOOK = "AUDIOBOOK",
|
||||
RADIO_STATION = "RADIO_STATION",
|
||||
PODCAST_SERIES = "PODCAST_SERIES",
|
||||
TV_SERIES = "TV_SERIES",
|
||||
VIDEO_PLAYLIST = "VIDEO_PLAYLIST",
|
||||
LIVE_TV = "LIVETV",
|
||||
MOVIE = "MOVIE"
|
||||
}
|
||||
|
||||
export enum RepeatMode {
|
||||
OFF = "REPEAT_OFF",
|
||||
ALL = "REPEAT_ALL",
|
||||
SINGLE = "REPEAT_SINGLE",
|
||||
ALL_AND_SHUFFLE = "REPEAT_ALL_AND_SHUFFLE"
|
||||
}
|
||||
|
||||
export enum ResumeState {
|
||||
PLAYBACK_START = "PLAYBACK_START",
|
||||
PLAYBACK_PAUSE = "PLAYBACK_PAUSE"
|
||||
}
|
||||
|
||||
export enum StreamType {
|
||||
BUFFERED = "BUFFERED",
|
||||
LIVE = "LIVE",
|
||||
OTHER = "OTHER"
|
||||
}
|
||||
|
||||
export enum TextTrackEdgeType {
|
||||
NONE = "NONE",
|
||||
OUTLINE = "OUTLINE",
|
||||
DROP_SHADOW = "DROP_SHADOW",
|
||||
RAISED = "RAISED",
|
||||
DEPRESSED = "DEPRESSED"
|
||||
}
|
||||
|
||||
export enum TextTrackFontGenericFamily {
|
||||
SANS_SERIF = "SANS_SERIF",
|
||||
MONOSPACED_SANS_SERIF = "MONOSPACED_SANS_SERIF",
|
||||
SERIF = "SERIF",
|
||||
MONOSPACED_SERIF = "MONOSPACED_SERIF",
|
||||
CASUAL = "CASUAL",
|
||||
CURSIVE = "CURSIVE",
|
||||
SMALL_CAPITALS = "SMALL_CAPITALS"
|
||||
}
|
||||
|
||||
export enum TextTrackFontStyle {
|
||||
NORMAL = "NORMAL",
|
||||
BOLD = "BOLD",
|
||||
BOLD_ITALIC = "BOLD_ITALIC",
|
||||
ITALIC = "ITALIC"
|
||||
}
|
||||
|
||||
export enum TextTrackType {
|
||||
SUBTITLES = "SUBTITLES",
|
||||
CAPTIONS = "CAPTIONS",
|
||||
DESCRIPTIONS = "DESCRIPTIONS",
|
||||
CHAPTERS = "CHAPTERS",
|
||||
METADATA = "METADATA"
|
||||
}
|
||||
|
||||
export enum TextTrackWindowType {
|
||||
NONE = "NONE",
|
||||
NORMAL = "NORMAL",
|
||||
ROUNDED_CORNERS = "ROUNDED_CORNERS"
|
||||
}
|
||||
|
||||
export enum TrackType {
|
||||
TEXT = "TEXT",
|
||||
AUDIO = "AUDIO",
|
||||
VIDEO = "VIDEO"
|
||||
}
|
||||
|
||||
export enum UserAction {
|
||||
LIKE = "LIKE",
|
||||
DISLIKE = "DISLIKE",
|
||||
FOLLOW = "FOLLOW",
|
||||
UNFOLLOW = "UNFOLLOW"
|
||||
}
|
||||
18
extension/src/cast/sdk/media/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export * from "./enums";
|
||||
export * from "./classes";
|
||||
|
||||
export { default as Media } from "./Media";
|
||||
|
||||
export const DEFAULT_MEDIA_RECEIVER_APP_ID = "CC1AD845";
|
||||
|
||||
export const timeout = {
|
||||
editTracksInfo: 0,
|
||||
getStatus: 0,
|
||||
load: 0,
|
||||
pause: 0,
|
||||
play: 0,
|
||||
queue: 0,
|
||||
seek: 0,
|
||||
setVolume: 0,
|
||||
stop: 0
|
||||
};
|
||||
208
extension/src/cast/sdk/types.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Keep in sync with bridge types at:
|
||||
* app/src/bridge/components/cast/types.ts
|
||||
*/
|
||||
|
||||
import type { SenderApplication, Volume, Image } from "./classes";
|
||||
import type {
|
||||
BreakStatus,
|
||||
LiveSeekableRange,
|
||||
MediaInfo,
|
||||
QueueItem
|
||||
} from "./media/classes";
|
||||
import type {
|
||||
IdleReason,
|
||||
PlayerState,
|
||||
RepeatMode,
|
||||
ResumeState
|
||||
} from "./media/enums";
|
||||
|
||||
export interface MediaStatus {
|
||||
activeTrackIds?: number[];
|
||||
breakStatus?: BreakStatus;
|
||||
currentItemId?: number;
|
||||
currentTime: Nullable<number>;
|
||||
customData: unknown;
|
||||
idleReason?: IdleReason;
|
||||
items?: QueueItem[];
|
||||
liveSeekableRange?: LiveSeekableRange;
|
||||
media?: MediaInfo;
|
||||
mediaSessionId: number;
|
||||
playbackRate: number;
|
||||
playerState: PlayerState;
|
||||
repeatMode?: RepeatMode;
|
||||
supportedMediaCommands: number;
|
||||
volume: Volume;
|
||||
}
|
||||
|
||||
export interface ReceiverApplication {
|
||||
appId: string;
|
||||
appType?: string;
|
||||
displayName: string;
|
||||
iconUrl: string;
|
||||
isIdleScreen: boolean;
|
||||
launchedFromCloud: boolean;
|
||||
namespaces: Array<{ name: string }>;
|
||||
sessionId: string;
|
||||
statusText: string;
|
||||
transportId: string;
|
||||
universalAppId: string;
|
||||
}
|
||||
|
||||
export interface ReceiverStatus {
|
||||
applications?: ReceiverApplication[];
|
||||
isActiveInput?: boolean;
|
||||
isStandBy?: boolean;
|
||||
volume: Volume;
|
||||
}
|
||||
|
||||
export interface CastSessionUpdatedDetails {
|
||||
sessionId: string;
|
||||
statusText: string;
|
||||
namespaces: Array<{ name: string }>;
|
||||
volume: Volume;
|
||||
}
|
||||
|
||||
export interface CastSessionCreatedDetails extends CastSessionUpdatedDetails {
|
||||
appId: string;
|
||||
appImages: Image[];
|
||||
displayName: string;
|
||||
receiverId: string;
|
||||
receiverFriendlyName: string;
|
||||
senderApps: SenderApplication[];
|
||||
transportId: string;
|
||||
}
|
||||
|
||||
/** supportedMediaCommands bitflag returned in MEDIA_STATUS messages */
|
||||
export enum _MediaCommand {
|
||||
PAUSE = 1,
|
||||
SEEK = 2,
|
||||
STREAM_VOLUME = 4,
|
||||
STREAM_MUTE = 8,
|
||||
QUEUE_NEXT = 64,
|
||||
QUEUE_PREV = 128,
|
||||
QUEUE_SHUFFLE = 256,
|
||||
QUEUE_SKIP_AD = 512,
|
||||
QUEUE_REPEAT_ALL = 1024,
|
||||
QUEUE_REPEAT_ONE = 2048,
|
||||
QUEUE_REPEAT = 3072,
|
||||
EDIT_TRACKS = 4096,
|
||||
PLAYBACK_RATE = 8192
|
||||
}
|
||||
|
||||
interface ReqBase {
|
||||
requestId: number;
|
||||
}
|
||||
|
||||
// NS: urn:x-cast:com.google.cast.receiver
|
||||
export type SenderMessage =
|
||||
| (ReqBase & { type: "LAUNCH"; appId: string })
|
||||
| (ReqBase & { type: "STOP"; sessionId?: string })
|
||||
| (ReqBase & { type: "GET_STATUS" })
|
||||
| (ReqBase & { type: "GET_APP_AVAILABILITY"; appId: string[] })
|
||||
| (ReqBase & { type: "SET_VOLUME"; volume: Partial<Volume> });
|
||||
|
||||
export type ReceiverMessage =
|
||||
| (ReqBase & { type: "RECEIVER_STATUS"; status: ReceiverStatus })
|
||||
| (ReqBase & { type: "LAUNCH_ERROR"; reason: string });
|
||||
|
||||
interface MediaReqBase extends ReqBase {
|
||||
mediaSessionId: number;
|
||||
customData?: unknown;
|
||||
}
|
||||
|
||||
// NS: urn:x-cast:com.google.cast.media
|
||||
export type SenderMediaMessage =
|
||||
| (MediaReqBase & { type: "PLAY" })
|
||||
| (MediaReqBase & { type: "PAUSE" })
|
||||
| {
|
||||
type: "MEDIA_GET_STATUS";
|
||||
mediaSessionId?: number;
|
||||
customData?: unknown;
|
||||
requestId: number;
|
||||
}
|
||||
| {
|
||||
type: "GET_STATUS";
|
||||
mediaSessionId?: number;
|
||||
customData?: unknown;
|
||||
requestId: number;
|
||||
}
|
||||
| (MediaReqBase & { type: "STOP" })
|
||||
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
||||
| (MediaReqBase & { type: "SET_VOLUME"; volume: Volume })
|
||||
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
||||
| (ReqBase & {
|
||||
type: "LOAD";
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
atvCredentials?: string;
|
||||
atvCredentialsType?: string;
|
||||
autoplay?: Nullable<boolean>;
|
||||
currentTime?: Nullable<number>;
|
||||
customData?: unknown;
|
||||
media: MediaInfo;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "SEEK";
|
||||
resumeState?: Nullable<ResumeState>;
|
||||
currentTime?: Nullable<number>;
|
||||
})
|
||||
| (MediaReqBase & {
|
||||
type: "EDIT_TRACKS_INFO";
|
||||
activeTrackIds?: Nullable<number[]>;
|
||||
textTrackStyle?: Nullable<string>;
|
||||
})
|
||||
// QueueLoadRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_LOAD";
|
||||
items: QueueItem[];
|
||||
startIndex: number;
|
||||
repeatMode: string;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueInsertItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_INSERT";
|
||||
items: QueueItem[];
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueUpdateItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
items: QueueItem[];
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueJumpRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
jump?: Nullable<number>;
|
||||
currentItemId?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueRemoveItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REMOVE";
|
||||
itemIds: number[];
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueReorderItemsRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_REORDER";
|
||||
itemIds: number[];
|
||||
insertBefore?: Nullable<number>;
|
||||
sessionId?: Nullable<string>;
|
||||
})
|
||||
// QueueSetPropertiesRequest
|
||||
| (MediaReqBase & {
|
||||
type: "QUEUE_UPDATE";
|
||||
repeatMode?: Nullable<string>;
|
||||
sessionId?: Nullable<string>;
|
||||
});
|
||||
|
||||
export type ReceiverMediaMessage =
|
||||
| (MediaReqBase & { type: "MEDIA_STATUS"; status: MediaStatus[] })
|
||||
| (MediaReqBase & { type: "INVALID_PLAYER_STATE" })
|
||||
| (MediaReqBase & { type: "LOAD_FAILED" })
|
||||
| (MediaReqBase & { type: "LOAD_CANCELLED" })
|
||||
| (MediaReqBase & { type: "INVALID_REQUEST" });
|
||||
367
extension/src/cast/senders/media.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { Logger } from "../../lib/logger";
|
||||
import options from "../../lib/options";
|
||||
|
||||
import type { Message } from "../../messaging";
|
||||
|
||||
// Cast types
|
||||
import { AutoJoinPolicy, ReceiverAvailability } from "../sdk/enums";
|
||||
import type Session from "../sdk/Session";
|
||||
import type Media from "../sdk/media/Media";
|
||||
|
||||
import cast, { ensureInit, CastPort } from "../export";
|
||||
|
||||
const logger = new Logger("fx_cast [media sender]");
|
||||
|
||||
interface MediaSenderOpts {
|
||||
mediaUrl: string;
|
||||
contextTabId?: number;
|
||||
mediaElement?: HTMLMediaElement;
|
||||
}
|
||||
|
||||
export default class MediaSender {
|
||||
private port?: CastPort;
|
||||
|
||||
private mediaUrl: string;
|
||||
private contextTabId?: number;
|
||||
|
||||
/** Target media element if loaded as a content script. */
|
||||
private mediaElement?: HTMLMediaElement;
|
||||
|
||||
private isLocalMedia = false;
|
||||
private isLocalMediaEnabled = false;
|
||||
|
||||
private wasSessionRequested = false;
|
||||
|
||||
// Cast API objects
|
||||
private session?: Session;
|
||||
private media?: Media;
|
||||
|
||||
constructor(opts: MediaSenderOpts) {
|
||||
this.mediaUrl = opts.mediaUrl;
|
||||
this.contextTabId = opts.contextTabId;
|
||||
this.mediaElement = opts.mediaElement;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.port?.postMessage({ subject: "bridge:stopMediaServer" });
|
||||
this.session?.stop();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
try {
|
||||
this.port = await ensureInit({ contextTabId: this.contextTabId });
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize cast API", err);
|
||||
}
|
||||
|
||||
window.addEventListener("beforeunload", async () => {
|
||||
if (await options.get("mediaStopOnUnload")) {
|
||||
this.port?.postMessage({
|
||||
subject: "bridge:stopMediaServer"
|
||||
});
|
||||
|
||||
this.session?.stop();
|
||||
}
|
||||
});
|
||||
|
||||
this.isLocalMedia = this.mediaUrl.startsWith("file://");
|
||||
this.isLocalMediaEnabled = await options.get("localMediaEnabled");
|
||||
|
||||
if (this.isLocalMedia && !this.isLocalMediaEnabled) {
|
||||
throw logger.error("Local media casting not enabled");
|
||||
}
|
||||
|
||||
const capabilities = [cast.Capability.AUDIO_OUT];
|
||||
if (
|
||||
this.mediaElement instanceof HTMLVideoElement ||
|
||||
this.mediaElement instanceof HTMLImageElement
|
||||
) {
|
||||
capabilities.push(cast.Capability.VIDEO_OUT);
|
||||
}
|
||||
|
||||
cast.initialize(
|
||||
new cast.ApiConfig(
|
||||
new cast.SessionRequest(
|
||||
cast.media.DEFAULT_MEDIA_RECEIVER_APP_ID,
|
||||
capabilities
|
||||
),
|
||||
this.sessionListener.bind(this),
|
||||
this.receiverListener.bind(this),
|
||||
AutoJoinPolicy.PAGE_SCOPED
|
||||
),
|
||||
undefined,
|
||||
err => {
|
||||
logger.error("Failed to initialize cast SDK", err);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private sessionListener() {
|
||||
// Unused
|
||||
}
|
||||
private receiverListener(availability: ReceiverAvailability) {
|
||||
if (this.wasSessionRequested) return;
|
||||
this.wasSessionRequested = false;
|
||||
|
||||
if (availability === cast.ReceiverAvailability.AVAILABLE) {
|
||||
cast.requestSession(
|
||||
session => {
|
||||
this.session = session;
|
||||
this.loadMedia();
|
||||
},
|
||||
err => {
|
||||
logger.error("Session request failed", err);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async loadMedia() {
|
||||
let mediaUrl = new URL(this.mediaUrl);
|
||||
const mediaTitle = mediaUrl.pathname.slice(1);
|
||||
const subtitleUrls: URL[] = [];
|
||||
|
||||
if (this.isLocalMedia) {
|
||||
const port = await options.get("localMediaServerPort");
|
||||
try {
|
||||
const { localAddress, mediaPath, subtitlePaths } =
|
||||
await this.startMediaServer(mediaTitle, port);
|
||||
|
||||
const baseUrl = new URL(`http://${localAddress}:${port}/`);
|
||||
mediaUrl = new URL(mediaPath, baseUrl);
|
||||
subtitleUrls.push(
|
||||
...subtitlePaths.map(path => new URL(path, baseUrl))
|
||||
);
|
||||
} catch (err) {
|
||||
throw logger.error("Failed to start media server", err);
|
||||
}
|
||||
}
|
||||
|
||||
const mediaInfo = new cast.media.MediaInfo(mediaUrl.href, "");
|
||||
mediaInfo.metadata = new cast.media.GenericMediaMetadata();
|
||||
mediaInfo.metadata.title = mediaTitle;
|
||||
mediaInfo.tracks = [];
|
||||
|
||||
const activeTrackIds: number[] = [];
|
||||
|
||||
let trackIndex = 0;
|
||||
for (const url of subtitleUrls) {
|
||||
const track = new cast.media.Track(
|
||||
trackIndex++,
|
||||
cast.media.TrackType.TEXT
|
||||
);
|
||||
track.name = url.pathname;
|
||||
track.trackContentId = url.href;
|
||||
track.trackContentType = "text/vtt";
|
||||
track.subtype = cast.media.TextTrackType.SUBTITLES;
|
||||
|
||||
mediaInfo.tracks.push(track);
|
||||
}
|
||||
|
||||
if (this.mediaElement instanceof HTMLMediaElement) {
|
||||
if (this.mediaElement instanceof HTMLVideoElement) {
|
||||
if (this.mediaElement.poster) {
|
||||
mediaInfo.metadata.images = [
|
||||
new cast.Image(this.mediaElement.poster)
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (this.mediaElement.textTracks.length) {
|
||||
const textTracks = Array.from(this.mediaElement.textTracks);
|
||||
const trackElements =
|
||||
this.mediaElement.querySelectorAll("track");
|
||||
|
||||
let mediaTrackIndex = mediaInfo.tracks.length;
|
||||
textTracks.forEach((track, index) => {
|
||||
const trackElement = trackElements[index];
|
||||
|
||||
/**
|
||||
* Create media.Track object with the index as the track ID
|
||||
* and type as TrackType.TEXT.
|
||||
*/
|
||||
const castTrack = new cast.media.Track(
|
||||
mediaTrackIndex,
|
||||
cast.media.TrackType.TEXT
|
||||
);
|
||||
|
||||
// Copy TextTrack properties
|
||||
castTrack.name = track.label || `track-${mediaTrackIndex}`;
|
||||
castTrack.language = track.language;
|
||||
castTrack.trackContentId = trackElement.src;
|
||||
castTrack.trackContentType = "text/vtt";
|
||||
|
||||
switch (track.kind) {
|
||||
case "subtitles":
|
||||
castTrack.subtype =
|
||||
cast.media.TextTrackType.SUBTITLES;
|
||||
break;
|
||||
case "captions":
|
||||
castTrack.subtype =
|
||||
cast.media.TextTrackType.CAPTIONS;
|
||||
break;
|
||||
case "descriptions":
|
||||
castTrack.subtype =
|
||||
cast.media.TextTrackType.DESCRIPTIONS;
|
||||
break;
|
||||
case "chapters":
|
||||
castTrack.subtype =
|
||||
cast.media.TextTrackType.CHAPTERS;
|
||||
break;
|
||||
case "metadata":
|
||||
castTrack.subtype =
|
||||
cast.media.TextTrackType.METADATA;
|
||||
break;
|
||||
|
||||
// Default to subtitles
|
||||
default:
|
||||
castTrack.subtype =
|
||||
cast.media.TextTrackType.SUBTITLES;
|
||||
}
|
||||
|
||||
// Add track to mediaInfo
|
||||
mediaInfo.tracks?.push(castTrack);
|
||||
|
||||
// If enabled, mark as active track for load request
|
||||
if (track.mode === "showing" || trackElement.default) {
|
||||
activeTrackIds.push(mediaTrackIndex);
|
||||
}
|
||||
|
||||
mediaTrackIndex++;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const loadRequest = new cast.media.LoadRequest(mediaInfo);
|
||||
loadRequest.autoplay = true;
|
||||
loadRequest.activeTrackIds = activeTrackIds;
|
||||
|
||||
this.session?.loadMedia(loadRequest, async media => {
|
||||
this.media = media;
|
||||
|
||||
if (
|
||||
(await options.get("mediaSyncElement")) &&
|
||||
this.mediaElement instanceof HTMLMediaElement
|
||||
) {
|
||||
this.addMediaElementListeners(this.mediaElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private addMediaElementListeners(mediaElement: HTMLMediaElement) {
|
||||
this.session?.addUpdateListener(isAlive => {
|
||||
if (!isAlive) return;
|
||||
|
||||
// Update volume level
|
||||
const volume = this.session?.receiver.volume;
|
||||
if (!volume) return;
|
||||
|
||||
if (
|
||||
volume?.level !== null &&
|
||||
volume.level !== mediaElement.volume
|
||||
) {
|
||||
mediaElement.volume = volume.level;
|
||||
}
|
||||
// Update muted state
|
||||
if (volume?.muted !== null && volume.muted !== mediaElement.muted) {
|
||||
mediaElement.muted = volume.muted;
|
||||
}
|
||||
});
|
||||
|
||||
this.media?.addUpdateListener(isAlive => {
|
||||
if (!isAlive || !this.media) return;
|
||||
|
||||
/**
|
||||
* If media element time and estimated time are off by more
|
||||
* than two seconds, set the media element time to the
|
||||
* estimated time.
|
||||
*/
|
||||
const estimatedTime = this.media.getEstimatedTime();
|
||||
if (Math.abs(mediaElement.currentTime - estimatedTime) > 2) {
|
||||
mediaElement.currentTime = estimatedTime;
|
||||
}
|
||||
|
||||
const mediaElementPlayerState = mediaElement.paused
|
||||
? cast.media.PlayerState.PAUSED
|
||||
: cast.media.PlayerState.PLAYING;
|
||||
|
||||
if (mediaElementPlayerState !== this.media.playerState) {
|
||||
switch (this.media.playerState) {
|
||||
case cast.media.PlayerState.PLAYING:
|
||||
mediaElement.play();
|
||||
break;
|
||||
case cast.media.PlayerState.PAUSED:
|
||||
case cast.media.PlayerState.BUFFERING:
|
||||
case cast.media.PlayerState.IDLE:
|
||||
mediaElement.pause();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startMediaServer(
|
||||
filePath: string,
|
||||
port: number
|
||||
): Promise<{
|
||||
mediaPath: string;
|
||||
subtitlePaths: string[];
|
||||
localAddress: string;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.port) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
this.port.postMessage({
|
||||
subject: "bridge:startMediaServer",
|
||||
data: {
|
||||
filePath: decodeURI(filePath),
|
||||
port: port
|
||||
}
|
||||
});
|
||||
|
||||
const onMessage = (ev: MessageEvent<Message>) => {
|
||||
const message = ev.data;
|
||||
|
||||
if (message.subject.startsWith("mediaCast:mediaServer")) {
|
||||
this.port?.removeEventListener("message", onMessage);
|
||||
}
|
||||
|
||||
switch (message.subject) {
|
||||
case "mediaCast:mediaServerStarted":
|
||||
resolve(message.data);
|
||||
break;
|
||||
case "mediaCast:mediaServerError":
|
||||
reject(message.data);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
this.port.addEventListener("message", onMessage);
|
||||
this.port.start();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If loaded as a content script, opts are stored on the window object.
|
||||
*/
|
||||
if (window.location.protocol !== "moz-extension:") {
|
||||
const window_ = window as any;
|
||||
|
||||
let mediaElement: Optional<HTMLMediaElement>;
|
||||
if (window_.targetElementId) {
|
||||
mediaElement = browser.menus.getTargetElement(
|
||||
window_.targetElementId
|
||||
) as HTMLMediaElement;
|
||||
}
|
||||
|
||||
new MediaSender({
|
||||
mediaUrl: window_.mediaUrl,
|
||||
mediaElement
|
||||
});
|
||||
}
|
||||
223
extension/src/cast/senders/mirroring.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import options from "../../lib/options";
|
||||
import { Logger } from "../../lib/logger";
|
||||
|
||||
import type { ReceiverDevice } from "../../types";
|
||||
|
||||
import { AutoJoinPolicy, ReceiverAvailability } from "../sdk/enums";
|
||||
import type Session from "../sdk/Session";
|
||||
|
||||
import cast, { ensureInit } from "../export";
|
||||
|
||||
const logger = new Logger("fx_cast [mirroring sender]");
|
||||
|
||||
const NS_FX_CAST = "urn:x-cast:fx_cast";
|
||||
|
||||
type MirroringAppMessage =
|
||||
| { subject: "peerConnectionOffer"; data: RTCSessionDescriptionInit }
|
||||
| { subject: "peerConnectionAnswer"; data: RTCSessionDescriptionInit }
|
||||
| { subject: "iceCandidate"; data: RTCIceCandidateInit }
|
||||
| { subject: "close" };
|
||||
|
||||
interface MirroringSenderOpts {
|
||||
receiverDevice: ReceiverDevice;
|
||||
onSessionCreated: () => void;
|
||||
onMirroringConnected: () => void;
|
||||
onMirroringStopped: () => void;
|
||||
}
|
||||
|
||||
export default class MirroringSender {
|
||||
private receiverDevice: ReceiverDevice;
|
||||
private sessionCreatedCallback: () => void;
|
||||
private mirroringConnectedCallback: () => void;
|
||||
private mirroringStoppedCallback: () => void;
|
||||
|
||||
private session?: Session;
|
||||
private wasSessionRequested = false;
|
||||
|
||||
private peerConnection: Optional<RTCPeerConnection>;
|
||||
|
||||
// Stream opts
|
||||
private streamMaxFrameRate = 1;
|
||||
private streamMaxBitRate = 1;
|
||||
private streamDownscaleFactor = 1;
|
||||
private streamUseMaxResolution = false;
|
||||
private streamMaxResolution: { width?: number; height?: number } = {};
|
||||
|
||||
constructor(opts: MirroringSenderOpts) {
|
||||
this.receiverDevice = opts.receiverDevice;
|
||||
this.sessionCreatedCallback = opts.onSessionCreated;
|
||||
this.mirroringConnectedCallback = opts.onMirroringConnected;
|
||||
this.mirroringStoppedCallback = opts.onMirroringStopped;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
private async init() {
|
||||
try {
|
||||
await ensureInit({ receiverDevice: this.receiverDevice });
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize cast API", err);
|
||||
}
|
||||
|
||||
const {
|
||||
mirroringAppId,
|
||||
mirroringStreamMaxFrameRate,
|
||||
mirroringStreamMaxBitRate,
|
||||
mirroringStreamDownscaleFactor,
|
||||
mirroringStreamUseMaxResolution,
|
||||
mirroringStreamMaxResolution
|
||||
} = await options.getAll();
|
||||
|
||||
this.streamMaxFrameRate = mirroringStreamMaxFrameRate;
|
||||
this.streamMaxBitRate = mirroringStreamMaxBitRate;
|
||||
this.streamDownscaleFactor = mirroringStreamDownscaleFactor;
|
||||
this.streamUseMaxResolution = mirroringStreamUseMaxResolution;
|
||||
this.streamMaxResolution = mirroringStreamMaxResolution;
|
||||
|
||||
const sessionRequest = new cast.SessionRequest(mirroringAppId);
|
||||
|
||||
const apiConfig = new cast.ApiConfig(
|
||||
sessionRequest,
|
||||
this.sessionListener,
|
||||
this.receiverListener,
|
||||
AutoJoinPolicy.PAGE_SCOPED
|
||||
);
|
||||
|
||||
cast.initialize(apiConfig);
|
||||
}
|
||||
|
||||
private sessionListener() {
|
||||
// Unused
|
||||
}
|
||||
private receiverListener = (availability: ReceiverAvailability) => {
|
||||
if (this.wasSessionRequested) return;
|
||||
this.wasSessionRequested = true;
|
||||
|
||||
if (availability === cast.ReceiverAvailability.AVAILABLE) {
|
||||
cast.requestSession(
|
||||
session => {
|
||||
this.session = session;
|
||||
this.sessionCreatedCallback();
|
||||
},
|
||||
err => {
|
||||
logger.error("Session request failed", err);
|
||||
}
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
private sendMirroringAppMessage(message: MirroringAppMessage) {
|
||||
if (!this.session) return;
|
||||
this.session.sendMessage(NS_FX_CAST, message);
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.peerConnection?.close();
|
||||
this.session?.stop();
|
||||
|
||||
this.mirroringStoppedCallback();
|
||||
}
|
||||
|
||||
async createMirroringConnection(stream: MediaStream) {
|
||||
const pc = new RTCPeerConnection();
|
||||
this.peerConnection = pc;
|
||||
|
||||
this.session?.addMessageListener(NS_FX_CAST, async (_ns, message) => {
|
||||
const parsedMessage = JSON.parse(message) as MirroringAppMessage;
|
||||
switch (parsedMessage.subject) {
|
||||
case "peerConnectionAnswer":
|
||||
pc.setRemoteDescription(parsedMessage.data);
|
||||
break;
|
||||
case "iceCandidate":
|
||||
pc.addIceCandidate(parsedMessage.data);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
pc.addEventListener("negotiationneeded", async () => {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
|
||||
this.sendMirroringAppMessage({
|
||||
subject: "peerConnectionOffer",
|
||||
data: offer
|
||||
});
|
||||
});
|
||||
|
||||
pc.addEventListener("icecandidate", ev => {
|
||||
if (!ev.candidate) return;
|
||||
this.sendMirroringAppMessage({
|
||||
subject: "iceCandidate",
|
||||
data: ev.candidate
|
||||
});
|
||||
});
|
||||
|
||||
// Connection listener
|
||||
pc.addEventListener("iceconnectionstatechange", async () => {
|
||||
if (pc.iceConnectionState !== "connected") {
|
||||
return;
|
||||
}
|
||||
|
||||
this.mirroringConnectedCallback();
|
||||
applyParameters();
|
||||
});
|
||||
|
||||
/** Applies stream encoding parameters. */
|
||||
const applyParameters = async () => {
|
||||
// Set stream encoding parameters
|
||||
const [sender] = pc.getSenders();
|
||||
const params = sender.getParameters();
|
||||
if (!params.encodings) {
|
||||
params.encodings = [{}];
|
||||
}
|
||||
|
||||
const [encoding] = params.encodings;
|
||||
|
||||
if (!(encoding as any).maxFramerate) {
|
||||
(encoding as any).maxFramerate = this.streamMaxFrameRate;
|
||||
}
|
||||
if (!encoding.maxBitrate) {
|
||||
encoding.maxBitrate = this.streamMaxBitRate;
|
||||
}
|
||||
|
||||
encoding.scaleResolutionDownBy = this.streamDownscaleFactor;
|
||||
|
||||
// Handle limiting stream resolution
|
||||
if (this.streamUseMaxResolution) {
|
||||
const { width: trackWidth, height: trackHeight } =
|
||||
sender.track?.getSettings() ?? {};
|
||||
|
||||
// Calculate downscale ratios for width/height
|
||||
let widthRatio = 1;
|
||||
let heightRatio = 1;
|
||||
if (trackWidth && this.streamMaxResolution.width) {
|
||||
widthRatio = trackWidth / this.streamMaxResolution.width;
|
||||
}
|
||||
if (trackHeight && this.streamMaxResolution.height) {
|
||||
heightRatio = trackHeight / this.streamMaxResolution.height;
|
||||
}
|
||||
|
||||
// Use the largest ratio to ensure below resolution limit
|
||||
const downscaleRatio = Math.max(1, widthRatio, heightRatio);
|
||||
|
||||
// Multiply existing downscale
|
||||
encoding.scaleResolutionDownBy *= downscaleRatio;
|
||||
}
|
||||
|
||||
await sender.setParameters(params);
|
||||
};
|
||||
|
||||
const [track] = stream.getVideoTracks();
|
||||
pc.addTrack(track, stream);
|
||||
track.addEventListener("ended", () => this.stop());
|
||||
|
||||
/**
|
||||
* Use a video element to get stream resize events and update
|
||||
* scaling parameters.
|
||||
*/
|
||||
const video = document.createElement("video");
|
||||
video.srcObject = stream;
|
||||
video.addEventListener("resize", () => applyParameters());
|
||||
video.play();
|
||||
}
|
||||
}
|
||||
41
extension/src/cast/urls.ts
Normal file
@@ -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
@@ -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;
|
||||
}
|
||||
109
extension/src/defaultOptions.ts
Normal 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
@@ -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;
|
||||
}
|
||||
6
extension/src/icons/AirPlay_Audio.svg
Normal 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 |
3
extension/src/icons/AirPlay_Video.svg
Normal 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 |
7
extension/src/icons/cast-connected.svg
Normal 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 |
24
extension/src/icons/cast-connecting-dark.svg
Normal 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 |
24
extension/src/icons/cast-connecting-light.svg
Normal 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 |
6
extension/src/icons/cast-default-dark.svg
Normal 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 |
6
extension/src/icons/cast-default-light.svg
Normal 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 |
6
extension/src/icons/icon.svg
Normal 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 |
32
extension/src/lib/TypedEventTarget.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
7
extension/src/lib/TypedMessagePort.ts
Normal 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;
|
||||
}
|
||||
22
extension/src/lib/TypedPort.ts
Normal 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;
|
||||
}
|
||||
42
extension/src/lib/TypedStorageArea.ts
Normal 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
@@ -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
|
||||
};
|
||||
60
extension/src/lib/chromecastConfigApi.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
42
extension/src/lib/logger.ts
Normal 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");
|
||||
134
extension/src/lib/matchPattern.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
214
extension/src/lib/nativeMessaging.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
145
extension/src/lib/options.ts
Normal 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);
|
||||
}
|
||||
})();
|
||||
25
extension/src/lib/userAgents.ts
Normal 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}`;
|
||||
}
|
||||
42
extension/src/lib/utils.ts
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
31
extension/src/ui/LoadingIndicator.svelte
Normal 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>
|
||||
16
extension/src/ui/assets/photon_arrowhead_down.svg
Normal 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 |
16
extension/src/ui/assets/photon_arrowhead_up.svg
Normal 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 |
16
extension/src/ui/assets/photon_cancel.svg
Normal 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 |
16
extension/src/ui/assets/photon_info.svg
Normal 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 |
16
extension/src/ui/assets/photon_warning.svg
Normal 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 |
101
extension/src/ui/mirroring/MirroringPopup.svelte
Normal 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}
|
||||
15
extension/src/ui/mirroring/index.html
Normal 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>
|
||||
9
extension/src/ui/mirroring/index.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
30
extension/src/ui/mirroring/style.css
Normal 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;
|
||||
}
|
||||
369
extension/src/ui/options/Bridge.svelte
Normal 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>
|
||||
513
extension/src/ui/options/Options.svelte
Normal 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}
|
||||
294
extension/src/ui/options/Whitelist.svelte
Normal 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">
|
||||
({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>
|
||||
BIN
extension/src/ui/options/assets/icons8-cancel-120.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
extension/src/ui/options/assets/icons8-ok-120.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
BIN
extension/src/ui/options/assets/icons8-warn-120.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
17
extension/src/ui/options/assets/photon_delete.svg
Normal 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 |
16
extension/src/ui/options/assets/photon_edit.svg
Normal 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 |
16
extension/src/ui/options/assets/photon_new.svg
Normal 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 |
16
extension/src/ui/options/assets/photon_refresh.svg
Normal 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 |
15
extension/src/ui/options/index.html
Normal 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>
|
||||
23
extension/src/ui/options/index.ts
Normal 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 });
|
||||
}
|
||||
482
extension/src/ui/options/styles/index.css
Normal 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;
|
||||
}
|
||||
22
extension/src/ui/options/styles/mac.css
Normal 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;
|
||||
}
|
||||
94
extension/src/ui/photon-colors.css
Normal 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;
|
||||
|
||||
}
|
||||
198
extension/src/ui/photon-widgets.css
Normal 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;
|
||||
}
|
||||
493
extension/src/ui/popup/Popup.svelte
Normal 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>
|
||||
501
extension/src/ui/popup/Receiver.svelte
Normal 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>
|
||||
394
extension/src/ui/popup/ReceiverMedia.svelte
Normal 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>
|
||||
19
extension/src/ui/popup/icons/audio-muted.svg
Normal 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 |
18
extension/src/ui/popup/icons/audio-none.svg
Normal 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 |
18
extension/src/ui/popup/icons/audio.svg
Normal 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 |
17
extension/src/ui/popup/icons/backward.svg
Normal 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 |
23
extension/src/ui/popup/icons/cc-off.svg
Normal 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 |
24
extension/src/ui/popup/icons/cc-on.svg
Normal 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 |
13
extension/src/ui/popup/icons/device-audio.svg
Normal 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 |