Misc fixes/improvements from WIP branches

This commit is contained in:
hensm
2022-03-15 06:11:25 +00:00
parent 7ab541643a
commit cef8f3a261
13 changed files with 559 additions and 457 deletions

View File

@@ -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 });
}); });
} }
} }

View 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();
}
}

View File

@@ -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;
} }

View 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);
}
}

View File

@@ -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";

View File

@@ -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);
} }
} });

View File

@@ -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

View File

@@ -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);

View File

@@ -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.");
} }

View File

@@ -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,

View File

@@ -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

View File

@@ -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>();

View File

@@ -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", () => {