From cef8f3a261fd536823a04bf82ec68550ec3cd96b Mon Sep 17 00:00:00 2001 From: hensm Date: Tue, 15 Mar 2022 06:11:25 +0000 Subject: [PATCH] Misc fixes/improvements from WIP branches --- app/src/bridge/components/cast/Session.ts | 105 ++---- app/src/bridge/components/cast/client.ts | 90 ++++++ app/src/bridge/components/cast/index.ts | 15 +- app/src/bridge/components/cast/remote.ts | 117 +++++++ app/src/bridge/components/cast/types.ts | 12 +- app/src/bridge/components/discovery.ts | 242 +++++--------- app/src/bridge/messaging.ts | 22 +- ext/src/background/receiverDevices.ts | 54 ++-- .../receiverSelector/ReceiverSelector.ts | 6 +- ext/src/background/receiverSelector/index.ts | 3 + ext/src/lib/utils.ts | 2 +- ext/src/messaging.ts | 44 +-- ext/src/ui/popup/index.tsx | 304 ++++++++++-------- 13 files changed, 559 insertions(+), 457 deletions(-) create mode 100644 app/src/bridge/components/cast/client.ts create mode 100644 app/src/bridge/components/cast/remote.ts diff --git a/app/src/bridge/components/cast/Session.ts b/app/src/bridge/components/cast/Session.ts index 3a1ed99..76546c5 100644 --- a/app/src/bridge/components/cast/Session.ts +++ b/app/src/bridge/components/cast/Session.ts @@ -1,76 +1,13 @@ "use strict"; -import { Channel, Client } from "castv2"; +import { Channel } from "castv2"; import { sendMessage } from "../../lib/nativeMessaging"; import { ReceiverDevice } from "../../types"; import { ReceiverApplication, ReceiverMessage, SenderMessage } from "./types"; -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 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((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(); - }); - }); - } -} +import CastClient, { NS_CONNECTION, NS_HEARTBEAT, NS_RECEIVER } from "./client"; type OnSessionCreatedCallback = (sessionId: string) => void; @@ -96,8 +33,6 @@ export default class Session extends CastClient { */ private launchRequestId?: number; - private onSessionCreated?: OnSessionCreatedCallback; - private establishAppConnection(transportId: string) { this.transportConnection = this.createChannel( NS_CONNECTION, @@ -236,7 +171,11 @@ export default class Session extends CastClient { return requestId; } - constructor(public appId: string, public receiverDevice: ReceiverDevice) { + constructor( + private appId: string, + private receiverDevice: ReceiverDevice, + private onSessionCreated?: OnSessionCreatedCallback + ) { super(); this.client.on("close", () => { @@ -247,27 +186,19 @@ export default class Session extends CastClient { }); } }); - } - async connect( - host: string, - port: number, - onSessionCreated?: OnSessionCreatedCallback - ) { - if (onSessionCreated) { - this.onSessionCreated = onSessionCreated; - } - - await super.connect(host, port, () => { - // Include transport heartbeat with platform heartbeat - if (this.transportHeartbeat) { - this.transportHeartbeat.send({ type: "PING" }); + super.connect(receiverDevice.host, { + onHeartbeat: () => { + // Include transport heartbeat with platform heartbeat + if (this.transportHeartbeat) { + this.transportHeartbeat.send({ type: "PING" }); + } } - }); - - this.launchRequestId = this.sendReceiverMessage({ - type: "LAUNCH", - appId: this.appId + }).then(() => { + this.launchRequestId = this.sendReceiverMessage({ + type: "LAUNCH", + appId: this.appId + }); }); } } diff --git a/app/src/bridge/components/cast/client.ts b/app/src/bridge/components/cast/client.ts new file mode 100644 index 0000000..8e1a09c --- /dev/null +++ b/app/src/bridge/components/cast/client.ts @@ -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((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(); + } +} diff --git a/app/src/bridge/components/cast/index.ts b/app/src/bridge/components/cast/index.ts index da8b394..30b28c7 100644 --- a/app/src/bridge/components/cast/index.ts +++ b/app/src/bridge/components/cast/index.ts @@ -5,7 +5,9 @@ import castv2 from "castv2"; import { sendMessage } from "../../lib/nativeMessaging"; 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(); @@ -15,14 +17,9 @@ export function handleCastMessage(message: Message) { const { appId, receiverDevice } = message.data; // Connect and store with returned ID - const session = new Session(appId, receiverDevice); - session.connect( - receiverDevice.host, - receiverDevice.port, - sessionId => { - sessions.set(sessionId, session); - } - ); + const session = new Session(appId, receiverDevice, sessionId => { + sessions.set(sessionId, session); + }); break; } diff --git a/app/src/bridge/components/cast/remote.ts b/app/src/bridge/components/cast/remote.ts new file mode 100644 index 0000000..66bd7bb --- /dev/null +++ b/app/src/bridge/components/cast/remote.ts @@ -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); + } +} diff --git a/app/src/bridge/components/cast/types.ts b/app/src/bridge/components/cast/types.ts index 0b4ac1e..1efb363 100644 --- a/app/src/bridge/components/cast/types.ts +++ b/app/src/bridge/components/cast/types.ts @@ -353,9 +353,19 @@ interface MediaReqBase extends ReqBase { export type SenderMediaMessage = | (MediaReqBase & { type: "PLAY" }) | (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: "MEDIA_SET_VOLUME"; volume: Volume }) + | (MediaReqBase & { type: "SET_VOLUME"; volume: Volume }) | (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number }) | (ReqBase & { type: "LOAD"; diff --git a/app/src/bridge/components/discovery.ts b/app/src/bridge/components/discovery.ts index 8842313..4de2676 100644 --- a/app/src/bridge/components/discovery.ts +++ b/app/src/bridge/components/discovery.ts @@ -1,24 +1,28 @@ "use strict"; -import { EventEmitter } from "events"; - -import { Channel, Client } from "castv2"; - import mdns from "mdns"; +import Remote from "./cast/remote"; +import { ReceiverDevice } from "../types"; import { sendMessage } from "../lib/nativeMessaging"; -import { ReceiverStatus } from "./cast/types"; -import { NS_CONNECTION, NS_HEARTBEAT, NS_RECEIVER } from "./cast/Session"; - -interface CastTxtRecord { +/** + * Chromecast TXT record + */ +interface CastRecord { + // Device ID 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; rm: string; - ve: string; - md: string; - ic: string; - fn: string; ca: string; st: 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 { shouldWatchStatus?: boolean; } +let shouldWatchStatus: boolean; export function startDiscovery(options: InitializeOptions) { - if (options.shouldWatchStatus) { - browser.on("serviceUp", onStatusBrowserServiceUp); - browser.on("serviceDown", onStatusBrowserServiceDown); - } + shouldWatchStatus = options.shouldWatchStatus ?? false; browser.start(); - - // Receiver status listeners for status mode - const statusListeners = new Map(); - - 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() { @@ -127,91 +57,73 @@ export function stopDiscovery() { } /** - * Creates a connection to a receiver device and forwards - * RECEIVER_STATUS updates to the extension. + * Map of device IDs to remote instances. */ -export default class StatusListener extends EventEmitter { - private client: Client; - private clientReceiver?: Channel; - private clientHeartbeatIntervalId?: NodeJS.Timeout; +const remotes = new Map(); - 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(); - this.client.connect({ host, port }, this.onConnect.bind(this)); + const record = service.txtRecord as CastRecord; + const device: ReceiverDevice = { + host: service.addresses[0], + port: service.port, + id: service.name, + friendlyName: record.fn + }; - this.client.on("close", () => { - clearInterval(this.clientHeartbeatIntervalId!); - }); - - this.client.on("error", () => { - clearInterval(this.clientHeartbeatIntervalId!); - }); - } - - /** - * Closes status listener connection. - */ - public deregister(): void { - try { - this.clientReceiver?.send({ type: "CLOSE" }); - } catch (err) { - // Supress + sendMessage({ + subject: "main:receiverDeviceUp", + data: { + deviceId: service.name, + deviceInfo: device } + }); - 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"; - const destinationId = "receiver-0"; +/** + * When a service is lost, send a `main:receiverDeviceDown` message with + * the service name as the `deviceId`. + */ +browser.on("serviceDown", service => { + // Filter invalid results + if (!service.name) return; - const clientConnection = this.client.createChannel( - sourceId, - destinationId, - NS_CONNECTION, - "JSON" - ); - const clientHeartbeat = this.client.createChannel( - sourceId, - destinationId, - NS_HEARTBEAT, - "JSON" - ); - const clientReceiver = this.client.createChannel( - sourceId, - destinationId, - NS_RECEIVER, - "JSON" - ); + sendMessage({ + subject: "main:receiverDeviceDown", + data: { deviceId: service.name } + }); - clientReceiver.on("message", data => { - switch (data.type) { - 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); + if (shouldWatchStatus) { + remotes.get(service.name)?.disconnect(); } -} +}); diff --git a/app/src/bridge/messaging.ts b/app/src/bridge/messaging.ts index 59556d5..0bfc0c9 100644 --- a/app/src/bridge/messaging.ts +++ b/app/src/bridge/messaging.ts @@ -2,6 +2,7 @@ import { Image, + MediaStatus, ReceiverStatus, SenderApplication, SenderMessage, @@ -30,6 +31,11 @@ interface CastSessionCreated extends CastSessionUpdated { 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 = { "shim:castSessionCreated": CastSessionCreated; "shim:castSessionUpdated": CastSessionUpdated; @@ -98,12 +104,16 @@ type MessageDefinitions = { id: string; status: ReceiverStatus; }; - "main:receiverDeviceUp": { receiverDevice: ReceiverDevice }; - "main:receiverDeviceDown": { receiverDeviceId: string }; - "main:receiverDeviceUpdated": { - receiverDeviceId: string; + "main:receiverDeviceUp": { deviceId: string, deviceInfo: ReceiverDevice }; + "main:receiverDeviceDown": { deviceId: string }; + "main:receiverDeviceStatusUpdated": { + deviceId: string; status: ReceiverStatus; }; + "main:receiverDeviceMediaStatusUpdated": { + deviceId: string; + status: MediaStatus; + }; }; interface MessageBase { @@ -116,8 +126,8 @@ type Messages = { }; /** - * For better call semantics, make message data key optional if - * specified as blank or with all-optional keys. + * Make message data key optional if specified as blank or with + * all-optional keys. */ type NarrowedMessage> = L extends any diff --git a/ext/src/background/receiverDevices.ts b/ext/src/background/receiverDevices.ts index c809b9b..8400c61 100644 --- a/ext/src/background/receiverDevices.ts +++ b/ext/src/background/receiverDevices.ts @@ -9,10 +9,10 @@ import { ReceiverDevice } from "../types"; import { ReceiverStatus } from "../shim/cast/types"; interface EventMap { - receiverDeviceUp: { receiverDevice: ReceiverDevice }; - receiverDeviceDown: { receiverDeviceId: string }; + receiverDeviceUp: { deviceInfo: ReceiverDevice }; + receiverDeviceDown: { deviceId: string }; receiverDeviceUpdated: { - receiverDeviceId: string; + deviceId: string; status: ReceiverStatus; }; } @@ -25,7 +25,6 @@ export default new (class extends TypedEventTarget { private receiverDevices = new Map(); private bridgePort?: Port; - async init() { if (!this.bridgePort) { await this.refresh(); @@ -38,21 +37,19 @@ export default new (class extends TypedEventTarget { */ async refresh() { 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); - port.onDisconnect.addListener(this.onBridgeDisconnect); - - port.postMessage({ + this.bridgePort.postMessage({ subject: "bridge:startDiscovery", data: { // Also send back status messages shouldWatchStatus: true } }); - - this.bridgePort = port; } /** @@ -85,12 +82,12 @@ export default new (class extends TypedEventTarget { private onBridgeMessage = (message: Message) => { switch (message.subject) { 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( new CustomEvent("receiverDeviceUp", { - detail: { receiverDevice } + detail: { deviceInfo } }) ); @@ -98,29 +95,26 @@ export default new (class extends TypedEventTarget { } case "main:receiverDeviceDown": { - const { receiverDeviceId } = message.data; + const { deviceId } = message.data; - if (this.receiverDevices.has(receiverDeviceId)) { - this.receiverDevices.delete(receiverDeviceId); + if (this.receiverDevices.has(deviceId)) { + this.receiverDevices.delete(deviceId); } this.dispatchEvent( new CustomEvent("receiverDeviceDown", { - detail: { receiverDeviceId } + detail: { deviceId: deviceId } }) ); break; } - case "main:receiverDeviceUpdated": { - const { receiverDeviceId, status } = message.data; - const receiverDevice = - this.receiverDevices.get(receiverDeviceId); + case "main:receiverDeviceStatusUpdated": { + const { deviceId, status } = message.data; + const receiverDevice = this.receiverDevices.get(deviceId); if (!receiverDevice) { - logger.error( - `Receiver ID \`${receiverDeviceId}\` not found!` - ); + logger.error(`Receiver ID \`${deviceId}\` not found!`); break; } @@ -140,11 +134,17 @@ export default new (class extends TypedEventTarget { this.dispatchEvent( new CustomEvent("receiverDeviceUpdated", { detail: { - receiverDeviceId, + deviceId, status: receiverDevice.status } }) ); + + break; + } + + case "main:receiverDeviceMediaStatusUpdated": { + break; } } }; @@ -153,7 +153,7 @@ export default new (class extends TypedEventTarget { // Notify listeners of device availablility for (const [, receiverDevice] of this.receiverDevices) { const event = new CustomEvent("receiverDeviceDown", { - detail: { receiverDeviceId: receiverDevice.id } + detail: { deviceId: receiverDevice.id } }); this.dispatchEvent(event); diff --git a/ext/src/background/receiverSelector/ReceiverSelector.ts b/ext/src/background/receiverSelector/ReceiverSelector.ts index 5b40a1f..1a311a6 100644 --- a/ext/src/background/receiverSelector/ReceiverSelector.ts +++ b/ext/src/background/receiverSelector/ReceiverSelector.ts @@ -167,9 +167,9 @@ export default class ReceiverSelector extends TypedEventTarget MessagesBase + * app/src/bridge/messaging.ts > MessageDefinitions */ type AppMessageDefinitions = { "shim:castSessionCreated": CastSessionCreated; @@ -125,12 +128,18 @@ type AppMessageDefinitions = { }; "mediaCast:mediaServerStopped": {}; "mediaCast:mediaServerError": {}; - "main:receiverDeviceUp": { receiverDevice: ReceiverDevice }; - "main:receiverDeviceDown": { receiverDeviceId: string }; - "main:receiverDeviceUpdated": { - receiverDeviceId: string; + + // Device discovery + "main:receiverDeviceUp": { deviceId: string; deviceInfo: ReceiverDevice }; + "main:receiverDeviceDown": { deviceId: string }; + "main:receiverDeviceStatusUpdated": { + deviceId: string; status: ReceiverStatus; }; + "main:receiverDeviceMediaStatusUpdated": { + deviceId: string; + status: MediaStatus; + }; }; type MessageDefinitions = ExtMessageDefinitions & AppMessageDefinitions; @@ -145,8 +154,8 @@ type Messages = { }; /** - * For better call semantics, make message data key optional if - * specified as blank or with all-optional keys. + * Make message data key optional if specified as blank or with + * all-optional keys. */ type NarrowedMessage> = L extends any @@ -161,29 +170,24 @@ export type Message = NarrowedMessage; /** * Typed WebExtension-style messaging utility class. */ -class Messenger { +export default new (class Messenger { connect(connectInfo: { name: string }) { - return browser.runtime.connect(connectInfo) as unknown as TypedPort; + return browser.runtime.connect(connectInfo) as unknown as Port; } connectTab(tabId: number, connectInfo: { name: string; frameId: number }) { - return browser.tabs.connect( - tabId, - connectInfo - ) as unknown as TypedPort; + return browser.tabs.connect(tabId, connectInfo) as unknown as Port; } onConnect = { - addListener(cb: (port: TypedPort) => void) { + addListener(cb: (port: Port) => void) { browser.runtime.onConnect.addListener(cb as any); }, - removeListener(cb: (port: TypedPort) => void) { + removeListener(cb: (port: Port) => void) { browser.runtime.onConnect.removeListener(cb as any); }, - hasListener(cb: (port: TypedPort) => void) { + hasListener(cb: (port: Port) => void) { return browser.runtime.onConnect.hasListener(cb as any); } }; -} - -export default new Messenger(); +})(); diff --git a/ext/src/ui/popup/index.tsx b/ext/src/ui/popup/index.tsx index 34fdb86..d2f3852 100755 --- a/ext/src/ui/popup/index.tsx +++ b/ext/src/ui/popup/index.tsx @@ -11,23 +11,22 @@ import messaging, { Message, Port } from "../../messaging"; import { getNextEllipsis } from "../../lib/utils"; import { ReceiverDevice } from "../../types"; -import { ReceiverSelectionActionType - , ReceiverSelectorMediaType } from "../../background/receiverSelector"; - +import { + ReceiverSelectionActionType, + ReceiverSelectorMediaType +} from "../../background/receiverSelector"; const _ = browser.i18n.getMessage; // macOS styles -browser.runtime.getPlatformInfo() - .then(platformInfo => { - if (platformInfo.os === "mac") { - const link = document.createElement("link"); - link.rel = "stylesheet"; - link.href = "styles/mac.css"; - document.head.appendChild(link); - } - }); - +browser.runtime.getPlatformInfo().then(platformInfo => { + if (platformInfo.os === "mac") { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = "styles/mac.css"; + document.head.appendChild(link); + } +}); interface PopupAppProps {} interface PopupAppState { @@ -51,11 +50,11 @@ class PopupApp extends Component { super(props); this.state = { - receivers: [] - , mediaType: ReceiverSelectorMediaType.App - , availableMediaTypes: ReceiverSelectorMediaType.App - , isLoading: false - , mirroringEnabled: false + receivers: [], + mediaType: ReceiverSelectorMediaType.App, + availableMediaTypes: ReceiverSelectorMediaType.App, + isLoading: false, + mirroringEnabled: false }; // Store window ref @@ -63,11 +62,28 @@ class PopupApp extends Component { this.win = win; }); + new ResizeObserver(() => { + this.updateWindowHeight(); + }).observe(document.body); + this.onSelectChange = this.onSelectChange.bind(this); this.onCast = this.onCast.bind(this); this.onStop = this.onStop.bind(this); } + public updateWindowHeight() { + if (this.win?.id === undefined) { + return; + } + + const frameHeight = window.outerHeight - window.innerHeight; + const windowHeight = document.body.clientHeight + frameHeight; + + browser.windows.update(this.win.id, { + height: windowHeight + }); + } + public async componentDidMount() { this.port = messaging.connect({ name: "popup" }); @@ -82,18 +98,19 @@ class PopupApp extends Component { } case "popup:update": { - const { receivers - , availableMediaTypes - , defaultMediaType } = message.data; - - this.defaultMediaType = defaultMediaType; + const { receivers, availableMediaTypes, defaultMediaType } = + message.data; this.setState({ receivers }); - if (availableMediaTypes && defaultMediaType) { + if ( + availableMediaTypes !== undefined && + defaultMediaType !== undefined + ) { + this.defaultMediaType = defaultMediaType; this.setState({ - availableMediaTypes - , mediaType: defaultMediaType + availableMediaTypes, + mediaType: defaultMediaType }); } @@ -114,17 +131,7 @@ class PopupApp extends Component { public componentDidUpdate() { setTimeout(() => { - if (this.win?.id === undefined) { - 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 - }); + this.updateWindowHeight(); }, 1); } @@ -146,72 +153,99 @@ class PopupApp extends Component { */ const isAppMediaTypeSelected = - this.state.mediaType === ReceiverSelectorMediaType.App; + this.state.mediaType === ReceiverSelectorMediaType.App; const isTabMediaTypeSelected = - this.state.mediaType === ReceiverSelectorMediaType.Tab; + this.state.mediaType === ReceiverSelectorMediaType.Tab; const isScreenMediaTypeSelected = - this.state.mediaType === ReceiverSelectorMediaType.Screen; + this.state.mediaType === ReceiverSelectorMediaType.Screen; - const isSelectedMediaTypeAvailable = - !!(this.state.availableMediaTypes & this.state.mediaType); - const isAppMediaTypeAvailable = !!(this.state.availableMediaTypes - & ReceiverSelectorMediaType.App); + const isSelectedMediaTypeAvailable = !!( + this.state.availableMediaTypes & this.state.mediaType + ); + const isAppMediaTypeAvailable = !!( + this.state.availableMediaTypes & ReceiverSelectorMediaType.App + ); - return <> -
-
- { _("popupMediaSelectCastLabel") } -
-
- + disabled={ + this.state.availableMediaTypes === + ReceiverSelectorMediaType.None + } + > + - - - { this.state.mirroringEnabled && - <> - - - } - + {this.state.mirroringEnabled && ( + <> + + + + )} + +
+
+ {_("popupMediaSelectToLabel")} +
-
- { _("popupMediaSelectToLabel") } -
- -
    - { this.state.receivers && this.state.receivers.length - ? this.state.receivers.map((receiver, i) => ( - )) - : ( +
      + {this.state.receivers && this.state.receivers.length ? ( + this.state.receivers.map((receiver, i) => ( + + )) + ) : (
      - { _("popupNoReceiversFound") } -
      )} -
    - ; + {_("popupNoReceiversFound")} + + )} +
+ + ); } private onCast(receiver: ReceiverDevice) { @@ -220,22 +254,22 @@ class PopupApp extends Component { }); this.port?.postMessage({ - subject: "receiverSelector:selected" - , data: { - actionType: ReceiverSelectionActionType.Cast - , receiver - , mediaType: this.state.mediaType - , filePath: this.state.filePath + subject: "receiverSelector:selected", + data: { + actionType: ReceiverSelectionActionType.Cast, + receiver, + mediaType: this.state.mediaType, + filePath: this.state.filePath } }); } private onStop(receiver: ReceiverDevice) { this.port?.postMessage({ - subject: "receiverSelector:stop" - , data: { - actionType: ReceiverSelectionActionType.Stop - , receiver + subject: "receiverSelector:stop", + data: { + actionType: ReceiverSelectionActionType.Stop, + receiver } }); } @@ -247,8 +281,8 @@ class PopupApp extends Component { const fileUrl = window.prompt(); if (fileUrl) { this.setState({ - mediaType - , filePath: fileUrl + mediaType, + filePath: fileUrl }); return; @@ -272,13 +306,12 @@ class PopupApp extends Component { } } - interface ReceiverEntryProps { receiver: ReceiverDevice; isLoading: boolean; canCast: boolean; - onCast (receiver: ReceiverDevice): void; - onStop (receiver: ReceiverDevice): void; + onCast(receiver: ReceiverDevice): void; + onStop(receiver: ReceiverDevice): void; } interface ReceiverEntryState { @@ -292,9 +325,9 @@ class ReceiverEntry extends Component { super(props); this.state = { - ellipsis: "" - , isLoading: false - , showAlternateAction: false + ellipsis: "", + isLoading: false, + showAlternateAction: false }; const handleActionKeyEvents = (ev: KeyboardEvent) => { @@ -315,41 +348,40 @@ class ReceiverEntry extends Component { }); }); - this.handleCast = this.handleCast.bind(this); } public render() { const { status } = this.props.receiver; - if (!status) { - return; - } - - const application = status.applications?.[0]; + const application = status?.applications?.[0]; return (
  • - { this.props.receiver.friendlyName } + {this.props.receiver.friendlyName}
    - { application && !application.isIdleScreen + {application && !application.isIdleScreen ? application.statusText - : `${this.props.receiver.host}:${this.props.receiver.port}` } + : `${this.props.receiver.host}:${this.props.receiver.port}`}
    -
  • ); @@ -376,18 +408,14 @@ class ReceiverEntry extends Component { this.setState(state => ({ ellipsis: getNextEllipsis(state.ellipsis) })); - }, 500); } } } - // Render after CSS has loaded window.addEventListener("load", () => { - ReactDOM.render( - - , document.querySelector("#root")); + ReactDOM.render(, document.querySelector("#root")); }); window.addEventListener("contextmenu", () => {