Rewrite popup with Svelte

This commit is contained in:
hensm
2022-06-03 16:08:09 +01:00
committed by Matt Hensman
parent 63f9af30ae
commit 8051904cb4
11 changed files with 531 additions and 728 deletions

View File

@@ -47,24 +47,6 @@ const unpackedPath = path.join(distPath, "unpacked");
const outPath = argv.package ? unpackedPath : distPath;
/** @type esbuild.Plugin */
const preactCompatPlugin = {
/**
* Handle react/react-dom preact compat modules.
*/
name: "preact-compat",
setup(build) {
const preactPath = path.resolve(
__dirname,
"../node_modules/preact/compat/dist/compat.module.js"
);
build.onResolve({ filter: /^(react|react-dom)$/ }, () => ({
path: preactPath
}));
}
};
/** @type esbuild.BuildOptions */
const buildOpts = {
bundle: true,
@@ -87,7 +69,7 @@ const buildOpts = {
// Mirroring sender
path.join(srcPath, "/cast/senders/mirroring.ts"),
// UI
path.join(srcPath, "ui/popup/index.tsx"),
path.join(srcPath, "ui/popup/index.ts"),
path.join(srcPath, "ui/options/index.ts")
],
define: {
@@ -96,7 +78,6 @@ const buildOpts = {
MIRRORING_APP_ID: `"${argv.mirroringAppId}"`
},
plugins: [
preactCompatPlugin,
// @ts-ignore
sveltePlugin({
// @ts-ignore
@@ -107,7 +88,7 @@ const buildOpts = {
copyFilesPlugin({
src: srcPath,
dest: outPath,
excludePattern: /^(manifest\.json|.*\.(ts|tsx|js|jsx|svelte))$/
excludePattern: /^(manifest\.json|.*\.(ts|js|svelte))$/
})
]
};

18
ext/package-lock.json generated
View File

@@ -6,12 +6,10 @@
"": {
"devDependencies": {
"@types/firefox-webext-browser": "^94.0.1",
"@types/react": "^18.0.6",
"@types/react-dom": "^18.0.2",
"@types/semver": "^7.3.9",
"@types/uuid": "^8.3.4",
"esbuild": "^0.14.38",
"preact": "^10.7.1",
"semver": "^7.3.7",
"ts-loader": "^9.2.8",
"typescript": "^4.6.3",
@@ -5556,16 +5554,6 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/preact": {
"version": "10.7.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.7.1.tgz",
"integrity": "sha512-MufnRFz39aIhs9AMFisonjzTud1PK1bY+jcJLo6m2T9Uh8AqjD77w11eAAawmjUogoGOnipECq7e/1RClIKsxg==",
"dev": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/preact"
}
},
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -11812,12 +11800,6 @@
"source-map-js": "^1.0.2"
}
},
"preact": {
"version": "10.7.1",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.7.1.tgz",
"integrity": "sha512-MufnRFz39aIhs9AMFisonjzTud1PK1bY+jcJLo6m2T9Uh8AqjD77w11eAAawmjUogoGOnipECq7e/1RClIKsxg==",
"dev": true
},
"prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",

View File

@@ -8,12 +8,10 @@
},
"devDependencies": {
"@types/firefox-webext-browser": "^94.0.1",
"@types/react": "^18.0.6",
"@types/react-dom": "^18.0.2",
"@types/semver": "^7.3.9",
"@types/uuid": "^8.3.4",
"esbuild": "^0.14.38",
"preact": "^10.7.1",
"semver": "^7.3.7",
"ts-loader": "^9.2.8",
"typescript": "^4.6.3",

29
ext/src/cast/utils.ts Normal file
View File

@@ -0,0 +1,29 @@
import { ReceiverDevice, ReceiverDeviceCapabilities } from "../types";
import { Capability } from "./sdk/enums";
/**
* 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;
}
});
}

View File

@@ -0,0 +1,29 @@
<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="ellipsis">{ellipsis}</span>

View File

@@ -2,11 +2,12 @@
import semver from "semver";
import { onMount } from "svelte";
import LoadingIndicator from "../LoadingIndicator.svelte";
import bridge, { BridgeInfo, BridgeTimedOutError } from "../../lib/bridge";
import logger from "../../lib/logger";
import { Options } from "../../lib/options";
import { getNextEllipsis } from "../utils";
const _ = browser.i18n.getMessage;
@@ -68,8 +69,6 @@
let isCheckingUpdate = false;
let isUpdateAvailable = false;
let checkUpdateEllipsis = "";
interface GitHubRelease {
url: string;
tag_name: string;
@@ -83,10 +82,6 @@
async function checkUpdate() {
isCheckingUpdate = true;
const checkUpdateTimeout = window.setInterval(() => {
checkUpdateEllipsis = getNextEllipsis(checkUpdateEllipsis);
}, 500);
let releases: GitHubRelease[];
try {
releases = await fetch(
@@ -96,8 +91,6 @@
isCheckingUpdate = false;
updateStatus = _("optionsBridgeUpdateStatusError");
return;
} finally {
window.clearTimeout(checkUpdateTimeout);
}
// Ensure valid response
@@ -278,7 +271,8 @@
on:click={checkUpdate}
>
{#if isCheckingUpdate}
{_("optionsBridgeUpdateChecking", checkUpdateEllipsis)}
{_("optionsBridgeUpdateChecking", "")}<LoadingIndicator
/>
{:else}
{_("optionsBridgeUpdateCheck")}
{/if}

View File

@@ -0,0 +1,451 @@
<script lang="ts">
import { afterUpdate, onMount, tick } from "svelte";
import LoadingIndicator from "../LoadingIndicator.svelte";
import { ReceiverSelectorPageInfo } from "../../background/ReceiverSelector";
import { menuIdPopupCast, menuIdPopupStop } from "../../background/menus";
import messaging, { Message, Port } from "../../messaging";
import options, { Options } from "../../lib/options";
import { RemoteMatchPattern } from "../../lib/matchPattern";
import {
ReceiverDevice,
ReceiverSelectionActionType,
ReceiverSelectorMediaType
} from "../../types";
import knownApps, { KnownApp } from "../../cast/knownApps";
import { hasRequiredCapabilities } from "../../cast/utils";
const _ = browser.i18n.getMessage;
/** List of devices to show in receiver list. */
let receiverDevices: ReceiverDevice[] = [];
/** Currently selected media type. */
let mediaType = ReceiverSelectorMediaType.App;
/** Media types available to select. */
let availableMediaTypes = ReceiverSelectorMediaType.App;
/** Sender app ID (if available). */
let appId: Optional<string>;
/** 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;
let connectingId: Nullable<string> = null;
/** 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);
let port: Nullable<Port> = null;
let browserWindow: Nullable<browser.windows.Window> = null;
let resizeObserver = new ResizeObserver(() => 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")) {
const mirroringMediaTypes =
ReceiverSelectorMediaType.Tab |
ReceiverSelectorMediaType.Screen;
if (!opts.mirroringEnabled) {
availableMediaTypes &= ~mirroringMediaTypes;
} else {
availableMediaTypes |= mirroringMediaTypes;
}
if (!(availableMediaTypes & mediaType)) {
if (availableMediaTypes & ReceiverSelectorMediaType.App) {
mediaType = ReceiverSelectorMediaType.App;
} else if (
availableMediaTypes & ReceiverSelectorMediaType.Tab
) {
mediaType = ReceiverSelectorMediaType.Tab;
} else {
mediaType = ReceiverSelectorMediaType.App;
}
}
}
});
updateKnownApp();
window.addEventListener("contextmenu", onContextMenu);
browser.menus.onClicked.addListener(onMenuClicked);
browser.menus.onShown.addListener(onMenuShown);
return () => {
port?.disconnect();
resizeObserver.disconnect();
window.addEventListener("contextmenu", onContextMenu);
browser.menus.onClicked.removeListener(onMenuClicked);
browser.menus.onShown.removeListener(onMenuShown);
};
});
afterUpdate(async () => {
await tick();
fitWindowHeight();
});
function onMessage(message: Message) {
switch (message.subject) {
case "popup:init":
appId = message.data.appId;
pageInfo = message.data.pageInfo;
break;
case "popup:close":
window.close();
break;
case "popup:update": {
/**
* Filter receiver devices without the required
* capabilities.
*/
receiverDevices = message.data.receiverDevices.filter(device =>
hasRequiredCapabilities(
device,
pageInfo?.sessionRequest?.capabilities
)
);
if (
message.data.availableMediaTypes !== undefined &&
message.data.defaultMediaType !== undefined
) {
console.log(message);
availableMediaTypes = message.data.availableMediaTypes;
if (availableMediaTypes & message.data.defaultMediaType) {
mediaType = message.data.defaultMediaType;
}
}
updateKnownApp();
break;
}
}
}
/** Resize browser window to fit content height. */
function fitWindowHeight() {
if (browserWindow?.id === undefined) return;
browser.windows.update(browserWindow.id, {
height:
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 && appId) {
newKnownApp = knownApps[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 });
await options.set("siteWhitelist", whitelist);
await browser.tabs.reload(pageInfo.tabId);
window.close();
}
}
function onContextMenu(ev: MouseEvent) {
if (!(ev.target instanceof Element)) return;
const receiverElement = ev.target.closest(".receiver");
if (receiverElement) {
browser.menus.overrideContext({
showDefaults: false
});
}
}
function getDeviceFromElement(target: Element) {
const receiverElement = target.closest(".receiver");
if (!receiverElement) return;
const receiverElementIndex = [
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...receiverElement.parentElement!.children
].indexOf(receiverElement);
// Match by index rendered receiver element to device array
if (receiverElementIndex > -1) {
return receiverDevices[receiverElementIndex];
}
}
/** Handle show events for receiver context menus. */
function onMenuShown(info: browser.menus._OnShownInfo) {
if (!info.targetElementId) return;
const target = browser.menus.getTargetElement(info.targetElementId);
if (!target) return;
const device = getDeviceFromElement(target);
if (!device) {
browser.menus.update(menuIdPopupCast, { visible: false });
browser.menus.update(menuIdPopupStop, { visible: false });
} else {
const app = device.status?.applications?.[0];
const isAppRunning = !!(app && !app.isIdleScreen);
browser.menus.update(menuIdPopupCast, {
visible: true,
title: _("popupCastMenuTitle", device.friendlyName),
enabled:
// Not already connecting to a receiver
!isConnecting &&
// Selected media type available
isMediaTypeAvailable
});
browser.menus.update(menuIdPopupStop, {
visible: isAppRunning,
title: isAppRunning
? _("popupStopMenuTitle", [
app.displayName,
device.friendlyName
])
: ""
});
}
browser.menus.refresh();
}
/** Handle click events for receiver context menus. */
function onMenuClicked(info: browser.menus.OnClickData) {
if (
info.menuItemId !== menuIdPopupCast &&
info.menuItemId !== menuIdPopupStop
) {
return;
}
if (!info.targetElementId) return;
const target = browser.menus.getTargetElement(info.targetElementId);
if (!target) return;
const device = getDeviceFromElement(target);
if (!device) return;
switch (info.menuItemId) {
case menuIdPopupCast:
onReceiverCast(device);
break;
case menuIdPopupStop:
onReceiverStop(device);
break;
}
}
function onReceiverCast(receiverDevice: ReceiverDevice) {
console.log(receiverDevice);
isConnecting = true;
connectingId = receiverDevice.id;
console.log(port);
port?.postMessage({
subject: "receiverSelector:selected",
data: {
receiverDevice,
actionType: ReceiverSelectionActionType.Cast,
mediaType
}
});
}
function onReceiverStop(receiverDevice: ReceiverDevice) {
port?.postMessage({
subject: "receiverSelector:stop",
data: {
receiverDevice,
actionType: ReceiverSelectionActionType.Stop
}
});
}
</script>
<div class="whitelist-banner" hidden={!shouldSuggestWhitelist}>
<img src="photon_info.svg" alt="icon, info" />
{_("popupWhitelistNotWhitelisted", knownApp?.name)}
<button
on:click={() => {
if (!knownApp || !pageInfo) return;
addToWhitelist(knownApp, pageInfo);
}}
>
{_("popupWhitelistAddToWhitelist")}
</button>
</div>
<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}
disabled={availableMediaTypes === ReceiverSelectorMediaType.None}
>
<option
value={ReceiverSelectorMediaType.App}
disabled={!isAppMediaTypeAvailable}
>
{knownApp?.name ?? _("popupMediaTypeApp")}
</option>
{#if opts?.mirroringEnabled}
<option
value={ReceiverSelectorMediaType.Tab}
disabled={!(
availableMediaTypes & ReceiverSelectorMediaType.Tab
)}
>
{_("popupMediaTypeTab")}
</option>
<option
value={ReceiverSelectorMediaType.Screen}
disabled={!(
availableMediaTypes & ReceiverSelectorMediaType.Screen
)}
>
{_("popupMediaTypeScreen")}
</option>
{/if}
</select>
</div>
<div class="media-type-select__label-to">
{_("popupMediaSelectToLabel")}
</div>
</div>
<ul class="receivers">
{#if !receiverDevices.length}
<div class="receivers__not-found">
{_("popupNoReceiversFound")}
</div>
{:else}
{#each receiverDevices as device}
{@const application = device.status?.applications?.[0]}
{@const isDeviceConnecting =
isConnecting && connectingId === device.id}
<li class="receiver">
<div class="receiver__name">
{device.friendlyName}
</div>
<div class="receiver__address">
{application && !application.isIdleScreen
? application.statusText
: `${device.host}:${device.port}`}
</div>
<button
class="button receiver__connect"
on:click={() => onReceiverCast(device)}
disabled={isConnecting ||
isDeviceConnecting ||
!isMediaTypeAvailable}
>
{#if isDeviceConnecting}
{_("popupCastingButtonTitle", "")}<LoadingIndicator />
{:else}
{_("popupCastButtonTitle")}
{/if}
</button>
</li>
{/each}
{/if}
</ul>

16
ext/src/ui/popup/index.ts Normal file
View File

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

View File

@@ -1,639 +0,0 @@
/* eslint-disable max-len */
"use strict";
import React, { Component } from "react";
import ReactDOM from "react-dom";
import { menuIdPopupCast, menuIdPopupStop } from "../../background/menus";
import type { ReceiverSelectorPageInfo } from "../../background/ReceiverSelector";
import type { WhitelistItemData } from "../../background/whitelist";
import knownApps, { KnownApp } from "../../cast/knownApps";
import options from "../../lib/options";
import messaging, { Message, Port } from "../../messaging";
import { getNextEllipsis } from "../utils";
import { RemoteMatchPattern } from "../../lib/matchPattern";
import {
ReceiverDevice,
ReceiverDeviceCapabilities,
ReceiverSelectionActionType,
ReceiverSelectorMediaType
} from "../../types";
import { Capability } from "../../cast/sdk/enums";
const _ = browser.i18n.getMessage;
// macOS styles
browser.runtime.getPlatformInfo().then(platformInfo => {
if (platformInfo.os === "mac") {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "styles/mac.css";
document.head.appendChild(link);
}
});
/**
* Check receiver device capabilities bitflags against array of
* capability strings requested by the sender application.
*/
function hasRequiredCapabilities(
receiverDevice: ReceiverDevice,
capabilities: Capability[] = []
) {
const { capabilities: deviceCapabilities } = receiverDevice;
return capabilities.every(capability => {
switch (capability) {
case Capability.AUDIO_IN:
return deviceCapabilities & ReceiverDeviceCapabilities.AUDIO_IN;
case Capability.AUDIO_OUT:
return (
deviceCapabilities & ReceiverDeviceCapabilities.AUDIO_OUT
);
case Capability.MULTIZONE_GROUP:
return (
deviceCapabilities &
ReceiverDeviceCapabilities.MULTIZONE_GROUP
);
case Capability.VIDEO_IN:
return deviceCapabilities & ReceiverDeviceCapabilities.VIDEO_IN;
case Capability.VIDEO_OUT:
return (
deviceCapabilities & ReceiverDeviceCapabilities.VIDEO_OUT
);
}
});
}
interface PopupAppProps {}
interface PopupAppState {
/** List of devices to show in receiver list. */
receiverDevices: ReceiverDevice[];
/** Currently selected media type. */
mediaType: ReceiverSelectorMediaType;
/** Media types available to select. */
availableMediaTypes: ReceiverSelectorMediaType;
/** Sender app ID (if available). */
appId?: string;
/** Page info (if launched from page context). */
pageInfo?: ReceiverSelectorPageInfo;
/** App details (if matches known app). */
knownApp?: KnownApp;
/** Whether current page URL matches a whitelist pattern. */
isPageWhitelisted: boolean;
/** Whether casting to a device been initiated from this selector. */
isConnecting: boolean;
// Options
mirroringEnabled: boolean;
siteWhitelistEnabled: boolean;
siteWhitelist: WhitelistItemData[];
}
class PopupApp extends Component<PopupAppProps, PopupAppState> {
private port?: Port;
private browserWindow?: browser.windows.Window;
private resizeObserver = new ResizeObserver(() => {
this.fitWindowHeight();
});
constructor(props: PopupAppProps) {
super(props);
this.state = {
receiverDevices: [],
mediaType: ReceiverSelectorMediaType.App,
availableMediaTypes: ReceiverSelectorMediaType.App,
isPageWhitelisted: false,
isConnecting: false,
mirroringEnabled: false,
siteWhitelistEnabled: true,
siteWhitelist: []
};
// Store window ref
browser.windows.getCurrent().then(win => {
this.browserWindow = win;
});
this.onMessage = this.onMessage.bind(this);
this.onAddToWhitelist = this.onAddToWhitelist.bind(this);
this.onReceiverCast = this.onReceiverCast.bind(this);
this.onReceiverStop = this.onReceiverStop.bind(this);
this.onContextMenu = this.onContextMenu.bind(this);
this.onMenuShown = this.onMenuShown.bind(this);
this.onMenuClicked = this.onMenuClicked.bind(this);
}
private onMessage(message: Message) {
switch (message.subject) {
case "popup:init":
this.setState({
appId: message.data?.appId,
pageInfo: message.data?.pageInfo
});
break;
case "popup:close":
window.close();
break;
case "popup:update": {
this.setState({
/**
* Filter receiver devices without the required
* capabilities.
*/
receiverDevices: message.data.receiverDevices.filter(
receiverDevice => {
return hasRequiredCapabilities(
receiverDevice,
this.state.pageInfo?.sessionRequest
?.capabilities
);
}
)
});
const { availableMediaTypes, defaultMediaType } = message.data;
if (
availableMediaTypes !== undefined &&
defaultMediaType !== undefined
) {
this.setState({
availableMediaTypes,
mediaType: defaultMediaType
});
}
this.updateKnownApp();
break;
}
}
}
/** Resize browser window to fit content height. */
private fitWindowHeight() {
if (this.browserWindow?.id === undefined) {
return;
}
browser.windows.update(this.browserWindow.id, {
height:
document.body.clientHeight +
(window.outerHeight - window.innerHeight)
});
}
private updateKnownApp() {
const isAppMediaTypeAvailable = !!(
this.state.availableMediaTypes & ReceiverSelectorMediaType.App
);
let knownApp: KnownApp | undefined;
/**
* Check knownApps for an app with an ID matching the registered
* app on the target page.
*/
if (isAppMediaTypeAvailable && this.state.appId) {
knownApp = knownApps[this.state.appId];
} else if (this.state.pageInfo) {
const pageUrl = this.state.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)) {
knownApp = app;
break;
}
}
}
let isPageWhitelisted = false;
// Check if target page URL is whitelisted.
if (this.state.pageInfo) {
for (const item of this.state.siteWhitelist) {
const pattern = new RemoteMatchPattern(item.pattern);
if (pattern.matches(this.state.pageInfo.url)) {
isPageWhitelisted = true;
break;
}
}
}
this.setState({ knownApp, isPageWhitelisted });
}
private async onAddToWhitelist(
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 });
await options.set("siteWhitelist", whitelist);
await browser.tabs.reload(pageInfo.tabId);
window.close();
}
}
private onReceiverCast(receiverDevice: ReceiverDevice) {
this.setState({ isConnecting: true });
this.port?.postMessage({
subject: "receiverSelector:selected",
data: {
receiverDevice,
actionType: ReceiverSelectionActionType.Cast,
mediaType: this.state.mediaType
}
});
}
private onReceiverStop(receiverDevice: ReceiverDevice) {
this.port?.postMessage({
subject: "receiverSelector:stop",
data: {
receiverDevice,
actionType: ReceiverSelectionActionType.Stop
}
});
}
private onContextMenu(ev: MouseEvent) {
if (!(ev.target instanceof Element)) return;
const receiverElement = ev.target.closest(".receiver");
if (receiverElement) {
browser.menus.overrideContext({
showDefaults: false
});
}
}
private getDeviceFromElement(target: Element) {
const receiverElement = target.closest(".receiver");
if (!receiverElement) return;
const receiverElementIndex = [
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
...receiverElement.parentElement!.children
].indexOf(receiverElement);
// Match by index rendered receiver element to device array
if (receiverElementIndex > -1) {
return this.state.receiverDevices[receiverElementIndex];
}
}
/** Handle show events for receiver context menus. */
private onMenuShown(info: browser.menus._OnShownInfo) {
if (!info.targetElementId) return;
const target = browser.menus.getTargetElement(info.targetElementId);
if (!target) return;
const device = this.getDeviceFromElement(target);
if (!device) {
browser.menus.update(menuIdPopupCast, { visible: false });
browser.menus.update(menuIdPopupStop, { visible: false });
} else {
const app = device.status?.applications?.[0];
const isAppRunning = !!(app && !app.isIdleScreen);
browser.menus.update(menuIdPopupCast, {
visible: true,
title: _("popupCastMenuTitle", device.friendlyName),
enabled:
// Not already connecting to a receiver
!this.state.isConnecting &&
// Selected media type available
!!(this.state.availableMediaTypes & this.state.mediaType)
});
browser.menus.update(menuIdPopupStop, {
visible: isAppRunning,
title: isAppRunning
? _("popupStopMenuTitle", [
app.displayName,
device.friendlyName
])
: ""
});
}
browser.menus.refresh();
}
/** Handle click events for receiver context menus. */
private onMenuClicked(info: browser.menus.OnClickData) {
if (
info.menuItemId !== menuIdPopupCast &&
info.menuItemId !== menuIdPopupStop
) {
return;
}
if (!info.targetElementId) return;
const target = browser.menus.getTargetElement(info.targetElementId);
if (!target) return;
const device = this.getDeviceFromElement(target);
if (!device) return;
switch (info.menuItemId) {
case menuIdPopupCast:
this.onReceiverCast(device);
break;
case menuIdPopupStop:
this.onReceiverStop(device);
break;
}
}
public async componentDidMount() {
this.port = messaging.connect({ name: "popup" });
this.port.onMessage.addListener(this.onMessage);
// Start observing content size changes
this.resizeObserver.observe(document.body);
options.getAll().then(opts => {
this.setState({
mirroringEnabled: opts.mirroringEnabled,
siteWhitelistEnabled: opts.siteWhitelistEnabled,
siteWhitelist: opts.siteWhitelist
});
});
this.updateKnownApp();
window.addEventListener("contextmenu", this.onContextMenu);
browser.menus.onClicked.addListener(this.onMenuClicked);
browser.menus.onShown.addListener(this.onMenuShown);
}
public componentWillUnmount() {
this.port?.disconnect();
this.resizeObserver.disconnect();
window.removeEventListener("contextmenu", this.onContextMenu);
browser.menus.onClicked.removeListener(this.onMenuClicked);
browser.menus.onShown.removeListener(this.onMenuShown);
}
public componentDidUpdate() {
setTimeout(() => {
this.fitWindowHeight();
}, 1);
}
public render() {
const isAppMediaTypeSelected =
this.state.mediaType === ReceiverSelectorMediaType.App;
const isTabMediaTypeSelected =
this.state.mediaType === ReceiverSelectorMediaType.Tab;
const isScreenMediaTypeSelected =
this.state.mediaType === ReceiverSelectorMediaType.Screen;
const isAppMediaTypeAvailable = !!(
this.state.availableMediaTypes & ReceiverSelectorMediaType.App
);
return (
<>
<div
className="whitelist-banner"
hidden={
// If we don't know the app
!this.state.knownApp ||
// If the whitelist is disabled
!this.state.siteWhitelistEnabled ||
// If the whitelist is enabled, and the page is whitelisted
(this.state.siteWhitelistEnabled &&
this.state.isPageWhitelisted) ||
// If an app is already loaded on the page
!!(
this.state.availableMediaTypes &
ReceiverSelectorMediaType.App
)
}
>
<img src="photon_info.svg" />
{_(
"popupWhitelistNotWhitelisted",
this.state.knownApp?.name
)}
<button
onClick={() => {
if (!this.state.knownApp || !this.state.pageInfo) {
return;
}
this.onAddToWhitelist(
this.state.knownApp,
this.state.pageInfo
);
}}
>
{_("popupWhitelistAddToWhitelist")}
</button>
</div>
<div className="media-type-select">
<div className="media-type-select__label-cast">
{_("popupMediaSelectCastLabel")}
</div>
<div className="select-wrapper">
<select
onChange={ev =>
this.setState({
mediaType: parseInt(ev.target.value)
})
}
className="media-type-select__dropdown"
disabled={
this.state.availableMediaTypes ===
ReceiverSelectorMediaType.None
}
>
<option
value={ReceiverSelectorMediaType.App}
selected={isAppMediaTypeSelected}
disabled={!isAppMediaTypeAvailable}
>
{this.state.knownApp?.name ??
_("popupMediaTypeApp")}
</option>
{this.state.mirroringEnabled && (
<>
<option
value={ReceiverSelectorMediaType.Tab}
selected={isTabMediaTypeSelected}
disabled={
!(
this.state.availableMediaTypes &
ReceiverSelectorMediaType.Tab
)
}
>
{_("popupMediaTypeTab")}
</option>
<option
value={ReceiverSelectorMediaType.Screen}
selected={isScreenMediaTypeSelected}
disabled={
!(
this.state.availableMediaTypes &
ReceiverSelectorMediaType.Screen
)
}
>
{_("popupMediaTypeScreen")}
</option>
</>
)}
</select>
</div>
<div className="media-type-select__label-to">
{_("popupMediaSelectToLabel")}
</div>
</div>
<ul className="receivers">
{!this.state.receiverDevices.length ? (
<div className="receivers__not-found">
{_("popupNoReceiversFound")}
</div>
) : (
this.state.receiverDevices.map((device, i) => (
<Receiver
details={device}
isAnyConnecting={this.state.isConnecting}
isMediaTypeAvailable={
!!(
this.state.availableMediaTypes &
this.state.mediaType
)
}
onCast={this.onReceiverCast}
onStop={this.onReceiverStop}
key={i}
/>
))
)}
</ul>
</>
);
}
}
interface ReceiverProps {
details: ReceiverDevice;
isMediaTypeAvailable: boolean;
isAnyConnecting: boolean;
// Events
onCast(receiverDevice: ReceiverDevice): void;
onStop(receiverDevice: ReceiverDevice): void;
}
interface ReceiverState {
isConnecting: boolean;
connectingEllipsis: string;
}
class Receiver extends Component<ReceiverProps, ReceiverState> {
private ellipsisInterval?: number;
constructor(props: ReceiverProps) {
super(props);
this.state = {
isConnecting: false,
connectingEllipsis: ""
};
this.handleCast = this.handleCast.bind(this);
}
private handleCast() {
if (!this.props.details.status) {
return;
}
this.ellipsisInterval = window.setInterval(() => {
this.setState(state => ({
connectingEllipsis: getNextEllipsis(state.connectingEllipsis)
}));
}, 500);
this.setState({ isConnecting: true });
this.props.onCast(this.props.details);
}
componentWillUnmount() {
window.clearInterval(this.ellipsisInterval);
}
render() {
const application = this.props.details.status?.applications?.[0];
return (
<li className="receiver">
<div className="receiver__name">
{this.props.details.friendlyName}
</div>
<div className="receiver__address">
{application && !application.isIdleScreen
? application.statusText
: `${this.props.details.host}:${this.props.details.port}`}
</div>
<button
className="button receiver__connect"
onClick={this.handleCast}
disabled={
this.props.isAnyConnecting ||
this.state.isConnecting ||
!this.props.isMediaTypeAvailable
}
>
{this.state.isConnecting
? _(
"popupCastingButtonTitle",
this.state.isConnecting
? this.state.connectingEllipsis
: ""
)
: _("popupCastButtonTitle")}
</button>
</li>
);
}
}
// Render after CSS has loaded
window.addEventListener("load", () => {
ReactDOM.render(<PopupApp />, document.querySelector("#root"));
});

View File

@@ -1,37 +0,0 @@
"use strict";
import React, { useEffect, useState } from "react";
export function getNextEllipsis(ellipsis: string): string {
if (ellipsis === "") return ".";
if (ellipsis === ".") return "..";
if (ellipsis === "..") return "...";
if (ellipsis === "...") return "";
return "";
}
export interface LoadingIndicatorProps {
text: string;
duration?: number;
}
export function LoadingIndicator(props: LoadingIndicatorProps) {
const [ellipsis, setEllipsis] = useState("");
useEffect(() => {
const interval = window.setInterval(() => {
setEllipsis(prev => getNextEllipsis(prev));
}, props.duration ?? 500);
return () => {
window.clearInterval(interval);
};
}, []);
return (
<div className="loading">
{props.text}
{ellipsis}
</div>
);
}

View File

@@ -1,7 +1,6 @@
{
"extends": "../tsconfig",
"compilerOptions": {
"jsx": "react",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"moduleResolution": "node",
"sourceMap": true,