mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-12 18:39:58 +00:00
App refactor (#140)
* Add additional types * Split components from single index module into smaller modules * Misc smaller changes
This commit is contained in:
@@ -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;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
import { Channel } from "castv2";
|
import castv2 from "castv2";
|
||||||
|
|
||||||
import Session from "./Session";
|
import Session from "./Session";
|
||||||
|
|
||||||
import { Message
|
import { Message } from "../../types";
|
||||||
, SendMessageCallback } 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 {
|
export interface UpdateMessageData {
|
||||||
_volumeLevel?: number;
|
_volumeLevel?: number;
|
||||||
@@ -25,15 +25,14 @@ export interface UpdateMessageData {
|
|||||||
|
|
||||||
|
|
||||||
export default class Media {
|
export default class Media {
|
||||||
private channel: Channel;
|
private channel: castv2.Channel;
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private referenceId: string
|
private referenceId: string
|
||||||
, private session: Session
|
, private session: Session) {
|
||||||
, private sendMessageCallback: SendMessageCallback) {
|
|
||||||
|
|
||||||
this.session.createChannel(MEDIA_NAMESPACE);
|
this.session.createChannel(NS_MEDIA);
|
||||||
this.channel = this.session.channelMap.get(MEDIA_NAMESPACE)!;
|
this.channel = this.session.channelMap.get(NS_MEDIA)!;
|
||||||
|
|
||||||
this.channel.on("message", (data: any) => {
|
this.channel.on("message", (data: any) => {
|
||||||
if (data && data.type === "MEDIA_STATUS"
|
if (data && data.type === "MEDIA_STATUS"
|
||||||
@@ -88,8 +87,8 @@ export default class Media {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendMessage (subject: string, data: any = {}) {
|
private sendMessage (subject: string, data: any) {
|
||||||
this.sendMessageCallback({
|
(sendMessage as any)({
|
||||||
subject
|
subject
|
||||||
, data
|
, data
|
||||||
, _id: this.referenceId
|
, _id: this.referenceId
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import { Channel, Client } from "castv2";
|
import { Channel, Client } from "castv2";
|
||||||
|
|
||||||
import { Message
|
import { Message } from "../../types";
|
||||||
, SendMessageCallback } from "./types";
|
import { sendMessage } from "../../lib/messaging";
|
||||||
|
|
||||||
|
|
||||||
export const NS_CONNECTION = "urn:x-cast:com.google.cast.tp.connection";
|
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 {
|
export default class Session {
|
||||||
public channelMap = new Map<string, Channel>();
|
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 client: Client;
|
||||||
private clientConnection?: Channel;
|
private clientConnection?: Channel;
|
||||||
private clientHeartbeat?: Channel;
|
private clientHeartbeat?: Channel;
|
||||||
@@ -34,92 +27,82 @@ export default class Session {
|
|||||||
private app: any;
|
private app: any;
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
host: string
|
public host: string
|
||||||
, port: number
|
, public port: number
|
||||||
, appId: string
|
, private appId: string
|
||||||
, sessionId: number
|
, private sessionId: string
|
||||||
, referenceId: string
|
, private referenceId: string) {
|
||||||
, sendMessageCallback: SendMessageCallback) {
|
|
||||||
|
|
||||||
this.host = host;
|
|
||||||
this.port = port;
|
|
||||||
|
|
||||||
this.sessionId = sessionId;
|
|
||||||
this.referenceId = referenceId;
|
|
||||||
this.sendMessageCallback = sendMessageCallback;
|
|
||||||
|
|
||||||
this.client = new Client();
|
this.client = new Client();
|
||||||
|
this.client.connect({ host, port }, this.onConnect.bind(this));
|
||||||
|
}
|
||||||
|
|
||||||
this.client.connect({ host, port }, () => {
|
private onConnect () {
|
||||||
let transportHeartbeat: Channel;
|
let transportHeartbeat: Channel;
|
||||||
|
|
||||||
const sourceId = "sender-0";
|
const sourceId = "sender-0";
|
||||||
const destinationId = "receiver-0";
|
const destinationId = "receiver-0";
|
||||||
|
|
||||||
this.clientConnection = this.client.createChannel(
|
this.clientConnection = this.client.createChannel(
|
||||||
sourceId, destinationId, NS_CONNECTION, "JSON");
|
sourceId, destinationId, NS_CONNECTION, "JSON");
|
||||||
this.clientHeartbeat = this.client.createChannel(
|
this.clientHeartbeat = this.client.createChannel(
|
||||||
sourceId, destinationId, NS_HEARTBEAT, "JSON");
|
sourceId, destinationId, NS_HEARTBEAT, "JSON");
|
||||||
this.clientReceiver = this.client.createChannel(
|
this.clientReceiver = this.client.createChannel(
|
||||||
sourceId, destinationId, NS_RECEIVER, "JSON");
|
sourceId, destinationId, NS_RECEIVER, "JSON");
|
||||||
|
|
||||||
this.clientConnection.send({ type: "CONNECT" });
|
this.clientConnection.send({ type: "CONNECT" });
|
||||||
this.clientHeartbeat.send({ type: "PING" });
|
this.clientHeartbeat.send({ type: "PING" });
|
||||||
|
|
||||||
this.clientHeartbeatIntervalId = setInterval(() => {
|
this.clientHeartbeatIntervalId = setInterval(() => {
|
||||||
if (transportHeartbeat) {
|
if (transportHeartbeat) {
|
||||||
transportHeartbeat.send({ type: "PING" });
|
transportHeartbeat.send({ type: "PING" });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.clientHeartbeat!.send({ type: "PING" });
|
this.clientHeartbeat!.send({ type: "PING" });
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
this.clientReceiver.send({
|
this.clientReceiver.send({
|
||||||
type: "LAUNCH"
|
type: "LAUNCH"
|
||||||
, appId
|
, appId: this.appId
|
||||||
, requestId: 1
|
, requestId: 1
|
||||||
});
|
});
|
||||||
|
|
||||||
this.clientReceiver.on("message", (message: any) => {
|
this.clientReceiver.on("message", (message: any) => {
|
||||||
if (message.type === "RECEIVER_STATUS") {
|
if (message.type === "RECEIVER_STATUS") {
|
||||||
this.sendMessage("shim:/session/updateStatus"
|
this.sendMessage("shim:/session/updateStatus", message.status);
|
||||||
, message.status);
|
|
||||||
|
|
||||||
if (!message.status.applications) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (message.status.applications) {
|
||||||
const receiverApp = message.status.applications[0];
|
const receiverApp = message.status.applications[0];
|
||||||
const receiverAppId = receiverApp.appId;
|
const receiverAppId = receiverApp.appId;
|
||||||
|
|
||||||
this.app = receiverApp;
|
this.app = receiverApp;
|
||||||
|
|
||||||
if (receiverAppId !== appId) {
|
if (receiverAppId !== this.appId) {
|
||||||
// Close session
|
// Close session
|
||||||
this.sendMessage("shim:/session/stopped");
|
this.sendMessage("shim:/session/stopped");
|
||||||
this.client.close();
|
this.client.close();
|
||||||
clearInterval(this.clientHeartbeatIntervalId!);
|
clearInterval(this.clientHeartbeatIntervalId!);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.isSessionCreated) {
|
if (!this.isSessionCreated) {
|
||||||
this.isSessionCreated = true;
|
this.isSessionCreated = true;
|
||||||
|
|
||||||
this.transportId = this.app.transportId;
|
this.transportId = this.app.transportId;
|
||||||
this.clientId =
|
this.clientId =
|
||||||
`client-${Math.floor(Math.random() * 10e5)}`;
|
`client-${Math.floor(Math.random() * 10e5)}`;
|
||||||
|
|
||||||
this.transportConnection = this.client.createChannel(
|
this.transportConnection = this.client.createChannel(
|
||||||
this.clientId, this.transportId!
|
this.clientId, this.transportId!
|
||||||
, NS_CONNECTION, "JSON");
|
, NS_CONNECTION, "JSON");
|
||||||
transportHeartbeat = this.client.createChannel(
|
transportHeartbeat = this.client.createChannel(
|
||||||
this.clientId, this.transportId!
|
this.clientId, this.transportId!
|
||||||
, NS_HEARTBEAT, "JSON");
|
, NS_HEARTBEAT, "JSON");
|
||||||
|
|
||||||
this.transportConnection.send({ type: "CONNECT" });
|
this.transportConnection.send({ type: "CONNECT" });
|
||||||
|
|
||||||
this.sessionId = this.app.sessionId;
|
this.sessionId = this.app.sessionId;
|
||||||
|
|
||||||
this.sendMessage("shim:/session/connected", {
|
this.sendMessage("shim:/session/connected", {
|
||||||
sessionId: this.app.sessionId
|
sessionId: this.app.sessionId
|
||||||
, namespaces: this.app.namespaces
|
, namespaces: this.app.namespaces
|
||||||
@@ -128,7 +111,7 @@ export default class Session {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,25 +152,24 @@ export default class Session {
|
|||||||
|
|
||||||
public createChannel (namespace: string) {
|
public createChannel (namespace: string) {
|
||||||
if (!this.channelMap.has(namespace)) {
|
if (!this.channelMap.has(namespace)) {
|
||||||
this.channelMap.set(namespace
|
this.channelMap.set(namespace, this.client.createChannel(
|
||||||
, this.client.createChannel(
|
this.clientId!, this.transportId!
|
||||||
this.clientId!, this.transportId!, namespace, "JSON"));
|
, namespace, "JSON"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public close () {
|
public close () {
|
||||||
this.clientConnection!.send({ type: "CLOSE" });
|
this.clientConnection?.send({ type: "CLOSE" });
|
||||||
if (this.transportConnection) {
|
this.transportConnection?.send({ type: "CLOSE" });
|
||||||
this.transportConnection.send({ type: "CLOSE" });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public stop () {
|
public stop () {
|
||||||
this.clientConnection!.send({ type: "STOP" });
|
this.clientConnection?.send({ type: "STOP" });
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendMessage (subject: string, data: any = {}) {
|
private sendMessage (subject: string, data: any = {}) {
|
||||||
this.sendMessageCallback({
|
sendMessage({
|
||||||
|
// @ts-ignore
|
||||||
subject
|
subject
|
||||||
, data
|
, data
|
||||||
, _id: this.referenceId
|
, _id: this.referenceId
|
||||||
@@ -196,7 +178,7 @@ export default class Session {
|
|||||||
|
|
||||||
private _impl_addMessageListener (namespace: string) {
|
private _impl_addMessageListener (namespace: string) {
|
||||||
this.createChannel(namespace);
|
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", {
|
this.sendMessage("shim:/session/impl_addMessageListener", {
|
||||||
namespace
|
namespace
|
||||||
, data: JSON.stringify(data)
|
, data: JSON.stringify(data)
|
||||||
79
app/src/bridge/components/chromecast/index.ts
Normal file
79
app/src/bridge/components/chromecast/index.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
}
|
||||||
111
app/src/bridge/components/discovery.ts
Normal file
111
app/src/bridge/components/discovery.ts
Normal 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();
|
||||||
|
}
|
||||||
157
app/src/bridge/components/mediaServer.ts
Normal file
157
app/src/bridge/components/mediaServer.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/src/bridge/components/receiverSelector.ts
Normal file
88
app/src/bridge/components/receiverSelector.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,99 +1,22 @@
|
|||||||
import mdns from "mdns";
|
"use strict";
|
||||||
|
|
||||||
import child_process from "child_process";
|
import { decodeTransform, encodeTransform } from "./lib/messaging";
|
||||||
import events from "events";
|
import { Message } from "./types";
|
||||||
import fs from "fs";
|
|
||||||
import http from "http";
|
|
||||||
import mime from "mime-types";
|
|
||||||
import path from "path";
|
|
||||||
import stream from "stream";
|
|
||||||
|
|
||||||
import Media from "./Media";
|
import { handleSessionMessage, handleMediaMessage, stopReceiverApp }
|
||||||
import Session from "./Session";
|
from "./components/chromecast";
|
||||||
import StatusListener from "./StatusListener";
|
import { startDiscovery, stopDiscovery } from "./components/discovery";
|
||||||
|
import { startMediaServer, stopMediaServer } from "./components/mediaServer";
|
||||||
|
import { startReceiverSelector, stopReceiverSelector }
|
||||||
|
from "./components/receiverSelector";
|
||||||
|
|
||||||
import { DecodeTransform
|
import { __applicationName, __applicationVersion} from "../../package.json";
|
||||||
, 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();
|
|
||||||
|
|
||||||
|
|
||||||
process.on("SIGTERM", () => {
|
process.on("SIGTERM", () => {
|
||||||
if (mediaServer && mediaServer.listening) {
|
stopDiscovery();
|
||||||
mediaServer.close();
|
stopMediaServer();
|
||||||
}
|
stopReceiverSelector();
|
||||||
|
|
||||||
if (receiverSelectorApp && !receiverSelectorAppClosed) {
|
|
||||||
receiverSelectorApp.kill();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (browser) {
|
|
||||||
browser.stop();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -104,71 +27,16 @@ process.on("SIGTERM", () => {
|
|||||||
* Initializes the counterpart objects and is responsible
|
* Initializes the counterpart objects and is responsible
|
||||||
* for managing existing ones.
|
* for managing existing ones.
|
||||||
*/
|
*/
|
||||||
async function handleMessage (message: Message) {
|
decodeTransform.on("data", (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;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.subject.startsWith("bridge:/session/")) {
|
if (message.subject.startsWith("bridge:/session/")) {
|
||||||
if (!message._id) {
|
handleSessionMessage(message);
|
||||||
console.error("Session message missing _id");
|
return;
|
||||||
return;
|
}
|
||||||
}
|
if (message.subject.startsWith("bridge:/media/")) {
|
||||||
|
handleMediaMessage(message);
|
||||||
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));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.subject.startsWith("bridge:/receiverSelector/")) {
|
|
||||||
handleReceiverSelectorMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.subject.startsWith("bridge:/mediaServer/")) {
|
|
||||||
handleMediaServerMessage(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (message.subject) {
|
switch (message.subject) {
|
||||||
case "bridge:/getInfo": {
|
case "bridge:/getInfo": {
|
||||||
@@ -177,406 +45,32 @@ async function handleMessage (message: Message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "bridge:/initialize": {
|
case "bridge:/initialize": {
|
||||||
const options: InitializeOptions = message.data;
|
startDiscovery(message.data);
|
||||||
initialize(options);
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "bridge:/stopReceiverApp": {
|
case "bridge:/stopReceiverApp": {
|
||||||
const receiver: Receiver = message.data.receiver;
|
stopReceiverApp(message.data.receiver.host
|
||||||
const client = new Client();
|
, message.data.receiver.port);
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleReceiverSelectorMessage (message: Message) {
|
// Receiver selector
|
||||||
switch (message.subject) {
|
|
||||||
case "bridge:/receiverSelector/open": {
|
case "bridge:/receiverSelector/open": {
|
||||||
const receiverSelectorData = message.data;
|
startReceiverSelector(message.data); break;
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case "bridge:/receiverSelector/close": {
|
case "bridge:/receiverSelector/close": {
|
||||||
receiverSelectorApp.kill();
|
stopReceiverSelector(); break;
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Media server
|
||||||
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) {
|
|
||||||
case "bridge:/mediaServer/start": {
|
case "bridge:/mediaServer/start": {
|
||||||
const { filePath, port }
|
startMediaServer(message.data.filePath, message.data.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);
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case "bridge:/mediaServer/stop": {
|
case "bridge:/mediaServer/stop": {
|
||||||
if (mediaServer && mediaServer.listening) {
|
stopMediaServer();
|
||||||
mediaServer.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
15
app/src/bridge/lib/messaging.ts
Normal file
15
app/src/bridge/lib/messaging.ts
Normal 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);
|
||||||
|
}
|
||||||
56
app/src/bridge/lib/subtitles.ts
Normal file
56
app/src/bridge/lib/subtitles.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,11 +1,80 @@
|
|||||||
"use strict";
|
"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 {
|
export interface MediaStatus {
|
||||||
subject: string;
|
mediaSessionId: number;
|
||||||
data?: any;
|
supportedMediaCommands: number;
|
||||||
_id?: string;
|
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 {
|
export interface Receiver {
|
||||||
@@ -16,4 +85,206 @@ export interface Receiver {
|
|||||||
status?: ReceiverStatus;
|
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];
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import minimist from "minimist";
|
|||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
import { DecodeTransform
|
import { DecodeTransform
|
||||||
, EncodeTransform } from "../transforms";
|
, EncodeTransform } from "./transforms";
|
||||||
|
|
||||||
|
|
||||||
export function init (port: number) {
|
export function init (port: number) {
|
||||||
Reference in New Issue
Block a user