@tb.p/serve-media 1.0.0 → 1.2.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.
package/README.md CHANGED
@@ -13,6 +13,7 @@ npx @tb.p/serve-media <path>
13
13
  - Filter by name, sort by name / newest / oldest / largest / shuffle.
14
14
  - A status bar lists the extensions of files that were **not** served in the current folder (recursively), with counts — so you always know what was skipped.
15
15
  - Draggable sidebar splitter; tile size and sidebar width persist across sessions.
16
+ - **Single viewer, self-terminating** — only one page may connect at a time (others get a "busy" page). Once the viewer connects, closing that page shuts the server down; no orphaned servers left running. Refreshing the page is fine (5-second grace window). Disable with `--no-exit`.
16
17
 
17
18
  Recognized extensions — images: `jpg jpeg jfif png apng gif webp avif bmp svg ico`; video: `mp4 m4v webm mov mkv ogv ogg 3gp`. This is the set latest Chrome renders natively; formats Chrome can't decode (heic, tiff, jxl, avi, wmv, …) are intentionally excluded so they don't appear as broken tiles. Note containers like `mov`/`mkv` still depend on the codec inside (H.264/VP9/AV1 play; e.g. ProRes won't).
18
19
 
@@ -25,6 +26,7 @@ serve-media [path] [options]
25
26
  -p, --port <n> Port (default: 4321; auto-increments if busy)
26
27
  --host <host> Host to bind (default: 127.0.0.1)
27
28
  --no-open Don't open the browser automatically
29
+ --no-exit Allow multiple viewers; keep running after the page closes
28
30
  ```
29
31
 
30
32
  Zero dependencies. Binds to `127.0.0.1` by default, so it is only reachable from this machine; pass `--host 0.0.0.0` to expose it on your LAN.
package/bin/cli.js CHANGED
@@ -15,18 +15,22 @@ Options:
15
15
  -p, --port <n> Port to listen on (default: 4321, auto-increments if busy)
16
16
  --host <host> Host to bind (default: 127.0.0.1)
17
17
  --no-open Don't open the browser automatically
18
+ --no-exit Allow multiple viewers and keep running after the page closes
19
+ (by default only one viewer is allowed, and the server exits
20
+ when that page disconnects)
18
21
  -h, --help Show this help
19
22
  `);
20
23
  }
21
24
 
