@tb.p/serve-media 1.0.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 +36 -0
- package/bin/cli.js +74 -0
- package/lib/server.js +249 -0
- package/package.json +28 -0
- package/public/index.html +634 -0
package/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# @tb.p/serve-media
|
|
2
|
+
|
|
3
|
+
Spin up a local single-page media browser for any directory:
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
npx @tb.p/serve-media <path>
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
- **Explorer-style folder tree** on the left — only folders that (recursively) contain media are shown, with item counts. Clicking a folder shows all media under it, recursively.
|
|
10
|
+
- **Tile grid** on the right — images and videos only; everything else is ignored.
|
|
11
|
+
- **Resizable tiles** — toolbar slider or `Ctrl` + mouse wheel, just like Windows Explorer.
|
|
12
|
+
- Videos stream with range-request support, show duration, and **play on hover**; click any tile for a full-size lightbox (arrow keys / `Esc`).
|
|
13
|
+
- Filter by name, sort by name / newest / oldest / largest / shuffle.
|
|
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
|
+
- Draggable sidebar splitter; tile size and sidebar width persist across sessions.
|
|
16
|
+
|
|
17
|
+
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
|
+
## Options
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
serve-media [path] [options]
|
|
23
|
+
|
|
24
|
+
path Directory to serve (default: current directory)
|
|
25
|
+
-p, --port <n> Port (default: 4321; auto-increments if busy)
|
|
26
|
+
--host <host> Host to bind (default: 127.0.0.1)
|
|
27
|
+
--no-open Don't open the browser automatically
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
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.
|
|
31
|
+
|
|
32
|
+
## Publish
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
npm publish --access public
|
|
36
|
+
```
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const { startServer } = require('../lib/server');
|
|
7
|
+
|
|
8
|
+
function usage() {
|
|
9
|
+
console.log(`
|
|
10
|
+
Usage: serve-media [path] [options]
|
|
11
|
+
|
|
12
|
+
path Directory to serve (default: current directory)
|
|
13
|
+
|
|
14
|
+
Options:
|
|
15
|
+
-p, --port <n> Port to listen on (default: 4321, auto-increments if busy)
|
|
16
|
+
--host <host> Host to bind (default: 127.0.0.1)
|
|
17
|
+
--no-open Don't open the browser automatically
|
|
18
|
+
-h, --help Show this help
|
|
19
|
+
`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseArgs(argv) {
|
|
23
|
+
const opts = { dir: null, port: 4321, host: '127.0.0.1', open: true };
|
|
24
|
+
for (let i = 0; i < argv.length; i++) {
|
|
25
|
+
const a = argv[i];
|
|
26
|
+
if (a === '-h' || a === '--help') { usage(); process.exit(0); }
|
|
27
|
+
else if (a === '-p' || a === '--port') { opts.port = parseInt(argv[++i], 10); }
|
|
28
|
+
else if (a === '--host') { opts.host = argv[++i]; }
|
|
29
|
+
else if (a === '--no-open') { opts.open = false; }
|
|
30
|
+
else if (a.startsWith('-')) { console.error(`Unknown option: ${a}`); usage(); process.exit(1); }
|
|
31
|
+
else if (opts.dir === null) { opts.dir = a; }
|
|
32
|
+
else { console.error(`Unexpected argument: ${a}`); usage(); process.exit(1); }
|
|
33
|
+
}
|
|
34
|
+
if (!Number.isInteger(opts.port) || opts.port < 0 || opts.port > 65535) {
|
|
35
|
+
console.error('Invalid port.');
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
return opts;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function openBrowser(url) {
|
|
42
|
+
const { spawn } = require('child_process');
|
|
43
|
+
let cmd, args;
|
|
44
|
+
if (process.platform === 'win32') { cmd = 'cmd'; args = ['/c', 'start', '', url]; }
|
|
45
|
+
else if (process.platform === 'darwin') { cmd = 'open'; args = [url]; }
|
|
46
|
+
else { cmd = 'xdg-open'; args = [url]; }
|
|
47
|
+
try {
|
|
48
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
|
49
|
+
} catch { /* opening the browser is best-effort */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function main() {
|
|
53
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
54
|
+
const root = path.resolve(opts.dir || '.');
|
|
55
|
+
|
|
56
|
+
let stat;
|
|
57
|
+
try { stat = fs.statSync(root); } catch { stat = null; }
|
|
58
|
+
if (!stat || !stat.isDirectory()) {
|
|
59
|
+
console.error(`Not a directory: ${root}`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const { port } = await startServer({ root, port: opts.port, host: opts.host });
|
|
64
|
+
const url = `http://${opts.host === '0.0.0.0' ? 'localhost' : opts.host}:${port}/`;
|
|
65
|
+
console.log(`Serving ${root}`);
|
|
66
|
+
console.log(`Browse ${url}`);
|
|
67
|
+
console.log('Press Ctrl+C to stop.');
|
|
68
|
+
if (opts.open) openBrowser(url);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
main().catch((err) => {
|
|
72
|
+
console.error(err.message || err);
|
|
73
|
+
process.exit(1);
|
|
74
|
+
});
|
package/lib/server.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const http = require('http');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const fsp = fs.promises;
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
|
|
9
|
+
const PREFS_FILE = path.join(os.homedir(), '.serve-media.json');
|
|
10
|
+
|
|
11
|
+
const IMAGE_EXT = new Set(['.jpg', '.jpeg', '.jfif', '.png', '.apng', '.gif', '.webp', '.avif', '.bmp', '.svg', '.ico']);
|
|
12
|
+
const VIDEO_EXT = new Set(['.mp4', '.m4v', '.webm', '.mov', '.mkv', '.ogv', '.ogg', '.3gp']);
|
|
13
|
+
|
|
14
|
+
const MIME = {
|
|
15
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.jfif': 'image/jpeg',
|
|
16
|
+
'.png': 'image/png', '.apng': 'image/apng', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
17
|
+
'.avif': 'image/avif', '.bmp': 'image/bmp', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
|
|
18
|
+
'.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm',
|
|
19
|
+
'.mov': 'video/quicktime', '.mkv': 'video/x-matroska', '.ogv': 'video/ogg',
|
|
20
|
+
'.ogg': 'video/ogg', '.3gp': 'video/3gpp',
|
|
21
|
+
'.html': 'text/html; charset=utf-8', '.json': 'application/json; charset=utf-8',
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const SKIP_DIRS = new Set(['node_modules', '$RECYCLE.BIN', 'System Volume Information']);
|
|
25
|
+
|
|
26
|
+
function mediaKind(name) {
|
|
27
|
+
const ext = path.extname(name).toLowerCase();
|
|
28
|
+
if (IMAGE_EXT.has(ext)) return 'image';
|
|
29
|
+
if (VIDEO_EXT.has(ext)) return 'video';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function naturalCompare(a, b) {
|
|
34
|
+
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Folder tree containing only directories that (recursively) hold media. */
|
|
38
|
+
async function buildTree(abs, rel, name) {
|
|
39
|
+
let count = 0;
|
|
40
|
+
const children = [];
|
|
41
|
+
let entries = [];
|
|
42
|
+
try { entries = await fsp.readdir(abs, { withFileTypes: true }); } catch { /* unreadable dir */ }
|
|
43
|
+
for (const e of entries) {
|
|
44
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
45
|
+
if (e.isDirectory()) {
|
|
46
|
+
const child = await buildTree(path.join(abs, e.name), rel ? `${rel}/${e.name}` : e.name, e.name);
|
|
47
|
+
if (child.count > 0) children.push(child);
|
|
48
|
+
count += child.count;
|
|
49
|
+
} else if (e.isFile() && mediaKind(e.name)) {
|
|
50
|
+
count++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
children.sort((a, b) => naturalCompare(a.name, b.name));
|
|
54
|
+
return { name, rel, count, children };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Recursive scan under absDir: collects media files into out.files and
|
|
59
|
+
* tallies the extensions of files that are NOT served into out.ignored.
|
|
60
|
+
*/
|
|
61
|
+
async function listMedia(absDir, relDir, out) {
|
|
62
|
+
let entries = [];
|
|
63
|
+
try { entries = await fsp.readdir(absDir, { withFileTypes: true }); } catch { return out; }
|
|
64
|
+
for (const e of entries) {
|
|
65
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
66
|
+
const rel = relDir ? `${relDir}/${e.name}` : e.name;
|
|
67
|
+
if (e.isDirectory()) {
|
|
68
|
+
await listMedia(path.join(absDir, e.name), rel, out);
|
|
69
|
+
} else if (e.isFile()) {
|
|
70
|
+
const kind = mediaKind(e.name);
|
|
71
|
+
if (!kind) {
|
|
72
|
+
const ext = path.extname(e.name).toLowerCase() || '(no ext)';
|
|
73
|
+
out.ignored[ext] = (out.ignored[ext] || 0) + 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
let st;
|
|
77
|
+
try { st = await fsp.stat(path.join(absDir, e.name)); } catch { continue; }
|
|
78
|
+
out.files.push({ rel, name: e.name, kind, size: st.size, mtime: st.mtimeMs });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sendJson(res, obj) {
|
|
85
|
+
const body = JSON.stringify(obj);
|
|
86
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
87
|
+
res.end(body);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function sendError(res, code, msg) {
|
|
91
|
+
res.writeHead(code, { 'Content-Type': 'text/plain; charset=utf-8' });
|
|
92
|
+
res.end(msg);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Resolve a URL-style relative path safely inside root; null if it escapes. */
|
|
96
|
+
function safeResolve(root, rel) {
|
|
97
|
+
if (rel.includes('\\') || rel.includes('\0')) return null;
|
|
98
|
+
const parts = rel.split('/').filter(Boolean);
|
|
99
|
+
if (parts.some((p) => p === '..' || p === '.')) return null;
|
|
100
|
+
const abs = path.join(root, ...parts);
|
|
101
|
+
if (abs !== root && !abs.startsWith(root + path.sep)) return null;
|
|
102
|
+
return abs;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function serveFile(req, res, abs, stat, cacheControl) {
|
|
106
|
+
const ext = path.extname(abs).toLowerCase();
|
|
107
|
+
const type = MIME[ext] || 'application/octet-stream';
|
|
108
|
+
const lastMod = stat.mtime.toUTCString();
|
|
109
|
+
|
|
110
|
+
if (req.headers['if-modified-since'] === lastMod) {
|
|
111
|
+
res.writeHead(304);
|
|
112
|
+
return res.end();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const headers = {
|
|
116
|
+
'Content-Type': type,
|
|
117
|
+
'Accept-Ranges': 'bytes',
|
|
118
|
+
'Last-Modified': lastMod,
|
|
119
|
+
'Cache-Control': cacheControl || 'private, max-age=3600',
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const range = req.headers.range;
|
|
123
|
+
if (range) {
|
|
124
|
+
const m = /^bytes=(\d*)-(\d*)$/.exec(range);
|
|
125
|
+
if (m && (m[1] || m[2])) {
|
|
126
|
+
let start, end;
|
|
127
|
+
if (m[1] === '') { // suffix range: last N bytes
|
|
128
|
+
start = Math.max(0, stat.size - parseInt(m[2], 10));
|
|
129
|
+
end = stat.size - 1;
|
|
130
|
+
} else {
|
|
131
|
+
start = parseInt(m[1], 10);
|
|
132
|
+
end = m[2] === '' ? stat.size - 1 : Math.min(parseInt(m[2], 10), stat.size - 1);
|
|
133
|
+
}
|
|
134
|
+
if (start > end || start >= stat.size) {
|
|
135
|
+
res.writeHead(416, { 'Content-Range': `bytes */${stat.size}` });
|
|
136
|
+
return res.end();
|
|
137
|
+
}
|
|
138
|
+
headers['Content-Range'] = `bytes ${start}-${end}/${stat.size}`;
|
|
139
|
+
headers['Content-Length'] = end - start + 1;
|
|
140
|
+
res.writeHead(206, headers);
|
|
141
|
+
if (req.method === 'HEAD') return res.end();
|
|
142
|
+
const stream = fs.createReadStream(abs, { start, end });
|
|
143
|
+
stream.on('error', () => res.destroy());
|
|
144
|
+
return stream.pipe(res);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
headers['Content-Length'] = stat.size;
|
|
149
|
+
res.writeHead(200, headers);
|
|
150
|
+
if (req.method === 'HEAD') return res.end();
|
|
151
|
+
const stream = fs.createReadStream(abs);
|
|
152
|
+
stream.on('error', () => res.destroy());
|
|
153
|
+
stream.pipe(res);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function createServer(root) {
|
|
157
|
+
root = path.resolve(root);
|
|
158
|
+
const indexPath = path.join(__dirname, '..', 'public', 'index.html');
|
|
159
|
+
|
|
160
|
+
return http.createServer(async (req, res) => {
|
|
161
|
+
let url;
|
|
162
|
+
try { url = new URL(req.url, 'http://localhost'); } catch { return sendError(res, 400, 'Bad request'); }
|
|
163
|
+
const p = url.pathname;
|
|
164
|
+
|
|
165
|
+
if (req.method === 'POST' && p === '/api/prefs') {
|
|
166
|
+
let body = '';
|
|
167
|
+
req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy(); });
|
|
168
|
+
req.on('end', async () => {
|
|
169
|
+
try {
|
|
170
|
+
JSON.parse(body); // validate before persisting
|
|
171
|
+
await fsp.writeFile(PREFS_FILE, body);
|
|
172
|
+
sendJson(res, { ok: true });
|
|
173
|
+
} catch {
|
|
174
|
+
sendError(res, 400, 'Bad prefs');
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
180
|
+
return sendError(res, 405, 'Method not allowed');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
if (p === '/api/prefs') {
|
|
185
|
+
let prefs = {};
|
|
186
|
+
try { prefs = JSON.parse(await fsp.readFile(PREFS_FILE, 'utf8')); } catch { /* no prefs yet */ }
|
|
187
|
+
return sendJson(res, prefs);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (p === '/' || p === '/index.html') {
|
|
191
|
+
const st = await fsp.stat(indexPath);
|
|
192
|
+
return serveFile(req, res, indexPath, st, 'no-cache');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (p === '/api/tree') {
|
|
196
|
+
const tree = await buildTree(root, '', path.basename(root) || root);
|
|
197
|
+
return sendJson(res, tree);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (p === '/api/media') {
|
|
201
|
+
const rel = url.searchParams.get('dir') || '';
|
|
202
|
+
const abs = safeResolve(root, rel);
|
|
203
|
+
if (!abs) return sendError(res, 400, 'Bad path');
|
|
204
|
+
const out = await listMedia(abs, rel, { files: [], ignored: {} });
|
|
205
|
+
return sendJson(res, { dir: rel, files: out.files, ignored: out.ignored });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (p.startsWith('/file/')) {
|
|
209
|
+
let rel;
|
|
210
|
+
try { rel = decodeURIComponent(p.slice('/file/'.length)); } catch { return sendError(res, 400, 'Bad path'); }
|
|
211
|
+
const abs = safeResolve(root, rel);
|
|
212
|
+
if (!abs || !mediaKind(abs)) return sendError(res, 404, 'Not found');
|
|
213
|
+
let st;
|
|
214
|
+
try { st = await fsp.stat(abs); } catch { return sendError(res, 404, 'Not found'); }
|
|
215
|
+
if (!st.isFile()) return sendError(res, 404, 'Not found');
|
|
216
|
+
return serveFile(req, res, abs, st);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return sendError(res, 404, 'Not found');
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return sendError(res, 500, 'Server error: ' + (err.message || err));
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Listen, auto-incrementing the port if it's taken (up to 50 tries). */
|
|
227
|
+
function startServer({ root, port, host }) {
|
|
228
|
+
const server = createServer(root);
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
let attempts = 0;
|
|
231
|
+
const tryListen = (p) => {
|
|
232
|
+
server.once('error', (err) => {
|
|
233
|
+
if (err.code === 'EADDRINUSE' && attempts < 50) {
|
|
234
|
+
attempts++;
|
|
235
|
+
tryListen(p + 1);
|
|
236
|
+
} else {
|
|
237
|
+
reject(err);
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
server.listen(p, host, () => {
|
|
241
|
+
server.removeAllListeners('error');
|
|
242
|
+
resolve({ server, port: server.address().port });
|
|
243
|
+
});
|
|
244
|
+
};
|
|
245
|
+
tryListen(port);
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = { startServer, createServer };
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@tb.p/serve-media",
|
|
3
|
+
"version": "1.0.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"bin": {
|
|
7
|
+
"serve-media": "bin/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"public"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=16"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"media",
|
|
22
|
+
"gallery",
|
|
23
|
+
"server",
|
|
24
|
+
"images",
|
|
25
|
+
"video",
|
|
26
|
+
"explorer"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>serve-media</title>
|
|
7
|
+
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🖼️</text></svg>">
|
|
8
|
+
<style>
|
|
9
|
+
:root {
|
|
10
|
+
--bg: #1c1c1c;
|
|
11
|
+
--panel: #242424;
|
|
12
|
+
--border: #333;
|
|
13
|
+
--fg: #e8e8e8;
|
|
14
|
+
--muted: #9a9a9a;
|
|
15
|
+
--accent: #4cc2ff;
|
|
16
|
+
--hover: rgba(255, 255, 255, .07);
|
|
17
|
+
--sel: rgba(255, 255, 255, .13);
|
|
18
|
+
--tile: 200px;
|
|
19
|
+
--side: 270px;
|
|
20
|
+
}
|
|
21
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
22
|
+
html, body { height: 100%; }
|
|
23
|
+
body {
|
|
24
|
+
display: grid;
|
|
25
|
+
grid-template-columns: var(--side) 5px 1fr;
|
|
26
|
+
background: var(--bg);
|
|
27
|
+
color: var(--fg);
|
|
28
|
+
font-family: "Segoe UI", "Segoe UI Variable", system-ui, sans-serif;
|
|
29
|
+
font-size: 14px;
|
|
30
|
+
overflow: hidden;
|
|
31
|
+
user-select: none;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* ---- sidebar / tree ---- */
|
|
35
|
+
#sidebar {
|
|
36
|
+
background: var(--panel);
|
|
37
|
+
overflow: auto;
|
|
38
|
+
padding: 8px 4px 24px;
|
|
39
|
+
border-right: 1px solid var(--border);
|
|
40
|
+
}
|
|
41
|
+
#split { cursor: col-resize; }
|
|
42
|
+
#split:hover { background: var(--accent); opacity: .4; }
|
|
43
|
+
.row {
|
|
44
|
+
display: flex;
|
|
45
|
+
align-items: center;
|
|
46
|
+
gap: 5px;
|
|
47
|
+
height: 28px;
|
|
48
|
+
padding: 0 8px 0 calc(6px + var(--d, 0) * 16px);
|
|
49
|
+
border-radius: 5px;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
white-space: nowrap;
|
|
52
|
+
}
|
|
53
|
+
.row:hover { background: var(--hover); }
|
|
54
|
+
.row.sel { background: var(--sel); }
|
|
55
|
+
.row.sel::before {
|
|
56
|
+
content: "";
|
|
57
|
+
position: absolute;
|
|
58
|
+
width: 3px; height: 16px;
|
|
59
|
+
margin-left: -8px;
|
|
60
|
+
border-radius: 2px;
|
|
61
|
+
background: var(--accent);
|
|
62
|
+
}
|
|
63
|
+
.chev {
|
|
64
|
+
width: 16px; height: 16px;
|
|
65
|
+
flex: none;
|
|
66
|
+
display: flex; align-items: center; justify-content: center;
|
|
67
|
+
color: var(--muted);
|
|
68
|
+
transition: transform .12s;
|
|
69
|
+
}
|
|
70
|
+
.open > .row > .chev { transform: rotate(90deg); }
|
|
71
|
+
.chev.leaf { visibility: hidden; }
|
|
72
|
+
.fico { flex: none; width: 16px; height: 16px; color: #eac054; }
|
|
73
|
+
.lbl { overflow: hidden; text-overflow: ellipsis; }
|
|
74
|
+
.cnt { margin-left: auto; padding-left: 8px; font-size: 11px; color: var(--muted); }
|
|
75
|
+
.kids { display: none; }
|
|
76
|
+
.open > .kids { display: block; }
|
|
77
|
+
|
|
78
|
+
/* ---- main ---- */
|
|
79
|
+
#main { display: flex; flex-direction: column; min-width: 0; height: 100vh; }
|
|
80
|
+
#toolbar {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 12px;
|
|
84
|
+
padding: 8px 14px;
|
|
85
|
+
background: var(--panel);
|
|
86
|
+
border-bottom: 1px solid var(--border);
|
|
87
|
+
flex: none;
|
|
88
|
+
}
|
|
89
|
+
#crumbs { display: flex; align-items: center; gap: 2px; overflow: hidden; white-space: nowrap; flex: 1; min-width: 0; }
|
|
90
|
+
.crumb { padding: 3px 7px; border-radius: 4px; cursor: pointer; color: var(--fg); }
|
|
91
|
+
.crumb:hover { background: var(--hover); }
|
|
92
|
+
.crumb-sep { color: var(--muted); font-size: 11px; }
|
|
93
|
+
#count { color: var(--muted); font-size: 12px; flex: none; }
|
|
94
|
+
#search {
|
|
95
|
+
background: var(--bg);
|
|
96
|
+
border: 1px solid var(--border);
|
|
97
|
+
border-radius: 5px;
|
|
98
|
+
color: var(--fg);
|
|
99
|
+
padding: 5px 9px;
|
|
100
|
+
width: 170px;
|
|
101
|
+
outline: none;
|
|
102
|
+
font: inherit; font-size: 13px;
|
|
103
|
+
}
|
|
104
|
+
#search:focus { border-color: var(--accent); }
|
|
105
|
+
select {
|
|
106
|
+
background: var(--bg);
|
|
107
|
+
border: 1px solid var(--border);
|
|
108
|
+
border-radius: 5px;
|
|
109
|
+
color: var(--fg);
|
|
110
|
+
padding: 5px 6px;
|
|
111
|
+
outline: none;
|
|
112
|
+
font: inherit; font-size: 13px;
|
|
113
|
+
}
|
|
114
|
+
#zoomwrap { display: flex; align-items: center; gap: 7px; flex: none; color: var(--muted); }
|
|
115
|
+
#zoom { width: 110px; accent-color: var(--accent); }
|
|
116
|
+
|
|
117
|
+
#scroller { flex: 1; overflow: auto; }
|
|
118
|
+
#grid {
|
|
119
|
+
display: grid;
|
|
120
|
+
grid-template-columns: repeat(auto-fill, minmax(var(--tile), 1fr));
|
|
121
|
+
gap: 8px;
|
|
122
|
+
padding: 12px;
|
|
123
|
+
align-content: start;
|
|
124
|
+
}
|
|
125
|
+
.tile { padding: 6px; border-radius: 7px; cursor: pointer; }
|
|
126
|
+
.tile:hover { background: var(--hover); }
|
|
127
|
+
.thumb {
|
|
128
|
+
position: relative;
|
|
129
|
+
aspect-ratio: 1;
|
|
130
|
+
border-radius: 5px;
|
|
131
|
+
overflow: hidden;
|
|
132
|
+
background: #141414;
|
|
133
|
+
display: flex; align-items: center; justify-content: center;
|
|
134
|
+
}
|
|
135
|
+
.thumb img, .thumb video { width: 100%; height: 100%; object-fit: cover; display: block; }
|
|
136
|
+
.badge {
|
|
137
|
+
position: absolute;
|
|
138
|
+
left: 6px; bottom: 6px;
|
|
139
|
+
background: rgba(0, 0, 0, .65);
|
|
140
|
+
border-radius: 4px;
|
|
141
|
+
padding: 2px 6px;
|
|
142
|
+
font-size: 11px;
|
|
143
|
+
display: flex; align-items: center; gap: 4px;
|
|
144
|
+
pointer-events: none;
|
|
145
|
+
}
|
|
146
|
+
.tname {
|
|
147
|
+
margin-top: 6px;
|
|
148
|
+
font-size: 12px;
|
|
149
|
+
line-height: 1.3;
|
|
150
|
+
text-align: center;
|
|
151
|
+
overflow: hidden;
|
|
152
|
+
display: -webkit-box;
|
|
153
|
+
-webkit-line-clamp: 2;
|
|
154
|
+
-webkit-box-orient: vertical;
|
|
155
|
+
word-break: break-word;
|
|
156
|
+
color: #d6d6d6;
|
|
157
|
+
}
|
|
158
|
+
#empty { padding: 60px 20px; text-align: center; color: var(--muted); display: none; }
|
|
159
|
+
|
|
160
|
+
#statusbar {
|
|
161
|
+
flex: none;
|
|
162
|
+
display: none;
|
|
163
|
+
align-items: center;
|
|
164
|
+
gap: 6px;
|
|
165
|
+
padding: 5px 14px;
|
|
166
|
+
background: var(--panel);
|
|
167
|
+
border-top: 1px solid var(--border);
|
|
168
|
+
font-size: 12px;
|
|
169
|
+
color: var(--muted);
|
|
170
|
+
white-space: nowrap;
|
|
171
|
+
overflow-x: auto;
|
|
172
|
+
}
|
|
173
|
+
#statusbar.show { display: flex; }
|
|
174
|
+
.ig {
|
|
175
|
+
background: var(--bg);
|
|
176
|
+
border: 1px solid var(--border);
|
|
177
|
+
border-radius: 4px;
|
|
178
|
+
padding: 1px 7px;
|
|
179
|
+
flex: none;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ---- lightbox ---- */
|
|
183
|
+
#lb {
|
|
184
|
+
position: fixed; inset: 0;
|
|
185
|
+
background: rgba(0, 0, 0, .92);
|
|
186
|
+
display: none;
|
|
187
|
+
flex-direction: column;
|
|
188
|
+
z-index: 10;
|
|
189
|
+
}
|
|
190
|
+
#lb.show { display: flex; }
|
|
191
|
+
#lb-stage { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; padding: 12px; }
|
|
192
|
+
#lb-stage img, #lb-stage video { max-width: 100%; max-height: 100%; object-fit: contain; }
|
|
193
|
+
#lb-bar {
|
|
194
|
+
flex: none;
|
|
195
|
+
display: flex; align-items: center; justify-content: center; gap: 14px;
|
|
196
|
+
padding: 10px;
|
|
197
|
+
color: var(--muted); font-size: 13px;
|
|
198
|
+
}
|
|
199
|
+
.lb-btn {
|
|
200
|
+
position: absolute;
|
|
201
|
+
top: 50%;
|
|
202
|
+
transform: translateY(-50%);
|
|
203
|
+
width: 44px; height: 80px;
|
|
204
|
+
display: flex; align-items: center; justify-content: center;
|
|
205
|
+
background: rgba(255, 255, 255, .06);
|
|
206
|
+
border: none; border-radius: 8px;
|
|
207
|
+
color: var(--fg); font-size: 26px;
|
|
208
|
+
cursor: pointer;
|
|
209
|
+
}
|
|
210
|
+
.lb-btn:hover { background: rgba(255, 255, 255, .15); }
|
|
211
|
+
#lb-prev { left: 12px; }
|
|
212
|
+
#lb-next { right: 12px; }
|
|
213
|
+
#lb-close {
|
|
214
|
+
position: absolute;
|
|
215
|
+
top: 12px; right: 12px;
|
|
216
|
+
width: 40px; height: 40px;
|
|
217
|
+
background: rgba(255, 255, 255, .06);
|
|
218
|
+
border: none; border-radius: 8px;
|
|
219
|
+
color: var(--fg); font-size: 20px;
|
|
220
|
+
cursor: pointer;
|
|
221
|
+
}
|
|
222
|
+
#lb-close:hover { background: rgba(255, 80, 80, .5); }
|
|
223
|
+
|
|
224
|
+
::-webkit-scrollbar { width: 12px; height: 12px; }
|
|
225
|
+
::-webkit-scrollbar-thumb { background: #444; border-radius: 6px; border: 3px solid var(--bg); }
|
|
226
|
+
::-webkit-scrollbar-thumb:hover { background: #555; }
|
|
227
|
+
</style>
|
|
228
|
+
</head>
|
|
229
|
+
<body>
|
|
230
|
+
<div id="sidebar"><div id="tree"></div></div>
|
|
231
|
+
<div id="split"></div>
|
|
232
|
+
<div id="main">
|
|
233
|
+
<div id="toolbar">
|
|
234
|
+
<div id="crumbs"></div>
|
|
235
|
+
<span id="count"></span>
|
|
236
|
+
<input id="search" type="search" placeholder="Filter names…">
|
|
237
|
+
<select id="sort">
|
|
238
|
+
<option value="name">Name</option>
|
|
239
|
+
<option value="new">Newest</option>
|
|
240
|
+
<option value="old">Oldest</option>
|
|
241
|
+
<option value="big">Largest</option>
|
|
242
|
+
<option value="rand">Shuffle</option>
|
|
243
|
+
</select>
|
|
244
|
+
<div id="zoomwrap">
|
|
245
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><rect x="1" y="1" width="6" height="6" rx="1"/><rect x="9" y="1" width="6" height="6" rx="1"/><rect x="1" y="9" width="6" height="6" rx="1"/><rect x="9" y="9" width="6" height="6" rx="1"/></svg>
|
|
246
|
+
<input id="zoom" type="range" min="90" max="480" step="10" title="Tile size (Ctrl+scroll also works)">
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
<div id="scroller">
|
|
250
|
+
<div id="grid"></div>
|
|
251
|
+
<div id="empty">No media in this folder.</div>
|
|
252
|
+
<div id="sentinel"></div>
|
|
253
|
+
</div>
|
|
254
|
+
<div id="statusbar"></div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
<div id="lb">
|
|
258
|
+
<div id="lb-stage"></div>
|
|
259
|
+
<div id="lb-bar"><span id="lb-caption"></span></div>
|
|
260
|
+
<button class="lb-btn" id="lb-prev" title="Previous (←)">‹</button>
|
|
261
|
+
<button class="lb-btn" id="lb-next" title="Next (→)">›</button>
|
|
262
|
+
<button id="lb-close" title="Close (Esc)">✕</button>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<script>
|
|
266
|
+
(() => {
|
|
267
|
+
'use strict';
|
|
268
|
+
const $ = (id) => document.getElementById(id);
|
|
269
|
+
const BATCH = 120;
|
|
270
|
+
|
|
271
|
+
const CHEV = '<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><path d="M5 2l7 6-7 6V2z"/></svg>';
|
|
272
|
+
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
|
+
const PLAY = '<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2l10 6-10 6V2z"/></svg>';
|
|
274
|
+
|
|
275
|
+
const state = {
|
|
276
|
+
tree: null,
|
|
277
|
+
sel: null, // selected folder rel path ('' = root)
|
|
278
|
+
files: [], // raw list from server
|
|
279
|
+
view: [], // sorted + filtered
|
|
280
|
+
shown: 0,
|
|
281
|
+
expanded: new Set(),
|
|
282
|
+
rows: new Map(), // rel -> row element
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const fileUrl = (rel) => '/file/' + rel.split('/').map(encodeURIComponent).join('/');
|
|
286
|
+
const fmtDur = (s) => {
|
|
287
|
+
if (!isFinite(s)) return '';
|
|
288
|
+
s = Math.round(s);
|
|
289
|
+
const m = Math.floor(s / 60), sec = String(s % 60).padStart(2, '0');
|
|
290
|
+
return m >= 60 ? `${Math.floor(m / 60)}:${String(m % 60).padStart(2, '0')}:${sec}` : `${m}:${sec}`;
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
/* ---------- prefs (persisted server-side in ~/.serve-media.json) ---------- */
|
|
294
|
+
let saveTimer;
|
|
295
|
+
function savePrefs() {
|
|
296
|
+
clearTimeout(saveTimer);
|
|
297
|
+
saveTimer = setTimeout(() => {
|
|
298
|
+
const prefs = {
|
|
299
|
+
tile: parseInt(zoom.value, 10),
|
|
300
|
+
side: parseInt(getComputedStyle(document.documentElement).getPropertyValue('--side'), 10),
|
|
301
|
+
};
|
|
302
|
+
fetch('/api/prefs', { method: 'POST', body: JSON.stringify(prefs) }).catch(() => {});
|
|
303
|
+
}, 400);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/* ---------- tile size ---------- */
|
|
307
|
+
const zoom = $('zoom');
|
|
308
|
+
const setTile = (px) => {
|
|
309
|
+
px = Math.min(480, Math.max(90, px));
|
|
310
|
+
document.documentElement.style.setProperty('--tile', px + 'px');
|
|
311
|
+
zoom.value = px;
|
|
312
|
+
localStorage.setItem('sm-tile', px);
|
|
313
|
+
};
|
|
314
|
+
setTile(parseInt(localStorage.getItem('sm-tile'), 10) || 200);
|
|
315
|
+
zoom.addEventListener('input', () => { setTile(+zoom.value); savePrefs(); });
|
|
316
|
+
$('scroller').addEventListener('wheel', (e) => {
|
|
317
|
+
if (!e.ctrlKey) return;
|
|
318
|
+
e.preventDefault();
|
|
319
|
+
setTile(+zoom.value + (e.deltaY < 0 ? 20 : -20));
|
|
320
|
+
savePrefs();
|
|
321
|
+
}, { passive: false });
|
|
322
|
+
|
|
323
|
+
/* ---------- sidebar resize ---------- */
|
|
324
|
+
const split = $('split');
|
|
325
|
+
split.addEventListener('pointerdown', (e) => {
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
split.setPointerCapture(e.pointerId);
|
|
328
|
+
const move = (ev) => {
|
|
329
|
+
const w = Math.min(600, Math.max(160, ev.clientX));
|
|
330
|
+
document.documentElement.style.setProperty('--side', w + 'px');
|
|
331
|
+
localStorage.setItem('sm-side', w);
|
|
332
|
+
};
|
|
333
|
+
const up = () => {
|
|
334
|
+
split.removeEventListener('pointermove', move);
|
|
335
|
+
split.removeEventListener('pointerup', up);
|
|
336
|
+
savePrefs();
|
|
337
|
+
};
|
|
338
|
+
split.addEventListener('pointermove', move);
|
|
339
|
+
split.addEventListener('pointerup', up);
|
|
340
|
+
});
|
|
341
|
+
const savedSide = parseInt(localStorage.getItem('sm-side'), 10);
|
|
342
|
+
if (savedSide) document.documentElement.style.setProperty('--side', savedSide + 'px');
|
|
343
|
+
|
|
344
|
+
/* ---------- tree ---------- */
|
|
345
|
+
function nodeEl(node, depth) {
|
|
346
|
+
const wrap = document.createElement('div');
|
|
347
|
+
if (state.expanded.has(node.rel)) wrap.classList.add('open');
|
|
348
|
+
|
|
349
|
+
const row = document.createElement('div');
|
|
350
|
+
row.className = 'row';
|
|
351
|
+
row.style.setProperty('--d', depth);
|
|
352
|
+
row.title = node.name;
|
|
353
|
+
|
|
354
|
+
const chev = document.createElement('span');
|
|
355
|
+
chev.className = 'chev' + (node.children.length ? '' : ' leaf');
|
|
356
|
+
chev.innerHTML = CHEV;
|
|
357
|
+
|
|
358
|
+
const ico = document.createElement('span');
|
|
359
|
+
ico.className = 'fico';
|
|
360
|
+
ico.innerHTML = FOLDER;
|
|
361
|
+
|
|
362
|
+
const lbl = document.createElement('span');
|
|
363
|
+
lbl.className = 'lbl';
|
|
364
|
+
lbl.textContent = node.name;
|
|
365
|
+
|
|
366
|
+
const cnt = document.createElement('span');
|
|
367
|
+
cnt.className = 'cnt';
|
|
368
|
+
cnt.textContent = node.count;
|
|
369
|
+
|
|
370
|
+
row.append(chev, ico, lbl, cnt);
|
|
371
|
+
wrap.append(row);
|
|
372
|
+
|
|
373
|
+
const kids = document.createElement('div');
|
|
374
|
+
kids.className = 'kids';
|
|
375
|
+
for (const c of node.children) kids.append(nodeEl(c, depth + 1));
|
|
376
|
+
wrap.append(kids);
|
|
377
|
+
|
|
378
|
+
const toggle = () => {
|
|
379
|
+
wrap.classList.toggle('open');
|
|
380
|
+
if (wrap.classList.contains('open')) state.expanded.add(node.rel);
|
|
381
|
+
else state.expanded.delete(node.rel);
|
|
382
|
+
};
|
|
383
|
+
chev.addEventListener('click', (e) => { e.stopPropagation(); toggle(); });
|
|
384
|
+
row.addEventListener('click', () => {
|
|
385
|
+
if (node.children.length && !wrap.classList.contains('open')) toggle();
|
|
386
|
+
select(node.rel);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
state.rows.set(node.rel, row);
|
|
390
|
+
return wrap;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function renderTree() {
|
|
394
|
+
state.rows.clear();
|
|
395
|
+
const tree = $('tree');
|
|
396
|
+
tree.textContent = '';
|
|
397
|
+
state.expanded.add('');
|
|
398
|
+
tree.append(nodeEl(state.tree, 0));
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/* ---------- breadcrumbs ---------- */
|
|
402
|
+
function renderCrumbs() {
|
|
403
|
+
const el = $('crumbs');
|
|
404
|
+
el.textContent = '';
|
|
405
|
+
const parts = state.sel ? state.sel.split('/') : [];
|
|
406
|
+
const mk = (label, rel) => {
|
|
407
|
+
const c = document.createElement('span');
|
|
408
|
+
c.className = 'crumb';
|
|
409
|
+
c.textContent = label;
|
|
410
|
+
c.addEventListener('click', () => select(rel));
|
|
411
|
+
return c;
|
|
412
|
+
};
|
|
413
|
+
el.append(mk(state.tree ? state.tree.name : '…', ''));
|
|
414
|
+
let acc = '';
|
|
415
|
+
for (const p of parts) {
|
|
416
|
+
acc = acc ? `${acc}/${p}` : p;
|
|
417
|
+
const sep = document.createElement('span');
|
|
418
|
+
sep.className = 'crumb-sep';
|
|
419
|
+
sep.textContent = '›';
|
|
420
|
+
el.append(sep, mk(p, acc));
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/* ---------- grid ---------- */
|
|
425
|
+
const grid = $('grid');
|
|
426
|
+
let hoverVideo = null;
|
|
427
|
+
|
|
428
|
+
const mountObserver = new IntersectionObserver((entries) => {
|
|
429
|
+
for (const en of entries) {
|
|
430
|
+
if (!en.isIntersecting) continue;
|
|
431
|
+
mountObserver.unobserve(en.target);
|
|
432
|
+
mountThumb(en.target);
|
|
433
|
+
}
|
|
434
|
+
}, { root: $('scroller'), rootMargin: '600px' });
|
|
435
|
+
|
|
436
|
+
function mountThumb(thumb) {
|
|
437
|
+
const f = state.view[+thumb.dataset.i];
|
|
438
|
+
if (!f) return;
|
|
439
|
+
if (f.kind === 'image') {
|
|
440
|
+
const img = document.createElement('img');
|
|
441
|
+
img.loading = 'lazy';
|
|
442
|
+
img.decoding = 'async';
|
|
443
|
+
img.src = fileUrl(f.rel);
|
|
444
|
+
img.alt = f.name;
|
|
445
|
+
thumb.prepend(img);
|
|
446
|
+
} else {
|
|
447
|
+
const v = document.createElement('video');
|
|
448
|
+
v.muted = true;
|
|
449
|
+
v.playsInline = true;
|
|
450
|
+
v.preload = 'metadata';
|
|
451
|
+
v.src = fileUrl(f.rel) + '#t=0.1';
|
|
452
|
+
v.addEventListener('loadedmetadata', () => {
|
|
453
|
+
const b = thumb.querySelector('.badge span');
|
|
454
|
+
if (b) b.textContent = fmtDur(v.duration);
|
|
455
|
+
});
|
|
456
|
+
thumb.prepend(v);
|
|
457
|
+
thumb.addEventListener('mouseenter', () => {
|
|
458
|
+
hoverVideo = v;
|
|
459
|
+
v.play().catch(() => {});
|
|
460
|
+
});
|
|
461
|
+
thumb.addEventListener('mouseleave', () => {
|
|
462
|
+
v.pause();
|
|
463
|
+
v.currentTime = 0.1;
|
|
464
|
+
if (hoverVideo === v) hoverVideo = null;
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function makeTile(i) {
|
|
470
|
+
const f = state.view[i];
|
|
471
|
+
const tile = document.createElement('div');
|
|
472
|
+
tile.className = 'tile';
|
|
473
|
+
tile.title = f.rel;
|
|
474
|
+
|
|
475
|
+
const thumb = document.createElement('div');
|
|
476
|
+
thumb.className = 'thumb';
|
|
477
|
+
thumb.dataset.i = i;
|
|
478
|
+
if (f.kind === 'video') {
|
|
479
|
+
const badge = document.createElement('div');
|
|
480
|
+
badge.className = 'badge';
|
|
481
|
+
badge.innerHTML = PLAY + '<span></span>';
|
|
482
|
+
thumb.append(badge);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const name = document.createElement('div');
|
|
486
|
+
name.className = 'tname';
|
|
487
|
+
name.textContent = f.name;
|
|
488
|
+
|
|
489
|
+
tile.append(thumb, name);
|
|
490
|
+
tile.addEventListener('click', () => openLb(i));
|
|
491
|
+
mountObserver.observe(thumb);
|
|
492
|
+
return tile;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function showMore() {
|
|
496
|
+
const end = Math.min(state.view.length, state.shown + BATCH);
|
|
497
|
+
const frag = document.createDocumentFragment();
|
|
498
|
+
for (let i = state.shown; i < end; i++) frag.append(makeTile(i));
|
|
499
|
+
grid.append(frag);
|
|
500
|
+
state.shown = end;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
new IntersectionObserver((entries) => {
|
|
504
|
+
if (entries.some((e) => e.isIntersecting) && state.shown < state.view.length) showMore();
|
|
505
|
+
}, { root: $('scroller'), rootMargin: '900px' }).observe($('sentinel'));
|
|
506
|
+
|
|
507
|
+
function applyView() {
|
|
508
|
+
const q = $('search').value.trim().toLowerCase();
|
|
509
|
+
let v = q ? state.files.filter((f) => f.name.toLowerCase().includes(q)) : state.files.slice();
|
|
510
|
+
const mode = $('sort').value;
|
|
511
|
+
if (mode === 'name') v.sort((a, b) => a.rel.localeCompare(b.rel, undefined, { numeric: true, sensitivity: 'base' }));
|
|
512
|
+
else if (mode === 'new') v.sort((a, b) => b.mtime - a.mtime);
|
|
513
|
+
else if (mode === 'old') v.sort((a, b) => a.mtime - b.mtime);
|
|
514
|
+
else if (mode === 'big') v.sort((a, b) => b.size - a.size);
|
|
515
|
+
else if (mode === 'rand') {
|
|
516
|
+
for (let i = v.length - 1; i > 0; i--) {
|
|
517
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
518
|
+
[v[i], v[j]] = [v[j], v[i]];
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
state.view = v;
|
|
522
|
+
state.shown = 0;
|
|
523
|
+
grid.textContent = '';
|
|
524
|
+
$('empty').style.display = v.length ? 'none' : 'block';
|
|
525
|
+
$('count').textContent = `${v.length.toLocaleString()} item${v.length === 1 ? '' : 's'}`;
|
|
526
|
+
$('scroller').scrollTop = 0;
|
|
527
|
+
showMore();
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
$('search').addEventListener('input', applyView);
|
|
531
|
+
$('sort').addEventListener('change', applyView);
|
|
532
|
+
|
|
533
|
+
/* ---------- selection ---------- */
|
|
534
|
+
function renderIgnored(ignored) {
|
|
535
|
+
const bar = $('statusbar');
|
|
536
|
+
bar.textContent = '';
|
|
537
|
+
const exts = Object.keys(ignored || {}).sort((a, b) => ignored[b] - ignored[a] || a.localeCompare(b));
|
|
538
|
+
if (!exts.length) { bar.classList.remove('show'); return; }
|
|
539
|
+
const total = exts.reduce((s, e) => s + ignored[e], 0);
|
|
540
|
+
const lead = document.createElement('span');
|
|
541
|
+
lead.textContent = `Not served (${total.toLocaleString()}):`;
|
|
542
|
+
bar.append(lead);
|
|
543
|
+
for (const e of exts) {
|
|
544
|
+
const chip = document.createElement('span');
|
|
545
|
+
chip.className = 'ig';
|
|
546
|
+
chip.textContent = `${e} ×${ignored[e].toLocaleString()}`;
|
|
547
|
+
bar.append(chip);
|
|
548
|
+
}
|
|
549
|
+
bar.classList.add('show');
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
async function select(rel) {
|
|
553
|
+
state.sel = rel;
|
|
554
|
+
for (const [r, row] of state.rows) row.classList.toggle('sel', r === rel);
|
|
555
|
+
renderCrumbs();
|
|
556
|
+
const res = await fetch('/api/media?dir=' + encodeURIComponent(rel));
|
|
557
|
+
const data = await res.json();
|
|
558
|
+
if (state.sel !== rel) return; // a newer selection won
|
|
559
|
+
state.files = data.files;
|
|
560
|
+
renderIgnored(data.ignored);
|
|
561
|
+
applyView();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/* ---------- lightbox ---------- */
|
|
565
|
+
const lb = $('lb');
|
|
566
|
+
let lbIndex = -1;
|
|
567
|
+
|
|
568
|
+
function openLb(i) {
|
|
569
|
+
if (hoverVideo) { hoverVideo.pause(); hoverVideo = null; }
|
|
570
|
+
lbIndex = i;
|
|
571
|
+
const f = state.view[i];
|
|
572
|
+
if (!f) return;
|
|
573
|
+
const stage = $('lb-stage');
|
|
574
|
+
stage.textContent = '';
|
|
575
|
+
let el;
|
|
576
|
+
if (f.kind === 'image') {
|
|
577
|
+
el = document.createElement('img');
|
|
578
|
+
el.src = fileUrl(f.rel);
|
|
579
|
+
el.alt = f.name;
|
|
580
|
+
} else {
|
|
581
|
+
el = document.createElement('video');
|
|
582
|
+
el.src = fileUrl(f.rel);
|
|
583
|
+
el.controls = true;
|
|
584
|
+
el.autoplay = true;
|
|
585
|
+
el.loop = true;
|
|
586
|
+
}
|
|
587
|
+
stage.append(el);
|
|
588
|
+
$('lb-caption').textContent = `${f.rel} · ${i + 1} / ${state.view.length}`;
|
|
589
|
+
lb.classList.add('show');
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function closeLb() {
|
|
593
|
+
lb.classList.remove('show');
|
|
594
|
+
$('lb-stage').textContent = '';
|
|
595
|
+
lbIndex = -1;
|
|
596
|
+
}
|
|
597
|
+
const lbStep = (d) => {
|
|
598
|
+
if (lbIndex < 0) return;
|
|
599
|
+
const n = (lbIndex + d + state.view.length) % state.view.length;
|
|
600
|
+
openLb(n);
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
$('lb-close').addEventListener('click', closeLb);
|
|
604
|
+
$('lb-prev').addEventListener('click', () => lbStep(-1));
|
|
605
|
+
$('lb-next').addEventListener('click', () => lbStep(1));
|
|
606
|
+
lb.addEventListener('click', (e) => { if (e.target === lb || e.target === $('lb-stage')) closeLb(); });
|
|
607
|
+
document.addEventListener('keydown', (e) => {
|
|
608
|
+
if (!lb.classList.contains('show')) return;
|
|
609
|
+
if (e.key === 'Escape') closeLb();
|
|
610
|
+
else if (e.key === 'ArrowLeft') lbStep(-1);
|
|
611
|
+
else if (e.key === 'ArrowRight') lbStep(1);
|
|
612
|
+
});
|
|
613
|
+
|
|
614
|
+
/* ---------- boot ---------- */
|
|
615
|
+
(async () => {
|
|
616
|
+
try {
|
|
617
|
+
const prefs = await (await fetch('/api/prefs')).json();
|
|
618
|
+
if (prefs.tile) setTile(prefs.tile);
|
|
619
|
+
if (prefs.side) {
|
|
620
|
+
const w = Math.min(600, Math.max(160, prefs.side));
|
|
621
|
+
document.documentElement.style.setProperty('--side', w + 'px');
|
|
622
|
+
localStorage.setItem('sm-side', w);
|
|
623
|
+
}
|
|
624
|
+
} catch { /* prefs are best-effort */ }
|
|
625
|
+
const res = await fetch('/api/tree');
|
|
626
|
+
state.tree = await res.json();
|
|
627
|
+
document.title = state.tree.name + ' — serve-media';
|
|
628
|
+
renderTree();
|
|
629
|
+
select('');
|
|
630
|
+
})();
|
|
631
|
+
})();
|
|
632
|
+
</script>
|
|
633
|
+
</body>
|
|
634
|
+
</html>
|