@nodecg/types
Version:
Dynamic broadcast graphics rendered in a browser
620 lines (547 loc) • 15.2 kB
text/typescript
import type { DeepReadonly } from "ts-essentials";
import { NodeCGAPIBase } from "../../shared/api.base";
import type { NodeCG } from "../../types/nodecg";
import type { TypedClientSocket } from "../../types/socket-protocol";
import { filteredConfig } from "./config";
import { Logger } from "./logger";
import { ClientReplicant } from "./replicant";
type SendMessageCb<T> = (error?: unknown, response?: T) => void;
type ReadReplicantCb<T = unknown> = (value: T | undefined) => void;
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface EventMap {
//
}
const soundMetadata = new WeakMap<
createjs.AbstractSoundInstance,
{ cueName: string; updateVolume: boolean }
>();
const apiContexts = new Set<NodeCGAPIClient<Record<string, any>>>();
/**
* This is what enables intra-context messaging.
* I.e., passing messages from one extension to another in the same Node.js context.
*/
function _forwardMessageToContext(
messageName: string,
bundleName: string,
data: unknown,
): void {
setTimeout(() => {
apiContexts.forEach((ctx) => {
ctx._messageHandlers.forEach((handler) => {
if (
messageName === handler.messageName &&
bundleName === handler.bundleName
) {
handler.func(data);
}
});
});
}, 0);
}
export class NodeCGAPIClient<
C extends Record<string, any> = NodeCG.Bundle.UnknownConfig,
> extends NodeCGAPIBase<"client", C, EventMap> {
static Replicant<
V,
O extends NodeCG.Replicant.Options<V> = NodeCG.Replicant.Options<V>,
>(
name: string,
namespace: string,
opts: O = {} as Record<any, unknown>,
): ClientReplicant<V, O> {
return new ClientReplicant<V, O>(
name,
namespace,
opts,
(globalThis as any).socket,
);
}
static sendMessageToBundle<T = unknown>(
messageName: string,
bundleName: string,
cb: SendMessageCb<T>,
): void;
static sendMessageToBundle<T = unknown>(
messageName: string,
bundleName: string,
data?: unknown,
): Promise<T>;
static sendMessageToBundle<T = unknown>(
messageName: string,
bundleName: string,
data: unknown,
cb: SendMessageCb<T>,
): void;
static sendMessageToBundle<T = unknown>(
messageName: string,
bundleName: string,
dataOrCb?: unknown,
cb?: SendMessageCb<T>,
): void | Promise<T> {
let data: any = null;
if (typeof dataOrCb === "function") {
cb = dataOrCb as SendMessageCb<T>;
} else {
data = dataOrCb;
}
_forwardMessageToContext(messageName, bundleName, data);
if (typeof cb === "function") {
globalThis.socket.emit(
"message",
{
bundleName,
messageName,
content: data,
},
(err, response?) => {
if (response) {
cb(err, response as T);
} else {
cb(err);
}
},
);
} else {
return new Promise<T>((resolve, reject) => {
globalThis.socket.emit(
"message",
{
bundleName,
messageName,
content: data,
},
(err, response?) => {
if (err) {
reject(err);
} else {
resolve(response as T);
}
},
);
});
}
}
static readReplicant<T = unknown>(
name: string,
namespace: string,
cb: ReadReplicantCb<T>,
): void {
globalThis.socket.emit(
"replicant:read",
{ name, namespace },
(error, value?) => {
if (error) {
console.error(error);
} else {
cb(value as T | undefined);
}
},
);
}
get Logger(): new (name: string) => NodeCG.Logger {
return Logger;
}
get log(): NodeCG.Logger {
if (this._memoizedLogger) {
return this._memoizedLogger;
}
this._memoizedLogger = new Logger(this.bundleName);
return this._memoizedLogger;
}
/**
* A filtered copy of the NodeCG server config with some sensitive keys removed.
*/
get config(): DeepReadonly<typeof filteredConfig> {
return Object.freeze(JSON.parse(JSON.stringify(filteredConfig)));
}
readonly socket: TypedClientSocket;
soundsReady = false;
private readonly _soundFiles?: ClientReplicant<NodeCG.AssetFile[]>;
private readonly _bundleVolume?: ClientReplicant<number>;
private readonly _masterVolume?: ClientReplicant<number>;
private _soundCues: NodeCG.SoundCue[] = [];
private _memoizedLogger?: NodeCG.Logger;
constructor(
bundle: NodeCG.Bundle & { _hasSounds?: boolean },
socket: TypedClientSocket,
) {
super(bundle);
apiContexts.add(this);
// If title isn't set, set it to the bundle name
if (globalThis.document) {
document.addEventListener(
"DOMContentLoaded",
() => {
if (document.title === "") {
document.title = this.bundleName;
}
},
false,
);
}
// Make socket accessible to public methods
this.socket = socket;
// eslint-disable-next-line @typescript-eslint/no-empty-function
this.socket.emit("joinRoom", bundle.name, () => {});
if (globalThis.window && bundle._hasSounds && window.createjs?.Sound) {
const soundCuesRep = new ClientReplicant<NodeCG.SoundCue[]>(
"soundCues",
this.bundleName,
{},
socket,
);
this._soundFiles = new ClientReplicant<NodeCG.AssetFile[]>(
"assets:sounds",
this.bundleName,
{},
socket,
);
this._bundleVolume = new ClientReplicant<number>(
`volume:${this.bundleName}`,
"_sounds",
{},
socket,
);
this._masterVolume = new ClientReplicant<number>(
"volume:master",
"_sounds",
{},
socket,
);
this._soundCues = [];
const loadedSums = new Set();
window.createjs.Sound.on("fileload", (e: any) => {
if (this.soundsReady || !e.data.sum) {
return;
}
loadedSums.add(e.data.sum);
const foundUnloaded = this._soundCues.some((cue) => {
if (cue.file) {
return !loadedSums.has(cue.file.sum);
}
return false;
});
if (!foundUnloaded && !this.soundsReady) {
this.soundsReady = true;
window.dispatchEvent(new CustomEvent("ncgSoundsReady"));
}
});
const handleAnyCuesRepChange = (): void => {
this._soundCues = soundCuesRep.value ?? [];
this._updateInstanceVolumes();
this._registerSounds();
};
soundCuesRep.on("change", handleAnyCuesRepChange);
this._soundFiles.on("change", () => this._registerSounds.bind(this));
this._bundleVolume.on("change", () =>
this._updateInstanceVolumes.bind(this),
);
this._masterVolume.on("change", () =>
this._updateInstanceVolumes.bind(this),
);
}
// Upon receiving a message, execute any handlers for it
socket.on("message", (data) => {
this.log.trace(
"Received message %s (sent to bundle %s) with data:",
data.messageName,
data.bundleName,
data.content,
);
this._messageHandlers.forEach((handler) => {
if (
data.messageName === handler.messageName &&
data.bundleName === handler.bundleName
) {
handler.func(data.content);
}
});
});
socket.io.on("error", (err) => {
this.log.warn("Unhandled socket connection error:", err);
});
socket.on("protocol_error", (err) => {
if (err.type === "UnauthorizedError") {
if (globalThis.window) {
const url = [
location.protocol,
"//",
location.host,
location.pathname,
].join("");
window.location.href = `/authError?code=${err.code as string}&message=${err.message}&viewUrl=${url}`;
} else {
globalThis.close();
}
} else {
this.log.error("Unhandled socket protocol error:", err);
}
});
}
/**
* _Browser only, except workers_<br/>
* Returns the specified dialog element.
* @param {string} name - The desired dialog's name.
* @param {string} [bundle=CURR_BNDL] - The bundle from which to select the dialog.
* @returns {object}
*/
getDialog(
name: string,
bundle?: string,
):
| (HTMLElement & {
opened: boolean;
close: () => void;
open: () => void;
})
| undefined {
if (!globalThis.window) {
return undefined;
}
bundle = bundle ?? this.bundleName;
const topDoc = window.top?.document;
if (!topDoc) {
return undefined;
}
const dialog = topDoc
.querySelector("ncg-dashboard")
?.shadowRoot?.querySelector(`#dialogs #${bundle}_${name}`);
return (dialog as any) ?? undefined;
}
/**
* _Browser only, except workers_<br/>
* Returns the specified dialog's iframe document.
* @param {string} name - The desired dialog's name.
* @param {string} [bundle=CURR_BNDL] - The bundle from which to select the dialog.
* @returns {object}
*/
getDialogDocument(name: string, bundle?: string): Document | undefined {
if (!globalThis.window) {
return undefined;
}
bundle = bundle ?? this.bundleName;
return this.getDialog(name, bundle)?.querySelector("iframe")?.contentWindow
?.document;
}
/**
* Returns the sound cue of the provided `cueName` in the current bundle.
* Returns undefined if a cue by that name cannot be found in this bundle.
*/
findCue(cueName: string): NodeCG.SoundCue | undefined {
return this._soundCues.find((cue) => cue.name === cueName);
}
/**
* Plays the sound cue of the provided `cueName` in the current bundle.
* Does nothing if the cue doesn't exist or if the cue has no assigned file to play.
* @param cueName {String}
* @param [opts] {Object}
* @param [opts.updateVolume=true] - Whether or not to let NodeCG automatically update this instance's volume
* when the user changes it on the dashboard.
* @returns {Object|undefined} - A SoundJS AbstractAudioInstance.
*/
playSound(
cueName: string,
{ updateVolume = true }: { updateVolume: boolean } = { updateVolume: true },
): createjs.AbstractSoundInstance {
if (!globalThis.window) {
throw new Error("NodeCG Sound API methods are not available in workers");
}
if (!this._soundCues) {
throw new Error(`Bundle "${this.bundleName}" has no soundCues`);
}
const cue = this.findCue(cueName);
if (!cue) {
throw new Error(
`Cue "${cueName}" does not exist in bundle "${this.bundleName}"`,
);
}
if (!window.createjs?.Sound) {
throw new Error(
"NodeCG Sound API methods are not available when SoundJS isn't present",
);
}
if (!cue.file) {
throw new Error(`Cue $"{cueName}" does not have a file specified.`);
}
// Create an instance of the sound, which begins playing immediately.
const instance = window.createjs.Sound.play(cueName);
// Set the volume.
this._setInstanceVolume(instance, cue);
soundMetadata.set(instance, { cueName, updateVolume });
return instance;
}
/**
* Stops all currently playing instances of the provided `cueName`.
* @param cueName {String}
*/
stopSound(cueName: string): void {
if (!globalThis.window) {
throw new Error("NodeCG Sound API methods are not available in workers");
}
if (!this._soundCues) {
throw new Error(`Bundle "${this.bundleName}" has no soundCues`);
}
if (!this._soundCues.find((cue) => cue.name === cueName)) {
throw new Error(
`Cue "${cueName}" does not exist in bundle "${this.bundleName}"`,
);
}
if (!window.createjs?.Sound) {
throw new Error(
"NodeCG Sound API methods are not available when SoundJS isn't present",
);
}
const instancesArr = (window.createjs.Sound as any)
._instances as createjs.AbstractSoundInstance[];
for (let i = instancesArr.length - 1; i >= 0; i--) {
const instance = instancesArr[i]!;
const meta = soundMetadata.get(instance);
if (meta?.cueName === cueName) {
instance.stop();
}
}
}
/**
* Stops all currently playing sounds on the page.
*/
stopAllSounds(): void {
if (!globalThis.window) {
throw new Error("NodeCG Sound API methods are not available in workers");
}
if (!window.createjs?.Sound) {
throw new Error(
"NodeCG Sound API methods are not available when SoundJS isn't present",
);
}
window.createjs.Sound.stop();
}
sendMessageToBundle<T = unknown>(
messageName: string,
bundleName: string,
cb: SendMessageCb<T>,
): void;
sendMessageToBundle<T = unknown>(
messageName: string,
bundleName: string,
data?: unknown,
): Promise<T>;
sendMessageToBundle<T = unknown>(
messageName: string,
bundleName: string,
data: unknown,
cb: SendMessageCb<T>,
): void;
sendMessageToBundle<T = unknown>(
messageName: string,
bundleName: string,
dataOrCb?: unknown,
cb?: SendMessageCb<T>,
): void | Promise<T> {
return NodeCGAPIClient.sendMessageToBundle(
messageName,
bundleName,
dataOrCb,
cb as any,
);
}
sendMessage<T = unknown>(messageName: string, cb: SendMessageCb<T>): void;
sendMessage<T = unknown>(messageName: string, data?: unknown): Promise<T>;
sendMessage<T = unknown>(
messageName: string,
data: unknown,
cb: SendMessageCb<T>,
): void;
sendMessage<T = unknown>(
messageName: string,
dataOrCb?: unknown,
cb?: SendMessageCb<T>,
): void | Promise<T> {
return this.sendMessageToBundle(
messageName,
this.bundleName,
dataOrCb,
cb as any,
);
}
readReplicant<T = unknown>(name: string, cb: ReadReplicantCb<T>): void;
readReplicant<T = unknown>(
name: string,
namespace: string,
cb: ReadReplicantCb<T>,
): void;
readReplicant<T = unknown>(
name: string,
nspOrCb: string | ReadReplicantCb<T>,
maybeCb?: ReadReplicantCb<T>,
): void {
let namespace = this.bundleName;
let cb: ReadReplicantCb<T>;
if (typeof nspOrCb === "string") {
namespace = nspOrCb;
cb = maybeCb!;
} else {
cb = nspOrCb;
}
NodeCGAPIClient.readReplicant<T>(name, namespace, cb);
}
protected _replicantFactory = <
V,
O extends NodeCG.Replicant.Options<V> = NodeCG.Replicant.Options<V>,
>(
name: string,
namespace: string,
opts: O,
): ClientReplicant<V, O> =>
new ClientReplicant<V, O>(name, namespace, opts, this.socket);
private _registerSounds(): void {
if (!globalThis.window) {
throw new Error("NodeCG Sound API methods are not available in workers");
}
this._soundCues.forEach((cue) => {
if (!cue.file) {
return;
}
window.createjs.Sound.registerSound(
`${cue.file.url}?sum=${cue.file.sum}`,
cue.name,
{
channels: typeof cue.channels === "undefined" ? 100 : cue.channels,
sum: cue.file.sum,
},
);
});
}
private _setInstanceVolume(
instance: createjs.AbstractSoundInstance,
cue: NodeCG.SoundCue,
): void {
if (
this._masterVolume?.status !== "declared" ||
typeof this._masterVolume.value !== "number" ||
this._bundleVolume?.status !== "declared" ||
typeof this._bundleVolume.value !== "number"
) {
return;
}
const volume =
(this._masterVolume.value / 100) *
(this._bundleVolume.value / 100) *
(cue.volume / 100);
// Volue value must be finite or SoundJS throws error
instance.volume = isFinite(volume) ? volume : 0;
}
private _updateInstanceVolumes(): void {
const instancesArr = (window.createjs.Sound as any)
._instances as createjs.AbstractSoundInstance[];
// Update the volume of any playing instances that haven't opted out of automatic volume updates.
this._soundCues.forEach((cue) => {
instancesArr.forEach((instance) => {
const meta = soundMetadata.get(instance);
if (meta && meta.cueName === cue.name && meta.updateVolume) {
this._setInstanceVolume(instance, cue);
}
});
});
}
}
(globalThis as any).NodeCG = NodeCGAPIClient;