Add media controls (#229)

This commit is contained in:
Matt Hensman
2022-08-24 02:17:35 +01:00
committed by GitHub
parent cbc039a355
commit ac46802431
37 changed files with 1694 additions and 432 deletions

View File

@@ -0,0 +1,89 @@
"use strict";
import mdns from "mdns";
import { ReceiverDevice } from "../../messagingTypes";
/**
* 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;
// Capabilities
ca: string;
// Version (?)
ve: string;
// Icon path (?)
ic: string;
cd: string;
rm: string;
st: string;
bs: string;
nf: string;
rs: string;
}
interface DiscoveryOptions {
onDeviceFound(device: ReceiverDevice): void;
onDeviceDown(deviceId: string): void;
}
export default class Discovery {
browser = mdns.createBrowser(mdns.tcp("googlecast"), {
resolverSequence: [
mdns.rst.DNSServiceResolve(),
"DNSServiceGetAddrInfo" in mdns.dns_sd
? mdns.rst.DNSServiceGetAddrInfo()
: // Some issues on Linux with IPv6, so restrict to IPv4
mdns.rst.getaddrinfo({ families: [4] }),
mdns.rst.makeAddressesUnique()
]
});
constructor(opts: DiscoveryOptions) {
/**
* When a service is found, gather device info from service object and
* TXT record, then send a `main:receiverDeviceUp` message.
*/
this.browser.on("serviceUp", service => {
// Filter invalid results
if (!service.txtRecord || !service.name) return;
const record = service.txtRecord as CastRecord;
const device: ReceiverDevice = {
id: record.id,
friendlyName: record.fn,
modelName: record.md,
capabilities: parseInt(record.ca),
host: service.addresses[0],
port: service.port
};
opts.onDeviceFound(device);
});
/**
* When a service is lost, send a `main:receiverDeviceDown` message with
* the service name as the `deviceId`.
*/
this.browser.on("serviceDown", service => {
// Filter invalid results
if (!service.name) return;
opts.onDeviceDown(service.name);
});
}
start() {
this.browser.start();
}
stop() {
this.browser.stop();
}
}

View File

