mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-08 08:39:59 +00:00
Add context menu to receiver selector + other improvements
This commit is contained in:
@@ -41,11 +41,15 @@ Missing/outdated strings:
|
||||
- `optionsBridgeCompatible`
|
||||
- `optionsBridgeLikelyCompatible`
|
||||
- `optionsBridgeIncompatible`
|
||||
- `popupCastMenuTitle`
|
||||
- `popupStopMenuTitle`
|
||||
|
||||
- `es`
|
||||
|
||||
- `popupWhitelistNotWhitelisted`
|
||||
- `popupWhitelistAddToWhitelist`
|
||||
- `popupCastMenuTitle`
|
||||
- `popupStopMenuTitle`
|
||||
|
||||
- `nl`
|
||||
|
||||
@@ -63,10 +67,14 @@ Missing/outdated strings:
|
||||
- `optionsBridgeCompatible`
|
||||
- `optionsBridgeLikelyCompatible`
|
||||
- `optionsBridgeIncompatible`
|
||||
- `popupCastMenuTitle`
|
||||
- `popupStopMenuTitle`
|
||||
|
||||
- `no`
|
||||
- `popupWhitelistNotWhitelisted`
|
||||
- `popupWhitelistAddToWhitelist`
|
||||
- `popupCastMenuTitle`
|
||||
- `popupStopMenuTitle`
|
||||
|
||||
### NSIS Installer Localization
|
||||
|
||||
|
||||
@@ -73,9 +73,30 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"contextCast": {
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface ReceiverSelectionCast {
|
||||
actionType: ReceiverSelectionActionType.Cast;
|
||||
receiverDevice: ReceiverDevice;
|
||||
mediaType: ReceiverSelectorMediaType;
|
||||
filePath?: string;
|
||||
}
|
||||
export interface ReceiverSelectionStop {
|
||||
actionType: ReceiverSelectionActionType.Stop;
|
||||
@@ -39,6 +38,7 @@ interface ReceiverSelectorEvents {
|
||||
error: string;
|
||||
cancelled: void;
|
||||
stop: ReceiverSelectionStop;
|
||||
close: void;
|
||||
}
|
||||
/**
|
||||
* Manages the receiver selector popup window and communication with the
|
||||
@@ -268,6 +268,8 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
|
||||
this.dispatchEvent(new CustomEvent("cancelled"));
|
||||
}
|
||||
|
||||
this.dispatchEvent(new CustomEvent("close"));
|
||||
|
||||
// Cleanup
|
||||
delete this.windowId;
|
||||
delete this.messagePort;
|
||||
|
||||
@@ -30,6 +30,9 @@ let menuIdCastMedia: MenuId;
|
||||
let menuIdWhitelist: MenuId;
|
||||
let menuIdWhitelistRecommended: MenuId;
|
||||
|
||||
export const menuIdPopupCast = "popup_cast";
|
||||
export const menuIdPopupStop = "popup_stop";
|
||||
|
||||
/** Match patterns for the whitelist option menus. */
|
||||
const whitelistChildMenuPatterns = new Map<MenuId, string>();
|
||||
|
||||
@@ -42,7 +45,9 @@ export async function initMenus() {
|
||||
// Global "Cast..." menu item
|
||||
menuIdCast = browser.menus.create({
|
||||
contexts: ["browser_action", "page", "tools_menu"],
|
||||
title: _("contextCast")
|
||||
title: _("contextCast"),
|
||||
documentUrlPatterns: ["http://*/*", "https://*/*"],
|
||||
icons: { "16": "icons/icon.svg" } // browser_action context
|
||||
});
|
||||
|
||||
// <video>/<audio> "Cast..." context menu item
|
||||
@@ -71,6 +76,19 @@ export async function initMenus() {
|
||||
parentId: menuIdWhitelist
|
||||
});
|
||||
|
||||
// Popup context menus
|
||||
const popupUrlPattern = `${browser.runtime.getURL("ui/popup")}/*`;
|
||||
browser.menus.create({
|
||||
id: menuIdPopupCast,
|
||||
title: _("popupCastButtonTitle"),
|
||||
documentUrlPatterns: [popupUrlPattern]
|
||||
});
|
||||
browser.menus.create({
|
||||
id: menuIdPopupStop,
|
||||
title: _("popupStopButtonTitle"),
|
||||
documentUrlPatterns: [popupUrlPattern]
|
||||
});
|
||||
|
||||
browser.menus.onShown.addListener(onMenuShown);
|
||||
browser.menus.onClicked.addListener(onMenuClicked);
|
||||
|
||||
|
||||
@@ -126,12 +126,10 @@ async function getSelection(
|
||||
function onSelectorSelected(ev: CustomEvent<ReceiverSelectionCast>) {
|
||||
logger.info("Selected receiver", ev.detail);
|
||||
|
||||
removeListeners();
|
||||
resolve({
|
||||
actionType: ReceiverSelectionActionType.Cast,
|
||||
receiverDevice: ev.detail.receiverDevice,
|
||||
mediaType: ev.detail.mediaType,
|
||||
filePath: ev.detail.filePath
|
||||
mediaType: ev.detail.mediaType
|
||||
});
|
||||
}
|
||||
function onSelectorStop(ev: CustomEvent<ReceiverSelectionStop>) {
|
||||
@@ -139,7 +137,6 @@ async function getSelection(
|
||||
|
||||
deviceManager.stopReceiverApp(ev.detail.receiverDevice.id);
|
||||
|
||||
removeListeners();
|
||||
resolve({
|
||||
actionType: ReceiverSelectionActionType.Stop,
|
||||
receiverDevice: ev.detail.receiverDevice
|
||||
@@ -148,11 +145,9 @@ async function getSelection(
|
||||
function onSelectorCancelled() {
|
||||
logger.info("Cancelled receiver selection");
|
||||
|
||||
removeListeners();
|
||||
resolve(null);
|
||||
}
|
||||
function onSelectorError(ev: CustomEvent<string>) {
|
||||
removeListeners();
|
||||
reject(ev.detail);
|
||||
}
|
||||
|
||||
@@ -160,6 +155,7 @@ async function getSelection(
|
||||
sharedSelector.addEventListener("stop", onSelectorStop);
|
||||
sharedSelector.addEventListener("cancelled", onSelectorCancelled);
|
||||
sharedSelector.addEventListener("error", onSelectorError);
|
||||
sharedSelector.addEventListener("close", removeListeners);
|
||||
|
||||
function removeListeners() {
|
||||
sharedSelector.removeEventListener("selected", onSelectorSelected);
|
||||
@@ -169,6 +165,7 @@ async function getSelection(
|
||||
onSelectorCancelled
|
||||
);
|
||||
sharedSelector.removeEventListener("error", onSelectorError);
|
||||
sharedSelector.removeEventListener("close", removeListeners);
|
||||
|
||||
deviceManager.removeEventListener(
|
||||
"receiverDeviceUp",
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"use strict";
|
||||
|
||||
import eventMessaging from "./eventMessaging";
|
||||
|
||||
import messaging, { Message } from "../messaging";
|
||||
import { PageEventMessenger, ExtensionEventMessenger } from "./eventMessaging";
|
||||
|
||||
// Create messengers manually instead of relying on getters
|
||||
const eventMessaging = {
|
||||
page: new PageEventMessenger(),
|
||||
extension: new ExtensionEventMessenger()
|
||||
};
|
||||
|
||||
// Message port to background script
|
||||
export const backgroundPort = messaging.connect({ name: "cast" });
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
import React, { Component } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import { menuIdPopupCast, menuIdPopupStop } from "../../background/menus";
|
||||
|
||||
import knownApps, { KnownApp } from "../../cast/knownApps";
|
||||
import options from "../../lib/options";
|
||||
|
||||
import messaging, { Message, Port } from "../../messaging";
|
||||
import { getNextEllipsis } from "../../lib/utils";
|
||||
import { getNextEllipsis } from "../utils";
|
||||
import { RemoteMatchPattern } from "../../lib/matchPattern";
|
||||
|
||||
import {
|
||||
@@ -67,27 +69,40 @@ function hasRequiredCapabilities(
|
||||
|
||||
interface PopupAppProps {}
|
||||
interface PopupAppState {
|
||||
/** List of devices to show in receiver list. */
|
||||
receiverDevices: ReceiverDevice[];
|
||||
mediaType: ReceiverSelectorMediaType;
|
||||
availableMediaTypes: ReceiverSelectorMediaType;
|
||||
isLoading: boolean;
|
||||
|
||||
filePath?: string;
|
||||
/** 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;
|
||||
userAgentWhitelistEnabled: boolean;
|
||||
userAgentWhitelist: string[];
|
||||
|
||||
knownApp?: KnownApp;
|
||||
isPageWhitelisted: boolean;
|
||||
}
|
||||
|
||||
class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
private port?: Port;
|
||||
private win?: browser.windows.Window;
|
||||
private defaultMediaType?: ReceiverSelectorMediaType;
|
||||
private browserWindow?: browser.windows.Window;
|
||||
|
||||
private resizeObserver = new ResizeObserver(() => {
|
||||
this.fitWindowHeight();
|
||||
});
|
||||
|
||||
constructor(props: PopupAppProps) {
|
||||
super(props);
|
||||
@@ -96,136 +111,107 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
receiverDevices: [],
|
||||
mediaType: ReceiverSelectorMediaType.App,
|
||||
availableMediaTypes: ReceiverSelectorMediaType.App,
|
||||
isLoading: false,
|
||||
isPageWhitelisted: false,
|
||||
isConnecting: false,
|
||||
mirroringEnabled: false,
|
||||
userAgentWhitelistEnabled: true,
|
||||
userAgentWhitelist: [],
|
||||
isPageWhitelisted: false
|
||||
userAgentWhitelist: []
|
||||
};
|
||||
|
||||
// Store window ref
|
||||
browser.windows.getCurrent().then(win => {
|
||||
this.win = win;
|
||||
this.browserWindow = win;
|
||||
});
|
||||
|
||||
new ResizeObserver(() => {
|
||||
this.updateWindowHeight();
|
||||
}).observe(document.body);
|
||||
|
||||
this.onMessage = this.onMessage.bind(this);
|
||||
this.onAddToWhitelist = this.onAddToWhitelist.bind(this);
|
||||
this.onSelectChange = this.onSelectChange.bind(this);
|
||||
this.onCast = this.onCast.bind(this);
|
||||
this.onStop = this.onStop.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);
|
||||
}
|
||||
|
||||
public updateWindowHeight() {
|
||||
if (this.win?.id === undefined) {
|
||||
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;
|
||||
}
|
||||
|
||||
const frameHeight = window.outerHeight - window.innerHeight;
|
||||
const windowHeight = document.body.clientHeight + frameHeight;
|
||||
|
||||
browser.windows.update(this.win.id, {
|
||||
height: windowHeight
|
||||
browser.windows.update(this.browserWindow.id, {
|
||||
height:
|
||||
document.body.clientHeight +
|
||||
(window.outerHeight - window.innerHeight)
|
||||
});
|
||||
}
|
||||
|
||||
public async componentDidMount() {
|
||||
this.port = messaging.connect({ name: "popup" });
|
||||
|
||||
this.port.onMessage.addListener((message: Message) => {
|
||||
switch (message.subject) {
|
||||
case "popup:init": {
|
||||
this.setState({
|
||||
appId: message.data?.appId,
|
||||
pageInfo: message.data?.pageInfo
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "popup:update": {
|
||||
const {
|
||||
receiverDevices,
|
||||
availableMediaTypes,
|
||||
defaultMediaType
|
||||
} = message.data;
|
||||
|
||||
this.setState({
|
||||
/**
|
||||
* Filter receiver devices without the required
|
||||
* capabilities.
|
||||
*/
|
||||
receiverDevices: receiverDevices.filter(
|
||||
receiverDevice => {
|
||||
return hasRequiredCapabilities(
|
||||
receiverDevice,
|
||||
this.state.pageInfo?.sessionRequest
|
||||
?.capabilities
|
||||
);
|
||||
}
|
||||
)
|
||||
});
|
||||
|
||||
if (
|
||||
availableMediaTypes !== undefined &&
|
||||
defaultMediaType !== undefined
|
||||
) {
|
||||
this.defaultMediaType = defaultMediaType;
|
||||
this.setState({
|
||||
availableMediaTypes,
|
||||
mediaType: defaultMediaType
|
||||
});
|
||||
}
|
||||
|
||||
this.updateKnownApp();
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "popup:close": {
|
||||
window.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const opts = await options.getAll();
|
||||
|
||||
this.setState({
|
||||
mirroringEnabled: opts.mirroringEnabled,
|
||||
userAgentWhitelistEnabled: opts.userAgentWhitelistEnabled,
|
||||
userAgentWhitelist: opts.userAgentWhitelist
|
||||
});
|
||||
|
||||
this.updateKnownApp();
|
||||
}
|
||||
|
||||
public componentDidUpdate() {
|
||||
setTimeout(() => {
|
||||
this.updateWindowHeight();
|
||||
}, 1);
|
||||
}
|
||||
|
||||
private updateKnownApp() {
|
||||
const isAppMediaTypeAvailable = !!(
|
||||
this.state.availableMediaTypes & ReceiverSelectorMediaType.App
|
||||
);
|
||||
|
||||
let knownApp: Nullable<KnownApp> = null;
|
||||
let knownApp: KnownApp | undefined;
|
||||
|
||||
/**
|
||||
* Check knownApps for an app with an ID matching the registered
|
||||
* app on the target page.
|
||||
* Or if there isn't an registered app, check for an app with a
|
||||
* match pattern matching the target page URL.
|
||||
*/
|
||||
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;
|
||||
@@ -241,9 +227,7 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
|
||||
let isPageWhitelisted = false;
|
||||
|
||||
/**
|
||||
* Check if target page URL is whitelisted.
|
||||
*/
|
||||
// Check if target page URL is whitelisted.
|
||||
if (this.state.pageInfo) {
|
||||
for (const patternString of this.state.userAgentWhitelist) {
|
||||
const pattern = new RemoteMatchPattern(patternString);
|
||||
@@ -254,29 +238,174 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
}
|
||||
}
|
||||
|
||||
this.setState({
|
||||
knownApp: knownApp ?? undefined,
|
||||
isPageWhitelisted
|
||||
this.setState({ knownApp, isPageWhitelisted });
|
||||
}
|
||||
|
||||
private async onAddToWhitelist(
|
||||
app: KnownApp,
|
||||
pageInfo: ReceiverSelectorPageInfo
|
||||
) {
|
||||
if (!app.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const whitelist = await options.get("userAgentWhitelist");
|
||||
if (!whitelist.includes(app.matches)) {
|
||||
whitelist.push(app.matches);
|
||||
await options.set("userAgentWhitelist", 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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public render() {
|
||||
/*
|
||||
private onReceiverStop(receiverDevice: ReceiverDevice) {
|
||||
this.port?.postMessage({
|
||||
subject: "receiverSelector:stop",
|
||||
data: {
|
||||
receiverDevice,
|
||||
actionType: ReceiverSelectionActionType.Stop
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Add file support back to popup
|
||||
private onContextMenu(ev: MouseEvent) {
|
||||
if (!(ev.target instanceof Element)) return;
|
||||
|
||||
let truncatedFileName: string;
|
||||
|
||||
if (this.state.filePath) {
|
||||
const filePath = this.state.filePath;
|
||||
const fileName = filePath.substring(filePath.lastIndexOf("/") + 1);
|
||||
|
||||
truncatedFileName = fileName.length > 12
|
||||
? `${fileName.substring(0, 12)}...`
|
||||
: fileName;
|
||||
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: !this.state.isConnecting
|
||||
});
|
||||
|
||||
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,
|
||||
userAgentWhitelistEnabled: opts.userAgentWhitelistEnabled,
|
||||
userAgentWhitelist: opts.userAgentWhitelist
|
||||
});
|
||||
});
|
||||
|
||||
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 =
|
||||
@@ -284,9 +413,6 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
const isScreenMediaTypeSelected =
|
||||
this.state.mediaType === ReceiverSelectorMediaType.Screen;
|
||||
|
||||
const isSelectedMediaTypeAvailable = !!(
|
||||
this.state.availableMediaTypes & this.state.mediaType
|
||||
);
|
||||
const isAppMediaTypeAvailable = !!(
|
||||
this.state.availableMediaTypes & ReceiverSelectorMediaType.App
|
||||
);
|
||||
@@ -294,7 +420,7 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="whitelist-suggest"
|
||||
className="whitelist-banner"
|
||||
hidden={
|
||||
// If we don't know the app
|
||||
!this.state.knownApp ||
|
||||
@@ -304,7 +430,10 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
(this.state.userAgentWhitelistEnabled &&
|
||||
this.state.isPageWhitelisted) ||
|
||||
// If an app is already loaded on the page
|
||||
isAppMediaTypeAvailable
|
||||
!!(
|
||||
this.state.availableMediaTypes &
|
||||
ReceiverSelectorMediaType.App
|
||||
)
|
||||
}
|
||||
>
|
||||
<img src="photon_info.svg" />
|
||||
@@ -327,14 +456,19 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
{_("popupWhitelistAddToWhitelist")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="media-select">
|
||||
<div className="media-select__label-cast">
|
||||
|
||||
<div className="media-type-select">
|
||||
<div className="media-type-select__label-cast">
|
||||
{_("popupMediaSelectCastLabel")}
|
||||
</div>
|
||||
<div className="select-wrapper">
|
||||
<select
|
||||
onChange={this.onSelectChange}
|
||||
className="media-select__dropdown"
|
||||
onChange={ev =>
|
||||
this.setState({
|
||||
mediaType: parseInt(ev.target.value)
|
||||
})
|
||||
}
|
||||
className="media-type-select__dropdown"
|
||||
disabled={
|
||||
this.state.availableMediaTypes ===
|
||||
ReceiverSelectorMediaType.None
|
||||
@@ -379,212 +513,119 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="media-select__label-to">
|
||||
<div className="media-type-select__label-to">
|
||||
{_("popupMediaSelectToLabel")}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="receivers">
|
||||
{this.state.receiverDevices &&
|
||||
this.state.receiverDevices.length ? (
|
||||
this.state.receiverDevices.map((receiver, i) => (
|
||||
<ReceiverEntry
|
||||
receiverDevice={receiver}
|
||||
onCast={this.onCast}
|
||||
onStop={this.onStop}
|
||||
isLoading={this.state.isLoading}
|
||||
canCast={isSelectedMediaTypeAvailable}
|
||||
key={i}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
{!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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
private async onAddToWhitelist(
|
||||
app: KnownApp,
|
||||
pageInfo: ReceiverSelectorPageInfo
|
||||
) {
|
||||
if (!app.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
const whitelist = await options.get("userAgentWhitelist");
|
||||
if (!whitelist.includes(app.matches)) {
|
||||
whitelist.push(app.matches);
|
||||
await options.set("userAgentWhitelist", whitelist);
|
||||
|
||||
await browser.tabs.reload(pageInfo.tabId);
|
||||
window.close();
|
||||
}
|
||||
}
|
||||
|
||||
private onCast(receiverDevice: ReceiverDevice) {
|
||||
this.setState({
|
||||
isLoading: true
|
||||
});
|
||||
|
||||
this.port?.postMessage({
|
||||
subject: "receiverSelector:selected",
|
||||
data: {
|
||||
receiverDevice,
|
||||
actionType: ReceiverSelectionActionType.Cast,
|
||||
mediaType: this.state.mediaType,
|
||||
filePath: this.state.filePath
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private onStop(receiverDevice: ReceiverDevice) {
|
||||
this.port?.postMessage({
|
||||
subject: "receiverSelector:stop",
|
||||
data: {
|
||||
receiverDevice,
|
||||
actionType: ReceiverSelectionActionType.Stop
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private onSelectChange(ev: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const mediaType = parseInt(ev.target.value);
|
||||
|
||||
if (mediaType === ReceiverSelectorMediaType.File) {
|
||||
const fileUrl = window.prompt();
|
||||
if (fileUrl) {
|
||||
this.setState({
|
||||
mediaType,
|
||||
filePath: fileUrl
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Set media type to default if failed to set filePath
|
||||
if (this.defaultMediaType) {
|
||||
this.setState({
|
||||
mediaType: this.defaultMediaType
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.setState({
|
||||
mediaType
|
||||
});
|
||||
}
|
||||
|
||||
this.setState({
|
||||
filePath: undefined
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
interface ReceiverEntryProps {
|
||||
receiverDevice: ReceiverDevice;
|
||||
isLoading: boolean;
|
||||
canCast: boolean;
|
||||
interface ReceiverProps {
|
||||
details: ReceiverDevice;
|
||||
isMediaTypeAvailable: boolean;
|
||||
isAnyConnecting: boolean;
|
||||
|
||||
// Events
|
||||
onCast(receiverDevice: ReceiverDevice): void;
|
||||
onStop(receiverDevice: ReceiverDevice): void;
|
||||
}
|
||||
|
||||
interface ReceiverEntryState {
|
||||
ellipsis: string;
|
||||
isLoading: boolean;
|
||||
showAlternateAction: boolean;
|
||||
interface ReceiverState {
|
||||
isConnecting: boolean;
|
||||
connectingEllipsis: string;
|
||||
}
|
||||
class Receiver extends Component<ReceiverProps, ReceiverState> {
|
||||
private ellipsisInterval?: number;
|
||||
|
||||
class ReceiverEntry extends Component<ReceiverEntryProps, ReceiverEntryState> {
|
||||
constructor(props: ReceiverEntryProps) {
|
||||
constructor(props: ReceiverProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
ellipsis: "",
|
||||
isLoading: false,
|
||||
showAlternateAction: false
|
||||
isConnecting: false,
|
||||
connectingEllipsis: ""
|
||||
};
|
||||
|
||||
const handleActionKeyEvents = (ev: KeyboardEvent) => {
|
||||
if (ev.key === "Alt" || ev.key === "Shift") {
|
||||
this.setState({
|
||||
// Only enable on keydown, otherwise disable
|
||||
showAlternateAction: ev.type === "keydown"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleActionKeyEvents);
|
||||
window.addEventListener("keyup", handleActionKeyEvents);
|
||||
|
||||
window.addEventListener("blur", () => {
|
||||
this.setState({
|
||||
showAlternateAction: false
|
||||
});
|
||||
});
|
||||
|
||||
this.handleCast = this.handleCast.bind(this);
|
||||
}
|
||||
|
||||
public render() {
|
||||
const { status } = this.props.receiverDevice;
|
||||
const application = status?.applications?.[0];
|
||||
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.receiverDevice.friendlyName}
|
||||
{this.props.details.friendlyName}
|
||||
</div>
|
||||
<div className="receiver__address">
|
||||
{application && !application.isIdleScreen
|
||||
? application.statusText
|
||||
: `${this.props.receiverDevice.host}:${this.props.receiverDevice.port}`}
|
||||
: `${this.props.details.host}:${this.props.details.port}`}
|
||||
</div>
|
||||
<button
|
||||
className="button receiver__connect"
|
||||
onClick={this.handleCast}
|
||||
disabled={
|
||||
this.state.showAlternateAction
|
||||
? !application || application.isIdleScreen
|
||||
: this.props.isLoading || !this.props.canCast
|
||||
this.props.isAnyConnecting ||
|
||||
this.state.isConnecting ||
|
||||
!this.props.isMediaTypeAvailable
|
||||
}
|
||||
>
|
||||
{this.state.isLoading
|
||||
{this.state.isConnecting
|
||||
? _(
|
||||
"popupCastingButtonTitle",
|
||||
this.state.isLoading ? this.state.ellipsis : ""
|
||||
this.state.isConnecting
|
||||
? this.state.connectingEllipsis
|
||||
: ""
|
||||
)
|
||||
: this.state.showAlternateAction
|
||||
? _("popupStopButtonTitle")
|
||||
: _("popupCastButtonTitle")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
private handleCast() {
|
||||
const { status } = this.props.receiverDevice;
|
||||
if (!status) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.showAlternateAction) {
|
||||
this.props.onStop(this.props.receiverDevice);
|
||||
} else {
|
||||
this.props.onCast(this.props.receiverDevice);
|
||||
|
||||
this.setState({
|
||||
isLoading: true
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
this.setState(state => ({
|
||||
ellipsis: getNextEllipsis(state.ellipsis)
|
||||
}));
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render after CSS has loaded
|
||||
|
||||
@@ -11,7 +11,7 @@ body {
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.media-select,
|
||||
.media-type-select,
|
||||
.receiver:not(:last-child) {
|
||||
border-bottom-color: var(--grey-50) !important;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.whitelist-suggest {
|
||||
.whitelist-banner {
|
||||
align-items: center;
|
||||
background-color: var(--blue-50-a30);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
||||
@@ -29,7 +29,7 @@ body {
|
||||
gap: 0.5em;
|
||||
padding: 0.5em 0.75em;
|
||||
}
|
||||
.whitelist-suggest > button {
|
||||
.whitelist-banner > button {
|
||||
--button-background: hsla(0, 0%, 50%, 0.3);
|
||||
--button-background-hover: hsla(0, 0%, 30%, 0.3);
|
||||
--button-background-active: hsla(0, 0%, 10%, 0.3);
|
||||
@@ -37,17 +37,17 @@ body {
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.whitelist-suggest {
|
||||
.whitelist-banner {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
.whitelist-suggest > button {
|
||||
.whitelist-banner > button {
|
||||
--button-background: hsla(0, 0%, 50%, 0.3);
|
||||
--button-background-hover: hsla(0, 0%, 70%, 0.3);
|
||||
--button-background-active: hsla(0, 0%, 90%, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.media-select {
|
||||
.media-type-select {
|
||||
align-items: baseline;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
|
||||
display: flex;
|
||||
@@ -55,14 +55,14 @@ body {
|
||||
padding: 0.75em 0;
|
||||
}
|
||||
|
||||
.media-select__label-cast,
|
||||
.media-select__label-to {
|
||||
.media-type-select__label-cast,
|
||||
.media-type-select__label-to {
|
||||
display: inline-block;
|
||||
}
|
||||
.media-select__label-cast:not(:empty) {
|
||||
.media-type-select__label-cast:not(:empty) {
|
||||
margin-inline-end: 0.5em;
|
||||
}
|
||||
.media-select__label-to:not(:empty) {
|
||||
.media-type-select__label-to:not(:empty) {
|
||||
margin-inline-start: 0.5em;
|
||||
}
|
||||
|
||||
|
||||
37
ext/src/ui/utils.tsx
Normal file
37
ext/src/ui/utils.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user