Add context menu to receiver selector + other improvements

This commit is contained in:
hensm
2022-05-09 23:45:20 +01:00
parent b1100bd258
commit 5f96e6ef29
9 changed files with 444 additions and 315 deletions

View File

@@ -41,11 +41,15 @@ Missing/outdated strings:
- `optionsBridgeCompatible` - `optionsBridgeCompatible`
- `optionsBridgeLikelyCompatible` - `optionsBridgeLikelyCompatible`
- `optionsBridgeIncompatible` - `optionsBridgeIncompatible`
- `popupCastMenuTitle`
- `popupStopMenuTitle`
- `es` - `es`
- `popupWhitelistNotWhitelisted` - `popupWhitelistNotWhitelisted`
- `popupWhitelistAddToWhitelist` - `popupWhitelistAddToWhitelist`
- `popupCastMenuTitle`
- `popupStopMenuTitle`
- `nl` - `nl`
@@ -63,10 +67,14 @@ Missing/outdated strings:
- `optionsBridgeCompatible` - `optionsBridgeCompatible`
- `optionsBridgeLikelyCompatible` - `optionsBridgeLikelyCompatible`
- `optionsBridgeIncompatible` - `optionsBridgeIncompatible`
- `popupCastMenuTitle`
- `popupStopMenuTitle`
- `no` - `no`
- `popupWhitelistNotWhitelisted` - `popupWhitelistNotWhitelisted`
- `popupWhitelistAddToWhitelist` - `popupWhitelistAddToWhitelist`
- `popupCastMenuTitle`
- `popupStopMenuTitle`
### NSIS Installer Localization ### NSIS Installer Localization

View File

