Files
fx_cast/ext/src/ui/popup/Popup.svelte

446 lines
14 KiB
Svelte

<script lang="ts">
import { afterUpdate, onMount, tick } from "svelte";
import LoadingIndicator from "../LoadingIndicator.svelte";
import messaging, { Message, Port } from "../../messaging";
import options, { Options } from "../../lib/options";
import { RemoteMatchPattern } from "../../lib/matchPattern";
import {
ReceiverDevice,
ReceiverSelectionActionType,
ReceiverSelectorMediaType,
ReceiverSelectorPageInfo
} 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
) {
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("popup_cast", { visible: false });
browser.menus.update("popup_stop", { visible: false });
} else {
const app = device.status?.applications?.[0];
const isAppRunning = !!(app && !app.isIdleScreen);
browser.menus.update("popup_cast", {
visible: true,
title: _("popupCastMenuTitle", device.friendlyName),
enabled:
// Not already connecting to a receiver
!isConnecting &&
// Selected media type available
isMediaTypeAvailable
});
browser.menus.update("popup_stop", {
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 !== "popup_cast" &&
info.menuItemId !== "popup_stop"
) {
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 "popup_cast":
onReceiverCast(device);
break;
case "popup_stop":
onReceiverStop(device);
break;
}
}
function onReceiverCast(receiverDevice: ReceiverDevice) {
isConnecting = true;
connectingId = receiverDevice.id;
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>