mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-10 17:49:58 +00:00
594 lines
19 KiB
TypeScript
Executable File
594 lines
19 KiB
TypeScript
Executable File
/* eslint-disable max-len */
|
|
"use strict";
|
|
|
|
import React, { Component } from "react";
|
|
import ReactDOM from "react-dom";
|
|
|
|
import knownApps, { KnownApp } from "../../cast/knownApps";
|
|
import options from "../../lib/options";
|
|
|
|
import messaging, { Message, Port } from "../../messaging";
|
|
import { getNextEllipsis } from "../../lib/utils";
|
|
import { RemoteMatchPattern } from "../../lib/matchPattern";
|
|
|
|
import {
|
|
ReceiverDevice,
|
|
ReceiverDeviceCapabilities,
|
|
ReceiverSelectionActionType,
|
|
ReceiverSelectorMediaType
|
|
} from "../../types";
|
|
|
|
import { ReceiverSelectorPageInfo } from "../../background/ReceiverSelector";
|
|
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 {
|
|
receiverDevices: ReceiverDevice[];
|
|
mediaType: ReceiverSelectorMediaType;
|
|
availableMediaTypes: ReceiverSelectorMediaType;
|
|
isLoading: boolean;
|
|
|
|
filePath?: string;
|
|
appId?: string;
|
|
pageInfo?: ReceiverSelectorPageInfo;
|
|
|
|
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;
|
|
|
|
constructor(props: PopupAppProps) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
receiverDevices: [],
|
|
mediaType: ReceiverSelectorMediaType.App,
|
|
availableMediaTypes: ReceiverSelectorMediaType.App,
|
|
isLoading: false,
|
|
mirroringEnabled: false,
|
|
userAgentWhitelistEnabled: true,
|
|
userAgentWhitelist: [],
|
|
isPageWhitelisted: false
|
|
};
|
|
|
|
// Store window ref
|
|
browser.windows.getCurrent().then(win => {
|
|
this.win = win;
|
|
});
|
|
|
|
new ResizeObserver(() => {
|
|
this.updateWindowHeight();
|
|
}).observe(document.body);
|
|
|
|
this.onAddToWhitelist = this.onAddToWhitelist.bind(this);
|
|
this.onSelectChange = this.onSelectChange.bind(this);
|
|
this.onCast = this.onCast.bind(this);
|
|
this.onStop = this.onStop.bind(this);
|
|
}
|
|
|
|
public updateWindowHeight() {
|
|
if (this.win?.id === undefined) {
|
|
return;
|
|
}
|
|
|
|
const frameHeight = window.outerHeight - window.innerHeight;
|
|
const windowHeight = document.body.clientHeight + frameHeight;
|
|
|
|
browser.windows.update(this.win.id, {
|
|
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() {
|
|
const isAppMediaTypeAvailable = !!(
|
|
this.state.availableMediaTypes & ReceiverSelectorMediaType.App
|
|
);
|
|
|
|
let knownApp: Nullable<KnownApp> = null;
|
|
|
|
/**
|
|
* 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;
|
|
|
|
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 patternString of this.state.userAgentWhitelist) {
|
|
const pattern = new RemoteMatchPattern(patternString);
|
|
if (pattern.matches(this.state.pageInfo.url)) {
|
|
isPageWhitelisted = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.setState({
|
|
knownApp: knownApp ?? undefined,
|
|
isPageWhitelisted
|
|
});
|
|
}
|
|
|
|
public render() {
|
|
/*
|
|
|
|
// TODO: Add file support back to popup
|
|
|
|
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 isAppMediaTypeSelected =
|
|
this.state.mediaType === ReceiverSelectorMediaType.App;
|
|
const isTabMediaTypeSelected =
|
|
this.state.mediaType === ReceiverSelectorMediaType.Tab;
|
|
const isScreenMediaTypeSelected =
|
|
this.state.mediaType === ReceiverSelectorMediaType.Screen;
|
|
|
|
const isSelectedMediaTypeAvailable = !!(
|
|
this.state.availableMediaTypes & this.state.mediaType
|
|
);
|
|
const isAppMediaTypeAvailable = !!(
|
|
this.state.availableMediaTypes & ReceiverSelectorMediaType.App
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
className="whitelist-suggest"
|
|
hidden={
|
|
// If we don't know the app
|
|
!this.state.knownApp ||
|
|
// If the whitelist is disabled
|
|
!this.state.userAgentWhitelistEnabled ||
|
|
// If the whitelist is enabled, and the page is whitelisted
|
|
(this.state.userAgentWhitelistEnabled &&
|
|
this.state.isPageWhitelisted) ||
|
|
// If an app is already loaded on the page
|
|
isAppMediaTypeAvailable
|
|
}
|
|
>
|
|
<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-select">
|
|
<div className="media-select__label-cast">
|
|
{_("popupMediaSelectCastLabel")}
|
|
</div>
|
|
<div className="select-wrapper">
|
|
<select
|
|
onChange={this.onSelectChange}
|
|
className="media-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-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}
|
|
/>
|
|
))
|
|
) : (
|
|
<div className="receivers__not-found">
|
|
{_("popupNoReceiversFound")}
|
|
</div>
|
|
)}
|
|
</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;
|
|
onCast(receiverDevice: ReceiverDevice): void;
|
|
onStop(receiverDevice: ReceiverDevice): void;
|
|
}
|
|
|
|
interface ReceiverEntryState {
|
|
ellipsis: string;
|
|
isLoading: boolean;
|
|
showAlternateAction: boolean;
|
|
}
|
|
|
|
class ReceiverEntry extends Component<ReceiverEntryProps, ReceiverEntryState> {
|
|
constructor(props: ReceiverEntryProps) {
|
|
super(props);
|
|
|
|
this.state = {
|
|
ellipsis: "",
|
|
isLoading: false,
|
|
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);
|
|
}
|
|
|
|
public render() {
|
|
const { status } = this.props.receiverDevice;
|
|
const application = status?.applications?.[0];
|
|
|
|
return (
|
|
<li className="receiver">
|
|
<div className="receiver__name">
|
|
{this.props.receiverDevice.friendlyName}
|
|
</div>
|
|
<div className="receiver__address">
|
|
{application && !application.isIdleScreen
|
|
? application.statusText
|
|
: `${this.props.receiverDevice.host}:${this.props.receiverDevice.port}`}
|
|
</div>
|
|
<button
|
|
className="button receiver__connect"
|
|
onClick={this.handleCast}
|
|
disabled={
|
|
this.state.showAlternateAction
|
|
? !application || application.isIdleScreen
|
|
: this.props.isLoading || !this.props.canCast
|
|
}
|
|
>
|
|
{this.state.isLoading
|
|
? _(
|
|
"popupCastingButtonTitle",
|
|
this.state.isLoading ? this.state.ellipsis : ""
|
|
)
|
|
: 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
|
|
window.addEventListener("load", () => {
|
|
ReactDOM.render(<PopupApp />, document.querySelector("#root"));
|
|
});
|