App refactor (#140)

* Add additional types
* Split components from single index module into smaller modules
* Misc smaller changes
This commit is contained in:
Matt Hensman
2020-08-21 13:19:01 +01:00
committed by GitHub
parent fa0cf0ac89
commit a6ab018171
15 changed files with 875 additions and 677 deletions

View File

@@ -1,54 +0,0 @@
"use strict";
export interface ReceiverStatus {
volume: {
muted: boolean;
stepInterval: number;
controlType: string;
level: number;
};
applications?: Array<{
displayName: string;
statusText: string;
transportId: string;
isIdleScreen: boolean;
sessionId: string;
namespaces: Array<{ name: string }>;
appId: string;
}>;
userEq?: {};
}
export interface MediaStatus {
mediaSessionId: number;
supportedMediaCommands: number;
currentTime: number;
media: {
duration: number;
contentId: string;
streamType: string;
contentType: string;
};
playbackRate: number;
volume: {
muted: boolean;
level: number;
};
currentItemId: number;
idleReason: string;
playerState: string;
extendedStatus: {
playerState: string;
media: {
contentId: string;
streamType: string;
contentType: string;
metadata: {
images: Array<{ url: string }>;
metadataType: number;
artist: string;
title: string;
};
}
};
}

View File

@@ -1,14 +1,14 @@
"use strict";
import { Channel } from "castv2";
import castv2 from "castv2";
import Session from "./Session";
import { Message
, SendMessageCallback } from "./types";
import { Message } from "../../types";
import { sendMessage } from "../../lib/messaging"
const MEDIA_NAMESPACE = "urn:x-cast:com.google.cast.media";
const NS_MEDIA = "urn:x-cast:com.google.cast.media";
export interface UpdateMessageData {
_volumeLevel?: number;
@@ -25,15 +25,14 @@ export interface UpdateMessageData {
export default class Media {
private channel: Channel;
private channel: castv2.Channel;
constructor (
private referenceId: string
, private session: Session
, private sendMessageCallback: SendMessageCallback) {
, private session: Session) {
this.session.createChannel(MEDIA_NAMESPACE);
this.channel = this.session.channelMap.get(MEDIA_NAMESPACE)!;
this.session.createChannel(NS_MEDIA);
this.channel = this.session.channelMap.get(NS_MEDIA)!;
this.channel.on("message", (data: any) => {
if (data && data.type === "MEDIA_STATUS"
@@ -88,8 +87,8 @@ export default class Media {
}
}
private sendMessage (subject: string, data: any = {}) {
this.sendMessageCallback({
private sendMessage (subject: string, data: any) {
(sendMessage as any)({
subject
, data
, _id: this.referenceId

View File

@@ -2,8 +2,8 @@
import { Channel, Client } from "castv2";
import { Message
, SendMessageCallback } from "./types";
import { Message } from "../../types";
import { sendMessage } from "../../lib/messaging";
export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
@@ -13,13 +13,6 @@ export const NS_RECEIVER = "urn:x-cast:com.google.cast.receiver";
export default class Session {
public channelMap = new Map<string, Channel>();
public host: string;
public port: number;
private sendMessageCallback: SendMessageCallback;
private sessionId: number;
private referenceId: string;
private client: Client;
private clientConnection?: Channel;
private clientHeartbeat?: Channel;
@@ -34,92 +27,82 @@ export default class Session {
private app: any;
constructor (
host: string
, port: number
, appId: string
, sessionId: number
, referenceId: string
, sendMessageCallback: SendMessageCallback) {
this.host = host;
this.port = port;
this.sessionId = sessionId;
this.referenceId = referenceId;
this.sendMessageCallback = sendMessageCallback;
public host: string
, public port: number
, private appId: string
, private sessionId: string
, private referenceId: string) {
this.client = new Client();
this.client.connect({ host, port }, this.onConnect.bind(this));
}
this.client.connect({ host, port }, () => {
let transportHeartbeat: Channel;
private onConnect () {
let transportHeartbeat: Channel;
const sourceId = "sender-0";
const destinationId = "receiver-0";
const sourceId = "sender-0";
const destinationId = "receiver-0";
this.clientConnection = this.client.createChannel(
this.clientConnection = this.client.createChannel(
sourceId, destinationId, NS_CONNECTION, "JSON");
this.clientHeartbeat = this.client.createChannel(
this.clientHeartbeat = this.client.createChannel(
sourceId, destinationId, NS_HEARTBEAT, "JSON");
this.clientReceiver = this.client.createChannel(
this.clientReceiver = this.client.createChannel(
sourceId, destinationId, NS_RECEIVER, "JSON");
this.clientConnection.send({ type: "CONNECT" });
this.clientHeartbeat.send({ type: "PING" });
this.clientConnection.send({ type: "CONNECT" });
this.clientHeartbeat.send({ type: "PING" });
this.clientHeartbeatIntervalId = setInterval(() => {
if (transportHeartbeat) {
transportHeartbeat.send({ type: "PING" });
}
this.clientHeartbeatIntervalId = setInterval(() => {
if (transportHeartbeat) {
transportHeartbeat.send({ type: "PING" });
}
this.clientHeartbeat!.send({ type: "PING" });
}, 5000);
this.clientHeartbeat!.send({ type: "PING" });
}, 5000);
this.clientReceiver.send({
type: "LAUNCH"
, appId
, requestId: 1
});
this.clientReceiver.send({
type: "LAUNCH"
, appId: this.appId
, requestId: 1
});
this.clientReceiver.on("message", (message: any) => {
if (message.type === "RECEIVER_STATUS") {
this.sendMessage("shim:/session/updateStatus"
, message.status);
if (!message.status.applications) {
return;
}
this.clientReceiver.on("message", (message: any) => {
if (message.type === "RECEIVER_STATUS") {
this.sendMessage("shim:/session/updateStatus", message.status);
if (message.status.applications) {
const receiverApp = message.status.applications[0];
const receiverAppId = receiverApp.appId;
this.app = receiverApp;
if (receiverAppId !== appId) {
if (receiverAppId !== this.appId) {
// Close session
this.sendMessage("shim:/session/stopped");
this.client.close();
clearInterval(this.clientHeartbeatIntervalId!);
return;
}
if (!this.isSessionCreated) {
this.isSessionCreated = true;
this.transportId = this.app.transportId;
this.clientId =
`client-${Math.floor(Math.random() * 10e5)}`;
this.transportConnection = this.client.createChannel(
this.clientId, this.transportId!
, NS_CONNECTION, "JSON");
transportHeartbeat = this.client.createChannel(
this.clientId, this.transportId!
, NS_HEARTBEAT, "JSON");
this.transportConnection.send({ type: "CONNECT" });
this.sessionId = this.app.sessionId;
this.sendMessage("shim:/session/connected", {
sessionId: this.app.sessionId
, namespaces: this.app.namespaces
@@ -128,7 +111,7 @@ export default class Session {
});
}
}
});
}
});
}
@@ -169,25 +152,24 @@ export default class Session {
public createChannel (namespace: string) {
if (!this.channelMap.has(namespace)) {
this.channelMap.set(namespace
, this.client.createChannel(
this.clientId!, this.transportId!, namespace, "JSON"));
this.channelMap.set(namespace, this.client.createChannel(
this.clientId!, this.transportId!
, namespace, "JSON"));
}
}
public close () {
this.clientConnection!.send({ type: "CLOSE" });
if (this.transportConnection) {
this.transportConnection.send({ type: "CLOSE" });
}
this.clientConnection?.send({ type: "CLOSE" });
this.transportConnection?.send({ type: "CLOSE" });
}
public stop () {
this.clientConnection!.send({ type: "STOP" });
this.clientConnection?.send({ type: "STOP" });
}
private sendMessage (subject: string, data: any = {}) {
this.sendMessageCallback({
sendMessage({
// @ts-ignore
subject
, data
, _id: this.referenceId
@@ -196,7 +178,7 @@ export default class Session {
private _impl_addMessageListener (namespace: string) {
this.createChannel(namespace);
this.channelMap.get(namespace)!.on("message", (data: any) => {
this.channelMap.get(namespace)?.on("message", (data: any) => {
this.sendMessage("shim:/session/impl_addMessageListener", {
namespace
, data: JSON.stringify(data)

View File

@@ -0,0 +1,79 @@
"use strict";
import castv2 from "castv2";
import Session, { NS_CONNECTION, NS_RECEIVER } from "./Session";
import Media from "./Media";
import { Receiver } from "../../types";
// Existing counterpart Media/Session objects
const existingSessions: Map<string, Session> = new Map();
const existingMedia: Map<string, Media> = new Map();
export function handleSessionMessage (message: any) {
if (!message._id) {
console.error("Session message missing _id");
return;
}
const sessionId = message._id;
if (existingSessions.has(sessionId)) {
// Forward message to instance message handler
existingSessions.get(sessionId)?.messageHandler(message);
} else {
if (message.subject === "bridge:/session/initialize") {
existingSessions.set(sessionId, new Session(
message.data.address
, message.data.port
, message.data.appId
, message.data.sessionId
, sessionId));
}
}
}
export function handleMediaMessage (message: any) {
if (!message._id) {
console.error("Media message missing _id");
return;
}
const mediaId = message._id;
if (existingMedia.has(mediaId)) {
// Forward message to instance message handler
existingMedia.get(mediaId)!.messageHandler(message);
} else {
if (message.subject === "bridge:/media/initialize") {
// Get Session object media belongs to
const parentSession = existingSessions.get(
message.data._internalSessionId);
if (parentSession) {
// Create Media
existingMedia.set(mediaId, new Media(
mediaId
, parentSession));
}
}
}
}
export function stopReceiverApp (host: string, port: number) {
const client = new castv2.Client();
client.connect({ host, port }, () => {
const sourceId = "sender-0";
const destinationId = "receiver-0";
const clientConnection = client.createChannel(
sourceId, destinationId, NS_CONNECTION, "JSON");
const clientReceiver = client.createChannel(
sourceId, destinationId, NS_RECEIVER, "JSON");
clientConnection.send({ type: "CONNECT" });
clientReceiver.send({ type: "STOP", requestId: 1 });
});
}

View File

@@ -0,0 +1,111 @@
"use strict";
import mdns from "mdns";
import StatusListener from "./chromecast/StatusListener";
import { ReceiverStatus } from "../types";
import { sendMessage } from "../lib/messaging";
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()
]
});
function onBrowserServiceUp (service: mdns.Service) {
sendMessage({
subject: "main:/serviceUp"
, data: {
host: service.addresses[0]
, port: service.port
, id: service.txtRecord.id
, friendlyName: service.txtRecord.fn
}
});
}
function onBrowserServiceDown (service: mdns.Service) {
sendMessage({
subject: "main:/serviceDown"
, data: {
id: service.txtRecord.id
}
});
}
browser.on("serviceUp", onBrowserServiceUp);
browser.on("servicedown", onBrowserServiceDown);
interface InitializeOptions {
shouldWatchStatus?: boolean;
}
export function startDiscovery (options: InitializeOptions) {
if (options.shouldWatchStatus) {
browser.on("serviceUp", onStatusBrowserServiceUp);
browser.on("serviceDown", onStatusBrowserServiceDown);
}
browser.start();
// Receiver status listeners for status mode
const statusListeners = new Map<string, StatusListener>();
function onStatusBrowserServiceUp (service: mdns.Service) {
const { id } = service.txtRecord;
const listener = new StatusListener(
service.addresses[0]
, service.port);
listener.on("receiverStatus", (status: ReceiverStatus) => {
const receiverStatusMessage: any = {
subject: "main:/receiverStatus"
, data: {
id
, status: {
volume: {
level: status.volume.level
, muted: status.volume.muted
}
}
}
};
if (status.applications && status.applications.length) {
const application = status.applications[0];
receiverStatusMessage.data.status.application = {
appId: application.appId
, displayName: application.displayName
, isIdleScreen: application.isIdleScreen
, statusText: application.statusText
};
}
sendMessage(receiverStatusMessage);
});
statusListeners.set(id, listener);
}
function onStatusBrowserServiceDown (service: mdns.Service) {
const { id } = service.txtRecord;
if (statusListeners.has(id)) {
statusListeners.get(id)!.deregister();
statusListeners.delete(id);
}
}
}
export function stopDiscovery () {
browser.stop();
}

View File

@@ -0,0 +1,157 @@
"use strict";
import fs from "fs";
import http from "http";
import path from "path";
import stream from "stream";
import mime from "mime-types";
import { sendMessage } from "../lib/messaging";
import { convertSrtToVtt } from "../lib/subtitles";
export let mediaServer: http.Server | undefined;
export async function startMediaServer (filePath: string, port: number) {
if (mediaServer?.listening) {
await stopMediaServer();
}
let fileDir: string;
let fileName: string;
let fileSize: number;
try {
const stat = await fs.promises.lstat(filePath);
if (stat.isFile()) {
fileDir = path.dirname(filePath);
fileName = path.basename(filePath);
fileSize = stat.size;
} else {
console.error("Error: Media path is not a file.");
sendMessage({
subject: "mediaCast:/mediaServer/error"
});
return;
}
} catch (err) {
console.error("Error: Failed to find media path.");
sendMessage({
subject: "mediaCast:/mediaServer/error"
});
return;
}
const contentType = mime.lookup(filePath);
if (!contentType) {
console.error("Error: Failed to find media type.");
sendMessage({
subject: "mediaCast:/mediaServer/error"
});
return;
}
/**
* Find any SubRip files within the same directory and
* convert to WebVTT source.
*/
const subtitles = new Map<string, string>();
try {
const dirEntries = await fs.promises.readdir(
fileDir, { withFileTypes: true });
for (const dirEntry of dirEntries) {
if (dirEntry.isFile()
&& mime.lookup(dirEntry.name) === "application/x-subrip") {
subtitles.set(dirEntry.name, await convertSrtToVtt(
path.join(fileDir, dirEntry.name)));
}
}
} catch (err) {}
mediaServer = http.createServer(async (req, res) => {
if (!req.url) {
return;
}
// Drop leading slash
if (req.url.startsWith("/")) {
req.url = req.url.slice(1);
}
switch (req.url) {
case fileName: {
const { range } = req.headers;
// Partial content HTTP 206
if (range) {
const bounds = range.substring(6).split("-");
const start = parseInt(bounds[0]);
const end = bounds[1]
? parseInt(bounds[1]) : fileSize - 1;
res.writeHead(206, {
"Accept-Ranges": "bytes"
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
, "Content-Length": (end - start) + 1
, "Content-Type": contentType
});
fs.createReadStream(
filePath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize
, "Content-Type": contentType
});
fs.createReadStream(filePath).pipe(res);
}
break;
}
default: {
if (subtitles.has(req.url)) {
const vttSource = subtitles.get(req.url)!;
const vttStream = stream.Readable.from(vttSource);
res.setHeader("Access-Control-Allow-Origin", "*");
vttStream.pipe(res);
}
break;
}
}
});
mediaServer.on("listening", () => sendMessage({
subject: "mediaCast:/mediaServer/started"
, data: {
mediaPath: fileName
, subtitlePaths: Array.from(subtitles.keys())
}
}));
mediaServer.on("close", () => sendMessage({
subject: "mediaCast:/mediaServer/stopped"
}));
mediaServer.on("error", () => sendMessage({
subject: "mediaCast:/mediaServer/error"
}));
mediaServer.listen(port);
}
export function stopMediaServer () {
if (mediaServer?.listening) {
mediaServer.close();
mediaServer = undefined;
}
}

View File

@@ -0,0 +1,88 @@
"use strict";
import child_process from "child_process";
import path from "path";
import { sendMessage } from "../lib/messaging";
function fatal (message: string) {
console.error(message);
process.exit(1);
}
let selectorApp: child_process.ChildProcess | undefined;
let selectorAppOpen = false;
export function startReceiverSelector (data: string) {
if (process.platform !== "darwin") {
fatal("Invalid platform for native receiver selector.");
}
if (!data) {
fatal("Missing native selector data");
} else {
try {
JSON.parse(data);
} catch (err) {
fatal("Invalid native selector data.");
}
}
if (selectorApp && selectorAppOpen) {
selectorApp.kill();
selectorAppOpen = false;
}
const selectorPath = path.join(process.cwd()
, "fx_cast_selector.app/Contents/MacOS/fx_cast_selector");
selectorApp = child_process.spawn(selectorPath, [ data ]);
selectorAppOpen = true;
if (selectorApp.stdout) {
selectorApp.stdout.setEncoding("utf-8");
selectorApp.stdout.on("data", data => {
const jsonData = JSON.parse(data);
if (!jsonData.mediaType) {
sendMessage({
subject: "main:/receiverSelector/stop"
, data: jsonData
});
return;
}
sendMessage({
subject: "main:/receiverSelector/selected"
, data: jsonData
});
});
}
selectorApp.on("error", err => {
sendMessage({
subject: "main:/receiverSelector/error"
, data: err.message
});
});
selectorApp.on("close", () => {
if (selectorAppOpen) {
selectorAppOpen = false;
sendMessage({
subject: "main:/receiverSelector/close"
});
}
});
}
export function stopReceiverSelector () {
if (!selectorApp?.killed) {
selectorApp?.kill();
selectorAppOpen = false;
}
}

View File

@@ -1,99 +1,22 @@
import mdns from "mdns";
"use strict";
import child_process from "child_process";
import events from "events";
import fs from "fs";
import http from "http";
import mime from "mime-types";
import path from "path";
import stream from "stream";
import { decodeTransform, encodeTransform } from "./lib/messaging";
import { Message } from "./types";
import Media from "./Media";
import Session from "./Session";
import StatusListener from "./StatusListener";
import { handleSessionMessage, handleMediaMessage, stopReceiverApp }
from "./components/chromecast";
import { startDiscovery, stopDiscovery } from "./components/discovery";
import { startMediaServer, stopMediaServer } from "./components/mediaServer";
import { startReceiverSelector, stopReceiverSelector }
from "./components/receiverSelector";
import { DecodeTransform
, EncodeTransform } from "../transforms";
import { ReceiverStatus } from "./castTypes";
import { Message, Receiver } from "./types";
import { __applicationName
, __applicationVersion } from "../../package.json";
import { Channel, Client } from "castv2";
import { NS_CONNECTION
, NS_HEARTBEAT
, NS_RECEIVER } from "./Session";
// Increase listener limit
events.EventEmitter.defaultMaxListeners = 50;
const decodeTransform = new DecodeTransform();
const encodeTransform = new EncodeTransform();
// stdin -> stdout
process.stdin.pipe(decodeTransform);
decodeTransform.on("data", handleMessage);
encodeTransform.pipe(process.stdout);
decodeTransform.on("error", err => {
console.error("Failed to decode message", err);
});
/**
* Encode and send a message to the extension. If message is
* a string, send that as the message subject, else send a
* passed message object.
*/
function sendMessage (message: string | object) {
try {
if (typeof message === "string") {
encodeTransform.write({
subject: message
});
} else {
encodeTransform.write(message);
}
} catch (err) {
console.error("Failed to encode message", err);
}
}
interface InitializeOptions {
shouldWatchStatus?: boolean;
}
let receiverSelectorApp: child_process.ChildProcess;
let receiverSelectorAppClosed = true;
// Local media server
let mediaServer: http.Server;
let browser: mdns.Browser;
// Existing counterpart Media/Session objects
const existingSessions: Map<string, Session> = new Map();
const existingMedia: Map<string, Media> = new Map();
import { __applicationName, __applicationVersion} from "../../package.json";
process.on("SIGTERM", () => {
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
if (receiverSelectorApp && !receiverSelectorAppClosed) {
receiverSelectorApp.kill();
}
if (browser) {
browser.stop();
}
stopDiscovery();
stopMediaServer();
stopReceiverSelector();
});
@@ -104,71 +27,16 @@ process.on("SIGTERM", () => {
* Initializes the counterpart objects and is responsible
* for managing existing ones.
*/
async function handleMessage (message: Message) {
if (message.subject.startsWith("bridge:/media/")) {
if (!message._id) {
console.error("Media message missing _id");
return;
}
const mediaId = message._id;
if (existingMedia.has(mediaId)) {
// Forward message to instance message handler
existingMedia.get(mediaId)!.messageHandler(message);
} else {
if (message.subject.endsWith("/initialize")) {
// Get Session object media belongs to
const parentSession = existingSessions.get(
message.data._internalSessionId);
if (parentSession) {
// Create Media
existingMedia.set(mediaId, new Media(
mediaId
, parentSession
, sendMessage));
}
}
}
return;
}
decodeTransform.on("data", (message: Message) => {
if (message.subject.startsWith("bridge:/session/")) {
if (!message._id) {
console.error("Session message missing _id");
return;
}
const sessionId = message._id;
if (existingSessions.has(sessionId)) {
// Forward message to instance message handler
existingSessions.get(sessionId)!.messageHandler(message);
} else {
if (message.subject.endsWith("/initialize")) {
// Create Session
existingSessions.set(sessionId, new Session(
message.data.address
, message.data.port
, message.data.appId
, message.data.sessionId
, sessionId
, sendMessage));
}
}
handleSessionMessage(message);
return;
}
if (message.subject.startsWith("bridge:/media/")) {
handleMediaMessage(message);
return;
}
if (message.subject.startsWith("bridge:/receiverSelector/")) {
handleReceiverSelectorMessage(message);
}
if (message.subject.startsWith("bridge:/mediaServer/")) {
handleMediaServerMessage(message);
}
switch (message.subject) {
case "bridge:/getInfo": {
@@ -177,406 +45,32 @@ async function handleMessage (message: Message) {
}
case "bridge:/initialize": {
const options: InitializeOptions = message.data;
initialize(options);
startDiscovery(message.data);
break;
}
case "bridge:/stopReceiverApp": {
const receiver: Receiver = message.data.receiver;
const client = new Client();
client.connect({ host: receiver.host, port: receiver.port }, () => {
const sourceId = "sender-0";
const destinationId = "receiver-0";
const clientConnection = client.createChannel(
sourceId, destinationId, NS_CONNECTION, "JSON");
const clientReceiver = client.createChannel(
sourceId, destinationId, NS_RECEIVER, "JSON");
clientConnection.send({ type: "CONNECT" });
clientReceiver.send({ type: "STOP", requestId: 1 });
});
stopReceiverApp(message.data.receiver.host
, message.data.receiver.port);
break;
}
}
}
function handleReceiverSelectorMessage (message: Message) {
switch (message.subject) {
// Receiver selector
case "bridge:/receiverSelector/open": {
const receiverSelectorData = message.data;
if (process.platform !== "darwin") {
console.error("Invalid platform for native selector.");
process.exit(1);
}
if (!receiverSelectorData) {
console.error("Missing native selector data.");
process.exit(1);
} else {
try {
JSON.parse(receiverSelectorData);
} catch (err) {
console.error("Invalid native selector data.");
}
}
// Kill existing process if it exists
if (receiverSelectorApp && !receiverSelectorAppClosed) {
receiverSelectorApp.kill();
}
const receiverSelectorPath = path.join(process.cwd()
, "fx_cast_selector.app/Contents/MacOS/fx_cast_selector");
receiverSelectorApp = child_process.spawn(
receiverSelectorPath
, [ receiverSelectorData ]);
receiverSelectorAppClosed = false;
receiverSelectorApp.stdout!.setEncoding("utf8");
receiverSelectorApp.stdout!.on("data", data => {
const parsedData = JSON.parse(data);
sendMessage({
subject: !parsedData.mediaType
? "main:/receiverSelector/stop"
: "main:/receiverSelector/selected"
, data: parsedData
});
});
receiverSelectorApp.on("error", err => {
sendMessage({
subject: "main:/receiverSelector/error"
, data: err.message
});
});
receiverSelectorApp.on("close", () => {
if (!receiverSelectorAppClosed) {
receiverSelectorAppClosed = true;
sendMessage({
subject: "main:/receiverSelector/close"
});
}
});
break;
startReceiverSelector(message.data); break;
}
case "bridge:/receiverSelector/close": {
receiverSelectorApp.kill();
receiverSelectorAppClosed = true;
break;
}
}
}
async function handleMediaServerMessage (message: Message) {
async function convertSrtToVtt (srtFilePath: string) {
const fileStream = fs.createReadStream(
srtFilePath, { encoding: "utf-8" });
let fileContents = "";
for await (let chunk of fileStream) {
// Omit BOM if present
if (!fileContents && chunk[0] === "\uFEFF") {
chunk = chunk.slice(1);
}
// Normalize line endings
fileContents += chunk.replace(/$\r\n/gm, "\n");
stopReceiverSelector(); break;
}
let vttText = "WEBVTT\n";
/**
* Matches a caption group within an SubRip file. Match groups
* are the index (followed by a new line), the time range
* (followed by a new line), then any text content until a blank
* line.
*/
const REGEX_CAPTION = /(?:(\d+)\n(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}))\n((?:.+)\n?)*/g;
/**
* WebVTT is very similar to SubRip, the main differences being
* the "WEBVTT" specifier and optional metadata at the head of
* the file, the optional caption indicies and the timecode
* millisecond separator.
*/
for (const groups of fileContents.matchAll(REGEX_CAPTION)) {
const captionSource = groups[0];
const captionIndex = groups[1];
const captionTime = groups[2];
const captionText = groups[3];
vttText += `\n${captionIndex}\n`;
vttText += `${captionTime.replace(/,/g, ".")}\n`;
if (captionText) {
vttText += `${captionText}`;
}
}
return vttText;
}
switch (message.subject) {
// Media server
case "bridge:/mediaServer/start": {
const { filePath, port }
: { filePath: string, port: number } = message.data;
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
let fileDir: string;
let fileName: string;
let fileSize: number;
try {
const stat = await fs.promises.lstat(filePath);
if (stat.isFile()) {
fileDir = path.dirname(filePath);
fileName = path.basename(filePath);
fileSize = stat.size;
} else {
console.error("Error: Media path is not a file.");
sendMessage("mediaCast:/mediaServer/error");
break;
}
} catch (err) {
console.error("Error: Failed to find media path.");
sendMessage("mediaCast:/mediaServer/error");
break;
}
const contentType = mime.lookup(filePath);
if (!contentType) {
sendMessage("mediaCast:/mediaServer/error");
break;
}
// file name -> file contents
const subtitles = new Map<string, string>();
try {
const dirEntries = await fs.promises.readdir(
fileDir, { withFileTypes: true });
/**
* Find any SubRip files within the same directory and
* convert to WebVTT source.
*/
for (const dirEntry of dirEntries) {
if (dirEntry.isFile()
&& mime.lookup(dirEntry.name) === "application/x-subrip") {
subtitles.set(dirEntry.name, await convertSrtToVtt(
path.join(fileDir, dirEntry.name)));
}
}
} catch (err) {
// Subtitles optional
}
mediaServer = http.createServer(async (req, res) => {
if (!req.url) {
return;
}
// Drop leading slash
if (req.url.startsWith("/")) {
req.url = req.url.slice(1);
}
switch (req.url) {
case fileName: {
const { range } = req.headers;
// Partial content HTTP 206
if (range) {
const bounds = range.substring(6).split("-");
const start = parseInt(bounds[0]);
const end = bounds[1]
? parseInt(bounds[1]) : fileSize - 1;
res.writeHead(206, {
"Accept-Ranges": "bytes"
, "Content-Range": `bytes ${start}-${end}/${fileSize}`
, "Content-Length": (end - start) + 1
, "Content-Type": contentType
});
fs.createReadStream(
filePath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
"Content-Length": fileSize
, "Content-Type": contentType
});
fs.createReadStream(filePath).pipe(res);
}
break;
}
default: {
if (subtitles.has(req.url)) {
const vttSource = subtitles.get(req.url)!;
const vttStream = stream.Readable.from(vttSource);
res.setHeader("Access-Control-Allow-Origin", "*");
vttStream.pipe(res);
}
break;
}
}
});
mediaServer.on("listening", () => {
sendMessage({
subject: "mediaCast:/mediaServer/started"
, data: {
mediaPath: fileName
, subtitlePaths: Array.from(subtitles.keys())
}
});
});
mediaServer.on("close", () => {
sendMessage("mediaCast:/mediaServer/stopped");
});
mediaServer.on("error", () => {
sendMessage("mediaCast:/mediaServer/error");
});
mediaServer.listen(port);
startMediaServer(message.data.filePath, message.data.port);
break;
}
case "bridge:/mediaServer/stop": {
if (mediaServer && mediaServer.listening) {
mediaServer.close();
}
stopMediaServer();
break;
}
}
}
function initialize (options: InitializeOptions) {
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()
]
});
browser.on("error", (err: any) => {
console.error("Discovery failed", err);
});
if (options.shouldWatchStatus) {
browser.on("serviceUp", onStatusBrowserServiceUp);
browser.on("serviceDown", onStatusBrowserServiceDown);
}
browser.on("serviceUp", onBrowserServiceUp);
browser.on("servicedown", onBrowserServiceDown);
browser.start();
function onBrowserServiceUp (service: mdns.Service) {
sendMessage({
subject: "main:/serviceUp"
, data: {
host: service.addresses[0]
, port: service.port
, id: service.txtRecord.id
, friendlyName: service.txtRecord.fn
}
});
}
function onBrowserServiceDown (service: mdns.Service) {
sendMessage({
subject: "main:/serviceDown"
, data: {
id: service.txtRecord.id
}
});
}
// Receiver status listeners for status mode
const statusListeners = new Map<string, StatusListener>();
function onStatusBrowserServiceUp (service: mdns.Service) {
const { id } = service.txtRecord;
const listener = new StatusListener(
service.addresses[0]
, service.port);
listener.on("receiverStatus", (status: ReceiverStatus) => {
const receiverStatusMessage: any = {
subject: "main:/receiverStatus"
, data: {
id
, status: {
volume: {
level: status.volume.level
, muted: status.volume.muted
}
}
}
};
if (status.applications && status.applications.length) {
const application = status.applications[0];
receiverStatusMessage.data.status.application = {
appId: application.appId
, displayName: application.displayName
, isIdleScreen: application.isIdleScreen
, statusText: application.statusText
};
}
sendMessage(receiverStatusMessage);
});
statusListeners.set(id, listener);
}
function onStatusBrowserServiceDown (service: mdns.Service) {
const { id } = service.txtRecord;
if (statusListeners.has(id)) {
statusListeners.get(id)!.deregister();
statusListeners.delete(id);
}
}
}
});

View File

@@ -0,0 +1,15 @@
"use strict";
import { DecodeTransform, EncodeTransform } from "../../transforms";
import { Message } from "../types";
export const decodeTransform = new DecodeTransform();
export const encodeTransform = new EncodeTransform();
process.stdin.pipe(decodeTransform);
encodeTransform.pipe(process.stdout);
export function sendMessage (message: Message) {
encodeTransform.write(message);
}

View File

@@ -0,0 +1,56 @@
"use strict";
import fs from "fs";
/**
* Reads a SubRip file and outputs text content as WebVTT.
*/
export async function convertSrtToVtt (srtFilePath: string) {
const fileStream = fs.createReadStream(
srtFilePath, { encoding: "utf-8" });
let fileContents = "";
for await (let chunk of fileStream) {
// Omit BOM if present
if (!fileContents && chunk[0] === "\uFEFF") {
chunk = chunk.slice(1);
}
// Normalize line endings
fileContents += chunk.replace(/$\r\n/gm, "\n");
}
let vttText = "WEBVTT\n";
/**
* Matches a caption group within an SubRip file. Match groups
* are the index (followed by a new line), the time range
* (followed by a new line), then any text content until a blank
* line.
*/
const REGEX_CAPTION = /(?:(\d+)\n(\d{2}:\d{2}:\d{2},\d{3} --> \d{2}:\d{2}:\d{2},\d{3}))\n((?:.+)\n?)*/g;
/**
* WebVTT is very similar to SubRip, the main differences being
* the "WEBVTT" specifier and optional metadata at the head of
* the file, the optional caption indicies and the timecode
* millisecond separator.
*/
for (const groups of fileContents.matchAll(REGEX_CAPTION)) {
const captionSource = groups[0];
const captionIndex = groups[1];
const captionTime = groups[2];
const captionText = groups[3];
vttText += `\n${captionIndex}\n`;
vttText += `${captionTime.replace(/,/g, ".")}\n`;
if (captionText) {
vttText += `${captionText}`;
}
}
return vttText;
}

View File

@@ -1,11 +1,80 @@
"use strict";
import { ReceiverStatus } from "./castTypes";
export interface ReceiverStatus {
volume: {
muted: boolean;
stepInterval: number;
controlType: string;
level: number;
};
applications?: Array<{
displayName: string;
statusText: string;
transportId: string;
isIdleScreen: boolean;
sessionId: string;
namespaces: Array<{ name: string }>;
appId: string;
}>;
userEq?: {};
}
export interface Message {
subject: string;
data?: any;
_id?: string;
export interface MediaStatus {
mediaSessionId: number;
supportedMediaCommands: number;
currentTime: number;
media: {
duration: number;
contentId: string;
streamType: string;
contentType: string;
};
playbackRate: number;
volume: {
muted: boolean;
level: number;
};
currentItemId: number;
idleReason: string;
playerState: string;
extendedStatus: {
playerState: string;
media: {
contentId: string;
streamType: string;
contentType: string;
metadata: {
images: Array<{ url: string }>;
metadataType: number;
artist: string;
title: string;
};
}
};
}
export enum ReceiverSelectorMediaType {
App = 1
, Tab = 2
, Screen = 4
, File = 8
}
export enum ReceiverSelectionActionType {
Cast = 1
, Stop = 2
}
export interface ReceiverSelectionCast {
actionType: ReceiverSelectionActionType.Cast;
receiver: Receiver;
mediaType: ReceiverSelectorMediaType;
filePath?: string;
}
export interface ReceiverSelectionStop {
actionType: ReceiverSelectionActionType.Stop;
receiver: Receiver;
}
export interface Receiver {
@@ -16,4 +85,206 @@ export interface Receiver {
status?: ReceiverStatus;
}
export type SendMessageCallback = (message: Message) => void;
export type Messages = [
{
subject: "shim:/serviceUp"
, data: { id: Receiver["id"] }
}
, {
subject: "shim:/serviceDown"
, data: { id: Receiver["id"] }
}
, {
subject: "shim:/launchApp"
, data: { receiver: Receiver }
}
// Session messages
, {
subject: "shim:/session/stopped"
}
, {
subject: "shim:/session/connected"
, data: {
sessionId: string;
namespaces: Array<{ name: string }>;
displayName: string;
statusText: string;
}
}
, {
subject: "shim:/session/updateStatus"
, data: any
}
, {
subject: "shim:/session/impl_addMessageListener"
, data: { namespace: string, data: string }
}
, {
subject: "shim:/session/impl_sendMessage"
, data: { messageId: string, error: boolean }
}
, {
subject: "shim:/session/impl_setReceiverMuted"
, data: { volumeId: string, error: boolean }
}
, {
subject: "shim:/session/impl_setReceiverVolumeLevel"
, data: { volumeId: string, error: boolean }
}
, {
subject: "shim:/session/impl_stop"
, data: { stopId: string, error: boolean }
}
// Bridge session messages
, {
subject: "bridge:/session/initialize"
, data: {
address: string
, port: number
, appId: string
, sessionId: string
}
, _id: string;
}
, {
subject: "bridge:/session/close"
, _id: string;
}
, {
subject: "bridge:/session/impl_leave"
, data: { id: string }
, _id: string
}
, {
subject: "bridge:/session/impl_sendMessage"
, data: { namespace: string, message: any, messageId: string }
, _id: string
}
, {
subject: "bridge:/session/impl_setReceiverMuted"
, data: { muted: boolean, volumeId: string }
, _id: string
}
, {
subject: "bridge:/session/impl_setReceiverVolumeLevel"
, data: { newLevel: number, volumeId: string }
, _id: string
}
, {
subject: "bridge:/session/impl_stop"
, data: { stopId: string }
, _id: string
}
, {
subject: "bridge:/session/impl_addMessageListener"
, data: { namespace: string }
, _id: string
}
// Media messages
, {
subject: "shim:/media/update"
, data: {
currentTime: number
, _lastCurrentTime: number
, customData: any
, playbackRate: number
, playerState: string
, repeatMode: string
, _volumeLevel: number
, _volumeMuted: boolean
, media: any
, mediaSessionId: number
}
}
, {
subject: "shim:/media/sendMediaMessageResponse"
, data: { messageId: string, error: boolean }
}
// Bridge media messages
, {
subject: "bridge:/media/initialize"
, data: {
sessionId: string
, mediaSessionId: number
, _internalSessionId: string
}
, _id: string;
}
, {
subject: "bridge:/media/sendMediaMessage"
, data: { message: any, messageId: string }
, _id: string;
}
// Bridge messages
, {
subject: "main:/receiverSelector/selected"
, data: ReceiverSelectionCast
}
, {
subject: "main:/receiverSelector/error"
, data: string
}
, {
subject: "main:/receiverSelector/close"
}
, {
subject: "main:/receiverSelector/stop"
, data: ReceiverSelectionStop
}
, {
subject: "bridge:/getInfo"
}
, {
subject: "bridge:/initialize"
, data: { shouldWatchStatus: boolean }
}
, {
subject: "bridge:/receiverSelector/open"
, data: any }
, {
subject: "bridge:/receiverSelector/close"
}
, {
subject: "bridge:/stopReceiverApp"
, data: { receiver: Receiver }
}
, {
subject: "bridge:/mediaServer/start"
, data: { filePath: string, port: number }
}
, {
subject: "bridge:/mediaServer/stop"
}
, {
subject: "mediaCast:/mediaServer/started"
, data: { mediaPath: string, subtitlePaths: string[] }
}
, {
subject: "mediaCast:/mediaServer/stopped"
}
, {
subject: "mediaCast:/mediaServer/error"
}
, {
subject: "main:/serviceUp"
, data: Receiver
}
, {
subject: "main:/serviceDown"
, data: { id: string }
}
, {
subject: "main:/receiverStatus"
, data: { id: string, status: ReceiverStatus }
}
];
export type Message = Messages[number];

View File

@@ -7,7 +7,7 @@ import minimist from "minimist";
import WebSocket from "ws";
import { DecodeTransform
, EncodeTransform } from "../transforms";
, EncodeTransform } from "./transforms";
export function init (port: number) {