@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 +2 -0
- package/bin/cli.js +7 -2
- package/lib/server.js +70 -3
- package/package.json +1 -1
- package/public/index.html +28 -0
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
|
-
|
|
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
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>';
|