@@ -73,9 +73,30 @@
} }
} }
}, },
"popupStopButtonTitle": {
"message": "Stop", "popupCastMenuTitle": {
"description": "Alternate action button text displayed instead of popupCastButtonTitle." "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": { "contextCast": {

View File

@@ -26,7 +26,6 @@ export interface ReceiverSelectionCast {
actionType: ReceiverSelectionActionType.Cast; actionType: ReceiverSelectionActionType.Cast;
receiverDevice: ReceiverDevice; receiverDevice: ReceiverDevice;
mediaType: ReceiverSelectorMediaType; mediaType: ReceiverSelectorMediaType;
filePath?: string;
} }
export interface ReceiverSelectionStop { export interface ReceiverSelectionStop {
actionType: ReceiverSelectionActionType.Stop; actionType: ReceiverSelectionActionType.Stop;
@@ -39,6 +38,7 @@ interface ReceiverSelectorEvents {
error: string; error: string;
cancelled: void; cancelled: void;
stop: ReceiverSelectionStop; stop: ReceiverSelectionStop;
close: void;
} }
/** /**
* Manages the receiver selector popup window and communication with the * 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("cancelled"));
} }
this.dispatchEvent(new CustomEvent("close"));
// Cleanup // Cleanup
delete this.windowId; delete this.windowId;
delete this.messagePort; delete this.messagePort;

View File

@@ -30,6 +30,9 @@ let menuIdCastMedia: MenuId;
let menuIdWhitelist: MenuId; let menuIdWhitelist: MenuId;
let menuIdWhitelistRecommended: MenuId; let menuIdWhitelistRecommended: MenuId;
export const menuIdPopupCast = "popup_cast";
export const menuIdPopupStop = "popup_stop";
/** Match patterns for the whitelist option menus. */ /** Match patterns for the whitelist option menus. */
const whitelistChildMenuPatterns = new Map<MenuId, string>(); const whitelistChildMenuPatterns = new Map<MenuId, string>();
@@ -42,7 +45,9 @@ export async function initMenus() {
// Global "Cast..." menu item // Global "Cast..." menu item
menuIdCast = browser.menus.create({ menuIdCast = browser.menus.create({
contexts: ["browser_action", "page", "tools_menu"], 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 // <video>/<audio> "Cast..." context menu item
@@ -71,6 +76,19 @@ export async function initMenus() {
parentId: menuIdWhitelist 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.onShown.addListener(onMenuShown);
browser.menus.onClicked.addListener(onMenuClicked); browser.menus.onClicked.addListener(onMenuClicked);

View File

@@ -126,12 +126,10 @@ async function getSelection(
function onSelectorSelected(ev: CustomEvent<ReceiverSelectionCast>) { function onSelectorSelected(ev: CustomEvent<ReceiverSelectionCast>) {
logger.info("Selected receiver", ev.detail); logger.info("Selected receiver", ev.detail);
removeListeners();
resolve({ resolve({
actionType: ReceiverSelectionActionType.Cast, actionType: ReceiverSelectionActionType.Cast,
receiverDevice: ev.detail.receiverDevice, receiverDevice: ev.detail.receiverDevice,
mediaType: ev.detail.mediaType, mediaType: ev.detail.mediaType
filePath: ev.detail.filePath
}); });
} }
function onSelectorStop(ev: CustomEvent<ReceiverSelectionStop>) { function onSelectorStop(ev: CustomEvent<ReceiverSelectionStop>) {
@@ -139,7 +137,6 @@ async function getSelection(
deviceManager.stopReceiverApp(ev.detail.receiverDevice.id); deviceManager.stopReceiverApp(ev.detail.receiverDevice.id);
removeListeners();
resolve({ resolve({
actionType: ReceiverSelectionActionType.Stop, actionType: ReceiverSelectionActionType.Stop,
receiverDevice: ev.detail.receiverDevice receiverDevice: ev.detail.receiverDevice
@@ -148,11 +145,9 @@ async function getSelection(
function onSelectorCancelled() { function onSelectorCancelled() {
logger.info("Cancelled receiver selection"); logger.info("Cancelled receiver selection");
removeListeners();
resolve(null); resolve(null);
} }
function onSelectorError(ev: CustomEvent<string>) { function onSelectorError(ev: CustomEvent<string>) {
removeListeners();
reject(ev.detail); reject(ev.detail);
} }
@@ -160,6 +155,7 @@ async function getSelection(
sharedSelector.addEventListener("stop", onSelectorStop); sharedSelector.addEventListener("stop", onSelectorStop);
sharedSelector.addEventListener("cancelled", onSelectorCancelled); sharedSelector.addEventListener("cancelled", onSelectorCancelled);
sharedSelector.addEventListener("error", onSelectorError); sharedSelector.addEventListener("error", onSelectorError);
sharedSelector.addEventListener("close", removeListeners);
function removeListeners() { function removeListeners() {
sharedSelector.removeEventListener("selected", onSelectorSelected); sharedSelector.removeEventListener("selected", onSelectorSelected);
@@ -169,6 +165,7 @@ async function getSelection(
onSelectorCancelled onSelectorCancelled
); );
sharedSelector.removeEventListener("error", onSelectorError); sharedSelector.removeEventListener("error", onSelectorError);
sharedSelector.removeEventListener("close", removeListeners);
deviceManager.removeEventListener( deviceManager.removeEventListener(
"receiverDeviceUp", "receiverDeviceUp",

View File

@@ -1,8 +1,13 @@
"use strict"; "use strict";
import eventMessaging from "./eventMessaging";
import messaging, { Message } from "../messaging"; 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 // Message port to background script
export const backgroundPort = messaging.connect({ name: "cast" }); export const backgroundPort = messaging.connect({ name: "cast" });

View File

@@ -4,11 +4,13 @@
import React, { Component } from "react"; import React, { Component } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import { menuIdPopupCast, menuIdPopupStop } from "../../background/menus";
import knownApps, { KnownApp } from "../../cast/knownApps"; import knownApps, { KnownApp } from "../../cast/knownApps";
import options from "../../lib/options"; import options from "../../lib/options";
import messaging, { Message, Port } from "../../messaging"; import messaging, { Message, Port } from "../../messaging";
import { getNextEllipsis } from "../../lib/utils"; import { getNextEllipsis } from "../utils";
import { RemoteMatchPattern } from "../../lib/matchPattern"; import { RemoteMatchPattern } from "../../lib/matchPattern";
import { import {
@@ -67,27 +69,40 @@ function hasRequiredCapabilities(
interface PopupAppProps {} interface PopupAppProps {}
interface PopupAppState { interface PopupAppState {
/** List of devices to show in receiver list. */
receiverDevices: ReceiverDevice[]; 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; appId?: string;
/** Page info (if launched from page context). */
pageInfo?: ReceiverSelectorPageInfo; 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; mirroringEnabled: boolean;
userAgentWhitelistEnabled: boolean; userAgentWhitelistEnabled: boolean;
userAgentWhitelist: string[]; userAgentWhitelist: string[];
knownApp?: KnownApp;
isPageWhitelisted: boolean;
} }
class PopupApp extends Component<PopupAppProps, PopupAppState> { class PopupApp extends Component<PopupAppProps, PopupAppState> {
private port?: Port; private port?: Port;
private win?: browser.windows.Window; private browserWindow?: browser.windows.Window;
private defaultMediaType?: ReceiverSelectorMediaType;
private resizeObserver = new ResizeObserver(() => {
this.fitWindowHeight();
});
constructor(props: PopupAppProps) { constructor(props: PopupAppProps) {
super(props); super(props);
@@ -96,136 +111,107 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
receiverDevices: [], receiverDevices: [],
mediaType: ReceiverSelectorMediaType.App, mediaType: ReceiverSelectorMediaType.App,
availableMediaTypes: ReceiverSelectorMediaType.App, availableMediaTypes: ReceiverSelectorMediaType.App,
isLoading: false, isPageWhitelisted: false,
isConnecting: false,
mirroringEnabled: false, mirroringEnabled: false,
userAgentWhitelistEnabled: true, userAgentWhitelistEnabled: true,
userAgentWhitelist: [], userAgentWhitelist: []
isPageWhitelisted: false
}; };
// Store window ref // Store window ref
browser.windows.getCurrent().then(win => { browser.windows.getCurrent().then(win => {
this.win = win; this.browserWindow = win;
}); });
new ResizeObserver(() => { this.onMessage = this.onMessage.bind(this);
this.updateWindowHeight();
}).observe(document.body);
this.onAddToWhitelist = this.onAddToWhitelist.bind(this); this.onAddToWhitelist = this.onAddToWhitelist.bind(this);
this.onSelectChange = this.onSelectChange.bind(this); this.onReceiverCast = this.onReceiverCast.bind(this);
this.onCast = this.onCast.bind(this); this.onReceiverStop = this.onReceiverStop.bind(this);
this.onStop = this.onStop.bind(this);
this.onContextMenu = this.onContextMenu.bind(this);
this.onMenuShown = this.onMenuShown.bind(this);
this.onMenuClicked = this.onMenuClicked.bind(this);
} }
public updateWindowHeight() { private onMessage(message: Message) {
if (this.win?.id === undefined) { 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; return;
} }
const frameHeight = window.outerHeight - window.innerHeight; browser.windows.update(this.browserWindow.id, {
const windowHeight = document.body.clientHeight + frameHeight; height:
document.body.clientHeight +
browser.windows.update(this.win.id, { (window.outerHeight - window.innerHeight)
height: windowHeight
}); });
} }
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() { private updateKnownApp() {
const isAppMediaTypeAvailable = !!( const isAppMediaTypeAvailable = !!(
this.state.availableMediaTypes & ReceiverSelectorMediaType.App 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 * Check knownApps for an app with an ID matching the registered
* app on the target page. * 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) { if (isAppMediaTypeAvailable && this.state.appId) {
knownApp = knownApps[this.state.appId]; knownApp = knownApps[this.state.appId];
} else if (this.state.pageInfo) { } else if (this.state.pageInfo) {
const pageUrl = this.state.pageInfo.url; 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)) { for (const [, app] of Object.entries(knownApps)) {
if (!app.matches) { if (!app.matches) {
continue; continue;
@@ -241,9 +227,7 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
let isPageWhitelisted = false; let isPageWhitelisted = false;
/** // Check if target page URL is whitelisted.
* Check if target page URL is whitelisted.
*/
if (this.state.pageInfo) { if (this.state.pageInfo) {
for (const patternString of this.state.userAgentWhitelist) { for (const patternString of this.state.userAgentWhitelist) {
const pattern = new RemoteMatchPattern(patternString); const pattern = new RemoteMatchPattern(patternString);
@@ -254,29 +238,174 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
} }
} }
this.setState({ this.setState({ knownApp, isPageWhitelisted });
knownApp: knownApp ?? undefined, }
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; const receiverElement = ev.target.closest(".receiver");
if (receiverElement) {
if (this.state.filePath) { browser.menus.overrideContext({
const filePath = this.state.filePath; showDefaults: false
const fileName = filePath.substring(filePath.lastIndexOf("/") + 1); });
truncatedFileName = fileName.length > 12
? `${fileName.substring(0, 12)}...`
: fileName;
} }
*/ }
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 = const isAppMediaTypeSelected =
this.state.mediaType === ReceiverSelectorMediaType.App; this.state.mediaType === ReceiverSelectorMediaType.App;
const isTabMediaTypeSelected = const isTabMediaTypeSelected =
@@ -284,9 +413,6 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
const isScreenMediaTypeSelected = const isScreenMediaTypeSelected =
this.state.mediaType === ReceiverSelectorMediaType.Screen; this.state.mediaType === ReceiverSelectorMediaType.Screen;
const isSelectedMediaTypeAvailable = !!(
this.state.availableMediaTypes & this.state.mediaType
);
const isAppMediaTypeAvailable = !!( const isAppMediaTypeAvailable = !!(
this.state.availableMediaTypes & ReceiverSelectorMediaType.App this.state.availableMediaTypes & ReceiverSelectorMediaType.App
); );
@@ -294,7 +420,7 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
return ( return (
<> <>
<div <div
className="whitelist-suggest" className="whitelist-banner"
hidden={ hidden={
// If we don't know the app // If we don't know the app
!this.state.knownApp || !this.state.knownApp ||
@@ -304,7 +430,10 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
(this.state.userAgentWhitelistEnabled && (this.state.userAgentWhitelistEnabled &&
this.state.isPageWhitelisted) || this.state.isPageWhitelisted) ||
// If an app is already loaded on the page // If an app is already loaded on the page
isAppMediaTypeAvailable !!(
this.state.availableMediaTypes &
ReceiverSelectorMediaType.App
)
} }
> >
<img src="photon_info.svg" /> <img src="photon_info.svg" />
@@ -327,14 +456,19 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
{_("popupWhitelistAddToWhitelist")} {_("popupWhitelistAddToWhitelist")}
</button> </button>
</div> </div>
<div className="media-select">
<div className="media-select__label-cast"> <div className="media-type-select">
<div className="media-type-select__label-cast">
{_("popupMediaSelectCastLabel")} {_("popupMediaSelectCastLabel")}
</div> </div>
<div className="select-wrapper"> <div className="select-wrapper">
<select <select
onChange={this.onSelectChange} onChange={ev =>
className="media-select__dropdown" this.setState({
mediaType: parseInt(ev.target.value)
})
}
className="media-type-select__dropdown"
disabled={ disabled={
this.state.availableMediaTypes === this.state.availableMediaTypes ===
ReceiverSelectorMediaType.None ReceiverSelectorMediaType.None
@@ -379,212 +513,119 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
)} )}
</select> </select>
</div> </div>
<div className="media-select__label-to"> <div className="media-type-select__label-to">
{_("popupMediaSelectToLabel")} {_("popupMediaSelectToLabel")}
</div> </div>
</div> </div>
<ul className="receivers"> <ul className="receivers">
{this.state.receiverDevices && {!this.state.receiverDevices.length ? (
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}
/>
))
) : (
<div className="receivers__not-found"> <div className="receivers__not-found">
{_("popupNoReceiversFound")} {_("popupNoReceiversFound")}
</div> </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> </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 { interface ReceiverProps {
receiverDevice: ReceiverDevice; details: ReceiverDevice;
isLoading: boolean; isMediaTypeAvailable: boolean;
canCast: boolean; isAnyConnecting: boolean;
// Events
onCast(receiverDevice: ReceiverDevice): void; onCast(receiverDevice: ReceiverDevice): void;
onStop(receiverDevice: ReceiverDevice): void; onStop(receiverDevice: ReceiverDevice): void;
} }
interface ReceiverState {
interface ReceiverEntryState { isConnecting: boolean;
ellipsis: string; connectingEllipsis: string;
isLoading: boolean;
showAlternateAction: boolean;
} }
class Receiver extends Component<ReceiverProps, ReceiverState> {
private ellipsisInterval?: number;
class ReceiverEntry extends Component<ReceiverEntryProps, ReceiverEntryState> { constructor(props: ReceiverProps) {
constructor(props: ReceiverEntryProps) {
super(props); super(props);
this.state = { this.state = {
ellipsis: "", isConnecting: false,
isLoading: false, connectingEllipsis: ""
showAlternateAction: false
}; };
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); this.handleCast = this.handleCast.bind(this);
} }
public render() { private handleCast() {
const { status } = this.props.receiverDevice; if (!this.props.details.status) {
const application = status?.applications?.[0]; 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 ( return (
<li className="receiver"> <li className="receiver">
<div className="receiver__name"> <div className="receiver__name">
{this.props.receiverDevice.friendlyName} {this.props.details.friendlyName}
</div> </div>
<div className="receiver__address"> <div className="receiver__address">
{application && !application.isIdleScreen {application && !application.isIdleScreen
? application.statusText ? application.statusText
: `${this.props.receiverDevice.host}:${this.props.receiverDevice.port}`} : `${this.props.details.host}:${this.props.details.port}`}
</div> </div>
<button <button
className="button receiver__connect" className="button receiver__connect"
onClick={this.handleCast} onClick={this.handleCast}
disabled={ disabled={
this.state.showAlternateAction this.props.isAnyConnecting ||
? !application || application.isIdleScreen this.state.isConnecting ||
: this.props.isLoading || !this.props.canCast !this.props.isMediaTypeAvailable
} }
> >
{this.state.isLoading {this.state.isConnecting
? _( ? _(
"popupCastingButtonTitle", "popupCastingButtonTitle",
this.state.isLoading ? this.state.ellipsis : "" this.state.isConnecting
? this.state.connectingEllipsis
: ""
) )
: this.state.showAlternateAction
? _("popupStopButtonTitle")
: _("popupCastButtonTitle")} : _("popupCastButtonTitle")}
</button> </button>
</li> </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 // Render after CSS has loaded

View File

@@ -11,7 +11,7 @@ body {
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.media-select, .media-type-select,
.receiver:not(:last-child) { .receiver:not(:last-child) {
border-bottom-color: var(--grey-50) !important; border-bottom-color: var(--grey-50) !important;
} }
@@ -20,7 +20,7 @@ body {
} }
} }
.whitelist-suggest { .whitelist-banner {
align-items: center; align-items: center;
background-color: var(--blue-50-a30); background-color: var(--blue-50-a30);
border-bottom: 1px solid rgba(0, 0, 0, 0.25); border-bottom: 1px solid rgba(0, 0, 0, 0.25);
@@ -29,7 +29,7 @@ body {
gap: 0.5em; gap: 0.5em;
padding: 0.5em 0.75em; padding: 0.5em 0.75em;
} }
.whitelist-suggest > button { .whitelist-banner > button {
--button-background: hsla(0, 0%, 50%, 0.3); --button-background: hsla(0, 0%, 50%, 0.3);
--button-background-hover: hsla(0, 0%, 30%, 0.3); --button-background-hover: hsla(0, 0%, 30%, 0.3);
--button-background-active: hsla(0, 0%, 10%, 0.3); --button-background-active: hsla(0, 0%, 10%, 0.3);
@@ -37,17 +37,17 @@ body {
} }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.whitelist-suggest { .whitelist-banner {
border-bottom: 1px solid rgba(255, 255, 255, 0.25); 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: hsla(0, 0%, 50%, 0.3);
--button-background-hover: hsla(0, 0%, 70%, 0.3); --button-background-hover: hsla(0, 0%, 70%, 0.3);
--button-background-active: hsla(0, 0%, 90%, 0.3); --button-background-active: hsla(0, 0%, 90%, 0.3);
} }
} }
.media-select { .media-type-select {
align-items: baseline; align-items: baseline;
border-bottom: 1px solid rgba(0, 0, 0, 0.25); border-bottom: 1px solid rgba(0, 0, 0, 0.25);
display: flex; display: flex;
@@ -55,14 +55,14 @@ body {
padding: 0.75em 0; padding: 0.75em 0;
} }
.media-select__label-cast, .media-type-select__label-cast,
.media-select__label-to { .media-type-select__label-to {
display: inline-block; display: inline-block;
} }
.media-select__label-cast:not(:empty) { .media-type-select__label-cast:not(:empty) {
margin-inline-end: 0.5em; margin-inline-end: 0.5em;
} }
.media-select__label-to:not(:empty) { .media-type-select__label-to:not(:empty) {
margin-inline-start: 0.5em; margin-inline-start: 0.5em;
} }

37
ext/src/ui/utils.tsx Normal file
View 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>
);
}