mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-11 10:09:59 +00:00
Misc fixes/improvements from WIP branches
This commit is contained in:
@@ -1,76 +1,13 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import { Channel, Client } from "castv2";
|
import { Channel } from "castv2";
|
||||||
|
|
||||||
import { sendMessage } from "../../lib/nativeMessaging";
|
import { sendMessage } from "../../lib/nativeMessaging";
|
||||||
|
|
||||||
import { ReceiverDevice } from "../../types";
|
import { ReceiverDevice } from "../../types";
|
||||||
import { ReceiverApplication, ReceiverMessage, SenderMessage } from "./types";
|
import { ReceiverApplication, ReceiverMessage, SenderMessage } from "./types";
|
||||||
|
|
||||||
export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
|
import CastClient, { NS_CONNECTION, NS_HEARTBEAT, NS_RECEIVER } from "./client";
|
||||||
export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
|
|
||||||
export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
|
|
||||||
|
|
||||||
const HEARTBEAT_INTERVAL = 5000;
|
|
||||||
|
|
||||||
class CastClient {
|
|
||||||
protected client = new Client();
|
|
||||||
|
|
||||||
protected connectionChannel?: Channel;
|
|
||||||
protected heartbeatChannel?: Channel;
|
|
||||||
protected heartbeatIntervalId?: NodeJS.Timeout;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
protected sourceId = "sender-0",
|
|
||||||
protected destinationId = "receiver-0"
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a channel on the client connection with a given
|
|
||||||
* namespace.
|
|
||||||
*/
|
|
||||||
createChannel(
|
|
||||||
namespace: string,
|
|
||||||
sourceId = this.sourceId,
|
|
||||||
destinationId = this.destinationId
|
|
||||||
) {
|
|
||||||
return this.client.createChannel(
|
|
||||||
sourceId,
|
|
||||||
destinationId,
|
|
||||||
namespace,
|
|
||||||
"JSON"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
connect(host: string, port: number, onHeartbeat?: () => void) {
|
|
||||||
return new Promise<void>((resolve, reject) => {
|
|
||||||
// Handle errors
|
|
||||||
this.client.on("error", reject);
|
|
||||||
this.client.on("close", () => {
|
|
||||||
if (this.heartbeatChannel && this.heartbeatIntervalId) {
|
|
||||||
clearInterval(this.heartbeatIntervalId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.client.connect({ host, port }, () => {
|
|
||||||
this.connectionChannel = this.createChannel(NS_CONNECTION);
|
|
||||||
this.heartbeatChannel = this.createChannel(NS_HEARTBEAT);
|
|
||||||
|
|
||||||
this.connectionChannel.send({ type: "CONNECT" });
|
|
||||||
this.heartbeatChannel.send({ type: "PING" });
|
|
||||||
|
|
||||||
this.heartbeatIntervalId = setInterval(() => {
|
|
||||||
this.heartbeatChannel?.send({ type: "PING" });
|
|
||||||
if (onHeartbeat) {
|
|
||||||
onHeartbeat();
|
|
||||||
}
|
|
||||||
}, HEARTBEAT_INTERVAL);
|
|
||||||
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type OnSessionCreatedCallback = (sessionId: string) => void;
|
type OnSessionCreatedCallback = (sessionId: string) => void;
|
||||||
|
|
||||||
@@ -96,8 +33,6 @@ export default class Session extends CastClient {
|
|||||||
*/
|
*/
|
||||||
private launchRequestId?: number;
|
private launchRequestId?: number;
|
||||||
|
|
||||||
private onSessionCreated?: OnSessionCreatedCallback;
|
|
||||||
|
|
||||||
private establishAppConnection(transportId: string) {
|
private establishAppConnection(transportId: string) {
|
||||||
this.transportConnection = this.createChannel(
|
this.transportConnection = this.createChannel(
|
||||||
NS_CONNECTION,
|
NS_CONNECTION,
|
||||||
@@ -236,7 +171,11 @@ export default class Session extends CastClient {
|
|||||||
return requestId;
|
return requestId;
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(public appId: string, public receiverDevice: ReceiverDevice) {
|
constructor(
|
||||||
|
private appId: string,
|
||||||
|
private receiverDevice: ReceiverDevice,
|
||||||
|
private onSessionCreated?: OnSessionCreatedCallback
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.client.on("close", () => {
|
this.client.on("close", () => {
|
||||||
@@ -247,27 +186,19 @@ export default class Session extends CastClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
async connect(
|
super.connect(receiverDevice.host, {
|
||||||
host: string,
|
onHeartbeat: () => {
|
||||||
port: number,
|
// Include transport heartbeat with platform heartbeat
|
||||||
onSessionCreated?: OnSessionCreatedCallback
|
if (this.transportHeartbeat) {
|
||||||
) {
|
this.transportHeartbeat.send({ type: "PING" });
|
||||||
if (onSessionCreated) {
|
}
|
||||||
this.onSessionCreated = onSessionCreated;
|
|
||||||
}
|
|
||||||
|
|
||||||
await super.connect(host, port, () => {
|
|
||||||
// Include transport heartbeat with platform heartbeat
|
|
||||||
if (this.transportHeartbeat) {
|
|
||||||
this.transportHeartbeat.send({ type: "PING" });
|
|
||||||
}
|
}
|
||||||
});
|
}).then(() => {
|
||||||
|
this.launchRequestId = this.sendReceiverMessage({
|
||||||
this.launchRequestId = this.sendReceiverMessage({
|
type: "LAUNCH",
|
||||||
type: "LAUNCH",
|
appId: this.appId
|
||||||
appId: this.appId
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
90
app/src/bridge/components/cast/client.ts
Normal file
90
app/src/bridge/components/cast/client.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import { Channel, Client } from "castv2";
|
||||||
|
|
||||||
|
export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
|
||||||
|
export const NS_HEARTBEAT = "urn:x-cast:com.google.cast.tp.heartbeat";
|
||||||
|
export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
|
||||||
|
|
||||||
|
const DEFAULT_PORT = 8009;
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
interface CastClientConnectOptions {
|
||||||
|
port?: number;
|
||||||
|
onHeartbeat?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class CastClient {
|
||||||
|
protected client = new Client();
|
||||||
|
|
||||||
|
protected connectionChannel?: Channel;
|
||||||
|
protected heartbeatChannel?: Channel;
|
||||||
|
protected heartbeatIntervalId?: NodeJS.Timeout;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
protected sourceId = "sender-0",
|
||||||
|
protected destinationId = "receiver-0"
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a channel on the client connection with a given
|
||||||
|
* namespace.
|
||||||
|
*/
|
||||||
|
protected createChannel(
|
||||||
|
namespace: string,
|
||||||
|
sourceId = this.sourceId,
|
||||||
|
destinationId = this.destinationId
|
||||||
|
) {
|
||||||
|
return this.client.createChannel(
|
||||||
|
sourceId,
|
||||||
|
destinationId,
|
||||||
|
namespace,
|
||||||
|
"JSON"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connects to a cast receiver at a given host, returning a
|
||||||
|
* promise that resolves once the client is connected.
|
||||||
|
*/
|
||||||
|
connect(host: string, options?: CastClientConnectOptions) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
// Handle errors
|
||||||
|
this.client.on("error", reject);
|
||||||
|
this.client.on("close", () => {
|
||||||
|
if (this.heartbeatChannel && this.heartbeatIntervalId) {
|
||||||
|
clearInterval(this.heartbeatIntervalId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const connectOpts = {
|
||||||
|
host,
|
||||||
|
port: options?.port ?? DEFAULT_PORT
|
||||||
|
};
|
||||||
|
|
||||||
|
this.client.connect(connectOpts, () => {
|
||||||
|
this.connectionChannel = this.createChannel(NS_CONNECTION);
|
||||||
|
this.heartbeatChannel = this.createChannel(NS_HEARTBEAT);
|
||||||
|
|
||||||
|
this.connectionChannel.send({ type: "CONNECT" });
|
||||||
|
this.heartbeatChannel.send({ type: "PING" });
|
||||||
|
|
||||||
|
this.heartbeatIntervalId = setInterval(() => {
|
||||||
|
this.heartbeatChannel?.send({ type: "PING" });
|
||||||
|
options?.onHeartbeat?.();
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.heartbeatIntervalId) {
|
||||||
|
clearInterval(this.heartbeatIntervalId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectionChannel?.send({ type: "CLOSE" });
|
||||||
|
this.client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ import castv2 from "castv2";
|
|||||||
import { sendMessage } from "../../lib/nativeMessaging";
|
import { sendMessage } from "../../lib/nativeMessaging";
|
||||||
import { Message } from "../../messaging";
|
import { Message } from "../../messaging";
|
||||||
|
|
||||||
import Session, { NS_CONNECTION, NS_RECEIVER } from "./Session";
|
import Session from "./Session";
|
||||||
|
import { NS_CONNECTION, NS_RECEIVER } from "./client";
|
||||||
|
|
||||||
|
|
||||||
const sessions = new Map<string, Session>();
|
const sessions = new Map<string, Session>();
|
||||||
|
|
||||||
@@ -15,14 +17,9 @@ export function handleCastMessage(message: Message) {
|
|||||||
const { appId, receiverDevice } = message.data;
|
const { appId, receiverDevice } = message.data;
|
||||||
|
|
||||||
// Connect and store with returned ID
|
// Connect and store with returned ID
|
||||||
const session = new Session(appId, receiverDevice);
|
const session = new Session(appId, receiverDevice, sessionId => {
|
||||||
session.connect(
|
sessions.set(sessionId, session);
|
||||||
receiverDevice.host,
|
});
|
||||||
receiverDevice.port,
|
|
||||||
sessionId => {
|
|
||||||
sessions.set(sessionId, session);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
117
app/src/bridge/components/cast/remote.ts
Normal file
117
app/src/bridge/components/cast/remote.ts
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
import CastClient, { NS_RECEIVER } from "./client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MediaStatus,
|
||||||
|
ReceiverMessage,
|
||||||
|
ReceiverMediaMessage,
|
||||||
|
ReceiverStatus,
|
||||||
|
SenderMediaMessage
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const NS_MEDIA = "urn:x-cast:com.google.cast.media";
|
||||||
|
|
||||||
|
interface CastRemoteOptions {
|
||||||
|
onApplicationFound?: () => void;
|
||||||
|
onApplicationClose?: () => void;
|
||||||
|
onReceiverStatusUpdate?: (status: ReceiverStatus) => void;
|
||||||
|
onMediaStatusUpdate?: (status?: MediaStatus) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* castv2 client for receiver tracking.
|
||||||
|
*/
|
||||||
|
export default class Remote extends CastClient {
|
||||||
|
private transportClient?: RemoteTransport;
|
||||||
|
|
||||||
|
constructor(private host: string, private options?: CastRemoteOptions) {
|
||||||
|
super();
|
||||||
|
this.connect(host).then(() => {
|
||||||
|
// Request receiver status
|
||||||
|
const receiverChannel = this.createChannel(NS_RECEIVER);
|
||||||
|
receiverChannel.on("message", message => {
|
||||||
|
this.onReceiverMessage(message);
|
||||||
|
});
|
||||||
|
receiverChannel.send({ type: "GET_STATUS", requestId: 1 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
super.disconnect();
|
||||||
|
this.transportClient?.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle `NS_RECEIVER` messages from the receiver device.
|
||||||
|
* On initial connection, a `GET_STATUS` message is sent that
|
||||||
|
* results in a `RECEIVER_STATUS` response. If an application
|
||||||
|
* is running, get the transport ID and make a connection to
|
||||||
|
* fetch media status updates.
|
||||||
|
*/
|
||||||
|
private onReceiverMessage(message: ReceiverMessage) {
|
||||||
|
if (message.type !== "RECEIVER_STATUS") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const application = message.status.applications?.[0];
|
||||||
|
if (!application || application.isIdleScreen) {
|
||||||
|
// Handle app close
|
||||||
|
if (this.transportClient) {
|
||||||
|
this.transportClient = undefined;
|
||||||
|
this.options?.onApplicationClose?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status before possible transport init
|
||||||
|
this.options?.onReceiverStatusUpdate?.(message.status);
|
||||||
|
|
||||||
|
// Handle app creation/discovery
|
||||||
|
if (application && !this.transportClient) {
|
||||||
|
this.transportClient = new RemoteTransport(
|
||||||
|
application.transportId,
|
||||||
|
message => this.onMediaMessage(message)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.transportClient.connect(this.host).then(() => {
|
||||||
|
this.transportClient?.sendMediaMessage({
|
||||||
|
type: "GET_STATUS"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.options?.onApplicationFound?.();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle `NS_MEDIA` messages from the receiver application.
|
||||||
|
* On initial connection. a `GET_STATUS` message is sent that
|
||||||
|
* results in a `MEDIA_STATUS` response.
|
||||||
|
*/
|
||||||
|
private onMediaMessage(message: ReceiverMediaMessage) {
|
||||||
|
if (message.type !== "MEDIA_STATUS") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.options?.onMediaStatusUpdate?.(message.status[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* castv2 client for receiver application tracking.
|
||||||
|
*/
|
||||||
|
class RemoteTransport extends CastClient {
|
||||||
|
private mediaChannel = this.createChannel(NS_MEDIA);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
transportId: string,
|
||||||
|
onMediaMessage: (message: ReceiverMediaMessage) => void
|
||||||
|
) {
|
||||||
|
super(undefined, transportId);
|
||||||
|
this.mediaChannel.on("message", message => onMediaMessage(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMediaMessage(message: SenderMediaMessage) {
|
||||||
|
this.mediaChannel.send(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,9 +353,19 @@ interface MediaReqBase extends ReqBase {
|
|||||||
export type SenderMediaMessage =
|
export type SenderMediaMessage =
|
||||||
| (MediaReqBase & { type: "PLAY" })
|
| (MediaReqBase & { type: "PLAY" })
|
||||||
| (MediaReqBase & { type: "PAUSE" })
|
| (MediaReqBase & { type: "PAUSE" })
|
||||||
| (MediaReqBase & { type: "MEDIA_GET_STATUS" })
|
| {
|
||||||
|
type: "MEDIA_GET_STATUS";
|
||||||
|
mediaSessionId?: number;
|
||||||
|
customData?: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "GET_STATUS";
|
||||||
|
mediaSessionId?: number;
|
||||||
|
customData?: unknown;
|
||||||
|
}
|
||||||
| (MediaReqBase & { type: "STOP" })
|
| (MediaReqBase & { type: "STOP" })
|
||||||
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
|
||||||
|
| (MediaReqBase & { type: "SET_VOLUME"; volume: Volume })
|
||||||
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
|
||||||
| (ReqBase & {
|
| (ReqBase & {
|
||||||
type: "LOAD";
|
type: "LOAD";
|
||||||
|
|||||||
@@ -1,24 +1,28 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import { EventEmitter } from "events";
|
|
||||||
|
|
||||||
import { Channel, Client } from "castv2";
|
|
||||||
|
|
||||||
import mdns from "mdns";
|
import mdns from "mdns";
|
||||||
|
|
||||||
|
import Remote from "./cast/remote";
|
||||||
|
import { ReceiverDevice } from "../types";
|
||||||
import { sendMessage } from "../lib/nativeMessaging";
|
import { sendMessage } from "../lib/nativeMessaging";
|
||||||
|
|
||||||
import { ReceiverStatus } from "./cast/types";
|
/**
|
||||||
import { NS_CONNECTION, NS_HEARTBEAT, NS_RECEIVER } from "./cast/Session";
|
* Chromecast TXT record
|
||||||
|
*/
|
||||||
interface CastTxtRecord {
|
interface CastRecord {
|
||||||
|
// Device ID
|
||||||
id: string;
|
id: string;
|
||||||
|
// Model name (e.g. Chromecast, Google Nest Mini, etc...)
|
||||||
|
md: string;
|
||||||
|
// Friendly name (user-visible)
|
||||||
|
fn: string;
|
||||||
|
// Version (?)
|
||||||
|
ve: string;
|
||||||
|
// Icon path (?)
|
||||||
|
ic: string;
|
||||||
|
|
||||||
cd: string;
|
cd: string;
|
||||||
rm: string;
|
rm: string;
|
||||||
ve: string;
|
|
||||||
md: string;
|
|
||||||
ic: string;
|
|
||||||
fn: string;
|
|
||||||
ca: string;
|
ca: string;
|
||||||
st: string;
|
st: string;
|
||||||
bs: string;
|
bs: string;
|
||||||
@@ -37,89 +41,15 @@ const browser = mdns.createBrowser(mdns.tcp("googlecast"), {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
function onBrowserServiceUp(service: mdns.Service) {
|
|
||||||
// Ignore without txt record / name
|
|
||||||
if (!service.txtRecord || !service.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const txtRecord = service.txtRecord as CastTxtRecord;
|
|
||||||
sendMessage({
|
|
||||||
subject: "main:receiverDeviceUp",
|
|
||||||
data: {
|
|
||||||
receiverDevice: {
|
|
||||||
host: service.addresses[0],
|
|
||||||
port: service.port,
|
|
||||||
id: service.name,
|
|
||||||
friendlyName: txtRecord.fn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBrowserServiceDown(service: mdns.Service) {
|
|
||||||
// Ignore without name
|
|
||||||
if (!service.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const txtRecord = service.txtRecord as CastTxtRecord;
|
|
||||||
sendMessage({
|
|
||||||
subject: "main:receiverDeviceDown",
|
|
||||||
data: { receiverDeviceId: service.name }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
browser.on("serviceUp", onBrowserServiceUp);
|
|
||||||
browser.on("serviceDown", onBrowserServiceDown);
|
|
||||||
|
|
||||||
interface InitializeOptions {
|
interface InitializeOptions {
|
||||||
shouldWatchStatus?: boolean;
|
shouldWatchStatus?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let shouldWatchStatus: boolean;
|
||||||
export function startDiscovery(options: InitializeOptions) {
|
export function startDiscovery(options: InitializeOptions) {
|
||||||
if (options.shouldWatchStatus) {
|
shouldWatchStatus = options.shouldWatchStatus ?? false;
|
||||||
browser.on("serviceUp", onStatusBrowserServiceUp);
|
|
||||||
browser.on("serviceDown", onStatusBrowserServiceDown);
|
|
||||||
}
|
|
||||||
|
|
||||||
browser.start();
|
browser.start();
|
||||||
|
|
||||||
// Receiver status listeners for status mode
|
|
||||||
const statusListeners = new Map<string, StatusListener>();
|
|
||||||
|
|
||||||
function onStatusBrowserServiceUp(service: mdns.Service) {
|
|
||||||
if (!service.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listener = new StatusListener(service.addresses[0], service.port);
|
|
||||||
|
|
||||||
listener.on("receiverStatus", (status: ReceiverStatus) => {
|
|
||||||
if (!service.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
sendMessage({
|
|
||||||
subject: "main:receiverDeviceUpdated",
|
|
||||||
data: {
|
|
||||||
receiverDeviceId: service.name,
|
|
||||||
status
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
statusListeners.set(service.name, listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onStatusBrowserServiceDown(service: mdns.Service) {
|
|
||||||
if (!service.name) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const listener = statusListeners.get(service.name);
|
|
||||||
listener?.deregister();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopDiscovery() {
|
export function stopDiscovery() {
|
||||||
@@ -127,91 +57,73 @@ export function stopDiscovery() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a connection to a receiver device and forwards
|
* Map of device IDs to remote instances.
|
||||||
* RECEIVER_STATUS updates to the extension.
|
|
||||||
*/
|
*/
|
||||||
export default class StatusListener extends EventEmitter {
|
const remotes = new Map<string, Remote>();
|
||||||
private client: Client;
|
|
||||||
private clientReceiver?: Channel;
|
|
||||||
private clientHeartbeatIntervalId?: NodeJS.Timeout;
|
|
||||||
|
|
||||||
constructor(host: string, port: number) {
|
/**
|
||||||
super();
|
* When a service is found, gather device info from service object and
|
||||||
|
* TXT record, then send a `main:receiverDeviceUp` message.
|
||||||
|
*/
|
||||||
|
browser.on("serviceUp", service => {
|
||||||
|
// Filter invalid results
|
||||||
|
if (!service.txtRecord || !service.name) return;
|
||||||
|
|
||||||
this.client = new Client();
|
const record = service.txtRecord as CastRecord;
|
||||||
this.client.connect({ host, port }, this.onConnect.bind(this));
|
const device: ReceiverDevice = {
|
||||||
|
host: service.addresses[0],
|
||||||
|
port: service.port,
|
||||||
|
id: service.name,
|
||||||
|
friendlyName: record.fn
|
||||||
|
};
|
||||||
|
|
||||||
this.client.on("close", () => {
|
sendMessage({
|
||||||
clearInterval(this.clientHeartbeatIntervalId!);
|
subject: "main:receiverDeviceUp",
|
||||||
});
|
data: {
|
||||||
|
deviceId: service.name,
|
||||||
this.client.on("error", () => {
|
deviceInfo: device
|
||||||
clearInterval(this.clientHeartbeatIntervalId!);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Closes status listener connection.
|
|
||||||
*/
|
|
||||||
public deregister(): void {
|
|
||||||
try {
|
|
||||||
this.clientReceiver?.send({ type: "CLOSE" });
|
|
||||||
} catch (err) {
|
|
||||||
// Supress
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
this.client.close();
|
if (shouldWatchStatus) {
|
||||||
|
remotes.set(
|
||||||
|
service.name,
|
||||||
|
new Remote(device.host, {
|
||||||
|
// RECEIVER_STATUS
|
||||||
|
onReceiverStatusUpdate(status) {
|
||||||
|
sendMessage({
|
||||||
|
subject: "main:receiverDeviceStatusUpdated",
|
||||||
|
data: { deviceId: device.id, status }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// MEDIA_STATUS
|
||||||
|
onMediaStatusUpdate(status) {
|
||||||
|
if (!status) return;
|
||||||
|
|
||||||
|
sendMessage({
|
||||||
|
subject: "main:receiverDeviceMediaStatusUpdated",
|
||||||
|
data: { deviceId: device.id, status }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
private onConnect(): void {
|
/**
|
||||||
const sourceId = "sender-0";
|
* When a service is lost, send a `main:receiverDeviceDown` message with
|
||||||
const destinationId = "receiver-0";
|
* the service name as the `deviceId`.
|
||||||
|
*/
|
||||||
|
browser.on("serviceDown", service => {
|
||||||
|
// Filter invalid results
|
||||||
|
if (!service.name) return;
|
||||||
|
|
||||||
const clientConnection = this.client.createChannel(
|
sendMessage({
|
||||||
sourceId,
|
subject: "main:receiverDeviceDown",
|
||||||
destinationId,
|
data: { deviceId: service.name }
|
||||||
NS_CONNECTION,
|
});
|
||||||
"JSON"
|
|
||||||
);
|
|
||||||
const clientHeartbeat = this.client.createChannel(
|
|
||||||
sourceId,
|
|
||||||
destinationId,
|
|
||||||
NS_HEARTBEAT,
|
|
||||||
"JSON"
|
|
||||||
);
|
|
||||||
const clientReceiver = this.client.createChannel(
|
|
||||||
sourceId,
|
|
||||||
destinationId,
|
|
||||||
NS_RECEIVER,
|
|
||||||
"JSON"
|
|
||||||
);
|
|
||||||
|
|
||||||
clientReceiver.on("message", data => {
|
if (shouldWatchStatus) {
|
||||||
switch (data.type) {
|
remotes.get(service.name)?.disconnect();
|
||||||
case "CLOSE": {
|
|
||||||
this.client.close();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "RECEIVER_STATUS": {
|
|
||||||
this.emit("receiverStatus", data.status);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "MEDIA_STATUS": {
|
|
||||||
this.emit("mediaStatus", data.status);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
clientConnection.send({ type: "CONNECT" });
|
|
||||||
clientHeartbeat.send({ type: "PING" });
|
|
||||||
clientReceiver.send({ type: "GET_STATUS", requestId: 1 });
|
|
||||||
|
|
||||||
this.clientReceiver = clientReceiver;
|
|
||||||
|
|
||||||
this.clientHeartbeatIntervalId = setInterval(() => {
|
|
||||||
clientHeartbeat.send({ type: "PING" });
|
|
||||||
}, 5000);
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Image,
|
Image,
|
||||||
|
MediaStatus,
|
||||||
ReceiverStatus,
|
ReceiverStatus,
|
||||||
SenderApplication,
|
SenderApplication,
|
||||||
SenderMessage,
|
SenderMessage,
|
||||||
@@ -30,6 +31,11 @@ interface CastSessionCreated extends CastSessionUpdated {
|
|||||||
transportId: string;
|
transportId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Messages that cross the native messaging channel. MUST keep
|
||||||
|
* in-sync with the extension's version at:
|
||||||
|
* ext/src/messaging.ts > MessageDefinitions
|
||||||
|
*/
|
||||||
type MessageDefinitions = {
|
type MessageDefinitions = {
|
||||||
"shim:castSessionCreated": CastSessionCreated;
|
"shim:castSessionCreated": CastSessionCreated;
|
||||||
"shim:castSessionUpdated": CastSessionUpdated;
|
"shim:castSessionUpdated": CastSessionUpdated;
|
||||||
@@ -98,12 +104,16 @@ type MessageDefinitions = {
|
|||||||
id: string;
|
id: string;
|
||||||
status: ReceiverStatus;
|
status: ReceiverStatus;
|
||||||
};
|
};
|
||||||
"main:receiverDeviceUp": { receiverDevice: ReceiverDevice };
|
"main:receiverDeviceUp": { deviceId: string, deviceInfo: ReceiverDevice };
|
||||||
"main:receiverDeviceDown": { receiverDeviceId: string };
|
"main:receiverDeviceDown": { deviceId: string };
|
||||||
"main:receiverDeviceUpdated": {
|
"main:receiverDeviceStatusUpdated": {
|
||||||
receiverDeviceId: string;
|
deviceId: string;
|
||||||
status: ReceiverStatus;
|
status: ReceiverStatus;
|
||||||
};
|
};
|
||||||
|
"main:receiverDeviceMediaStatusUpdated": {
|
||||||
|
deviceId: string;
|
||||||
|
status: MediaStatus;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
interface MessageBase<K extends keyof MessageDefinitions> {
|
interface MessageBase<K extends keyof MessageDefinitions> {
|
||||||
@@ -116,8 +126,8 @@ type Messages = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For better call semantics, make message data key optional if
|
* Make message data key optional if specified as blank or with
|
||||||
* specified as blank or with all-optional keys.
|
* all-optional keys.
|
||||||
*/
|
*/
|
||||||
type NarrowedMessage<L extends MessageBase<keyof MessageDefinitions>> =
|
type NarrowedMessage<L extends MessageBase<keyof MessageDefinitions>> =
|
||||||
L extends any
|
L extends any
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ import { ReceiverDevice } from "../types";
|
|||||||
import { ReceiverStatus } from "../shim/cast/types";
|
import { ReceiverStatus } from "../shim/cast/types";
|
||||||
|
|
||||||
interface EventMap {
|
interface EventMap {
|
||||||
receiverDeviceUp: { receiverDevice: ReceiverDevice };
|
receiverDeviceUp: { deviceInfo: ReceiverDevice };
|
||||||
receiverDeviceDown: { receiverDeviceId: string };
|
receiverDeviceDown: { deviceId: string };
|
||||||
receiverDeviceUpdated: {
|
receiverDeviceUpdated: {
|
||||||
receiverDeviceId: string;
|
deviceId: string;
|
||||||
status: ReceiverStatus;
|
status: ReceiverStatus;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,6 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
private receiverDevices = new Map<string, ReceiverDevice>();
|
private receiverDevices = new Map<string, ReceiverDevice>();
|
||||||
|
|
||||||
private bridgePort?: Port;
|
private bridgePort?: Port;
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
if (!this.bridgePort) {
|
if (!this.bridgePort) {
|
||||||
await this.refresh();
|
await this.refresh();
|
||||||
@@ -38,21 +37,19 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
*/
|
*/
|
||||||
async refresh() {
|
async refresh() {
|
||||||
this.bridgePort?.disconnect();
|
this.bridgePort?.disconnect();
|
||||||
|
this.receiverDevices.clear();
|
||||||
|
|
||||||
const port = await bridge.connect();
|
this.bridgePort = await bridge.connect();
|
||||||
|
this.bridgePort.onMessage.addListener(this.onBridgeMessage);
|
||||||
|
this.bridgePort.onDisconnect.addListener(this.onBridgeDisconnect);
|
||||||
|
|
||||||
port.onMessage.addListener(this.onBridgeMessage);
|
this.bridgePort.postMessage({
|
||||||
port.onDisconnect.addListener(this.onBridgeDisconnect);
|
|
||||||
|
|
||||||
port.postMessage({
|
|
||||||
subject: "bridge:startDiscovery",
|
subject: "bridge:startDiscovery",
|
||||||
data: {
|
data: {
|
||||||
// Also send back status messages
|
// Also send back status messages
|
||||||
shouldWatchStatus: true
|
shouldWatchStatus: true
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.bridgePort = port;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,12 +82,12 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
private onBridgeMessage = (message: Message) => {
|
private onBridgeMessage = (message: Message) => {
|
||||||
switch (message.subject) {
|
switch (message.subject) {
|
||||||
case "main:receiverDeviceUp": {
|
case "main:receiverDeviceUp": {
|
||||||
const { receiverDevice } = message.data;
|
const { deviceId, deviceInfo } = message.data;
|
||||||
|
|
||||||
this.receiverDevices.set(receiverDevice.id, receiverDevice);
|
this.receiverDevices.set(deviceId, deviceInfo);
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent("receiverDeviceUp", {
|
new CustomEvent("receiverDeviceUp", {
|
||||||
detail: { receiverDevice }
|
detail: { deviceInfo }
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -98,29 +95,26 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "main:receiverDeviceDown": {
|
case "main:receiverDeviceDown": {
|
||||||
const { receiverDeviceId } = message.data;
|
const { deviceId } = message.data;
|
||||||
|
|
||||||
if (this.receiverDevices.has(receiverDeviceId)) {
|
if (this.receiverDevices.has(deviceId)) {
|
||||||
this.receiverDevices.delete(receiverDeviceId);
|
this.receiverDevices.delete(deviceId);
|
||||||
}
|
}
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent("receiverDeviceDown", {
|
new CustomEvent("receiverDeviceDown", {
|
||||||
detail: { receiverDeviceId }
|
detail: { deviceId: deviceId }
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "main:receiverDeviceUpdated": {
|
case "main:receiverDeviceStatusUpdated": {
|
||||||
const { receiverDeviceId, status } = message.data;
|
const { deviceId, status } = message.data;
|
||||||
const receiverDevice =
|
const receiverDevice = this.receiverDevices.get(deviceId);
|
||||||
this.receiverDevices.get(receiverDeviceId);
|
|
||||||
|
|
||||||
if (!receiverDevice) {
|
if (!receiverDevice) {
|
||||||
logger.error(
|
logger.error(`Receiver ID \`${deviceId}\` not found!`);
|
||||||
`Receiver ID \`${receiverDeviceId}\` not found!`
|
|
||||||
);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,11 +134,17 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
new CustomEvent("receiverDeviceUpdated", {
|
new CustomEvent("receiverDeviceUpdated", {
|
||||||
detail: {
|
detail: {
|
||||||
receiverDeviceId,
|
deviceId,
|
||||||
status: receiverDevice.status
|
status: receiverDevice.status
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "main:receiverDeviceMediaStatusUpdated": {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -153,7 +153,7 @@ export default new (class extends TypedEventTarget<EventMap> {
|
|||||||
// Notify listeners of device availablility
|
// Notify listeners of device availablility
|
||||||
for (const [, receiverDevice] of this.receiverDevices) {
|
for (const [, receiverDevice] of this.receiverDevices) {
|
||||||
const event = new CustomEvent("receiverDeviceDown", {
|
const event = new CustomEvent("receiverDeviceDown", {
|
||||||
detail: { receiverDeviceId: receiverDevice.id }
|
detail: { deviceId: receiverDevice.id }
|
||||||
});
|
});
|
||||||
|
|
||||||
this.dispatchEvent(event);
|
this.dispatchEvent(event);
|
||||||
|
|||||||
@@ -167,9 +167,9 @@ export default class ReceiverSelector extends TypedEventTarget<ReceiverSelectorE
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!this.receivers ||
|
this.receivers === undefined ||
|
||||||
!this.defaultMediaType ||
|
this.defaultMediaType === undefined ||
|
||||||
!this.availableMediaTypes
|
this.availableMediaTypes === undefined
|
||||||
) {
|
) {
|
||||||
throw logger.error("Popup receiver data not found.");
|
throw logger.error("Popup receiver data not found.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
import { ReceiverDevice } from "../../types";
|
||||||
|
|
||||||
export enum ReceiverSelectorType {
|
export enum ReceiverSelectorType {
|
||||||
Popup,
|
Popup,
|
||||||
Native
|
Native
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ReceiverSelectorMediaType {
|
export enum ReceiverSelectorMediaType {
|
||||||
|
None = 0,
|
||||||
App = 1,
|
App = 1,
|
||||||
Tab = 2,
|
Tab = 2,
|
||||||
Screen = 4,
|
Screen = 4,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function getMediaTypesForPageUrl(
|
|||||||
pageUrl: string
|
pageUrl: string
|
||||||
): ReceiverSelectorMediaType {
|
): ReceiverSelectorMediaType {
|
||||||
const url = new URL(pageUrl);
|
const url = new URL(pageUrl);
|
||||||
let availableMediaTypes = ReceiverSelectorMediaType.File;
|
let availableMediaTypes = ReceiverSelectorMediaType.None;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content scripts are prohibited from running on some
|
* Content scripts are prohibited from running on some
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
CastSessionCreated,
|
CastSessionCreated,
|
||||||
CastSessionUpdated,
|
CastSessionUpdated,
|
||||||
|
MediaStatus,
|
||||||
ReceiverStatus,
|
ReceiverStatus,
|
||||||
SenderMessage
|
SenderMessage
|
||||||
} from "./shim/cast/types";
|
} from "./shim/cast/types";
|
||||||
@@ -37,7 +38,9 @@ import { ReceiverDevice } from "./types";
|
|||||||
* components.
|
* components.
|
||||||
*/
|
*/
|
||||||
type ExtMessageDefinitions = {
|
type ExtMessageDefinitions = {
|
||||||
"popup:init": { appId?: string };
|
"popup:init": {
|
||||||
|
appId?: string;
|
||||||
|
};
|
||||||
"popup:update": {
|
"popup:update": {
|
||||||
receivers: ReceiverDevice[];
|
receivers: ReceiverDevice[];
|
||||||
defaultMediaType?: ReceiverSelectorMediaType;
|
defaultMediaType?: ReceiverSelectorMediaType;
|
||||||
@@ -61,7 +64,7 @@ type ExtMessageDefinitions = {
|
|||||||
/**
|
/**
|
||||||
* Messages that cross the native messaging channel. MUST keep
|
* Messages that cross the native messaging channel. MUST keep
|
||||||
* in-sync with the bridge's version at:
|
* in-sync with the bridge's version at:
|
||||||
* app/bridge/messaging.ts > MessagesBase
|
* app/src/bridge/messaging.ts > MessageDefinitions
|
||||||
*/
|
*/
|
||||||
type AppMessageDefinitions = {
|
type AppMessageDefinitions = {
|
||||||
"shim:castSessionCreated": CastSessionCreated;
|
"shim:castSessionCreated": CastSessionCreated;
|
||||||
@@ -125,12 +128,18 @@ type AppMessageDefinitions = {
|
|||||||
};
|
};
|
||||||
"mediaCast:mediaServerStopped": {};
|
"mediaCast:mediaServerStopped": {};
|
||||||
"mediaCast:mediaServerError": {};
|
"mediaCast:mediaServerError": {};
|
||||||
"main:receiverDeviceUp": { receiverDevice: ReceiverDevice };
|
|
||||||
"main:receiverDeviceDown": { receiverDeviceId: string };
|
// Device discovery
|
||||||
"main:receiverDeviceUpdated": {
|
"main:receiverDeviceUp": { deviceId: string; deviceInfo: ReceiverDevice };
|
||||||
receiverDeviceId: string;
|
"main:receiverDeviceDown": { deviceId: string };
|
||||||
|
"main:receiverDeviceStatusUpdated": {
|
||||||
|
deviceId: string;
|
||||||
status: ReceiverStatus;
|
status: ReceiverStatus;
|
||||||
};
|
};
|
||||||
|
"main:receiverDeviceMediaStatusUpdated": {
|
||||||
|
deviceId: string;
|
||||||
|
status: MediaStatus;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type MessageDefinitions = ExtMessageDefinitions & AppMessageDefinitions;
|
type MessageDefinitions = ExtMessageDefinitions & AppMessageDefinitions;
|
||||||
@@ -145,8 +154,8 @@ type Messages = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For better call semantics, make message data key optional if
|
* Make message data key optional if specified as blank or with
|
||||||
* specified as blank or with all-optional keys.
|
* all-optional keys.
|
||||||
*/
|
*/
|
||||||
type NarrowedMessage<L extends MessageBase<keyof MessageDefinitions>> =
|
type NarrowedMessage<L extends MessageBase<keyof MessageDefinitions>> =
|
||||||
L extends any
|
L extends any
|
||||||
@@ -161,29 +170,24 @@ export type Message = NarrowedMessage<Messages[keyof Messages]>;
|
|||||||
/**
|
/**
|
||||||
* Typed WebExtension-style messaging utility class.
|
* Typed WebExtension-style messaging utility class.
|
||||||
*/
|
*/
|
||||||
class Messenger<T> {
|
export default new (class Messenger {
|
||||||
connect(connectInfo: { name: string }) {
|
connect(connectInfo: { name: string }) {
|
||||||
return browser.runtime.connect(connectInfo) as unknown as TypedPort<T>;
|
return browser.runtime.connect(connectInfo) as unknown as Port;
|
||||||
}
|
}
|
||||||
|
|
||||||
connectTab(tabId: number, connectInfo: { name: string; frameId: number }) {
|
connectTab(tabId: number, connectInfo: { name: string; frameId: number }) {
|
||||||
return browser.tabs.connect(
|
return browser.tabs.connect(tabId, connectInfo) as unknown as Port;
|
||||||
tabId,
|
|
||||||
connectInfo
|
|
||||||
) as unknown as TypedPort<T>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onConnect = {
|
onConnect = {
|
||||||
addListener(cb: (port: TypedPort<T>) => void) {
|
addListener(cb: (port: Port) => void) {
|
||||||
browser.runtime.onConnect.addListener(cb as any);
|
browser.runtime.onConnect.addListener(cb as any);
|
||||||
},
|
},
|
||||||
removeListener(cb: (port: TypedPort<T>) => void) {
|
removeListener(cb: (port: Port) => void) {
|
||||||
browser.runtime.onConnect.removeListener(cb as any);
|
browser.runtime.onConnect.removeListener(cb as any);
|
||||||
},
|
},
|
||||||
hasListener(cb: (port: TypedPort<T>) => void) {
|
hasListener(cb: (port: Port) => void) {
|
||||||
return browser.runtime.onConnect.hasListener(cb as any);
|
return browser.runtime.onConnect.hasListener(cb as any);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
})();
|
||||||
|
|
||||||
export default new Messenger<Message>();
|
|
||||||
|
|||||||
@@ -11,23 +11,22 @@ import messaging, { Message, Port } from "../../messaging";
|
|||||||
import { getNextEllipsis } from "../../lib/utils";
|
import { getNextEllipsis } from "../../lib/utils";
|
||||||
import { ReceiverDevice } from "../../types";
|
import { ReceiverDevice } from "../../types";
|
||||||
|
|
||||||
import { ReceiverSelectionActionType
|
import {
|
||||||
, ReceiverSelectorMediaType } from "../../background/receiverSelector";
|
ReceiverSelectionActionType,
|
||||||
|
ReceiverSelectorMediaType
|
||||||
|
} from "../../background/receiverSelector";
|
||||||
|
|
||||||
const _ = browser.i18n.getMessage;
|
const _ = browser.i18n.getMessage;
|
||||||
|
|
||||||
// macOS styles
|
// macOS styles
|
||||||
browser.runtime.getPlatformInfo()
|
browser.runtime.getPlatformInfo().then(platformInfo => {
|
||||||
.then(platformInfo => {
|
if (platformInfo.os === "mac") {
|
||||||
if (platformInfo.os === "mac") {
|
const link = document.createElement("link");
|
||||||
const link = document.createElement("link");
|
link.rel = "stylesheet";
|
||||||
link.rel = "stylesheet";
|
link.href = "styles/mac.css";
|
||||||
link.href = "styles/mac.css";
|
document.head.appendChild(link);
|
||||||
document.head.appendChild(link);
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
interface PopupAppProps {}
|
interface PopupAppProps {}
|
||||||
interface PopupAppState {
|
interface PopupAppState {
|
||||||
@@ -51,11 +50,11 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
|||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
receivers: []
|
receivers: [],
|
||||||
, mediaType: ReceiverSelectorMediaType.App
|
mediaType: ReceiverSelectorMediaType.App,
|
||||||
, availableMediaTypes: ReceiverSelectorMediaType.App
|
availableMediaTypes: ReceiverSelectorMediaType.App,
|
||||||
, isLoading: false
|
isLoading: false,
|
||||||
, mirroringEnabled: false
|
mirroringEnabled: false
|
||||||
};
|
};
|
||||||
|
|
||||||
// Store window ref
|
// Store window ref
|
||||||
@@ -63,11 +62,28 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
|||||||
this.win = win;
|
this.win = win;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
new ResizeObserver(() => {
|
||||||
|
this.updateWindowHeight();
|
||||||
|
}).observe(document.body);
|
||||||
|
|
||||||
this.onSelectChange = this.onSelectChange.bind(this);
|
this.onSelectChange = this.onSelectChange.bind(this);
|
||||||
this.onCast = this.onCast.bind(this);
|
this.onCast = this.onCast.bind(this);
|
||||||
this.onStop = this.onStop.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() {
|
public async componentDidMount() {
|
||||||
this.port = messaging.connect({ name: "popup" });
|
this.port = messaging.connect({ name: "popup" });
|
||||||
|
|
||||||
@@ -82,18 +98,19 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "popup:update": {
|
case "popup:update": {
|
||||||
const { receivers
|
const { receivers, availableMediaTypes, defaultMediaType } =
|
||||||
, availableMediaTypes
|
message.data;
|
||||||
, defaultMediaType } = message.data;
|
|
||||||
|
|
||||||
this.defaultMediaType = defaultMediaType;
|
|
||||||
|
|
||||||
this.setState({ receivers });
|
this.setState({ receivers });
|
||||||
|
|
||||||
if (availableMediaTypes && defaultMediaType) {
|
if (
|
||||||
|
availableMediaTypes !== undefined &&
|
||||||
|
defaultMediaType !== undefined
|
||||||
|
) {
|
||||||
|
this.defaultMediaType = defaultMediaType;
|
||||||
this.setState({
|
this.setState({
|
||||||
availableMediaTypes
|
availableMediaTypes,
|
||||||
, mediaType: defaultMediaType
|
mediaType: defaultMediaType
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,17 +131,7 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
|||||||
|
|
||||||
public componentDidUpdate() {
|
public componentDidUpdate() {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (this.win?.id === undefined) {
|
this.updateWindowHeight();
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fit window to content height
|
|
||||||
const frameHeight = window.outerHeight - window.innerHeight;
|
|
||||||
const windowHeight = document.body.clientHeight + frameHeight;
|
|
||||||
|
|
||||||
browser.windows.update(this.win.id, {
|
|
||||||
height: windowHeight
|
|
||||||
});
|
|
||||||
}, 1);
|
}, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,72 +153,99 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const isAppMediaTypeSelected =
|
const isAppMediaTypeSelected =
|
||||||
this.state.mediaType === ReceiverSelectorMediaType.App;
|
this.state.mediaType === ReceiverSelectorMediaType.App;
|
||||||
const isTabMediaTypeSelected =
|
const isTabMediaTypeSelected =
|
||||||
this.state.mediaType === ReceiverSelectorMediaType.Tab;
|
this.state.mediaType === ReceiverSelectorMediaType.Tab;
|
||||||
const isScreenMediaTypeSelected =
|
const isScreenMediaTypeSelected =
|
||||||
this.state.mediaType === ReceiverSelectorMediaType.Screen;
|
this.state.mediaType === ReceiverSelectorMediaType.Screen;
|
||||||
|
|
||||||
const isSelectedMediaTypeAvailable =
|
const isSelectedMediaTypeAvailable = !!(
|
||||||
!!(this.state.availableMediaTypes & this.state.mediaType);
|
this.state.availableMediaTypes & this.state.mediaType
|
||||||
const isAppMediaTypeAvailable = !!(this.state.availableMediaTypes
|
);
|
||||||
& ReceiverSelectorMediaType.App);
|
const isAppMediaTypeAvailable = !!(
|
||||||
|
this.state.availableMediaTypes & ReceiverSelectorMediaType.App
|
||||||
|
);
|
||||||
|
|
||||||
return <>
|
|
||||||
<div className="media-select">
|
return (
|
||||||
<div className="media-select__label-cast">
|
<>
|
||||||
{ _("popupMediaSelectCastLabel") }
|
<div className="media-select">
|
||||||
</div>
|
<div className="media-select__label-cast">
|
||||||
<div className="select-wrapper">
|
{_("popupMediaSelectCastLabel")}
|
||||||
<select onChange={ this.onSelectChange }
|
</div>
|
||||||
|
<div className="select-wrapper">
|
||||||
|
<select
|
||||||
|
onChange={this.onSelectChange}
|
||||||
className="media-select__dropdown"
|
className="media-select__dropdown"
|
||||||
disabled={ this.state.availableMediaTypes === 0 }>
|
disabled={
|
||||||
|
this.state.availableMediaTypes ===
|
||||||
|
ReceiverSelectorMediaType.None
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value={ReceiverSelectorMediaType.App}
|
||||||
|
selected={isAppMediaTypeSelected}
|
||||||
|
disabled={!isAppMediaTypeAvailable}
|
||||||
|
>
|
||||||
|
{(this.state.appId &&
|
||||||
|
knownApps[this.state.appId]?.name) ??
|
||||||
|
_("popupMediaTypeApp")}
|
||||||
|
</option>
|
||||||
|
|
||||||
<option value={ ReceiverSelectorMediaType.App }
|
{this.state.mirroringEnabled && (
|
||||||
selected={ isAppMediaTypeSelected }
|
<>
|
||||||
disabled={ !isAppMediaTypeAvailable }>
|
<option
|
||||||
{ (this.state.appId && knownApps[this.state.appId]?.name)
|
value={ReceiverSelectorMediaType.Tab}
|
||||||
?? (isAppMediaTypeAvailable
|
selected={isTabMediaTypeSelected}
|
||||||
? _("popupMediaTypeApp")
|
disabled={
|
||||||
: _("popupMediaTypeAppNotFound")) }
|
!(
|
||||||
</option>
|
this.state.availableMediaTypes &
|
||||||
|
ReceiverSelectorMediaType.Tab
|
||||||
{ this.state.mirroringEnabled &&
|
)
|
||||||
<>
|
}
|
||||||
<option value={ ReceiverSelectorMediaType.Tab }
|
>
|
||||||
selected={ isTabMediaTypeSelected }
|
{_("popupMediaTypeTab")}
|
||||||
disabled={ !(this.state.availableMediaTypes
|
</option>
|
||||||
& ReceiverSelectorMediaType.Tab) }>
|
<option
|
||||||
{ _("popupMediaTypeTab") }
|
value={ReceiverSelectorMediaType.Screen}
|
||||||
</option>
|
selected={isScreenMediaTypeSelected}
|
||||||
<option value={ ReceiverSelectorMediaType.Screen }
|
disabled={
|
||||||
selected={ isScreenMediaTypeSelected }
|
!(
|
||||||
disabled={ !(this.state.availableMediaTypes
|
this.state.availableMediaTypes &
|
||||||
& ReceiverSelectorMediaType.Screen) }>
|
ReceiverSelectorMediaType.Screen
|
||||||
{ _("popupMediaTypeScreen") }
|
)
|
||||||
</option>
|
}
|
||||||
</> }
|
>
|
||||||
</select>
|
{_("popupMediaTypeScreen")}
|
||||||
|
</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="media-select__label-to">
|
||||||
|
{_("popupMediaSelectToLabel")}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="media-select__label-to">
|
<ul className="receivers">
|
||||||
{ _("popupMediaSelectToLabel") }
|
{this.state.receivers && this.state.receivers.length ? (
|
||||||
</div>
|
this.state.receivers.map((receiver, i) => (
|
||||||
</div>
|
<ReceiverEntry
|
||||||
<ul className="receivers">
|
receiver={receiver}
|
||||||
{ this.state.receivers && this.state.receivers.length
|
onCast={this.onCast}
|
||||||
? this.state.receivers.map((receiver, i) => (
|
onStop={this.onStop}
|
||||||
<ReceiverEntry receiver={ receiver }
|
isLoading={this.state.isLoading}
|
||||||
onCast={ this.onCast }
|
canCast={isSelectedMediaTypeAvailable}
|
||||||
onStop={ this.onStop }
|
key={i}
|
||||||
isLoading={ this.state.isLoading }
|
/>
|
||||||
canCast={ isSelectedMediaTypeAvailable }
|
))
|
||||||
key={ i } /> ))
|
) : (
|
||||||
: (
|
|
||||||
<div className="receivers__not-found">
|
<div className="receivers__not-found">
|
||||||
{ _("popupNoReceiversFound") }
|
{_("popupNoReceiversFound")}
|
||||||
</div> )}
|
</div>
|
||||||
</ul>
|
)}
|
||||||
</>;
|
</ul>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private onCast(receiver: ReceiverDevice) {
|
private onCast(receiver: ReceiverDevice) {
|
||||||
@@ -220,22 +254,22 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
this.port?.postMessage({
|
this.port?.postMessage({
|
||||||
subject: "receiverSelector:selected"
|
subject: "receiverSelector:selected",
|
||||||
, data: {
|
data: {
|
||||||
actionType: ReceiverSelectionActionType.Cast
|
actionType: ReceiverSelectionActionType.Cast,
|
||||||
, receiver
|
receiver,
|
||||||
, mediaType: this.state.mediaType
|
mediaType: this.state.mediaType,
|
||||||
, filePath: this.state.filePath
|
filePath: this.state.filePath
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private onStop(receiver: ReceiverDevice) {
|
private onStop(receiver: ReceiverDevice) {
|
||||||
this.port?.postMessage({
|
this.port?.postMessage({
|
||||||
subject: "receiverSelector:stop"
|
subject: "receiverSelector:stop",
|
||||||
, data: {
|
data: {
|
||||||
actionType: ReceiverSelectionActionType.Stop
|
actionType: ReceiverSelectionActionType.Stop,
|
||||||
, receiver
|
receiver
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -247,8 +281,8 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
|||||||
const fileUrl = window.prompt();
|
const fileUrl = window.prompt();
|
||||||
if (fileUrl) {
|
if (fileUrl) {
|
||||||
this.setState({
|
this.setState({
|
||||||
mediaType
|
mediaType,
|
||||||
, filePath: fileUrl
|
filePath: fileUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@@ -272,13 +306,12 @@ class PopupApp extends Component<PopupAppProps, PopupAppState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface ReceiverEntryProps {
|
interface ReceiverEntryProps {
|
||||||
receiver: ReceiverDevice;
|
receiver: ReceiverDevice;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
canCast: boolean;
|
canCast: boolean;
|
||||||
onCast (receiver: ReceiverDevice): void;
|
onCast(receiver: ReceiverDevice): void;
|
||||||
onStop (receiver: ReceiverDevice): void;
|
onStop(receiver: ReceiverDevice): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ReceiverEntryState {
|
interface ReceiverEntryState {
|
||||||
@@ -292,9 +325,9 @@ class ReceiverEntry extends Component<ReceiverEntryProps, ReceiverEntryState> {
|
|||||||
super(props);
|
super(props);
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
ellipsis: ""
|
ellipsis: "",
|
||||||
, isLoading: false
|
isLoading: false,
|
||||||
, showAlternateAction: false
|
showAlternateAction: false
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleActionKeyEvents = (ev: KeyboardEvent) => {
|
const handleActionKeyEvents = (ev: KeyboardEvent) => {
|
||||||
@@ -315,41 +348,40 @@ class ReceiverEntry extends Component<ReceiverEntryProps, ReceiverEntryState> {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
this.handleCast = this.handleCast.bind(this);
|
this.handleCast = this.handleCast.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
public render() {
|
public render() {
|
||||||
const { status } = this.props.receiver;
|
const { status } = this.props.receiver;
|
||||||
if (!status) {
|
const application = status?.applications?.[0];
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const application = status.applications?.[0];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="receiver">
|
<li className="receiver">
|
||||||
<div className="receiver__name">
|
<div className="receiver__name">
|
||||||
{ this.props.receiver.friendlyName }
|
{this.props.receiver.friendlyName}
|
||||||
</div>
|
</div>
|
||||||
<div className="receiver__address">
|
<div className="receiver__address">
|
||||||
{ application && !application.isIdleScreen
|
{application && !application.isIdleScreen
|
||||||
? application.statusText
|
? application.statusText
|
||||||
: `${this.props.receiver.host}:${this.props.receiver.port}` }
|
: `${this.props.receiver.host}:${this.props.receiver.port}`}
|
||||||
</div>
|
</div>
|
||||||
<button className="button receiver__connect"
|
<button
|
||||||
onClick={ this.handleCast }
|
className="button receiver__connect"
|
||||||
disabled={ this.state.showAlternateAction
|
onClick={this.handleCast}
|
||||||
|
disabled={
|
||||||
|
this.state.showAlternateAction
|
||||||
? !application || application.isIdleScreen
|
? !application || application.isIdleScreen
|
||||||
: this.props.isLoading || !this.props.canCast }>
|
: this.props.isLoading || !this.props.canCast
|
||||||
{ this.state.isLoading
|
}
|
||||||
? _("popupCastingButtonTitle"
|
>
|
||||||
, (this.state.isLoading
|
{this.state.isLoading
|
||||||
? this.state.ellipsis
|
? _(
|
||||||
: ""))
|
"popupCastingButtonTitle",
|
||||||
|
this.state.isLoading ? this.state.ellipsis : ""
|
||||||
|
)
|
||||||
: this.state.showAlternateAction
|
: this.state.showAlternateAction
|
||||||
? _("popupStopButtonTitle")
|
? _("popupStopButtonTitle")
|
||||||
: _("popupCastButtonTitle") }
|
: _("popupCastButtonTitle")}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
@@ -376,18 +408,14 @@ class ReceiverEntry extends Component<ReceiverEntryProps, ReceiverEntryState> {
|
|||||||
this.setState(state => ({
|
this.setState(state => ({
|
||||||
ellipsis: getNextEllipsis(state.ellipsis)
|
ellipsis: getNextEllipsis(state.ellipsis)
|
||||||
}));
|
}));
|
||||||
|
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Render after CSS has loaded
|
// Render after CSS has loaded
|
||||||
window.addEventListener("load", () => {
|
window.addEventListener("load", () => {
|
||||||
ReactDOM.render(
|
ReactDOM.render(<PopupApp />, document.querySelector("#root"));
|
||||||
<PopupApp />
|
|
||||||
, document.querySelector("#root"));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
window.addEventListener("contextmenu", () => {
|
window.addEventListener("contextmenu", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user