mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-10 01:29:58 +00:00
Add media controls (#229)
This commit is contained in:
89
app/src/bridge/components/cast/discovery.ts
Normal file
89
app/src/bridge/components/cast/discovery.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user