22
25
  function parseArgs(argv) {
23
- const opts = { dir: null, port: 4321, host: '127.0.0.1', open: true };
26
+ const opts = { dir: null, port: 4321, host: '127.0.0.1', open: true, single: true };
24
27
  for (let i = 0; i < argv.length; i++) {
25
28
  const a = argv[i];
26
29
  if (a === '-h' || a === '--help') { usage(); process.exit(0); }
27
30
  else if (a === '-p' || a === '--port') { opts.port = parseInt(argv[++i], 10); }
28
31
  else if (a === '--host') { opts.host = argv[++i]; }
29
32
  else if (a === '--no-open') { opts.open = false; }
33
+ else if (a === '--no-exit') { opts.single = false; }
30
34
  else if (a.startsWith('-')) { console.error(`Unknown option: ${a}`); usage(); process.exit(1); }
31
35
  else if (opts.dir === null) { opts.dir = a; }
32
36
  else { console.error(`Unexpected argument: ${a}`); usage(); process.exit(1); }
@@ -60,10 +64,11 @@ async function main() {
60
64
  process.exit(1);
61
65
  }
62
66
 
63
- const { port } = await startServer({ root, port: opts.port, host: opts.host });
67
+ const { port } = await startServer({ root, port: opts.port, host: opts.host, single: opts.single });
64
68
  const url = `http://${opts.host === '0.0.0.0' ? 'localhost' : opts.host}:${port}/`;
65
69
  console.log(`Serving ${root}`);
66
70
  console.log(`Browse ${url}`);
71
+ if (opts.single) console.log('Single-viewer mode: the server exits when the page closes (--no-exit to disable).');
67
72
  console.log('Press Ctrl+C to stop.');
68
73
  if (opts.open) openBrowser(url);
69
74
  }
package/lib/server.js CHANGED
@@ -153,9 +153,23 @@ function serveFile(req, res, abs, stat, cacheControl) {
153
153
  stream.pipe(res);
154
154
  }
155
155
 
156
- function createServer(root) {
156
+ const BUSY_PAGE = `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><title>serve-media — busy</title>
157
+ <style>body{background:#1c1c1c;color:#e8e8e8;font-family:"Segoe UI",system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
158
+ div{text-align:center;color:#9a9a9a}h1{color:#e8e8e8;font-size:20px;margin-bottom:8px}</style></head>
159
+ <body><div><h1>Another viewer is already connected</h1><p>This server allows a single viewer at a time.</p></div></body></html>`;
160
+
161
+ /** Grace window after the viewer disconnects before exiting — long enough to survive F5. */
162
+ const GRACE_MS = 5000;
163
+
164
+ function createServer(root, opts = {}) {
157
165
  root = path.resolve(root);
158
166
  const indexPath = path.join(__dirname, '..', 'public', 'index.html');
167
+ const single = opts.single !== false;
168
+
169
+ // Single-viewer watchdog: the page holds an SSE connection to /api/watch.
170
+ // First connection arms the shutdown; when it closes and no viewer returns
171
+ // within GRACE_MS, onViewerGone fires.
172
+ const watch = { viewer: null, armed: false, timer: null };
159
173
 
160
174
  return http.createServer(async (req, res) => {
161
175
  let url;
@@ -180,6 +194,36 @@ function createServer(root) {
180
194
  return sendError(res, 405, 'Method not allowed');
181
195
  }
182
196
 
197
+ if (p === '/api/watch') {
198
+ if (single && watch.viewer) return sendError(res, 409, 'Another viewer is already connected.');
199
+ res.writeHead(200, {
200
+ 'Content-Type': 'text/event-stream',
201
+ 'Cache-Control': 'no-cache',
202
+ 'Connection': 'keep-alive',
203
+ });
204
+ res.write('event: armed\ndata: {}\n\n');
205
+ const hb = setInterval(() => res.write(':hb\n\n'), 15000);
206
+ if (single) {
207
+ watch.viewer = res;
208
+ clearTimeout(watch.timer);
209
+ if (!watch.armed) {
210
+ watch.armed = true;
211
+ if (opts.onArm) opts.onArm();
212
+ }
213
+ }
214
+ res.on('close', () => {
215
+ clearInterval(hb);
216
+ if (single && watch.viewer === res) {
217
+ watch.viewer = null;
218
+ clearTimeout(watch.timer);
219
+ watch.timer = setTimeout(() => {
220
+ if (!watch.viewer && opts.onViewerGone) opts.onViewerGone();
221
+ }, GRACE_MS);
222
+ }
223
+ });
224
+ return;
225
+ }
226
+
183
227
  try {
184
228
  if (p === '/api/prefs') {
185
229
  let prefs = {};
@@ -188,6 +232,10 @@ function createServer(root) {
188
232
  }
189
233
 
190
234
  if (p === '/' || p === '/index.html') {
235
+ if (single && watch.viewer) {
236
+ res.writeHead(409, { 'Content-Type': 'text/html; charset=utf-8' });
237
+ return res.end(BUSY_PAGE);
238
+ }
191
239
  const st = await fsp.stat(indexPath);
192
240
  return serveFile(req, res, indexPath, st, 'no-cache');
193
241
  }
@@ -224,8 +272,27 @@ function createServer(root) {
224
272
  }
225
273
 
226
274
  /** Listen, auto-incrementing the port if it's taken (up to 50 tries). */
227
- function startServer({ root, port, host }) {
228
- const server = createServer(root);
275
+ function startServer({ root, port, host, single }) {
276
+ const server = createServer(root, {
277
+ single,
278
+ onArm() {
279
+ console.log('Viewer connected — server will exit when the page closes.');
280
+ },
281
+ onViewerGone() {
282
+ console.log('Viewer disconnected — shutting down.');
283
+ server.close();
284
+ // When attached to a console, keep the window open (it would close with
285
+ // the process if the tool was launched via double-click / a shortcut).
286
+ if (process.stdin.isTTY) {
287
+ console.log('Press any key to close.');
288
+ process.stdin.setRawMode(true);
289
+ process.stdin.resume();
290
+ process.stdin.once('data', () => process.exit(0));
291
+ } else {
292
+ process.exit(0);
293
+ }
294
+ },
295
+ });
229
296
  return new Promise((resolve, reject) => {
230
297
  let attempts = 0;
231
298
  const tryListen = (p) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tb.p/serve-media",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Spin up a local web page that browses the media (images/video) in a directory — Explorer-style folder tree, resizable tile grid.",
5
5
  "license": "MIT",
6
6
  "bin": {
package/public/index.html CHANGED
@@ -221,6 +221,18 @@
221
221
  }
222
222
  #lb-close:hover { background: rgba(255, 80, 80, .5); }
223
223
 
224
+ #lockout {
225
+ position: fixed; inset: 0;
226
+ background: rgba(0, 0, 0, .88);
227
+ display: none;
228
+ align-items: center; justify-content: center;
229
+ text-align: center;
230
+ z-index: 20;
231
+ }
232
+ #lockout.show { display: flex; }
233
+ #lockout h1 { font-size: 20px; margin-bottom: 8px; }
234
+ #lockout p { color: var(--muted); }
235
+
224
236
  ::-webkit-scrollbar { width: 12px; height: 12px; }
225
237
  ::-webkit-scrollbar-thumb { background: #444; border-radius: 6px; border: 3px solid var(--bg); }
226
238
  ::-webkit-scrollbar-thumb:hover { background: #555; }
@@ -254,6 +266,13 @@
254
266
  <div id="statusbar"></div>
255
267
  </div>
256
268
 
269
+ <div id="lockout">
270
+ <div>
271
+ <h1>Disconnected</h1>
272
+ <p>The server has stopped, or another viewer is already connected.</p>
273
+ </div>
274
+ </div>
275
+
257
276
  <div id="lb">
258
277
  <div id="lb-stage"></div>
259
278
  <div id="lb-bar"><span id="lb-caption"></span></div>
@@ -268,6 +287,15 @@
268
287
  const $ = (id) => document.getElementById(id);
269
288
  const BATCH = 120;
270
289
 
290
+ /* ---------- viewer watch channel ----------
291
+ Held open for the whole session; the server exits when it closes.
292
+ Opened first thing so a refresh re-arms within the server's grace window. */
293
+ const es = new EventSource('/api/watch');
294
+ es.onerror = () => {
295
+ es.close();
296
+ $('lockout').classList.add('show');
297
+ };
298
+
271
299
  const CHEV = '<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><path d="M5 2l7 6-7 6V2z"/></svg>';
272
300
  const FOLDER = '<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M1.5 3A1.5 1.5 0 0 1 3 1.5h3.2c.4 0 .8.16 1.08.44L8.4 3H13a1.5 1.5 0 0 1 1.5 1.5v8A1.5 1.5 0 0 1 13 14H3a1.5 1.5 0 0 1-1.5-1.5V3z"/></svg>';
273
301
  const PLAY = '<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2l10 6-10 6V2z"/></svg>';