@@ -43,6 +43,10 @@ export default class Remote extends CastClient {
this.transportClient?.disconnect();
}
sendMediaMessage(message: SenderMediaMessage) {
this.transportClient?.sendMediaMessage(message);
}
/**
* Handle `NS_RECEIVER` messages from the receiver device.
* On initial connection, a `GET_STATUS` message is sent that
@@ -76,7 +80,8 @@ export default class Remote extends CastClient {
this.transportClient.connect(this.host).then(() => {
this.transportClient?.sendMediaMessage({
type: "GET_STATUS"
type: "GET_STATUS",
requestId: 0
});
});

View File

@@ -284,7 +284,7 @@ export interface MediaStatus {
playerState: PlayerState;
idleReason?: IdleReason;
items?: QueueItem[];
currentTime: number;
currentTime: Nullable<number>;
supportedMediaCommands: number;
repeatMode: RepeatMode;
volume: Volume;
@@ -357,11 +357,13 @@ export type SenderMediaMessage =
type: "MEDIA_GET_STATUS";
mediaSessionId?: number;
customData?: unknown;
requestId: number;
}
| {
type: "GET_STATUS";
mediaSessionId?: number;
customData?: unknown;
requestId: number;
}
| (MediaReqBase & { type: "STOP" })
| (MediaReqBase & { type: "MEDIA_SET_VOLUME"; volume: Volume })
@@ -369,24 +371,24 @@ export type SenderMediaMessage =
| (MediaReqBase & { type: "SET_PLAYBACK_RATE"; playbackRate: number })
| (ReqBase & {
type: "LOAD";
activeTrackIds: Nullable<number[]>;
activeTrackIds?: Nullable<number[]>;
atvCredentials?: string;
atvCredentialsType?: string;
autoplay: Nullable<boolean>;
currentTime: Nullable<number>;
autoplay?: Nullable<boolean>;
currentTime?: Nullable<number>;
customData?: unknown;
media: MediaInformation;
sessionId: Nullable<string>;
sessionId?: Nullable<string>;
})
| (MediaReqBase & {
type: "SEEK";
resumeState: Nullable<ResumeState>;
currentTime: Nullable<number>;
resumeState?: Nullable<ResumeState>;
currentTime?: Nullable<number>;
})
| (MediaReqBase & {
type: "EDIT_TRACKS_INFO";
activeTrackIds: Nullable<number[]>;
textTrackStyle: Nullable<string>;
activeTrackIds?: Nullable<number[]>;
textTrackStyle?: Nullable<string>;
})
// QueueLoadRequest
| (MediaReqBase & {
@@ -394,46 +396,46 @@ export type SenderMediaMessage =
items: QueueItem[];
startIndex: number;
repeatMode: string;
sessionId: Nullable<string>;
sessionId?: Nullable<string>;
})
// QueueInsertItemsRequest
| (MediaReqBase & {
type: "QUEUE_INSERT";
items: QueueItem[];
insertBefore: Nullable<number>;
sessionId: Nullable<string>;
insertBefore?: Nullable<number>;
sessionId?: Nullable<string>;
})
// QueueUpdateItemsRequest
| (MediaReqBase & {
type: "QUEUE_UPDATE";
items: QueueItem[];
sessionId: Nullable<string>;
sessionId?: Nullable<string>;
})
// QueueJumpRequest
| (MediaReqBase & {
type: "QUEUE_UPDATE";
jump: Nullable<number>;
currentItemId: Nullable<number>;
sessionId: Nullable<string>;
jump?: Nullable<number>;
currentItemId?: Nullable<number>;
sessionId?: Nullable<string>;
})
// QueueRemoveItemsRequest
| (MediaReqBase & {
type: "QUEUE_REMOVE";
itemIds: number[];
sessionId: Nullable<string>;
sessionId?: Nullable<string>;
})
// QueueReorderItemsRequest
| (MediaReqBase & {
type: "QUEUE_REORDER";
itemIds: number[];
insertBefore: Nullable<number>;
sessionId: Nullable<string>;
insertBefore?: Nullable<number>;
sessionId?: Nullable<string>;
})
// QueueSetPropertiesRequest
| (MediaReqBase & {
type: "QUEUE_UPDATE";
repeatMode: Nullable<string>;
sessionId: Nullable<string>;
repeatMode?: Nullable<string>;
sessionId?: Nullable<string>;
});
export type ReceiverMediaMessage =

View File

@@ -1,133 +0,0 @@
"use strict";
import mdns from "mdns";
import messaging from "../messaging";
import { ReceiverDevice } from "../messagingTypes";
import Remote from "./cast/remote";
/**
* 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;
// Capabilities
ca: string;
// Version (?)
ve: string;
// Icon path (?)
ic: string;
cd: string;
rm: string;
st: string;
bs: string;
nf: string;
rs: string;
}
const browser = mdns.createBrowser(mdns.tcp("googlecast"), {
resolverSequence: [
mdns.rst.DNSServiceResolve(),
"DNSServiceGetAddrInfo" in mdns.dns_sd
? mdns.rst.DNSServiceGetAddrInfo()
: // Some issues on Linux with IPv6, so restrict to IPv4
mdns.rst.getaddrinfo({ families: [4] }),
mdns.rst.makeAddressesUnique()
]
});
interface InitializeOptions {
shouldWatchStatus?: boolean;
}
let shouldWatchStatus: boolean;
export function startDiscovery(options: InitializeOptions) {
shouldWatchStatus = options.shouldWatchStatus ?? false;
browser.start();
}
export function stopDiscovery() {
browser.stop();
}
/**
* Map of device IDs to remote instances.
*/
const remotes = new Map<string, Remote>();
/**
* 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;
const record = service.txtRecord as CastRecord;
const device: ReceiverDevice = {
id: record.id,
friendlyName: record.fn,
modelName: record.md,
capabilities: parseInt(record.ca),
host: service.addresses[0],
port: service.port
};
messaging.sendMessage({
subject: "main:receiverDeviceUp",
data: {
deviceId: device.id,
deviceInfo: device
}
});
if (shouldWatchStatus) {
remotes.set(
service.name,
new Remote(device.host, {
// RECEIVER_STATUS
onReceiverStatusUpdate(status) {
messaging.sendMessage({
subject: "main:receiverDeviceStatusUpdated",
data: { deviceId: device.id, status }
});
},
// MEDIA_STATUS
onMediaStatusUpdate(status) {
if (!status) return;
messaging.sendMessage({
subject: "main:receiverDeviceMediaStatusUpdated",
data: { deviceId: device.id, status }
});
}
})
);
}
});
/**
* 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;
messaging.sendMessage({
subject: "main:receiverDeviceDown",
data: { deviceId: service.name }
});
if (shouldWatchStatus) {
remotes.get(service.name)?.disconnect();
}
});

View File

@@ -3,16 +3,21 @@
import messaging, { Message } from "./messaging";
import { handleCastMessage } from "./components/cast";
import { startDiscovery, stopDiscovery } from "./components/discovery";
import Discovery from "./components/cast/discovery";
import Remote from "./components/cast/remote";
import { startMediaServer, stopMediaServer } from "./components/mediaServer";
import { applicationVersion } from "../../config.json";
process.on("SIGTERM", () => {
stopDiscovery();
discovery?.stop();
stopMediaServer();
});
let discovery: Discovery | null = null;
const remotes = new Map<string, Remote>();
/**
* Handle incoming messages from the extension and forward
* them to the appropriate handlers.
@@ -29,7 +34,75 @@ messaging.on("message", (message: Message) => {
}
case "bridge:startDiscovery": {
startDiscovery(message.data);
const { shouldWatchStatus } = message.data;
discovery = new Discovery({
onDeviceFound(device) {
messaging.sendMessage({
subject: "main:receiverDeviceUp",
data: {
deviceId: device.id,
deviceInfo: device
}
});
if (shouldWatchStatus) {
remotes.set(
device.id,
new Remote(device.host, {
// RECEIVER_STATUS
onReceiverStatusUpdate(status) {
messaging.sendMessage({
subject:
"main:receiverDeviceStatusUpdated",
data: {
deviceId: device.id,
status
}
});
},
// MEDIA_STATUS
onMediaStatusUpdate(status) {
if (!status) return;
messaging.sendMessage({
subject:
"main:receiverDeviceMediaStatusUpdated",
data: {
deviceId: device.id,
status
}
});
}
})
);
}
},
onDeviceDown(deviceId) {
messaging.sendMessage({
subject: "main:receiverDeviceDown",
data: { deviceId }
});
if (shouldWatchStatus) {
remotes.get(deviceId)?.disconnect();
}
}
});
discovery.start();
break;
}
case "bridge:sendReceiverMessage": {
const { deviceId, message: receiverMessage } = message.data;
remotes.get(deviceId)?.sendReceiverMessage(receiverMessage);
break;
}
case "bridge:sendMediaMessage": {
const { deviceId, message: mediaMessage } = message.data;
remotes.get(deviceId)?.sendMediaMessage(mediaMessage);
break;
}

View File

@@ -7,6 +7,7 @@ import { DecodeTransform, EncodeTransform } from "../transforms";
import {
MediaStatus,
ReceiverStatus,
SenderMediaMessage,
SenderMessage
} from "./components/cast/types";
@@ -71,6 +72,23 @@ type MessageDefinitions = {
status: MediaStatus;
};
/**
* Sent to the bridge when non-session related receiver messages
* need to be sent (e.g. volume control, application stop, etc...).
*/
"bridge:sendReceiverMessage": {
deviceId: string;
message: SenderMessage;
};
/**
* Sent to the bridge when the receiver selector media UI is used
* to control media playback.
*/
"bridge:sendMediaMessage": {
deviceId: string;
message: SenderMediaMessage;
};
/**
* Sent to bridge from cast API instance when a session request is
* initiated.