Files
fx_cast/ext/src/ui/popup/Popup.svelte
2022-09-01 07:21:12 +01:00

377 lines
12 KiB
Svelte

<script lang="ts">
import { afterUpdate, onDestroy, onMount, tick } from "svelte";
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;
/** Devices to display. */
let devices: ReceiverDevice[] = [];
/** 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(device: ReceiverDevice) {
// 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
);
}
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")) {
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();
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": {
if (
message.data.availableMediaTypes !== undefined &&
message.data.defaultMediaType !== undefined
) {
availableMediaTypes = message.data.availableMediaTypes;
if (availableMediaTypes & message.data.defaultMediaType) {
mediaType = message.data.defaultMediaType;
}
}
updateKnownApp();
devices = message.data.receiverDevices;
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(receiverDevice: ReceiverDevice) {
isConnecting = true;
port?.postMessage({
subject: "main:receiverSelected",
data: {
receiverDevice,
mediaType
}
});
}
function onReceiverStop(receiverDevice: ReceiverDevice) {
port?.postMessage({
subject: "main:sendReceiverMessage",
data: {
deviceId: receiverDevice.id,
message: { requestId: 0, type: "STOP" }
}
});
port?.postMessage({
subject: "main:receiverStopped",
data: { deviceId: receiverDevice.id }
});
}
</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>
{#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.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>
{/if}
<ul class="receiver-list">
{#if !devices.length}
<div class="receiver-list__not-found">
{_("popupNoReceiversFound")}
</div>
{:else}
{#each devices as device}
<Receiver
{port}
{device}
{isMediaTypeAvailable}
isAnyMediaTypeAvailable={availableMediaTypes !==
ReceiverSelectorMediaType.None &&
isDeviceCompatible(device)}
isAnyConnecting={isConnecting}
bind:lastMenuShownDeviceId
on:cast={ev => onReceiverCast(ev.detail.device)}
on:stop={ev => onReceiverStop(ev.detail.device)}
/>
{/each}
{/if}
</ul>