pmptr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,223 @@
1
+ const { app, BrowserWindow, ipcMain, screen, shell } = require("electron");
2
+ const path = require("path");
3
+ const fs = require("fs");
4
+
5
+ let controlWin = null;
6
+ let prompterWin = null;
7
+
8
+ const settingsPath = () => path.join(app.getPath("userData"), "settings.json");
9
+
10
+ function loadSettings() {
11
+ try {
12
+ return JSON.parse(fs.readFileSync(settingsPath(), "utf8"));
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ function saveSettings(s) {
19
+ try {
20
+ fs.mkdirSync(path.dirname(settingsPath()), { recursive: true });
21
+ fs.writeFileSync(settingsPath(), JSON.stringify(s, null, 2));
22
+ } catch (e) {
23
+ console.error("settings save failed", e);
24
+ }
25
+ }
26
+
27
+ function createControlWindow() {
28
+ controlWin = new BrowserWindow({
29
+ width: 520,
30
+ height: 820,
31
+ minWidth: 380,
32
+ minHeight: 520,
33
+ title: "pmptr",
34
+ backgroundColor: "#0f1115",
35
+ webPreferences: {
36
+ preload: path.join(__dirname, "preload.js"),
37
+ contextIsolation: true,
38
+ nodeIntegration: false,
39
+ sandbox: true,
40
+ },
41
+ });
42
+ controlWin.removeMenu();
43
+ controlWin.loadFile(path.join(__dirname, "../control/control.html"));
44
+ controlWin.on("closed", () => {
45
+ controlWin = null;
46
+ if (prompterWin && !prompterWin.isDestroyed()) prompterWin.close();
47
+ app.quit();
48
+ });
49
+ }
50
+
51
+ function createPrompterWindow(initial) {
52
+ if (prompterWin && !prompterWin.isDestroyed()) {
53
+ prompterWin.show();
54
+ prompterWin.focus();
55
+ return;
56
+ }
57
+
58
+ const display = screen.getPrimaryDisplay();
59
+ const work = display.workArea;
60
+ const width = Math.round(
61
+ Math.min(initial?.windowWidth ?? 900, work.width - 40)
62
+ );
63
+ const height = Math.round(initial?.windowHeight ?? Math.min(320, work.height - 80));
64
+ const x = Math.round(work.x + (work.width - width) / 2);
65
+ const y = work.y + 24;
66
+
67
+ prompterWin = new BrowserWindow({
68
+ width,
69
+ height,
70
+ x,
71
+ y,
72
+ minWidth: 240,
73
+ minHeight: 80,
74
+ frame: false,
75
+ transparent: true,
76
+ resizable: true,
77
+ movable: true,
78
+ hasShadow: false,
79
+ alwaysOnTop: true,
80
+ skipTaskbar: true,
81
+ backgroundColor: "#00000000",
82
+ show: false,
83
+ webPreferences: {
84
+ preload: path.join(__dirname, "../prompter/prompter-preload.js"),
85
+ contextIsolation: true,
86
+ nodeIntegration: false,
87
+ sandbox: true,
88
+ },
89
+ });
90
+
91
+ prompterWin.setAlwaysOnTop(true, "screen-saver");
92
+ prompterWin.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
93
+ prompterWin.loadFile(path.join(__dirname, "../prompter/prompter.html"));
94
+ prompterWin.once("ready-to-show", () => {
95
+ prompterWin.showInactive();
96
+ });
97
+ prompterWin.on("closed", () => {
98
+ prompterWin = null;
99
+ if (controlWin && !controlWin.isDestroyed()) {
100
+ controlWin.webContents.send("prompter:closed");
101
+ }
102
+ });
103
+ }
104
+
105
+ ipcMain.handle("settings:load", () => loadSettings() || {});
106
+ ipcMain.handle("settings:save", (_e, s) => {
107
+ saveSettings(s);
108
+ return true;
109
+ });
110
+
111
+ ipcMain.handle("prompter:open", (_e, settings) => {
112
+ createPrompterWindow(settings);
113
+ return true;
114
+ });
115
+
116
+ ipcMain.handle("prompter:close", () => {
117
+ if (prompterWin && !prompterWin.isDestroyed()) prompterWin.close();
118
+ return true;
119
+ });
120
+
121
+ ipcMain.handle("prompter:isOpen", () => {
122
+ return !!(prompterWin && !prompterWin.isDestroyed());
123
+ });
124
+
125
+ ipcMain.handle("prompter:setClickThrough", (_e, enabled) => {
126
+ if (!prompterWin || prompterWin.isDestroyed()) return false;
127
+ // forward: true keeps mousemove + click on interactive children (HUD buttons)
128
+ prompterWin.setIgnoreMouseEvents(!!enabled, { forward: true });
129
+ return true;
130
+ });
131
+
132
+ ipcMain.handle("prompter:setIgnoreMouse", (_e, enabled) => {
133
+ if (!prompterWin || prompterWin.isDestroyed()) return false;
134
+ prompterWin.setIgnoreMouseEvents(!!enabled, { forward: true });
135
+ return true;
136
+ });
137
+
138
+ ipcMain.handle("prompter:setBounds", (_e, bounds) => {
139
+ if (!prompterWin || prompterWin.isDestroyed()) return false;
140
+ const { x, y, width, height } = bounds || {};
141
+ if ([x, y, width, height].every((v) => typeof v === "number")) {
142
+ prompterWin.setBounds({ x, y, width, height });
143
+ return true;
144
+ }
145
+ return false;
146
+ });
147
+
148
+ ipcMain.handle("prompter:setAlwaysOnTop", (_e, enabled) => {
149
+ if (!prompterWin || prompterWin.isDestroyed()) return false;
150
+ prompterWin.setAlwaysOnTop(!!enabled, "screen-saver");
151
+ return true;
152
+ });
153
+
154
+ ipcMain.handle("prompter:sendSettings", (_e, settings) => {
155
+ if (!prompterWin || prompterWin.isDestroyed()) return false;
156
+ prompterWin.webContents.send("prompter:settings", settings);
157
+ return true;
158
+ });
159
+
160
+ ipcMain.handle("prompter:sendCommand", (_e, cmd) => {
161
+ if (!prompterWin || prompterWin.isDestroyed()) return false;
162
+ prompterWin.webContents.send("prompter:command", cmd);
163
+ if (cmd && cmd.type === "position") {
164
+ const display = screen.getPrimaryDisplay();
165
+ const work = display.workArea;
166
+ const b = prompterWin.getBounds();
167
+ const w = b.width;
168
+ const h = b.height;
169
+ let x, y;
170
+ switch (cmd.value) {
171
+ case "top-left":
172
+ x = work.x + 20;
173
+ y = work.y + 20;
174
+ break;
175
+ case "top-right":
176
+ x = work.x + work.width - w - 20;
177
+ y = work.y + 20;
178
+ break;
179
+ case "bottom-center":
180
+ x = Math.round(work.x + (work.width - w) / 2);
181
+ y = work.y + work.height - h - 20;
182
+ break;
183
+ case "top-center":
184
+ default:
185
+ x = Math.round(work.x + (work.width - w) / 2);
186
+ y = work.y + 20;
187
+ break;
188
+ }
189
+ prompterWin.setBounds({ x, y, width: w, height: h });
190
+ }
191
+ return true;
192
+ });
193
+
194
+ ipcMain.on("prompter:state", (_e, state) => {
195
+ if (controlWin && !controlWin.isDestroyed()) {
196
+ controlWin.webContents.send("prompter:state", state);
197
+ }
198
+ });
199
+
200
+ ipcMain.handle("app:openExternal", (_e, url) => {
201
+ if (typeof url === "string" && /^https?:\/\//.test(url)) {
202
+ shell.openExternal(url);
203
+ return true;
204
+ }
205
+ return false;
206
+ });
207
+
208
+ ipcMain.handle("app:quit", () => {
209
+ app.quit();
210
+ return true;
211
+ });
212
+
213
+ app.whenReady().then(() => {
214
+ createControlWindow();
215
+
216
+ app.on("activate", () => {
217
+ if (BrowserWindow.getAllWindows().length === 0) createControlWindow();
218
+ });
219
+ });
220
+
221
+ app.on("window-all-closed", () => {
222
+ app.quit();
223
+ });
@@ -0,0 +1,35 @@
1
+ const { contextBridge, ipcRenderer } = require("electron");
2
+
3
+ contextBridge.exposeInMainWorld("pmptr", {
4
+ loadSettings: () => ipcRenderer.invoke("settings:load"),
5
+ saveSettings: (s) => ipcRenderer.invoke("settings:save", s),
6
+
7
+ openPrompter: (s) => ipcRenderer.invoke("prompter:open", s),
8
+ closePrompter: () => ipcRenderer.invoke("prompter:close"),
9
+ isPrompterOpen: () => ipcRenderer.invoke("prompter:isOpen"),
10
+
11
+ setClickThrough: (b) =>
12
+ ipcRenderer.invoke("prompter:setClickThrough", !!b),
13
+ setIgnoreMouse: (b) => ipcRenderer.invoke("prompter:setIgnoreMouse", !!b),
14
+ setAlwaysOnTop: (b) =>
15
+ ipcRenderer.invoke("prompter:setAlwaysOnTop", !!b),
16
+ setBounds: (b) => ipcRenderer.invoke("prompter:setBounds", b),
17
+
18
+ sendSettings: (s) => ipcRenderer.invoke("prompter:sendSettings", s),
19
+ sendCommand: (c) => ipcRenderer.invoke("prompter:sendCommand", c),
20
+
21
+ sendPrompterState: (s) => ipcRenderer.send("prompter:state", s),
22
+ onPrompterState: (cb) => {
23
+ const fn = (_e, s) => cb(s);
24
+ ipcRenderer.on("prompter:state", fn);
25
+ return () => ipcRenderer.removeListener("prompter:state", fn);
26
+ },
27
+ onPrompterClosed: (cb) => {
28
+ const fn = () => cb();
29
+ ipcRenderer.on("prompter:closed", fn);
30
+ return () => ipcRenderer.removeListener("prompter:closed", fn);
31
+ },
32
+
33
+ openExternal: (u) => ipcRenderer.invoke("app:openExternal", u),
34
+ quit: () => ipcRenderer.invoke("app:quit"),
35
+ });
@@ -0,0 +1,17 @@
1
+ const { contextBridge, ipcRenderer } = require("electron");
2
+
3
+ contextBridge.exposeInMainWorld("pmptrPrompter", {
4
+ onSettings: (cb) => {
5
+ const fn = (_e, s) => cb(s);
6
+ ipcRenderer.on("prompter:settings", fn);
7
+ return () => ipcRenderer.removeListener("prompter:settings", fn);
8
+ },
9
+ onCommand: (cb) => {
10
+ const fn = (_e, c) => cb(c);
11
+ ipcRenderer.on("prompter:command", fn);
12
+ return () => ipcRenderer.removeListener("prompter:command", fn);
13
+ },
14
+ sendState: (s) => ipcRenderer.send("prompter:state", s),
15
+ setClickThrough: (b) =>
16
+ ipcRenderer.invoke("prompter:setClickThrough", !!b),
17
+ });
@@ -0,0 +1,233 @@
1
+ :root {
2
+ color-scheme: dark light;
3
+ --text: #ffffff;
4
+ --hl: #ffd84d;
5
+ --bg-rgb: 0, 0, 0;
6
+ --bg-alpha: 0.35;
7
+ --dim: 0;
8
+ --font: 44px;
9
+ --lh: 1.45;
10
+ --ls: 0px;
11
+ --margin: 0px;
12
+ --stroke: 0px;
13
+ }
14
+
15
+ * {
16
+ box-sizing: border-box;
17
+ }
18
+
19
+ html,
20
+ body {
21
+ margin: 0;
22
+ padding: 0;
23
+ width: 100%;
24
+ height: 100%;
25
+ background: transparent;
26
+ overflow: hidden;
27
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
28
+ "Helvetica Neue", Arial, sans-serif;
29
+ -webkit-font-smoothing: antialiased;
30
+ }
31
+
32
+ .frame {
33
+ position: relative;
34
+ width: 100vw;
35
+ height: 100vh;
36
+ border-radius: 10px;
37
+ overflow: hidden;
38
+ background: rgba(var(--bg-rgb), var(--bg-alpha));
39
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.06),
40
+ 0 14px 40px rgba(0, 0, 0, 0.35);
41
+ transition: background 120ms ease;
42
+ }
43
+
44
+ .frame.dim {
45
+ background: rgba(0, 0, 0, calc(var(--bg-alpha) + var(--dim) / 100));
46
+ }
47
+
48
+ .read-area {
49
+ position: absolute;
50
+ inset: 0;
51
+ overflow: hidden;
52
+ }
53
+
54
+ .track {
55
+ position: absolute;
56
+ left: 0;
57
+ right: 0;
58
+ will-change: transform;
59
+ transform: translate3d(0, 0, 0);
60
+ }
61
+
62
+ .pad {
63
+ height: 50vh;
64
+ }
65
+
66
+ .text {
67
+ margin: 0;
68
+ padding: 0 calc(24px + var(--margin));
69
+ color: var(--text);
70
+ font-size: var(--font);
71
+ line-height: var(--lh);
72
+ letter-spacing: var(--ls);
73
+ text-transform: var(--tt, none);
74
+ font-weight: var(--weight, 500);
75
+ white-space: pre-wrap;
76
+ word-break: break-word;
77
+ text-align: center;
78
+ transform: var(--mirror, none);
79
+ text-shadow: 0 0 var(--stroke) rgba(0, 0, 0, 0.85);
80
+ -webkit-text-stroke: var(--stroke) rgba(0, 0, 0, 0.6);
81
+ }
82
+
83
+ .text b,
84
+ .text strong {
85
+ color: var(--hl);
86
+ font-weight: 700;
87
+ }
88
+
89
+ .reading-line {
90
+ position: absolute;
91
+ left: 8%;
92
+ right: 8%;
93
+ top: 50%;
94
+ height: 0;
95
+ border-top: 2px solid rgba(255, 216, 77, 0.55);
96
+ pointer-events: none;
97
+ display: var(--show-line, block);
98
+ }
99
+ .reading-line::before,
100
+ .reading-line::after {
101
+ content: "";
102
+ position: absolute;
103
+ top: -3px;
104
+ width: 6px;
105
+ height: 6px;
106
+ border-radius: 50%;
107
+ background: rgba(255, 216, 77, 0.85);
108
+ }
109
+ .reading-line::before {
110
+ left: -3px;
111
+ }
112
+ .reading-line::after {
113
+ right: -3px;
114
+ }
115
+
116
+ .fade {
117
+ position: absolute;
118
+ left: 0;
119
+ right: 0;
120
+ height: 22%;
121
+ pointer-events: none;
122
+ z-index: 2;
123
+ }
124
+ .fade.top {
125
+ top: 0;
126
+ background: linear-gradient(
127
+ to bottom,
128
+ rgba(0, 0, 0, 0.55),
129
+ rgba(0, 0, 0, 0)
130
+ );
131
+ }
132
+ .fade.bottom {
133
+ bottom: 0;
134
+ background: linear-gradient(
135
+ to top,
136
+ rgba(0, 0, 0, 0.55),
137
+ rgba(0, 0, 0, 0)
138
+ );
139
+ }
140
+
141
+ .hud {
142
+ position: absolute;
143
+ bottom: 8px;
144
+ right: 8px;
145
+ display: flex;
146
+ gap: 4px;
147
+ background: rgba(15, 17, 21, 0.6);
148
+ padding: 4px;
149
+ border-radius: 8px;
150
+ backdrop-filter: blur(6px);
151
+ -webkit-app-region: no-drag;
152
+ z-index: 5;
153
+ opacity: 0.18;
154
+ transition: opacity 160ms ease;
155
+ }
156
+ .hud:hover,
157
+ .hud:focus-within {
158
+ opacity: 1;
159
+ }
160
+ .hud[data-locked="true"] {
161
+ opacity: 0;
162
+ pointer-events: none;
163
+ }
164
+
165
+ .hud-btn {
166
+ appearance: none;
167
+ border: 0;
168
+ background: transparent;
169
+ color: #fff;
170
+ width: 28px;
171
+ height: 28px;
172
+ border-radius: 6px;
173
+ cursor: pointer;
174
+ font: 14px/1 system-ui;
175
+ display: inline-flex;
176
+ align-items: center;
177
+ justify-content: center;
178
+ }
179
+ .hud-btn:hover {
180
+ background: rgba(255, 255, 255, 0.12);
181
+ }
182
+ .hud-btn[aria-pressed="true"] {
183
+ background: rgba(255, 216, 77, 0.2);
184
+ color: var(--hl);
185
+ }
186
+
187
+ .grab {
188
+ position: absolute;
189
+ top: 0;
190
+ left: 0;
191
+ right: 60px;
192
+ height: 22px;
193
+ -webkit-app-region: drag;
194
+ cursor: grab;
195
+ z-index: 3;
196
+ background: linear-gradient(
197
+ to bottom,
198
+ rgba(255, 255, 255, 0.04),
199
+ rgba(255, 255, 255, 0)
200
+ );
201
+ }
202
+ .grab:active {
203
+ cursor: grabbing;
204
+ }
205
+
206
+ .resize {
207
+ position: absolute;
208
+ bottom: 0;
209
+ right: 0;
210
+ width: 18px;
211
+ height: 18px;
212
+ cursor: nwse-resize;
213
+ z-index: 4;
214
+ background: linear-gradient(
215
+ 135deg,
216
+ transparent 50%,
217
+ rgba(255, 255, 255, 0.25) 50%,
218
+ rgba(255, 255, 255, 0.25) 60%,
219
+ transparent 60%,
220
+ transparent 70%,
221
+ rgba(255, 255, 255, 0.15) 70%,
222
+ rgba(255, 255, 255, 0.15) 80%,
223
+ transparent 80%
224
+ );
225
+ }
226
+
227
+ /* When the whole window is in click-through mode (locked), the HUD is
228
+ hidden via the [data-locked] rule above, but the resize handle and
229
+ grab bar should also be non-blocking so users can still see through. */
230
+ .frame[data-locked="true"] .grab,
231
+ .frame[data-locked="true"] .resize {
232
+ pointer-events: none;
233
+ }
@@ -0,0 +1,44 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="color-scheme" content="dark light" />
6
+ <title>pmptr prompter</title>
7
+ <link rel="stylesheet" href="prompter.css" />
8
+ </head>
9
+ <body>
10
+ <div class="frame" id="frame">
11
+ <div class="read-area" id="readArea">
12
+ <div class="track" id="track">
13
+ <div class="pad" id="padTop"></div>
14
+ <pre class="text" id="text"></pre>
15
+ <div class="pad" id="padBottom"></div>
16
+ </div>
17
+ <div class="reading-line" id="readingLine" aria-hidden="true"></div>
18
+ <div class="fade top" aria-hidden="true"></div>
19
+ <div class="fade bottom" aria-hidden="true"></div>
20
+ </div>
21
+
22
+ <div class="hud" id="hud" data-locked="false">
23
+ <button id="btnPlay" class="hud-btn" title="Play / Pause (Space)">
24
+ <span class="icon" id="iconPlay">❚❚</span>
25
+ </button>
26
+ <button id="btnReset" class="hud-btn" title="Reset (R)">↺</button>
27
+ <button
28
+ id="btnLock"
29
+ class="hud-btn"
30
+ title="Lock - click-through (L)"
31
+ aria-pressed="false"
32
+ >
33
+ <span id="iconLock">🔓</span>
34
+ </button>
35
+ <button id="btnClose" class="hud-btn" title="Close (Esc)">✕</button>
36
+ </div>
37
+
38
+ <div class="grab" title="Drag to move" aria-hidden="true"></div>
39
+ <div class="resize" aria-hidden="true"></div>
40
+ </div>
41
+
42
+ <script src="prompter.js"></script>
43
+ </body>
44
+ </html>