From 454e40055dd6f63a8d76c7e06f7115092c2f800d Mon Sep 17 00:00:00 2001 From: Jeremy Zongker Date: Tue, 16 Jul 2024 00:45:58 -0500 Subject: [PATCH] Remove thumbnails (#665) * Simplifying Output * Moved Values * Moved Visibility * Move outputwindows to outputhelper * Moved more code to bounds * Added LifeCycle * Fully replaced output with OutputHelper * Moved OutputHelper * Updated all code to reference Output object instead of outputWindows[] * Got previewWindow displaying * Locked preview to window * Got screens aligned and sticking in place * Fixed minimize/restore and add/remove outputs * Removed unused preview capture code * Re-enabled ndi and server outputs without beginFrameSubscription * Added CaptureHelper * Reorganized * Moved captures list to Output.captureOptions * Moved CaptureTransmitter * Code cleanup * Removed output windows * Got good performance out of preview renders * Previews look good except full screen * Fixed fullscreen * Mirrored preview outputs * Got preview videos playing smoothly * Fixed website scaling --- src/electron/capture/CaptureHelper.ts | 72 ++++ src/electron/capture/CaptureOptions.ts | 10 + .../capture/helpers/CaptureLifecycle.ts | 86 ++++ .../helpers}/CaptureTransmitter.ts | 98 ++--- src/electron/index.ts | 36 +- src/electron/ndi/NdiSender.ts | 266 ++++++------- src/electron/ndi/talk.ts | 8 +- src/electron/output/Output.ts | 15 + src/electron/output/OutputHelper.ts | 69 ++++ src/electron/output/capture.ts | 190 --------- src/electron/output/helpers/OutputBounds.ts | 97 +++++ src/electron/output/helpers/OutputIdentify.ts | 35 ++ .../output/helpers/OutputLifecycle.ts | 136 +++++++ src/electron/output/helpers/OutputSend.ts | 35 ++ src/electron/output/helpers/OutputValues.ts | 38 ++ .../output/helpers/OutputVisibility.ts | 115 ++++++ src/electron/output/output.ts | 372 ------------------ src/electron/utils/responses.ts | 10 +- .../edit/editbox/EditboxOther.svelte | 2 +- .../output/layers/BackgroundMedia.svelte | 4 + .../output/preview/MultiOutputs.svelte | 25 +- .../output/preview/PreviewOutput.svelte | 43 ++ src/frontend/components/slide/Textbox.svelte | 2 +- .../components/slide/views/Website.svelte | 17 +- 24 files changed, 990 insertions(+), 791 deletions(-) create mode 100644 src/electron/capture/CaptureHelper.ts create mode 100644 src/electron/capture/CaptureOptions.ts create mode 100644 src/electron/capture/helpers/CaptureLifecycle.ts rename src/electron/{output => capture/helpers}/CaptureTransmitter.ts (59%) create mode 100644 src/electron/output/Output.ts create mode 100644 src/electron/output/OutputHelper.ts delete mode 100644 src/electron/output/capture.ts create mode 100644 src/electron/output/helpers/OutputBounds.ts create mode 100644 src/electron/output/helpers/OutputIdentify.ts create mode 100644 src/electron/output/helpers/OutputLifecycle.ts create mode 100644 src/electron/output/helpers/OutputSend.ts create mode 100644 src/electron/output/helpers/OutputValues.ts create mode 100644 src/electron/output/helpers/OutputVisibility.ts delete mode 100644 src/electron/output/output.ts create mode 100644 src/frontend/components/output/preview/PreviewOutput.svelte diff --git a/src/electron/capture/CaptureHelper.ts b/src/electron/capture/CaptureHelper.ts new file mode 100644 index 00000000..f60f1d54 --- /dev/null +++ b/src/electron/capture/CaptureHelper.ts @@ -0,0 +1,72 @@ +import type { BrowserWindow, Display, NativeImage, Size } from "electron" +import electron from "electron" +import { NdiSender } from "../ndi/NdiSender" +import { CaptureTransmitter } from "./helpers/CaptureTransmitter" +import { CaptureOptions } from "./CaptureOptions" +import { CaptureLifecycle } from "./helpers/CaptureLifecycle" +import { OutputHelper } from "../output/OutputHelper" + +export class CaptureHelper { + static Lifecycle = CaptureLifecycle + static Transmitter = CaptureTransmitter + + private static framerates: any = { + server: 30, + unconnected: 1, + connected: 30, + } + static customFramerates: any = {} + + static getDefaultCapture(window: BrowserWindow, id: string): CaptureOptions { + let screen: Display = this.getWindowScreen(window) + + let defaultFramerates = { + server: this.framerates.server, + ndi: this.framerates.connected, + } + + return { + window, + subscribed: false, + displayFrequency: screen.displayFrequency || 60, + options: { server: false, ndi: false }, + framerates: defaultFramerates, + id, + } + } + + // START + + static storedFrames: any = {} + + static updateFramerate(id: string) { + const captureOptions = OutputHelper.getOutput(id)?.captureOptions + if (!captureOptions) return + + if (NdiSender.NDI[id]) { + let ndiFramerate = this.framerates.unconnected + if (NdiSender.NDI[id].status === "connected") ndiFramerate = this.customFramerates[id]?.ndi || this.framerates.connected + + if (captureOptions.framerates.ndi !== parseInt(ndiFramerate)) { + captureOptions.framerates.ndi = parseInt(ndiFramerate) + CaptureTransmitter.startChannel(id, "ndi") + } + } + } + + static getWindowScreen(window: BrowserWindow) { + return electron.screen.getDisplayMatching({ + x: window.getBounds().x, + y: window.getBounds().y, + width: window.getBounds().width, + height: window.getBounds().height, + }) + } + + static resizeImage(image: NativeImage, initialSize: Size, newSize: Size) { + if (initialSize.width / initialSize.height >= newSize.width / newSize.height) image = image.resize({ width: newSize.width }) + else image = image.resize({ height: newSize.height }) + + return image + } +} diff --git a/src/electron/capture/CaptureOptions.ts b/src/electron/capture/CaptureOptions.ts new file mode 100644 index 00000000..a5195a11 --- /dev/null +++ b/src/electron/capture/CaptureOptions.ts @@ -0,0 +1,10 @@ +import { BrowserWindow } from "electron" + +export type CaptureOptions = { + id: string + window: BrowserWindow + subscribed: boolean + displayFrequency: number + options: any + framerates: any +} diff --git a/src/electron/capture/helpers/CaptureLifecycle.ts b/src/electron/capture/helpers/CaptureLifecycle.ts new file mode 100644 index 00000000..74235322 --- /dev/null +++ b/src/electron/capture/helpers/CaptureLifecycle.ts @@ -0,0 +1,86 @@ +import { NativeImage } from "electron" +import { CaptureHelper } from "../CaptureHelper" +import { OutputHelper } from "../../output/OutputHelper" + +export class CaptureLifecycle { + static startCapture(id: string, toggle: any = {}) { + const output = OutputHelper.getOutput(id) + let window = output?.window + let windowIsRemoved = !window || window.isDestroyed() + if (windowIsRemoved) { + delete output.captureOptions + return + } + + if (!output.captureOptions) output.captureOptions = CaptureHelper.getDefaultCapture(window, id) + + if (output.captureOptions) { + const capture = output.captureOptions + Object.keys(toggle).map((key) => { + capture.options[key] = toggle[key] + }) + } + + CaptureHelper.updateFramerate(id) + + if (output.captureOptions.subscribed) return + CaptureHelper.Transmitter.startTransmitting(id) + output.captureOptions.subscribed = true + + cpuCapture() + async function cpuCapture() { + if (!output.captureOptions || output.captureOptions.window.isDestroyed()) return + let image = await output.captureOptions.window.webContents.capturePage() + processFrame(image) + let frameRate = output.captureOptions.framerates.ndi + if (output.captureOptions.framerates.server > frameRate) frameRate = output.captureOptions.framerates.server + const ms = Math.round(1000 / frameRate) + setTimeout(cpuCapture, ms) + } + + function processFrame(image: NativeImage) { + CaptureHelper.storedFrames[id] = image + } + } + + // STOP + static stopAllCaptures() { + OutputHelper.getAllOutputs().forEach((output) => { + if (output[1].captureOptions) this.stopCapture(output[0]) + }) + } + + static stopCapture(id: string) { + const output = OutputHelper.getOutput(id) + const capture = output.captureOptions + return new Promise((resolve) => { + if (!capture) return resolve(true) + CaptureHelper.Transmitter.removeAllChannels(id) + let windowIsRemoved = !capture.window || capture.window.isDestroyed() + if (windowIsRemoved) return deleteAndResolve() + + console.log("Capture - stopping: " + id) + + endSubscription() + removeListeners() + deleteAndResolve() + + function deleteAndResolve() { + delete output.captureOptions + resolve(true) + } + }) + + function endSubscription() { + if (!capture?.subscribed) return + + capture.window.webContents.endFrameSubscription() + capture.subscribed = false + } + + function removeListeners() { + capture?.window.removeAllListeners() + capture?.window.webContents.removeAllListeners() + } + } +} diff --git a/src/electron/output/CaptureTransmitter.ts b/src/electron/capture/helpers/CaptureTransmitter.ts similarity index 59% rename from src/electron/output/CaptureTransmitter.ts rename to src/electron/capture/helpers/CaptureTransmitter.ts index 4eb5452d..79f3342c 100644 --- a/src/electron/output/CaptureTransmitter.ts +++ b/src/electron/capture/helpers/CaptureTransmitter.ts @@ -1,13 +1,12 @@ import type { NativeImage, Size } from "electron" import os from "os" -import { toApp } from ".." -import { OUTPUT, OUTPUT_STREAM } from "../../types/Channels" -import { toServer } from "../servers" -import { sendToWindow } from "./output" -import util from "../ndi/vingester-util" -import { NdiSender } from "../ndi/NdiSender" -import { CaptureOptions, captures, previewSize, storedFrames } from "./capture" - +import { OUTPUT_STREAM } from "../../../types/Channels" +import { toServer } from "../../servers" +import util from "../../ndi/vingester-util" +import { NdiSender } from "../../ndi/NdiSender" +import { CaptureHelper } from "../CaptureHelper" +import { CaptureOptions } from "../CaptureOptions" +import { OutputHelper } from "../../output/OutputHelper" export type Channel = { key: string @@ -17,17 +16,15 @@ export type Channel = { } export class CaptureTransmitter { - static stageWindows: string[] = [] static requestList: any[] = [] //static ndiFrameCount = 0 static channels: { [key: string]: Channel } = {} - static startTransmitting(captureId: string) { - const capture = captures[captureId] + const capture = OutputHelper.getOutput(captureId)?.captureOptions if (!capture) return - this.startChannel(captureId, "preview") + //this.startChannel(captureId, "preview") if (capture.options.ndi) this.startChannel(captureId, "ndi") if (capture.options.server) this.startChannel(captureId, "server") @@ -36,8 +33,8 @@ export class CaptureTransmitter { /* console.log("SETTING INTERVAL"); setInterval(() => { - console.log("NDI FRAMES:", CaptureTransmitter.ndiFrameCount, " - ", captureId); - CaptureTransmitter.ndiFrameCount = 0 + console.log("NDI FRAMES:", this.ndiFrameCount, " - ", captureId); + this.ndiFrameCount = 0 },1000); */ } @@ -45,78 +42,50 @@ export class CaptureTransmitter { static startChannel(captureId: string, key: string) { const combinedKey = `${captureId}-${key}` - const interval = 1000 / captures[captureId].framerates[key] - + const interval = 1000 / OutputHelper.getOutput(captureId)?.captureOptions?.framerates[key] || 10 + if (this.channels[combinedKey]?.timer) { clearInterval(this.channels[combinedKey].timer) this.channels[combinedKey].timer = setInterval(() => this.handleChannelInterval(captureId, key), interval) } else { - this.channels[combinedKey] = { - key, - captureId, - timer: setInterval(() => this.handleChannelInterval(captureId, key), interval), - lastImage: storedFrames[captureId] + this.channels[combinedKey] = { + key, + captureId, + timer: setInterval(() => this.handleChannelInterval(captureId, key), interval), + lastImage: CaptureHelper.storedFrames[captureId], } } } - static handleChannelInterval(captureId:string, key:string) { + static handleChannelInterval(captureId: string, key: string) { const combinedKey = `${captureId}-${key}` const channel = this.channels[combinedKey] if (!channel) return - const image = storedFrames[captureId] - if (!image || channel.lastImage===image) return + const image = CaptureHelper.storedFrames[captureId] + if (!image || channel.lastImage === image) return const size = image.getSize() channel.lastImage = image switch (key) { - case "preview": - this.sendBufferToPreview(channel.captureId, image, { size }) - break + //case "preview": + //this.sendBufferToPreview(channel.captureId, image, { size }) + //break case "ndi": this.sendBufferToNdi(channel.captureId, image, { size }) break case "server": - this.sendBufferToServer(captures[captureId], image) + const options = OutputHelper.getOutput(captureId)?.captureOptions + if (options) this.sendBufferToServer(options, image) break } - } - /* - static async sendFrames(capture: CaptureOptions, image: NativeImage, rates: any) { - if (!capture || !image) return - const size = image.getSize() - if (rates.previewFrame) this.sendBufferToPreview(capture.id, image, { size }) - if (rates.serverFrame && capture.options.server) this.sendBufferToServer(capture, image) - if (rates.ndiFrame && capture.options.ndi) this.sendBufferToNdi(capture.id, image, { size }) - } -*/ - // NDI - static sendBufferToNdi(captureId:string, image: NativeImage, { size }: any) { + static sendBufferToNdi(captureId: string, image: NativeImage, { size }: any) { const buffer = image.getBitmap() const ratio = image.getAspectRatio() //this.ndiFrameCount++ // WIP refresh on enable? - NdiSender.sendVideoBufferNDI(captureId, buffer, { size, ratio, framerate: captures[captureId].framerates.ndi }) - } - - // PREVIEW - static sendBufferToPreview(captureId:string, image: NativeImage, options: any) { - if (!image) return - image = this.resizeImage(image, options.size, previewSize) - - const buffer = image.getBitmap() - const size = image.getSize() - - /* convert from ARGB/BGRA (Electron/Chromium capture output) to RGBA (Web canvas) */ - if (os.endianness() === "BE") util.ImageBufferAdjustment.ARGBtoRGBA(buffer) - else util.ImageBufferAdjustment.BGRAtoRGBA(buffer) - - let msg = { channel: "PREVIEW", data: { id:captureId, buffer, size, originalSize: options.size } } - toApp(OUTPUT, msg) - this.sendToStageOutputs(msg) - this.sendToRequested(msg) + NdiSender.sendVideoBufferNDI(captureId, buffer, { size, ratio, framerate: OutputHelper.getOutput(captureId)?.captureOptions?.framerates.ndi || 10 }) } static resizeImage(image: NativeImage, initialSize: Size, newSize: Size) { @@ -127,7 +96,7 @@ export class CaptureTransmitter { } static sendToStageOutputs(msg: any) { - ;[...new Set(this.stageWindows)].forEach((id) => sendToWindow(id, msg)) + ;[...new Set(this.stageWindows)].forEach((id) => OutputHelper.Send.sendToWindow(id, msg)) } static sendToRequested(msg: any) { @@ -141,7 +110,7 @@ export class CaptureTransmitter { return } - sendToWindow(data.id, msg) + OutputHelper.Send.sendToWindow(data.id, msg) }) this.requestList = newList @@ -163,13 +132,13 @@ export class CaptureTransmitter { if (os.endianness() === "BE") util.ImageBufferAdjustment.ARGBtoRGBA(buffer) else util.ImageBufferAdjustment.BGRAtoRGBA(buffer) - toServer(OUTPUT_STREAM, { channel: "STREAM", data: { id:capture.id, buffer, size } }) + toServer(OUTPUT_STREAM, { channel: "STREAM", data: { id: capture.id, buffer, size } }) } static requestPreview(data: any) { this.requestList.push(JSON.stringify(data)) } - + static removeAllChannels(captureId: string) { Object.keys(this.channels).forEach((key) => { if (key.includes(captureId)) this.removeChannel(captureId, key) @@ -182,5 +151,4 @@ export class CaptureTransmitter { if (this.channels[combinedKey].timer) clearInterval(this.channels[combinedKey].timer) delete this.channels[combinedKey] } - -} \ No newline at end of file +} diff --git a/src/electron/index.ts b/src/electron/index.ts index a18f58fd..e891a393 100644 --- a/src/electron/index.ts +++ b/src/electron/index.ts @@ -10,7 +10,6 @@ import { startBackup } from "./data/backup" import { config, stores, updateDataPath, userDataPath } from "./data/store" import { NdiReceiver } from "./ndi/NdiReceiver" import { receiveNDI } from "./ndi/talk" -import { closeAllOutputs, receiveOutput } from "./output/output" import { closeServers } from "./servers" import { stopApiListener } from "./utils/api" import { checkShowsFolder, dataFolderNames, deleteFile, getDataFolder, loadShows, writeFile } from "./utils/files" @@ -20,6 +19,7 @@ import { catchErrors, loadScripture, loadShow, receiveMain, renameShows, saveRec import { loadingOptions, mainOptions } from "./utils/windowOptions" import { startExport } from "./data/export" import { currentlyDeletedShows } from "./cloud/drive" +import { OutputHelper } from "./output/OutputHelper" // ----- STARTUP ----- @@ -187,11 +187,33 @@ function retryLoadingContent() { function setMainListeners() { if (!mainWindow) return - mainWindow.on("maximize", () => config.set("maximized", true)) - mainWindow.on("unmaximize", () => config.set("maximized", false)) + /* + mainWindow.on("minimize", () => { + OutputHelper.Visibility.hideAllPreviews() + }) + mainWindow.on("restore", () => { + setTimeout(() => { + OutputHelper.Visibility.showAllPreviews() + }, 100) + })*/ + + mainWindow.on("maximize", () => { + //OutputHelper.Bounds.updatePreviewBounds() + config.set("maximized", true) + }) + mainWindow.on("unmaximize", () => { + //OutputHelper.Bounds.updatePreviewBounds() + config.set("maximized", false) + }) - mainWindow.on("resize", () => config.set("bounds", mainWindow?.getBounds())) - mainWindow.on("move", () => config.set("bounds", mainWindow?.getBounds())) + mainWindow.on("resize", () => { + //OutputHelper.Bounds.updatePreviewBounds() + config.set("bounds", mainWindow?.getBounds()) + }) + mainWindow.on("move", () => { + //OutputHelper.Bounds.updatePreviewBounds() + config.set("bounds", mainWindow?.getBounds()) + }) mainWindow.on("close", callClose) mainWindow.on("closed", exitApp) @@ -208,7 +230,7 @@ export async function exitApp() { mainWindow = null dialogClose = false - await closeAllOutputs() + await OutputHelper.Lifecycle.closeAllOutputs() NdiReceiver.stopReceiversNDI() closeServers() @@ -351,7 +373,7 @@ function save(data: any) { // ----- LISTENERS ----- ipcMain.on(MAIN, receiveMain) -ipcMain.on(OUTPUT, receiveOutput) +ipcMain.on(OUTPUT, OutputHelper.receiveOutput) ipcMain.on(IMPORT, startImport) ipcMain.on(EXPORT, startExport) ipcMain.on(SHOW, loadShow) diff --git a/src/electron/ndi/NdiSender.ts b/src/electron/ndi/NdiSender.ts index 01cfcb05..b2613bcc 100644 --- a/src/electron/ndi/NdiSender.ts +++ b/src/electron/ndi/NdiSender.ts @@ -3,7 +3,7 @@ import os from "os" import { isLinux, toApp } from ".." import util from "./vingester-util" import grandiose from "grandiose" -import { updateFramerate } from "../output/capture" +import { CaptureHelper } from "../capture/CaptureHelper" // WIP - NDI issue on Linux: libndi.so.5: No such file or dialog @@ -15,159 +15,159 @@ import { updateFramerate } from "../output/capture" // TODO: audio export class NdiSender { - static ndiDisabled = isLinux && os.arch() !== "x64" && os.arch() !== "ia32" - static timeStart = BigInt(Date.now()) * BigInt(1e6) - process.hrtime.bigint() - static NDI: any = {} - - static stopSenderNDI(id: string) { - if (!this.NDI[id]?.timer) return - - console.log("NDI - stopping sender: " + this.NDI[id].name) - clearInterval(this.NDI[id].timer) - - try { - this.NDI[id].sender.destroy() - } catch (error) { - console.log("ERROR", error) - } - - delete this.NDI[id] - } - - static async createSenderNDI(id: string, title: string = "") { - if (this.ndiDisabled) return + static ndiDisabled = isLinux && os.arch() !== "x64" && os.arch() !== "ia32" + static timeStart = BigInt(Date.now()) * BigInt(1e6) - process.hrtime.bigint() + static NDI: any = {} - if (this.NDI[id]) return - this.NDI[id] = {} + static stopSenderNDI(id: string) { + if (!this.NDI[id]?.timer) return - this.NDI[id].name = "FreeShow NDI" - if (title) this.NDI[id].name = this.NDI[id].name + " - " + title - console.log("NDI - creating sender: " + this.NDI[id].name) + console.log("NDI - stopping sender: " + this.NDI[id].name) + clearInterval(this.NDI[id].timer) - let error = false - - try { - this.NDI[id].sender = await grandiose.send({ - name: this.NDI[id].name, - clockVideo: false, - clockAudio: false, - }) - } catch (err) { - console.log("Could not create NDI sender:", err) - error = true - } + try { + this.NDI[id].sender.destroy() + } catch (error) { + console.log("ERROR", error) + } - if (error) { delete this.NDI[id] - return } - this.NDI[id].timer = setInterval(() => { - /* poll NDI for connections */ - const conns = this.NDI[id].sender?.connections() || 0 + static async createSenderNDI(id: string, title: string = "") { + if (this.ndiDisabled) return - this.NDI[id].status = "unconnected" - if (conns > 0) this.NDI[id].status = "connected" + if (this.NDI[id]) return + this.NDI[id] = {} - let newStatus = this.NDI[id].status + conns - if (newStatus !== this.NDI[id].previousStatus) { - toApp("NDI", { channel: "SEND_DATA", data: { id, status: this.NDI[id].status, connections: conns } }) - updateFramerate(id) + this.NDI[id].name = "FreeShow NDI" + if (title) this.NDI[id].name = this.NDI[id].name + " - " + title + console.log("NDI - creating sender: " + this.NDI[id].name) - this.NDI[id].previousStatus = newStatus + let error = false + + try { + this.NDI[id].sender = await grandiose.send({ + name: this.NDI[id].name, + clockVideo: false, + clockAudio: false, + }) + } catch (err) { + console.log("Could not create NDI sender:", err) + error = true + } + + if (error) { + delete this.NDI[id] + return } - }, 1000) - } - static async sendVideoBufferNDI(id: string, buffer: any, { size = { width: 1280, height: 720 }, ratio = 16 / 9, framerate = 1 }) { - if (!this.NDI[id]?.sender) return - if (this.ndiDisabled) return + this.NDI[id].timer = setInterval(() => { + /* poll NDI for connections */ + const conns = this.NDI[id].sender?.connections() || 0 + + this.NDI[id].status = "unconnected" + if (conns > 0) this.NDI[id].status = "connected" + + let newStatus = this.NDI[id].status + conns + if (newStatus !== this.NDI[id].previousStatus) { + toApp("NDI", { channel: "SEND_DATA", data: { id, status: this.NDI[id].status, connections: conns } }) + CaptureHelper.updateFramerate(id) - /* convert from ARGB (Electron/Chromium on big endian CPU) + this.NDI[id].previousStatus = newStatus + } + }, 1000) + } + + static async sendVideoBufferNDI(id: string, buffer: any, { size = { width: 1280, height: 720 }, ratio = 16 / 9, framerate = 1 }) { + if (!this.NDI[id]?.sender) return + if (this.ndiDisabled) return + + /* convert from ARGB (Electron/Chromium on big endian CPU) to BGRA (supported input of NDI SDK). On little endian CPU the input is already BGRA. */ - if (os.endianness() === "BE") { - util.ImageBufferAdjustment.ARGBtoBGRA(buffer) - } + if (os.endianness() === "BE") { + util.ImageBufferAdjustment.ARGBtoBGRA(buffer) + } - /* optionally convert from BGRA to BGRX (no alpha channel) */ - let fourCC = (grandiose as any).FOURCC_BGRA - // if (!this.cfg.v) { - // util.ImageBufferAdjustment.BGRAtoBGRX(buffer) - // fourCC = grandiose.FOURCC_BGRX - // } - - /* send NDI video frame */ - const now = this.timeStart + process.hrtime.bigint() - const timecode = now / BigInt(100) - const bytesForBGRA = 4 - const frame = { - /* base information */ - // type: "video", - timecode, - - /* type-specific information */ - xres: size.width, - yres: size.height, - frameRateN: framerate * 1000, - frameRateD: 1000, - pictureAspectRatio: ratio, - frameFormatType: (grandiose as any).FORMAT_TYPE_PROGRESSIVE, - lineStrideBytes: size.width * bytesForBGRA, - - /* the data itself */ - fourCC, - data: buffer, - } + /* optionally convert from BGRA to BGRX (no alpha channel) */ + let fourCC = (grandiose as any).FOURCC_BGRA + // if (!this.cfg.v) { + // util.ImageBufferAdjustment.BGRAtoBGRX(buffer) + // fourCC = grandiose.FOURCC_BGRX + // } + + /* send NDI video frame */ + const now = this.timeStart + process.hrtime.bigint() + const timecode = now / BigInt(100) + const bytesForBGRA = 4 + const frame = { + /* base information */ + // type: "video", + timecode, + + /* type-specific information */ + xres: size.width, + yres: size.height, + frameRateN: framerate * 1000, + frameRateD: 1000, + pictureAspectRatio: ratio, + frameFormatType: (grandiose as any).FORMAT_TYPE_PROGRESSIVE, + lineStrideBytes: size.width * bytesForBGRA, + + /* the data itself */ + fourCC, + data: buffer, + } - try { - await this.NDI[id].sender.video(frame) - } catch (error) { - console.log(error) + try { + await this.NDI[id].sender.video(frame) + } catch (error) { + console.log(error) + } } - } - static async sendAudioBufferNDI(id: string, buffer: Buffer, { sampleRate, noChannels, bytesForFloat32 }: any) { - if (!this.NDI[id].sender) return + static async sendAudioBufferNDI(id: string, buffer: Buffer, { sampleRate, noChannels, bytesForFloat32 }: any) { + if (!this.NDI[id].sender) return - if (this.ndiDisabled) return + if (this.ndiDisabled) return - /* convert from PCM/signed-16-bit/little-endian data + /* convert from PCM/signed-16-bit/little-endian data to NDI's "PCM/planar/signed-float32/little-endian */ - const pcmconvert: any = {} // TODO: - const buffer2 = pcmconvert( - buffer, - { - channels: noChannels, - dtype: "int16", - endianness: "le", - interleaved: true, - }, - { - dtype: "float32", - endianness: "le", - interleaved: false, + const pcmconvert: any = {} // TODO: + const buffer2 = pcmconvert( + buffer, + { + channels: noChannels, + dtype: "int16", + endianness: "le", + interleaved: true, + }, + { + dtype: "float32", + endianness: "le", + interleaved: false, + } + ) + + /* create frame */ + const now = this.timeStart + process.hrtime.bigint() + const timecode = now / BigInt(100) + const frame = { + /* base information */ + timecode, + + /* type-specific information */ + sampleRate, + noChannels, + noSamples: Math.trunc(buffer2.byteLength / noChannels / bytesForFloat32), + channelStrideBytes: Math.trunc(buffer2.byteLength / noChannels), + + /* the data itself */ + fourCC: (grandiose as any).FOURCC_FLTp, + data: buffer2, } - ) - - /* create frame */ - const now = this.timeStart + process.hrtime.bigint() - const timecode = now / BigInt(100) - const frame = { - /* base information */ - timecode, - - /* type-specific information */ - sampleRate, - noChannels, - noSamples: Math.trunc(buffer2.byteLength / noChannels / bytesForFloat32), - channelStrideBytes: Math.trunc(buffer2.byteLength / noChannels), - - /* the data itself */ - fourCC: (grandiose as any).FOURCC_FLTp, - data: buffer2, - } - await this.NDI[id].sender.audio(frame) - } -} \ No newline at end of file + await this.NDI[id].sender.audio(frame) + } +} diff --git a/src/electron/ndi/talk.ts b/src/electron/ndi/talk.ts index a547a505..d04a18f1 100644 --- a/src/electron/ndi/talk.ts +++ b/src/electron/ndi/talk.ts @@ -1,6 +1,6 @@ import { NDI } from "../../types/Channels" import { Message } from "../../types/Socket" -import { customFramerates, updateFramerate } from "../output/capture" +import { CaptureHelper } from "../capture/CaptureHelper" import { NdiReceiver } from "./NdiReceiver" export async function receiveNDI(e: any, msg: Message) { @@ -27,9 +27,9 @@ export function setDataNDI(data: any) { if (!data?.id) return if (data.framerate) { - if (!customFramerates[data.id]) customFramerates[data.id] = {} - customFramerates[data.id].ndi = data.framerate + if (!CaptureHelper.customFramerates[data.id]) CaptureHelper.customFramerates[data.id] = {} + CaptureHelper.customFramerates[data.id].ndi = data.framerate - updateFramerate(data.id) + CaptureHelper.updateFramerate(data.id) } } diff --git a/src/electron/output/Output.ts b/src/electron/output/Output.ts new file mode 100644 index 00000000..86ec6bff --- /dev/null +++ b/src/electron/output/Output.ts @@ -0,0 +1,15 @@ +import { BrowserWindow } from "electron" +import { CaptureOptions } from "../capture/CaptureOptions" + +export class Output { + window: BrowserWindow + //previewWindow: BrowserWindow + captureOptions?: CaptureOptions + /* + previewBounds?: { + x: number + y: number + width: number + height: number + }*/ +} diff --git a/src/electron/output/OutputHelper.ts b/src/electron/output/OutputHelper.ts new file mode 100644 index 00000000..10e76fb3 --- /dev/null +++ b/src/electron/output/OutputHelper.ts @@ -0,0 +1,69 @@ +import { OutputBounds } from "./helpers/OutputBounds" +import { OutputIdentify } from "./helpers/OutputIdentify" +import { OutputSend } from "./helpers/OutputSend" +import { OutputValues } from "./helpers/OutputValues" +import { OutputVisibility } from "./helpers/OutputVisibility" +import { OutputLifecycle } from "./helpers/OutputLifecycle" +import { Message } from "../../types/Socket" +import { toApp } from ".." +import { OUTPUT } from "../../types/Channels" +import { Output } from "./Output" +import { CaptureHelper } from "../capture/CaptureHelper" + +export class OutputHelper { + static receiveOutput(_e: any, msg: Message) { + const outputResponses: any = { + CREATE: (data: any) => OutputHelper.Lifecycle.createOutput(data), + REMOVE: (data: any) => OutputHelper.Lifecycle.removeOutput(data.id), + DISPLAY: (data: any) => OutputHelper.Visibility.displayOutput(data), + ALIGN_WITH_SCREEN: () => OutputHelper.Bounds.alignWithScreens(), + + MOVE: (data: any) => (OutputHelper.Bounds.moveEnabled = data.enabled), + + UPDATE_BOUNDS: (data: any) => OutputHelper.Bounds.updateBounds(data), + SET_VALUE: (data: any) => OutputHelper.Values.updateValue(data), + TO_FRONT: (data: any) => OutputHelper.Bounds.moveToFront(data), + + //PREVIEW_RESOLUTION: (data: any) => () => {}, //TODO: Eliminate? Was -> updatePreviewResolution(data) + REQUEST_PREVIEW: (data: any) => CaptureHelper.Transmitter.requestPreview(data), + + IDENTIFY_SCREENS: (data: any) => OutputHelper.Identify.identifyScreens(data), + //PREVIEW_BOUNDS: (data: any) => OutputHelper.Bounds.setPreviewBounds(data), + } + + if (msg.channel.includes("MAIN")) return toApp(OUTPUT, msg) + if (outputResponses[msg.channel]) return outputResponses[msg.channel](msg.data) + + OutputHelper.Send.sendToOutputWindow(msg) + } + + //static outputWindows: { [key: string]: BrowserWindow } = {} + private static outputs: { [key: string]: Output } = {} + + static getOutput(id: string) { + return this.outputs[id] + } + + static getAllOutputs() { + return Object.entries(this.outputs) + } + + static setOutput(id: string, output: Output) { + this.outputs[id] = output + } + + static deleteOutput(id: string) { + delete this.outputs[id] + } + + static getKeys() { + return Object.keys(OutputHelper.outputs) + } + + static Bounds = OutputBounds + static Identify = OutputIdentify + static Lifecycle = OutputLifecycle + static Send = OutputSend + static Values = OutputValues + static Visibility = OutputVisibility +} diff --git a/src/electron/output/capture.ts b/src/electron/output/capture.ts deleted file mode 100644 index dde34517..00000000 --- a/src/electron/output/capture.ts +++ /dev/null @@ -1,190 +0,0 @@ -import type { BrowserWindow, Display, NativeImage, Size } from "electron" -import electron from "electron" -import { outputWindows } from "./output" -import { NdiSender } from "../ndi/NdiSender" -import { CaptureTransmitter } from "./CaptureTransmitter" - -export type CaptureOptions = { - id: string - window: BrowserWindow - subscribed: boolean - displayFrequency: number - options: any - framerates: any -} - -export let captures: { [key: string]: CaptureOptions } = {} - -export const framerates: any = { - preview: 30, - server: 30, - unconnected: 1, - connected: 30, -} -export let customFramerates: any = {} - -function getDefaultCapture(window: BrowserWindow, id: string): CaptureOptions { - let screen: Display = getWindowScreen(window) - - const previewFramerate = Math.round(framerates.preview / Object.keys(captures).length) - - let defaultFramerates = { - preview: previewFramerate, - server: framerates.server, - ndi: framerates.connected, - } - - return { - window, - subscribed: false, - displayFrequency: screen.displayFrequency || 60, - options: { server: false, ndi: false }, - framerates: defaultFramerates, - id, - } -} - -// START - -export let storedFrames: any = {} -export function startCapture(id: string, toggle: any = {}, rate: any = "") { - let window = outputWindows[id] - let windowIsRemoved = !window || window.isDestroyed() - if (windowIsRemoved) { - delete captures[id] - return - } - - // change preview frame rate based on settings - if (!rate) rate = "auto" - if (rate === "optimized") framerates.preview = 1 // 1 fps - else if (rate === "reduced") framerates.preview = 10 // 10 fps - else if (rate === "full") framerates.preview = 60 // 60 fps - else framerates.preview = 30 // auto // 0.5 fpgs OR 30 fps - - if (!captures[id]) captures[id] = getDefaultCapture(window, id) - - Object.keys(toggle).map((key) => { - captures[id].options[key] = toggle[key] - }) - - updateFramerate(id) - - if (captures[id].subscribed) return - - //captures[id].options.ndi = true - CaptureTransmitter.startTransmitting(id) - - //framerates.preview = rate === "full" ? 60 : 30 - if (rate === "auto" || rate === "full") captures[id].window.webContents.beginFrameSubscription(true, processFrame) // updates approximately every 0.02s - captures[id].subscribed = true - - if (rate === "full" || (rate !== "optimized" && captures[id].options.ndi)) return - - // optimize cpu on low end devices - const autoOptimizePercentageCPU = 95 / 10 // % / 10 - const captureAmount = 4 * 60 - let captureCount = captureAmount - - cpuCapture() - async function cpuCapture() { - if (!captures[id] || captures[id].window.isDestroyed()) return - - let usage = process.getCPUUsage() - - let isOptimizedOrLagging = rate !== "auto" || captureCount < captureAmount || usage.percentCPUUsage > autoOptimizePercentageCPU - if (isOptimizedOrLagging) { - if (captureCount > captureAmount) captureCount = 0 - // limit frames - if (captures[id].window.webContents.isBeingCaptured()) captures[id].window.webContents.endFrameSubscription() - - // manually capture to reduce lag - let image = await captures[id].window.webContents.capturePage() - processFrame(image) - - // capture for 60 seconds then get cpu again (if rate is "auto") - captureCount++ - setTimeout(cpuCapture, rate === "optimized" ? 1000 : 100) - } else { - captureCount = captureAmount - if (!captures[id].window.webContents.isBeingCaptured()) captures[id].window.webContents.beginFrameSubscription(true, processFrame) - } - } - - function processFrame(image: NativeImage) { - storedFrames[id] = image - } -} - -export function updateFramerate(id: string) { - if (!captures[id]) return - - if (NdiSender.NDI[id]) { - let ndiFramerate = framerates.unconnected - if (NdiSender.NDI[id].status === "connected") ndiFramerate = customFramerates[id]?.ndi || framerates.connected - - if (captures[id].framerates.ndi !== parseInt(ndiFramerate)) { - captures[id].framerates.ndi = parseInt(ndiFramerate) - CaptureTransmitter.startChannel(id, "ndi") - } - } -} - -function getWindowScreen(window: BrowserWindow) { - return electron.screen.getDisplayMatching({ - x: window.getBounds().x, - y: window.getBounds().y, - width: window.getBounds().width, - height: window.getBounds().height, - }) -} - -export function resizeImage(image: NativeImage, initialSize: Size, newSize: Size) { - if (initialSize.width / initialSize.height >= newSize.width / newSize.height) image = image.resize({ width: newSize.width }) - else image = image.resize({ height: newSize.height }) - - return image -} - -export let previewSize: Size = { width: 320, height: 180 } -export function updatePreviewResolution(data: any) { - previewSize = data.size -} - -// STOP - -export function stopAllCaptures() { - Object.keys(captures).forEach(stopCapture) -} - -export function stopCapture(id: string) { - return new Promise((resolve) => { - if (!captures[id]) return resolve(true) - CaptureTransmitter.removeAllChannels(id) - let windowIsRemoved = !captures[id].window || captures[id].window.isDestroyed() - if (windowIsRemoved) return deleteAndResolve() - - console.log("Capture - stopping: " + id) - - endSubscription() - removeListeners() - deleteAndResolve() - - function deleteAndResolve() { - delete captures[id] - resolve(true) - } - }) - - function endSubscription() { - if (!captures[id].subscribed) return - - captures[id].window.webContents.endFrameSubscription() - captures[id].subscribed = false - } - - function removeListeners() { - captures[id].window.removeAllListeners() - captures[id].window.webContents.removeAllListeners() - } -} diff --git a/src/electron/output/helpers/OutputBounds.ts b/src/electron/output/helpers/OutputBounds.ts new file mode 100644 index 00000000..2ef78c89 --- /dev/null +++ b/src/electron/output/helpers/OutputBounds.ts @@ -0,0 +1,97 @@ +import { BrowserWindow, screen } from "electron" +import { OutputHelper } from "../OutputHelper" + +export class OutputBounds { + // BOUNDS + + static moveEnabled: boolean = false + static updatingBounds: boolean = false + private static boundsTimeout: any = null + + static disableWindowMoveListener() { + this.updatingBounds = true + + if (this.boundsTimeout) clearTimeout(this.boundsTimeout) + this.boundsTimeout = setTimeout(() => { + this.updatingBounds = false + this.boundsTimeout = null + }, 1000) + } + + static updateBounds(data: any) { + let window: BrowserWindow = OutputHelper.getOutput(data.id)?.window + if (!window || window.isDestroyed()) return + + this.disableWindowMoveListener() + window.setBounds(data.bounds) + + // has to be set twice to work first time + setTimeout(() => { + if (!window || window.isDestroyed()) return + window.setBounds(data.bounds) + }, 10) + } + + static moveToFront(id: string) { + let window: BrowserWindow = OutputHelper.getOutput(id)?.window + if (!window || window.isDestroyed()) return + + window.moveTop() + } + + static alignWithScreens() { + OutputHelper.getKeys().forEach((outputId) => { + let output = OutputHelper.getOutput(outputId) + + let wBounds = output.window.getBounds() + let centerLeft = wBounds.x + wBounds.width / 2 + let centerTop = wBounds.y + wBounds.height / 2 + + let point = { x: centerLeft, y: centerTop } + let closestScreen = screen.getDisplayNearestPoint(point) + + output.window.setBounds(closestScreen.bounds) + }) + } + + /* + static getPreviewBounds(mainWidth: number, mainHeight: number) { + if (mainWidth || mainHeight) return { width: 320, height: 180 } + else return { width: 320, height: 180 } + } + + static updatePreviewBounds() { + const mainBounds = mainWindow?.getBounds() + const devFrameOffsetY = 50 //TODO: Remove hack + const devFrameOffsetX = 8 + if (mainBounds) { + OutputHelper.getKeys().forEach((outputId) => { + const output = OutputHelper.getOutput(outputId) + if (output.previewWindow) { + const bounds: Electron.Rectangle = { + x: (output.previewBounds?.x || 0) + mainBounds?.x + devFrameOffsetX, + y: (output.previewBounds?.y || 0) + mainBounds?.y + devFrameOffsetY, + width: output.previewBounds?.width || 320, + height: output.previewBounds?.height || 180, + } + output.previewWindow.setBounds(bounds) + } + }) + } + } + + static setPreviewBounds(data: any) { + const output = OutputHelper.getOutput(data.id) + if (output) { + output.previewBounds = { + x: data.x, + y: data.y, + width: data.width, + height: data.height, + } + + this.updatePreviewBounds() + } + } + */ +} diff --git a/src/electron/output/helpers/OutputIdentify.ts b/src/electron/output/helpers/OutputIdentify.ts new file mode 100644 index 00000000..e4049e13 --- /dev/null +++ b/src/electron/output/helpers/OutputIdentify.ts @@ -0,0 +1,35 @@ +import { BrowserWindow } from "electron" +import { screenIdentifyOptions } from "../../utils/windowOptions" + +export class OutputIdentify { + // create numbered outputs for each screen + private static identifyActive: boolean = false + private static IDENTIFY_TIMEOUT: number = 3000 + + static identifyScreens(screens: any[]) { + if (this.identifyActive) return + this.identifyActive = true + + let activeWindows: any[] = screens.map(this.createIdentifyScreen) + + setTimeout(() => { + activeWindows.forEach((window) => { + window.destroy() + }) + this.identifyActive = false + }, this.IDENTIFY_TIMEOUT) + } + + private static createIdentifyScreen(screen: any, i: number) { + let window: BrowserWindow | null = new BrowserWindow(screenIdentifyOptions) + window.setBounds(screen.bounds) + window.loadFile("public/identify.html") + + window.webContents.on("did-finish-load", sendNumberToScreen) + function sendNumberToScreen() { + window!.webContents.send("NUMBER", i + 1) + } + + return window + } +} diff --git a/src/electron/output/helpers/OutputLifecycle.ts b/src/electron/output/helpers/OutputLifecycle.ts new file mode 100644 index 00000000..70999a9a --- /dev/null +++ b/src/electron/output/helpers/OutputLifecycle.ts @@ -0,0 +1,136 @@ +import { BrowserWindow } from "electron" +import { OUTPUT_CONSOLE, isMac, loadWindowContent, mainWindow, toApp } from "../.." +import { Output } from "../../../types/Output" +import { NdiSender } from "../../ndi/NdiSender" +import { setDataNDI } from "../../ndi/talk" +import { outputOptions } from "../../utils/windowOptions" +import { OutputHelper } from "../OutputHelper" +import { OUTPUT } from "../../../types/Channels" +import { CaptureHelper } from "../../capture/CaptureHelper" + +export class OutputLifecycle { + static async createOutput(output: Output) { + let id: string = output.id || "" + + if (OutputHelper.getOutput(id)) return this.removeOutput(id, output) + + const outputWindow = this.createOutputWindow({ ...output.bounds, alwaysOnTop: output.alwaysOnTop !== false, kiosk: output.kioskMode === true, backgroundColor: output.transparent ? "#00000000" : "#000000" }, id, output.name) + //const previewWindow = this.createPreviewWindow({ ...output.bounds, backgroundColor: "#000000" }) + + OutputHelper.setOutput(id, { window: outputWindow }) + //OutputHelper.setOutput(id, { window: outputWindow, previewWindow: previewWindow }) + OutputHelper.Bounds.updateBounds(output) + + //OutputHelper.Bounds.updatePreviewBounds() + + if (output.stageOutput) CaptureHelper.Transmitter.stageWindows.push(id) + + setTimeout(() => { + CaptureHelper.Lifecycle.startCapture(id, { ndi: output.ndi || false }) + }, 1200) + + // NDI + if (output.ndi) await NdiSender.createSenderNDI(id, output.name) + if (output.ndiData) setDataNDI({ id, ...output.ndiData }) + } + + /* + private static createPreviewWindow(options: any) { + const mainBounds = mainWindow?.getBounds() + + options = { ...outputOptions, ...options } + options.x = 0 + options.y = 0 + options.width = 320 + options.height = 180 + options.show = true + if (mainBounds) { + options.x = mainBounds.x + mainBounds.width - options.width - 20 - 300 + options.y = mainBounds.y + 100 + } + + let window: BrowserWindow | null = new BrowserWindow(options) + window.setSkipTaskbar(options.skipTaskbar) // hide from taskbar + if (isMac) window.minimize() // hide on mac + loadWindowContent(window, true) + window.showInactive() + window.moveTop() + return window + }*/ + + private static createOutputWindow(options: any, id: string, name: string) { + options = { ...outputOptions, ...options } + + if (options.alwaysOnTop === false) { + options.skipTaskbar = false + options.resizable = true + } + + if (OUTPUT_CONSOLE) options.webPreferences.devTools = true + let window: BrowserWindow | null = new BrowserWindow(options) + + // only win & linux + // window.removeMenu() // hide menubar + // window.setAutoHideMenuBar(true) // hide menubar + + window.setSkipTaskbar(options.skipTaskbar) // hide from taskbar + if (isMac) window.minimize() // hide on mac + + window.once("show", () => { + if (options.alwaysOnTop) window?.setAlwaysOnTop(true, "pop-up-menu", 1) + }) + // window.setVisibleOnAllWorkspaces(true) + + loadWindowContent(window, true) + this.setWindowListeners(window, { id, name }) + + // open devtools + if (OUTPUT_CONSOLE) window.webContents.openDevTools({ mode: "detach" }) + + return window + } + + static async removeOutput(id: string, reopen: any = null) { + await CaptureHelper.Lifecycle.stopCapture(id) + NdiSender.stopSenderNDI(id) + + if (!OutputHelper.getOutput(id)) return + if (OutputHelper.getOutput(id).window.isDestroyed()) { + OutputHelper.deleteOutput(id) + if (reopen) this.createOutput(reopen) + return + } + + OutputHelper.getOutput(id).window.on("closed", () => { + OutputHelper.deleteOutput(id) + if (reopen) this.createOutput(reopen) + }) + + try { + const output = OutputHelper.getOutput(id) + output?.window?.destroy() + //output?.previewWindow?.destroy() + } catch (error) { + console.log(error) + } + } + + static setWindowListeners(window: BrowserWindow, { id, name }: { [key: string]: string }) { + window.on("ready-to-show", () => { + mainWindow?.focus() + window.setMenu(null) + window.setTitle(name || "Output") + }) + + window.on("move", (e: any) => { + if (!OutputHelper.Bounds.moveEnabled || OutputHelper.Bounds.updatingBounds) return e.preventDefault() + + let bounds = window.getBounds() + toApp(OUTPUT, { channel: "MOVE", data: { id, bounds } }) + }) + } + + static async closeAllOutputs() { + await Promise.all(OutputHelper.getKeys().map(this.removeOutput)) + } +} diff --git a/src/electron/output/helpers/OutputSend.ts b/src/electron/output/helpers/OutputSend.ts new file mode 100644 index 00000000..494575fd --- /dev/null +++ b/src/electron/output/helpers/OutputSend.ts @@ -0,0 +1,35 @@ +import { OUTPUT } from "../../../types/Channels" +import { OutputHelper } from "../OutputHelper" + +export class OutputSend { + static sendToOutputWindow(msg: any) { + OutputHelper.getAllOutputs().forEach(sendToWindow) + + function sendToWindow([id, output]: any) { + if ((msg.data?.id && msg.data.id !== id) || !output.window || output.window.isDestroyed()) return + + let tempMsg: any = JSON.parse(JSON.stringify(msg)) + if (msg.channel === "OUTPUTS") tempMsg = onlySendToMatchingId(tempMsg, id) + + output.window.webContents.send(OUTPUT, tempMsg) + + //if (!output.previewWindow || output.previewWindow.isDestroyed()) return + //output.previewWindow.webContents.send(OUTPUT, tempMsg) + } + + function onlySendToMatchingId(tempMsg: any, id: string) { + if (!msg.data?.[id]) return tempMsg + + tempMsg.data = { [id]: msg.data[id] } + return tempMsg + } + } + + static sendToWindow(id: string, msg: any) { + const output = OutputHelper.getOutput(id) + if (!output.window || output.window.isDestroyed()) return + output.window.webContents.send(OUTPUT, msg) + //if (!output.previewWindow || output.previewWindow.isDestroyed()) return + //output.previewWindow.webContents.send(OUTPUT, msg) + } +} diff --git a/src/electron/output/helpers/OutputValues.ts b/src/electron/output/helpers/OutputValues.ts new file mode 100644 index 00000000..ed74b919 --- /dev/null +++ b/src/electron/output/helpers/OutputValues.ts @@ -0,0 +1,38 @@ +import { BrowserWindow } from "electron" +import { NdiSender } from "../../ndi/NdiSender" +import { OutputHelper } from "../OutputHelper" +import { CaptureHelper } from "../../capture/CaptureHelper" + +export class OutputValues { + private static setValues: any = { + ndi: async (value: boolean, window: BrowserWindow, id: string) => { + if (value) await NdiSender.createSenderNDI(id, window.getTitle()) + else NdiSender.stopSenderNDI(id) + + this.setValues.capture({ key: "ndi", value }, window, id) + }, + capture: async (data: any, _window: BrowserWindow, id: string) => { + CaptureHelper.Lifecycle.startCapture(id, { [data.key]: data.value }) + // if (data.value) sendFrames(id, storedFrames[id], {[data.key]: true}) + }, + transparent: (value: boolean, window: BrowserWindow) => { + window.setBackgroundColor(value ? "#00000000" : "#000000") + }, + alwaysOnTop: (value: boolean, window: BrowserWindow) => { + window.setAlwaysOnTop(value, "pop-up-menu", 1) + window.setResizable(!value) + window.setSkipTaskbar(value) + }, + kioskMode: (value: boolean, window: BrowserWindow) => { + window.setKiosk(value) + }, + } + + static async updateValue({ id, key, value }: any) { + const output = OutputHelper.getOutput(id) + if (!this.setValues[key]) return + + if (!output.window || output.window.isDestroyed()) return + this.setValues[key](value, output.window, id) + } +} diff --git a/src/electron/output/helpers/OutputVisibility.ts b/src/electron/output/helpers/OutputVisibility.ts new file mode 100644 index 00000000..5fd94eb4 --- /dev/null +++ b/src/electron/output/helpers/OutputVisibility.ts @@ -0,0 +1,115 @@ +import { BrowserWindow, Rectangle, screen } from "electron" +import { mainWindow, toApp } from "../.." +import { OutputHelper } from "../OutputHelper" +import { MAIN, OUTPUT } from "../../../types/Channels" + +export class OutputVisibility { + static displayOutput(data: any) { + let window: BrowserWindow = OutputHelper.getOutput(data.output?.id)?.window + + if (data.enabled === "toggle") data.enabled = !window?.isVisible() + if (data.enabled !== false) data.enabled = true + + if (!window || window.isDestroyed()) { + if (!data.output) return + + OutputHelper.Lifecycle.createOutput(data.output) + window = OutputHelper.getOutput(data.output?.id)?.window + if (!window || window.isDestroyed()) return + } + + ///// + + // don't auto position on mac (because of virtual) + if (data.autoPosition && !data.force && process.platform !== "darwin") data.output.bounds = this.getSecondDisplay(data.output.bounds) + let bounds: Rectangle = data.output.bounds + let windowNotCoveringMain: boolean = this.amountCovered(bounds, mainWindow!.getBounds()) < 0.5 + + if (data.enabled && bounds && (data.force || window.isAlwaysOnTop() === false || windowNotCoveringMain)) { + this.showWindow(window) + + if (bounds) OutputHelper.Bounds.updateBounds(data.output) + } else { + this.hideWindow(window, data.output) + + if (data.enabled && !data.auto) toApp(MAIN, { channel: "ALERT", data: "error.display" }) + data.enabled = false + } + + if (data.one !== true) toApp(OUTPUT, { channel: "DISPLAY", data }) + } + + static getSecondDisplay(bounds: Rectangle) { + let displays = screen.getAllDisplays() + if (displays.length !== 2) return bounds + + let mainWindowBounds = mainWindow!.getBounds() + let amountCoveredByWindow = this.amountCovered(displays[1].bounds, mainWindowBounds) + + let secondDisplay = displays[1] + if (amountCoveredByWindow > 0.5) secondDisplay = displays[0] + + let newBounds = secondDisplay.bounds + + // window zoomed (sometimes it's correct even with custom scaling, but not always) + // if windows overlap then something is wrong with the scaling + let scale = secondDisplay.scaleFactor || 1 + if (scale !== 1 && this.amountCovered(displays[0].bounds, displays[1].bounds) > 0) { + newBounds.width /= scale + newBounds.height /= scale + } + + return newBounds + } + + static amountCovered(displayBounds: Rectangle, windowBounds: Rectangle) { + const overlapX = Math.max(0, Math.min(displayBounds.x + displayBounds.width, windowBounds.x + windowBounds.width) - Math.max(displayBounds.x, windowBounds.x)) + const overlapY = Math.max(0, Math.min(displayBounds.y + displayBounds.height, windowBounds.y + windowBounds.height) - Math.max(displayBounds.y, windowBounds.y)) + const overlapArea = overlapX * overlapY + + const totalArea = displayBounds.width * displayBounds.height + const overlapAmount = overlapArea / totalArea + + return overlapAmount + } + + // MacOS Menu Bar + // https://stackoverflow.com/questions/39091964/remove-menubar-from-electron-app + // https://stackoverflow.com/questions/69629262/how-can-i-hide-the-menubar-from-an-electron-app + // https://github.com/electron/electron/issues/1415 + // https://github.com/electron/electron/issues/1054 + + static showWindow(window: BrowserWindow) { + if (!window || window.isDestroyed()) return + + window.showInactive() + window.moveTop() + } + + static hideWindow(window: BrowserWindow, data: any) { + if (!window || window.isDestroyed()) return + + window.setKiosk(false) + window.hide() + + // WIP has to restart because window is unresponsive when hidden again (until showed again)... + console.log("RESTARTING OUTPUT:", data.id) + toApp(OUTPUT, { channel: "RESTART" }) + } + + /* + static hideAllPreviews() { + OutputHelper.getKeys().forEach((outputId) => { + let output = OutputHelper.getOutput(outputId) + if (output.previewWindow) output.previewWindow.hide() + }) + } + + static showAllPreviews() { + OutputHelper.getKeys().forEach((outputId) => { + let output = OutputHelper.getOutput(outputId) + if (output.previewWindow) output.previewWindow.showInactive() + }) + } + */ +} diff --git a/src/electron/output/output.ts b/src/electron/output/output.ts deleted file mode 100644 index 87474769..00000000 --- a/src/electron/output/output.ts +++ /dev/null @@ -1,372 +0,0 @@ -import { BrowserWindow, Rectangle, screen } from "electron" -import { OUTPUT_CONSOLE, isMac, loadWindowContent, mainWindow, toApp } from ".." -import { MAIN, OUTPUT } from "../../types/Channels" -import { Output } from "../../types/Output" -import { Message } from "../../types/Socket" -import { NdiSender } from "../ndi/NdiSender" -import { setDataNDI } from "../ndi/talk" -import { outputOptions, screenIdentifyOptions } from "../utils/windowOptions" -import { CaptureTransmitter } from "./CaptureTransmitter" -import { startCapture, stopCapture, updatePreviewResolution } from "./capture" - -export let outputWindows: { [key: string]: BrowserWindow } = {} - -// CREATE - -async function createOutput(output: Output) { - let id: string = output.id || "" - - if (outputWindows[id]) return removeOutput(id, output) - - outputWindows[id] = createOutputWindow({ ...output.bounds, alwaysOnTop: output.alwaysOnTop !== false, kiosk: output.kioskMode === true, backgroundColor: output.transparent ? "#00000000" : "#000000" }, id, output.name) - updateBounds(output) - - if (output.stageOutput) CaptureTransmitter.stageWindows.push(id) - - setTimeout(() => { - startCapture(id, { ndi: output.ndi || false }, (output as any).rate) - }, 1200) - - // NDI - if (output.ndi) await NdiSender.createSenderNDI(id, output.name) - if (output.ndiData) setDataNDI({ id, ...output.ndiData }) -} - -function createOutputWindow(options: any, id: string, name: string) { - options = { ...outputOptions, ...options } - - if (options.alwaysOnTop === false) { - options.skipTaskbar = false - options.resizable = true - } - - if (OUTPUT_CONSOLE) options.webPreferences.devTools = true - let window: BrowserWindow | null = new BrowserWindow(options) - - // only win & linux - // window.removeMenu() // hide menubar - // window.setAutoHideMenuBar(true) // hide menubar - - window.setSkipTaskbar(options.skipTaskbar) // hide from taskbar - if (isMac) window.minimize() // hide on mac - - window.once("show", () => { - if (options.alwaysOnTop) window?.setAlwaysOnTop(true, "pop-up-menu", 1) - }) - // window.setVisibleOnAllWorkspaces(true) - - loadWindowContent(window, true) - setWindowListeners(window, { id, name }) - - // open devtools - if (OUTPUT_CONSOLE) window.webContents.openDevTools({ mode: "detach" }) - - return window -} - -function setWindowListeners(window: BrowserWindow, { id, name }: { [key: string]: string }) { - window.on("ready-to-show", () => { - mainWindow?.focus() - window.setMenu(null) - window.setTitle(name || "Output") - }) - - window.on("move", (e: any) => { - if (!moveEnabled || updatingBounds) return e.preventDefault() - - let bounds = window.getBounds() - toApp(OUTPUT, { channel: "MOVE", data: { id, bounds } }) - }) -} - -export async function closeAllOutputs() { - await Promise.all(Object.keys(outputWindows).map(removeOutput)) -} - -async function removeOutput(id: string, reopen: any = null) { - await stopCapture(id) - NdiSender.stopSenderNDI(id) - - if (!outputWindows[id]) return - if (outputWindows[id].isDestroyed()) { - delete outputWindows[id] - if (reopen) createOutput(reopen) - return - } - - outputWindows[id].on("closed", () => { - delete outputWindows[id] - if (reopen) createOutput(reopen) - }) - - try { - outputWindows[id].destroy() - } catch (error) { - console.log(error) - } -} - -// SHOW/HIDE - -function displayOutput(data: any) { - let window: BrowserWindow = outputWindows[data.output?.id] - - if (data.enabled === "toggle") data.enabled = !window?.isVisible() - if (data.enabled !== false) data.enabled = true - - if (!window || window.isDestroyed()) { - if (!data.output) return - - createOutput(data.output) - window = outputWindows[data.output.id] - if (!window || window.isDestroyed()) return - } - - ///// - - // don't auto position on mac (because of virtual) - if (data.autoPosition && !data.force && process.platform !== "darwin") data.output.bounds = getSecondDisplay(data.output.bounds) - let bounds: Rectangle = data.output.bounds - let windowNotCoveringMain: boolean = amountCovered(bounds, mainWindow!.getBounds()) < 0.5 - - if (data.enabled && bounds && (data.force || window.isAlwaysOnTop() === false || windowNotCoveringMain)) { - showWindow(window) - - if (bounds) updateBounds(data.output) - } else { - hideWindow(window, data.output) - - if (data.enabled && !data.auto) toApp(MAIN, { channel: "ALERT", data: "error.display" }) - data.enabled = false - } - - if (data.one !== true) toApp(OUTPUT, { channel: "DISPLAY", data }) -} - -function getSecondDisplay(bounds: Rectangle) { - let displays = screen.getAllDisplays() - if (displays.length !== 2) return bounds - - let mainWindowBounds = mainWindow!.getBounds() - let amountCoveredByWindow = amountCovered(displays[1].bounds, mainWindowBounds) - - let secondDisplay = displays[1] - if (amountCoveredByWindow > 0.5) secondDisplay = displays[0] - - let newBounds = secondDisplay.bounds - - // window zoomed (sometimes it's correct even with custom scaling, but not always) - // if windows overlap then something is wrong with the scaling - let scale = secondDisplay.scaleFactor || 1 - if (scale !== 1 && amountCovered(displays[0].bounds, displays[1].bounds) > 0) { - newBounds.width /= scale - newBounds.height /= scale - } - - return newBounds -} - -function amountCovered(displayBounds: Rectangle, windowBounds: Rectangle) { - const overlapX = Math.max(0, Math.min(displayBounds.x + displayBounds.width, windowBounds.x + windowBounds.width) - Math.max(displayBounds.x, windowBounds.x)) - const overlapY = Math.max(0, Math.min(displayBounds.y + displayBounds.height, windowBounds.y + windowBounds.height) - Math.max(displayBounds.y, windowBounds.y)) - const overlapArea = overlapX * overlapY - - const totalArea = displayBounds.width * displayBounds.height - const overlapAmount = overlapArea / totalArea - - return overlapAmount -} - -// MacOS Menu Bar -// https://stackoverflow.com/questions/39091964/remove-menubar-from-electron-app -// https://stackoverflow.com/questions/69629262/how-can-i-hide-the-menubar-from-an-electron-app -// https://github.com/electron/electron/issues/1415 -// https://github.com/electron/electron/issues/1054 - -function showWindow(window: BrowserWindow) { - if (!window || window.isDestroyed()) return - - window.showInactive() - window.moveTop() -} - -function hideWindow(window: BrowserWindow, data: any) { - if (!window || window.isDestroyed()) return - - window.setKiosk(false) - window.hide() - - // WIP has to restart because window is unresponsive when hidden again (until showed again)... - console.log("RESTARTING OUTPUT:", data.id) - toApp(OUTPUT, { channel: "RESTART" }) -} - -// BOUNDS - -let moveEnabled: boolean = false -let updatingBounds: boolean = false -let boundsTimeout: any = null -function disableWindowMoveListener() { - updatingBounds = true - - if (boundsTimeout) clearTimeout(boundsTimeout) - boundsTimeout = setTimeout(() => { - updatingBounds = false - boundsTimeout = null - }, 1000) -} - -function updateBounds(data: any) { - let window: BrowserWindow = outputWindows[data.id] - if (!window || window.isDestroyed()) return - - disableWindowMoveListener() - window.setBounds(data.bounds) - - // has to be set twice to work first time - setTimeout(() => { - if (!window || window.isDestroyed()) return - window.setBounds(data.bounds) - }, 10) -} - -// UPDATE - -const setValues: any = { - ndi: async (value: boolean, window: BrowserWindow, id: string) => { - if (value) await NdiSender.createSenderNDI(id, window.getTitle()) - else NdiSender.stopSenderNDI(id) - - setValues.capture({ key: "ndi", value }, window, id) - }, - capture: async (data: any, _window: BrowserWindow, id: string) => { - startCapture(id, { [data.key]: data.value }, data.rate) - // if (data.value) sendFrames(id, storedFrames[id], {[data.key]: true}) - }, - transparent: (value: boolean, window: BrowserWindow) => { - window.setBackgroundColor(value ? "#00000000" : "#000000") - }, - alwaysOnTop: (value: boolean, window: BrowserWindow) => { - window.setAlwaysOnTop(value, "pop-up-menu", 1) - window.setResizable(!value) - window.setSkipTaskbar(value) - }, - kioskMode: (value: boolean, window: BrowserWindow) => { - window.setKiosk(value) - }, -} - -async function updateValue({ id, key, value }: any) { - let window: BrowserWindow = outputWindows[id] - if (!window || window.isDestroyed()) return - - if (!setValues[key]) return - setValues[key](value, window, id) -} - -function moveToFront(id: string) { - let window: BrowserWindow = outputWindows[id] - if (!window || window.isDestroyed()) return - - window.moveTop() -} - -function alignWithScreens() { - Object.keys(outputWindows).forEach((outputId) => { - let output = outputWindows[outputId] - - let wBounds = output.getBounds() - let centerLeft = wBounds.x + wBounds.width / 2 - let centerTop = wBounds.y + wBounds.height / 2 - - let point = { x: centerLeft, y: centerTop } - let closestScreen = screen.getDisplayNearestPoint(point) - - output.setBounds(closestScreen.bounds) - }) -} - -// RESPONSES - -const outputResponses: any = { - CREATE: (data: any) => createOutput(data), - REMOVE: (data: any) => removeOutput(data.id), - DISPLAY: (data: any) => displayOutput(data), - ALIGN_WITH_SCREEN: () => alignWithScreens(), - - MOVE: (data: any) => (moveEnabled = data.enabled), - - UPDATE_BOUNDS: (data: any) => updateBounds(data), - SET_VALUE: (data: any) => updateValue(data), - TO_FRONT: (data: any) => moveToFront(data), - - PREVIEW_RESOLUTION: (data: any) => updatePreviewResolution(data), - REQUEST_PREVIEW: (data: any) => CaptureTransmitter.requestPreview(data), - - IDENTIFY_SCREENS: (data: any) => identifyScreens(data), -} - -export function receiveOutput(_e: any, msg: Message) { - if (msg.channel.includes("MAIN")) return toApp(OUTPUT, msg) - if (outputResponses[msg.channel]) return outputResponses[msg.channel](msg.data) - - sendToOutputWindow(msg) -} - -function sendToOutputWindow(msg: any) { - Object.entries(outputWindows).forEach(sendToWindow) - - function sendToWindow([id, window]: any) { - if ((msg.data?.id && msg.data.id !== id) || !window || window.isDestroyed()) return - - let tempMsg: any = JSON.parse(JSON.stringify(msg)) - if (msg.channel === "OUTPUTS") tempMsg = onlySendToMatchingId(tempMsg, id) - - window.webContents.send(OUTPUT, tempMsg) - } - - function onlySendToMatchingId(tempMsg: any, id: string) { - if (!msg.data?.[id]) return tempMsg - - tempMsg.data = { [id]: msg.data[id] } - return tempMsg - } -} - -export function sendToWindow(id: string, msg: any) { - let window = outputWindows[id] - if (!window || window.isDestroyed()) return - - window.webContents.send(OUTPUT, msg) -} - -// create numbered outputs for each screen -let identifyActive: boolean = false -const IDENTIFY_TIMEOUT: number = 3000 -function identifyScreens(screens: any[]) { - if (identifyActive) return - identifyActive = true - - let activeWindows: any[] = screens.map(createIdentifyScreen) - - setTimeout(() => { - activeWindows.forEach((window) => { - window.destroy() - }) - - identifyActive = false - }, IDENTIFY_TIMEOUT) -} - -function createIdentifyScreen(screen: any, i: number) { - let window: BrowserWindow | null = new BrowserWindow(screenIdentifyOptions) - window.setBounds(screen.bounds) - window.loadFile("public/identify.html") - - window.webContents.on("did-finish-load", sendNumberToScreen) - function sendNumberToScreen() { - window!.webContents.send("NUMBER", i + 1) - } - - return window -} diff --git a/src/electron/utils/responses.ts b/src/electron/utils/responses.ts index dce9fc2e..2f036218 100644 --- a/src/electron/utils/responses.ts +++ b/src/electron/utils/responses.ts @@ -1,7 +1,7 @@ // ----- FreeShow ----- // Respond to messages from the frontend -import { app, desktopCapturer, DesktopCapturerSource, Display, screen, shell, systemPreferences } from "electron" +import { app, BrowserWindow, desktopCapturer, DesktopCapturerSource, Display, screen, shell, systemPreferences } from "electron" import { getFonts } from "font-list" import { machineIdSync } from "node-machine-id" import os from "os" @@ -14,7 +14,6 @@ import { downloadMedia } from "../data/downloadMedia" import { importShow } from "../data/import" import { error_log } from "../data/store" import { getThumbnail, getThumbnailFolderPath, saveImage } from "../data/thumbnails" -import { outputWindows } from "../output/output" import { closeServers, startServers } from "../servers" import { Message } from "./../../types/Socket" import { startWebSocketAndRest, stopApiListener } from "./api" @@ -45,6 +44,7 @@ import { import { LyricSearch } from "./LyricSearch" import { closeMidiInPorts, getMidiInputs, getMidiOutputs, receiveMidi, sendMidi } from "./midi" import checkForUpdates from "./updater" +import { OutputHelper } from "../output/OutputHelper" // IMPORT export function startImport(_e: any, msg: Message) { @@ -290,7 +290,11 @@ function getScreens(type: "window" | "screen" = "screen") { }) function addFreeShowWindows(screens: any[], sources: DesktopCapturerSource[]) { - Object.values({ main: mainWindow, ...outputWindows }).forEach((window: any) => { + const windows: BrowserWindow[] = [] + OutputHelper.getAllOutputs().forEach(([output]: any) => { + output.window && windows.push(output.window) + }) + Object.values({ main: mainWindow, ...windows }).forEach((window: any) => { let mediaId = window?.getMediaSourceId() let windowsAlreadyExists = sources.find((a: any) => a.id === mediaId) if (windowsAlreadyExists) return diff --git a/src/frontend/components/edit/editbox/EditboxOther.svelte b/src/frontend/components/edit/editbox/EditboxOther.svelte index d3a916d6..ff883d3b 100644 --- a/src/frontend/components/edit/editbox/EditboxOther.svelte +++ b/src/frontend/components/edit/editbox/EditboxOther.svelte @@ -67,7 +67,7 @@ {:else if item?.type === "variable"} {:else if item?.type === "web"} - + {:else if item?.type === "mirror"} {:else if item?.type === "visualizer"} diff --git a/src/frontend/components/output/layers/BackgroundMedia.svelte b/src/frontend/components/output/layers/BackgroundMedia.svelte index 05d11928..13b8d959 100644 --- a/src/frontend/components/output/layers/BackgroundMedia.svelte +++ b/src/frontend/components/output/layers/BackgroundMedia.svelte @@ -53,7 +53,11 @@ videoData.loop = data.loop ?? false } // draw + + //Without the second if, the preview videos don't actually play but just skip ahead when kept in sync with the setTimeout() $: if (mirror && $videosData[outputId]?.paused) videoData.paused = true + $: if (mirror && $videosData[outputId]?.paused === false) videoData.paused = false + $: if (mirror && $videosTime[outputId]) videoTime = $videosTime[outputId] $: if (!mirror && !fadingOut) send(OUTPUT, ["MAIN_DATA"], { [outputId]: videoData }) diff --git a/src/frontend/components/output/preview/MultiOutputs.svelte b/src/frontend/components/output/preview/MultiOutputs.svelte index 7a8603e0..8e0c5c48 100644 --- a/src/frontend/components/output/preview/MultiOutputs.svelte +++ b/src/frontend/components/output/preview/MultiOutputs.svelte @@ -1,14 +1,14 @@ @@ -56,13 +59,7 @@ {#each updatedList as outputId} {#if !fullscreen || fullscreenId === outputId} - 1 && !fullscreen ? `border: 2px solid ${$outputs[outputId]?.color};width: 50%;` : ""} - disabled={outputList.length > 1 && !fullscreen && !$outputs[outputId]?.active} - capture={$previewBuffers[outputId]} - id={outputId} - {fullscreen} - /> + 1 && !fullscreen ? `border: 2px solid ${$outputs[outputId]?.color};width:50%` : ""} disabled={outputList.length > 1 && !fullscreen && !$outputs[outputId]?.active} {fullscreen} /> {/if} {/each} @@ -73,11 +70,11 @@ flex-wrap: wrap; height: fit-content; } + /* .multipleOutputs.multiple:not(.fullscreen) :global(.zoomed) { - /* width: unset !important; - min-width: 50%; */ width: 50% !important; } + */ .fullscreen { position: fixed; diff --git a/src/frontend/components/output/preview/PreviewOutput.svelte b/src/frontend/components/output/preview/PreviewOutput.svelte new file mode 100644 index 00000000..d8c1c12e --- /dev/null +++ b/src/frontend/components/output/preview/PreviewOutput.svelte @@ -0,0 +1,43 @@ + + +
+ +
+ + diff --git a/src/frontend/components/slide/Textbox.svelte b/src/frontend/components/slide/Textbox.svelte index 79aed2c6..2456064b 100644 --- a/src/frontend/components/slide/Textbox.svelte +++ b/src/frontend/components/slide/Textbox.svelte @@ -463,7 +463,7 @@ {:else if item?.type === "variable"} {:else if item?.type === "web"} - + {:else if item?.type === "mirror"} {#if !isMirrorItem} diff --git a/src/frontend/components/slide/views/Website.svelte b/src/frontend/components/slide/views/Website.svelte index 7027ca69..36fdea9d 100644 --- a/src/frontend/components/slide/views/Website.svelte +++ b/src/frontend/components/slide/views/Website.svelte @@ -2,6 +2,9 @@ export let src: string export let clickable: boolean = false + let webview: any + export let ratio: number + let parsedSrc: string = "" $: if (src) checkURL() @@ -20,9 +23,21 @@ parsedSrc = valid ? src : "" } + + $: if (webview && ratio) { + const inverse = Math.round(100 / ratio) + webview?.addEventListener("did-finish-load", () => { + webview.executeJavaScript(` + document.body.style.transform = 'scale(${ratio})'; + document.body.style.transformOrigin = '0 0'; + document.body.style.width = '${inverse}%'; // Scale factor inverse to maintain full width + document.body.style.height = '${inverse}%'; // Scale factor inverse to maintain full height + `) + }) + } - +