nixamp 0.7.22 → 0.7.25
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/dist/protocol.d.ts +10 -0
- package/dist/server.d.ts +25 -0
- package/dist/server.js +117 -12
- package/package.json +1 -1
- package/src/protocol.ts +10 -0
- package/src/server.ts +132 -12
- package/web/dist/assets/{hls-3VKVEQE3-C2NU6wnG.js → hls-3VKVEQE3-D-TeBz8d.js} +1 -1
- package/web/dist/assets/index-DDa16PVk.css +1 -0
- package/web/dist/assets/index-Z0E29INh.js +1 -0
- package/web/dist/assets/{mpegts-LO6RVLD6-h1JlF4Fz.js → mpegts-LO6RVLD6-Crs76Vy-.js} +1 -1
- package/web/dist/assets/{mpegts-DDXequBN.js → mpegts-melX63gT.js} +1 -1
- package/web/dist/index.html +7 -2
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-BjUE4krU.js +0 -1
- package/web/dist/assets/index-OC1WbK88.css +0 -1
package/dist/protocol.d.ts
CHANGED
|
@@ -28,6 +28,16 @@ export interface RemoteTrack {
|
|
|
28
28
|
* a client puts at the top of that block so the two are not one soup.
|
|
29
29
|
*/
|
|
30
30
|
group?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Which folder it sits in, under whatever it was loaded from.
|
|
33
|
+
*
|
|
34
|
+
* Empty for a track at the top. A library is a shelf of albums and seasons,
|
|
35
|
+
* and five thousand files in one flat list is a list nobody can find
|
|
36
|
+
* anything in -- so the shape of the folders comes across and a player can
|
|
37
|
+
* offer them as folders. Relative, always: where the library sits on
|
|
38
|
+
* somebody's disk is their business.
|
|
39
|
+
*/
|
|
40
|
+
folder?: string;
|
|
31
41
|
}
|
|
32
42
|
/** Everything a remote needs to draw the player. */
|
|
33
43
|
export interface Snapshot {
|
package/dist/server.d.ts
CHANGED
|
@@ -136,6 +136,8 @@ export declare function safeJoin(rootDir: string, urlPath: string): string | nul
|
|
|
136
136
|
*/
|
|
137
137
|
export type Loaded = Track & {
|
|
138
138
|
group?: string;
|
|
139
|
+
/** The folder it sits in, relative to what it was loaded from. */
|
|
140
|
+
folder?: string;
|
|
139
141
|
/**
|
|
140
142
|
* Whether this has a picture, when the name could not say.
|
|
141
143
|
*
|
|
@@ -205,6 +207,17 @@ export interface Engine {
|
|
|
205
207
|
}
|
|
206
208
|
export declare function toRemoteTracks(tracks: Loaded[]): RemoteTrack[];
|
|
207
209
|
export declare function hasPicture(path: string): boolean;
|
|
210
|
+
/**
|
|
211
|
+
* Where a track sits, relative to the thing it was loaded from.
|
|
212
|
+
*
|
|
213
|
+
* A library is a shelf of albums and seasons, and a flat list of five thousand
|
|
214
|
+
* files is one nobody can find anything in. This is what lets a player offer
|
|
215
|
+
* the folders as folders.
|
|
216
|
+
*
|
|
217
|
+
* Relative and never absolute: the shape of somebody's library is what a
|
|
218
|
+
* listener needs, and where it lives on their disk is not.
|
|
219
|
+
*/
|
|
220
|
+
export declare function folderOf(path: string, from: string): string;
|
|
208
221
|
/**
|
|
209
222
|
* Whether the name of a source tells us anything about what is inside it.
|
|
210
223
|
*
|
|
@@ -373,6 +386,18 @@ export interface HandlerOptions {
|
|
|
373
386
|
};
|
|
374
387
|
/** What this server calls itself, for the list of what is live on it. */
|
|
375
388
|
serverName?: string;
|
|
389
|
+
/**
|
|
390
|
+
* The source this server was started on -- its own library.
|
|
391
|
+
*
|
|
392
|
+
* Replacing the playlist with a stream leaves no way back to it: the address
|
|
393
|
+
* is a path on somebody else's machine, and a person looking at a player has
|
|
394
|
+
* no reason to know it. Reported to an administrator so there can be a
|
|
395
|
+
* button rather than a thing you have to remember and retype.
|
|
396
|
+
*
|
|
397
|
+
* Admin-only, because it is a filesystem path and a viewer has no business
|
|
398
|
+
* with it.
|
|
399
|
+
*/
|
|
400
|
+
homeSource?: string;
|
|
376
401
|
/**
|
|
377
402
|
* Going live: whether this server is listed, and how to change that.
|
|
378
403
|
*
|
package/dist/server.js
CHANGED
|
@@ -302,6 +302,7 @@ export function toRemoteTracks(tracks) {
|
|
|
302
302
|
// Only for what was added; the library's own tracks say nothing, which is
|
|
303
303
|
// how a client knows they are the library.
|
|
304
304
|
...(t.group ? { group: t.group } : {}),
|
|
305
|
+
...(t.folder ? { folder: t.folder } : {}),
|
|
305
306
|
}));
|
|
306
307
|
}
|
|
307
308
|
/** Video containers, as opposed to the songs that are most of a library. */
|
|
@@ -310,6 +311,34 @@ export function hasPicture(path) {
|
|
|
310
311
|
const dot = path.lastIndexOf(".");
|
|
311
312
|
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
312
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Where a track sits, relative to the thing it was loaded from.
|
|
316
|
+
*
|
|
317
|
+
* A library is a shelf of albums and seasons, and a flat list of five thousand
|
|
318
|
+
* files is one nobody can find anything in. This is what lets a player offer
|
|
319
|
+
* the folders as folders.
|
|
320
|
+
*
|
|
321
|
+
* Relative and never absolute: the shape of somebody's library is what a
|
|
322
|
+
* listener needs, and where it lives on their disk is not.
|
|
323
|
+
*/
|
|
324
|
+
export function folderOf(path, from) {
|
|
325
|
+
const strip = (value) => value.replace(/\/+$/, "");
|
|
326
|
+
const base = strip(from);
|
|
327
|
+
if (base === "" || !path.startsWith(base + "/"))
|
|
328
|
+
return "";
|
|
329
|
+
const rest = path.slice(base.length + 1);
|
|
330
|
+
const at = rest.lastIndexOf("/");
|
|
331
|
+
if (at === -1)
|
|
332
|
+
return "";
|
|
333
|
+
const folder = rest.slice(0, at);
|
|
334
|
+
// A URL's path is percent-encoded and a person reading a folder name is not.
|
|
335
|
+
try {
|
|
336
|
+
return isRemote(path) ? decodeURIComponent(folder) : folder;
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return folder;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
313
342
|
/**
|
|
314
343
|
* Whether the name of a source tells us anything about what is inside it.
|
|
315
344
|
*
|
|
@@ -521,7 +550,7 @@ export class PlayerEngine {
|
|
|
521
550
|
}
|
|
522
551
|
replace(tracks, root) {
|
|
523
552
|
this.stop();
|
|
524
|
-
this.tracks = tracks;
|
|
553
|
+
this.tracks = tracks.map((track) => ({ ...track, folder: folderOf(track.path, root) }));
|
|
525
554
|
this.root = root;
|
|
526
555
|
this.state.index = 0;
|
|
527
556
|
this.state.position = 0;
|
|
@@ -544,7 +573,9 @@ export class PlayerEngine {
|
|
|
544
573
|
add(tracks, from) {
|
|
545
574
|
const group = sourceLabel(from);
|
|
546
575
|
const known = new Set(this.tracks.map((track) => track.path));
|
|
547
|
-
const fresh = tracks
|
|
576
|
+
const fresh = tracks
|
|
577
|
+
.filter((track) => !known.has(track.path))
|
|
578
|
+
.map((track) => ({ ...track, group, folder: folderOf(track.path, from) }));
|
|
548
579
|
if (fresh.length === 0)
|
|
549
580
|
return 0;
|
|
550
581
|
this.tracks = [...this.tracks, ...fresh];
|
|
@@ -617,7 +648,7 @@ export class PlayerEngine {
|
|
|
617
648
|
// somebody pointed the server elsewhere. Theirs wins.
|
|
618
649
|
if (this.tracks.length > 0)
|
|
619
650
|
return;
|
|
620
|
-
this.tracks = tracks;
|
|
651
|
+
this.tracks = tracks.map((track) => ({ ...track, folder: folderOf(track.path, root) }));
|
|
621
652
|
this.root = root;
|
|
622
653
|
this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
|
|
623
654
|
this.push(true);
|
|
@@ -639,7 +670,11 @@ export class PlayerEngine {
|
|
|
639
670
|
changed = true;
|
|
640
671
|
// The group is ours, not the tagger's: it knows what a track is called,
|
|
641
672
|
// not which pile it is in.
|
|
642
|
-
return {
|
|
673
|
+
return {
|
|
674
|
+
...tagged,
|
|
675
|
+
...(track.group ? { group: track.group } : {}),
|
|
676
|
+
...(track.folder ? { folder: track.folder } : {}),
|
|
677
|
+
};
|
|
643
678
|
});
|
|
644
679
|
if (!changed)
|
|
645
680
|
return;
|
|
@@ -1692,6 +1727,16 @@ export function createHandler(engine, options) {
|
|
|
1692
1727
|
listeners: one.listeners,
|
|
1693
1728
|
startedAt: one.startedAt,
|
|
1694
1729
|
})),
|
|
1730
|
+
// Anything re-streamed into this server is a live stream too, and was
|
|
1731
|
+
// sitting in the middle of the playlist among the files -- which is
|
|
1732
|
+
// what made moving between a channel and an album so confusing. Named
|
|
1733
|
+
// here with the first track it owns, so it can be played from the list
|
|
1734
|
+
// of what is live rather than hunted for among five thousand files.
|
|
1735
|
+
restreams: engine.groups().map((name) => ({
|
|
1736
|
+
name,
|
|
1737
|
+
at: (engine.snapshot().tracks ?? []).findIndex((track) => track.group === name),
|
|
1738
|
+
tracks: (engine.snapshot().tracks ?? []).filter((track) => track.group === name).length,
|
|
1739
|
+
})),
|
|
1695
1740
|
});
|
|
1696
1741
|
return;
|
|
1697
1742
|
}
|
|
@@ -1938,6 +1983,10 @@ export function createHandler(engine, options) {
|
|
|
1938
1983
|
// And which of those slots somebody is already on, because the
|
|
1939
1984
|
// question you have in front of three addresses is which one is free.
|
|
1940
1985
|
channels: options.channels?.list().map(({ id, name, via }) => ({ id, name, via })) ?? [],
|
|
1986
|
+
// What this server's own library is, and whether it is loaded, so
|
|
1987
|
+
// there can be a way back to it that is not retyping a path.
|
|
1988
|
+
home: options.homeSource ?? "",
|
|
1989
|
+
root: engine.snapshot(false).root,
|
|
1941
1990
|
});
|
|
1942
1991
|
return;
|
|
1943
1992
|
}
|
|
@@ -2151,7 +2200,7 @@ export function createHandler(engine, options) {
|
|
|
2151
2200
|
return;
|
|
2152
2201
|
}
|
|
2153
2202
|
watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
|
|
2154
|
-
liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"]);
|
|
2203
|
+
await liveAudio(request, response, engine, options.ffmpeg ?? ["ffmpeg"], options.ffprobe ?? ["ffprobe"]);
|
|
2155
2204
|
return;
|
|
2156
2205
|
}
|
|
2157
2206
|
if (path.startsWith("/api/stream/")) {
|
|
@@ -2227,8 +2276,33 @@ const LIVE_IDLE_MS = 2000;
|
|
|
2227
2276
|
* seconds and then sit waiting for the player to catch up, so the thing that
|
|
2228
2277
|
* decides what plays next would be minutes behind what the listener hears.
|
|
2229
2278
|
*/
|
|
2230
|
-
|
|
2279
|
+
/**
|
|
2280
|
+
* The server's own output, as one address that keeps playing.
|
|
2281
|
+
*
|
|
2282
|
+
* This is the watch party: everybody pointed at it hears and sees whatever the
|
|
2283
|
+
* server is playing, and somebody joining halfway through joins halfway
|
|
2284
|
+
* through rather than starting the film again on their own.
|
|
2285
|
+
*
|
|
2286
|
+
* A film comes with its picture. It used to be `-vn` and MP3 whatever it was,
|
|
2287
|
+
* so inviting people to watch a film got them its soundtrack -- which is not
|
|
2288
|
+
* an invitation anybody wants. The container is decided when the connection
|
|
2289
|
+
* opens, because a response has one content type and MP4 and MP3 cannot be
|
|
2290
|
+
* spliced; going from a film to a song ends the stream, and a client that
|
|
2291
|
+
* wants to keep listening asks again and gets the right one.
|
|
2292
|
+
*/
|
|
2293
|
+
async function liveAudio(request, response, engine, ffmpeg, ffprobe) {
|
|
2231
2294
|
const [command, ...prefix] = ffmpeg;
|
|
2295
|
+
/** Whether this track is something to watch rather than only to hear. */
|
|
2296
|
+
const looksLikeVideo = async (source) => {
|
|
2297
|
+
if (hasPicture(source))
|
|
2298
|
+
return true;
|
|
2299
|
+
if (!nameSaysNothing(source))
|
|
2300
|
+
return false;
|
|
2301
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
2302
|
+
return codecs.video !== "";
|
|
2303
|
+
};
|
|
2304
|
+
const first = engine.trackPath(engine.snapshot().index);
|
|
2305
|
+
const asVideo = first === undefined ? false : await looksLikeVideo(first);
|
|
2232
2306
|
let child = null;
|
|
2233
2307
|
let waiting = null;
|
|
2234
2308
|
let closed = false;
|
|
@@ -2242,7 +2316,7 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
2242
2316
|
started = true;
|
|
2243
2317
|
response.writeHead(200, {
|
|
2244
2318
|
...CORS,
|
|
2245
|
-
"content-type": "audio/mpeg",
|
|
2319
|
+
"content-type": asVideo ? "video/mp4" : "audio/mpeg",
|
|
2246
2320
|
"cache-control": "no-store",
|
|
2247
2321
|
"transfer-encoding": "chunked",
|
|
2248
2322
|
});
|
|
@@ -2282,16 +2356,28 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
2282
2356
|
return;
|
|
2283
2357
|
}
|
|
2284
2358
|
playing = snapshot.index;
|
|
2359
|
+
// Joined where the server is, not where the track begins. Somebody
|
|
2360
|
+
// arriving forty minutes into a film should arrive forty minutes in;
|
|
2361
|
+
// starting it again for them is not a watch party, it is two people
|
|
2362
|
+
// watching the same film separately.
|
|
2363
|
+
//
|
|
2364
|
+
// Before -i, so ffmpeg seeks rather than decoding its way there.
|
|
2365
|
+
const from = Math.max(0, Math.floor(snapshot.position));
|
|
2285
2366
|
const spawned = spawn(command, [
|
|
2286
2367
|
...prefix,
|
|
2287
2368
|
"-hide_banner",
|
|
2288
2369
|
"-loglevel", "error",
|
|
2289
2370
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
2371
|
+
// A live source has no beginning to seek from.
|
|
2372
|
+
...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
|
|
2290
2373
|
"-re",
|
|
2291
2374
|
"-i", source,
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2375
|
+
...(asVideo
|
|
2376
|
+
// Copied where it can be, because a room full of viewers is a room
|
|
2377
|
+
// full of encoders otherwise.
|
|
2378
|
+
? ["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
|
|
2379
|
+
"-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
|
|
2380
|
+
: ["-vn", "-f", "mp3", "-b:a", "192k"]),
|
|
2295
2381
|
"-",
|
|
2296
2382
|
], { stdio: ["ignore", "pipe", "pipe"] });
|
|
2297
2383
|
child = spawned;
|
|
@@ -2317,8 +2403,26 @@ function liveAudio(request, response, engine, ffmpeg) {
|
|
|
2317
2403
|
// A track change that lands while we are between songs is the signal to go
|
|
2318
2404
|
// now rather than wait out the poll.
|
|
2319
2405
|
const unsubscribe = engine.subscribe(() => {
|
|
2320
|
-
if (
|
|
2321
|
-
|
|
2406
|
+
if (closed || child !== null)
|
|
2407
|
+
return;
|
|
2408
|
+
if (engine.snapshot().index === playing)
|
|
2409
|
+
return;
|
|
2410
|
+
// The kind changed under us -- a film after a song, or the other way --
|
|
2411
|
+
// and one response cannot carry both. Ending it is how the client is told
|
|
2412
|
+
// to ask again, which it does.
|
|
2413
|
+
const source = engine.trackPath(engine.snapshot().index);
|
|
2414
|
+
if (source !== undefined) {
|
|
2415
|
+
void looksLikeVideo(source).then((wants) => {
|
|
2416
|
+
if (closed)
|
|
2417
|
+
return;
|
|
2418
|
+
if (wants !== asVideo)
|
|
2419
|
+
stop();
|
|
2420
|
+
else
|
|
2421
|
+
next();
|
|
2422
|
+
});
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
next();
|
|
2322
2426
|
});
|
|
2323
2427
|
response.on("close", stop);
|
|
2324
2428
|
response.on("error", stop);
|
|
@@ -2723,6 +2827,7 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
2723
2827
|
channels,
|
|
2724
2828
|
publishUrls: () => publishUrls,
|
|
2725
2829
|
serverName: options.name || hostname(),
|
|
2830
|
+
homeSource: root,
|
|
2726
2831
|
live: {
|
|
2727
2832
|
status: () => ({
|
|
2728
2833
|
live: publisher !== null,
|
package/package.json
CHANGED
package/src/protocol.ts
CHANGED
|
@@ -29,6 +29,16 @@ export interface RemoteTrack {
|
|
|
29
29
|
* a client puts at the top of that block so the two are not one soup.
|
|
30
30
|
*/
|
|
31
31
|
group?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Which folder it sits in, under whatever it was loaded from.
|
|
34
|
+
*
|
|
35
|
+
* Empty for a track at the top. A library is a shelf of albums and seasons,
|
|
36
|
+
* and five thousand files in one flat list is a list nobody can find
|
|
37
|
+
* anything in -- so the shape of the folders comes across and a player can
|
|
38
|
+
* offer them as folders. Relative, always: where the library sits on
|
|
39
|
+
* somebody's disk is their business.
|
|
40
|
+
*/
|
|
41
|
+
folder?: string;
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
/** Everything a remote needs to draw the player. */
|
package/src/server.ts
CHANGED
|
@@ -408,6 +408,8 @@ export function safeJoin(rootDir: string, urlPath: string): string | null {
|
|
|
408
408
|
*/
|
|
409
409
|
export type Loaded = Track & {
|
|
410
410
|
group?: string;
|
|
411
|
+
/** The folder it sits in, relative to what it was loaded from. */
|
|
412
|
+
folder?: string;
|
|
411
413
|
/**
|
|
412
414
|
* Whether this has a picture, when the name could not say.
|
|
413
415
|
*
|
|
@@ -491,6 +493,7 @@ export function toRemoteTracks(tracks: Loaded[]): RemoteTrack[] {
|
|
|
491
493
|
// Only for what was added; the library's own tracks say nothing, which is
|
|
492
494
|
// how a client knows they are the library.
|
|
493
495
|
...(t.group ? { group: t.group } : {}),
|
|
496
|
+
...(t.folder ? { folder: t.folder } : {}),
|
|
494
497
|
}));
|
|
495
498
|
}
|
|
496
499
|
|
|
@@ -502,6 +505,32 @@ export function hasPicture(path: string): boolean {
|
|
|
502
505
|
return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
|
|
503
506
|
}
|
|
504
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Where a track sits, relative to the thing it was loaded from.
|
|
510
|
+
*
|
|
511
|
+
* A library is a shelf of albums and seasons, and a flat list of five thousand
|
|
512
|
+
* files is one nobody can find anything in. This is what lets a player offer
|
|
513
|
+
* the folders as folders.
|
|
514
|
+
*
|
|
515
|
+
* Relative and never absolute: the shape of somebody's library is what a
|
|
516
|
+
* listener needs, and where it lives on their disk is not.
|
|
517
|
+
*/
|
|
518
|
+
export function folderOf(path: string, from: string): string {
|
|
519
|
+
const strip = (value: string): string => value.replace(/\/+$/, "");
|
|
520
|
+
const base = strip(from);
|
|
521
|
+
if (base === "" || !path.startsWith(base + "/")) return "";
|
|
522
|
+
const rest = path.slice(base.length + 1);
|
|
523
|
+
const at = rest.lastIndexOf("/");
|
|
524
|
+
if (at === -1) return "";
|
|
525
|
+
const folder = rest.slice(0, at);
|
|
526
|
+
// A URL's path is percent-encoded and a person reading a folder name is not.
|
|
527
|
+
try {
|
|
528
|
+
return isRemote(path) ? decodeURIComponent(folder) : folder;
|
|
529
|
+
} catch {
|
|
530
|
+
return folder;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
505
534
|
/**
|
|
506
535
|
* Whether the name of a source tells us anything about what is inside it.
|
|
507
536
|
*
|
|
@@ -711,7 +740,7 @@ export class PlayerEngine implements Engine {
|
|
|
711
740
|
|
|
712
741
|
replace(tracks: Track[], root: string): void {
|
|
713
742
|
this.stop();
|
|
714
|
-
this.tracks = tracks;
|
|
743
|
+
this.tracks = tracks.map((track) => ({ ...track, folder: folderOf(track.path, root) }));
|
|
715
744
|
this.root = root;
|
|
716
745
|
this.state.index = 0;
|
|
717
746
|
this.state.position = 0;
|
|
@@ -735,7 +764,9 @@ export class PlayerEngine implements Engine {
|
|
|
735
764
|
add(tracks: Track[], from: string): number {
|
|
736
765
|
const group = sourceLabel(from);
|
|
737
766
|
const known = new Set(this.tracks.map((track) => track.path));
|
|
738
|
-
const fresh = tracks
|
|
767
|
+
const fresh = tracks
|
|
768
|
+
.filter((track) => !known.has(track.path))
|
|
769
|
+
.map((track) => ({ ...track, group, folder: folderOf(track.path, from) }));
|
|
739
770
|
if (fresh.length === 0) return 0;
|
|
740
771
|
this.tracks = [...this.tracks, ...fresh];
|
|
741
772
|
// The list itself changed, so it has to ride this frame; a count nobody
|
|
@@ -805,7 +836,7 @@ export class PlayerEngine implements Engine {
|
|
|
805
836
|
// Something is already loaded, so this is a scan that finished after
|
|
806
837
|
// somebody pointed the server elsewhere. Theirs wins.
|
|
807
838
|
if (this.tracks.length > 0) return;
|
|
808
|
-
this.tracks = tracks;
|
|
839
|
+
this.tracks = tracks.map((track) => ({ ...track, folder: folderOf(track.path, root) }));
|
|
809
840
|
this.root = root;
|
|
810
841
|
this.state.note = tracks.length === 0 ? `No audio files under ${root}.` : "";
|
|
811
842
|
this.push(true);
|
|
@@ -827,7 +858,11 @@ export class PlayerEngine implements Engine {
|
|
|
827
858
|
changed = true;
|
|
828
859
|
// The group is ours, not the tagger's: it knows what a track is called,
|
|
829
860
|
// not which pile it is in.
|
|
830
|
-
return {
|
|
861
|
+
return {
|
|
862
|
+
...tagged,
|
|
863
|
+
...(track.group ? { group: track.group } : {}),
|
|
864
|
+
...(track.folder ? { folder: track.folder } : {}),
|
|
865
|
+
};
|
|
831
866
|
});
|
|
832
867
|
if (!changed) return;
|
|
833
868
|
this.tracks = merged;
|
|
@@ -988,6 +1023,18 @@ export interface HandlerOptions {
|
|
|
988
1023
|
broadcast?: () => { destinations: Destination[]; settings: EncoderSettings };
|
|
989
1024
|
/** What this server calls itself, for the list of what is live on it. */
|
|
990
1025
|
serverName?: string;
|
|
1026
|
+
/**
|
|
1027
|
+
* The source this server was started on -- its own library.
|
|
1028
|
+
*
|
|
1029
|
+
* Replacing the playlist with a stream leaves no way back to it: the address
|
|
1030
|
+
* is a path on somebody else's machine, and a person looking at a player has
|
|
1031
|
+
* no reason to know it. Reported to an administrator so there can be a
|
|
1032
|
+
* button rather than a thing you have to remember and retype.
|
|
1033
|
+
*
|
|
1034
|
+
* Admin-only, because it is a filesystem path and a viewer has no business
|
|
1035
|
+
* with it.
|
|
1036
|
+
*/
|
|
1037
|
+
homeSource?: string;
|
|
991
1038
|
/**
|
|
992
1039
|
* Going live: whether this server is listed, and how to change that.
|
|
993
1040
|
*
|
|
@@ -2068,6 +2115,16 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2068
2115
|
listeners: one.listeners,
|
|
2069
2116
|
startedAt: one.startedAt,
|
|
2070
2117
|
})),
|
|
2118
|
+
// Anything re-streamed into this server is a live stream too, and was
|
|
2119
|
+
// sitting in the middle of the playlist among the files -- which is
|
|
2120
|
+
// what made moving between a channel and an album so confusing. Named
|
|
2121
|
+
// here with the first track it owns, so it can be played from the list
|
|
2122
|
+
// of what is live rather than hunted for among five thousand files.
|
|
2123
|
+
restreams: engine.groups().map((name) => ({
|
|
2124
|
+
name,
|
|
2125
|
+
at: (engine.snapshot().tracks ?? []).findIndex((track) => track.group === name),
|
|
2126
|
+
tracks: (engine.snapshot().tracks ?? []).filter((track) => track.group === name).length,
|
|
2127
|
+
})),
|
|
2071
2128
|
});
|
|
2072
2129
|
return;
|
|
2073
2130
|
}
|
|
@@ -2330,6 +2387,10 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2330
2387
|
// And which of those slots somebody is already on, because the
|
|
2331
2388
|
// question you have in front of three addresses is which one is free.
|
|
2332
2389
|
channels: options.channels?.list().map(({ id, name, via }) => ({ id, name, via })) ?? [],
|
|
2390
|
+
// What this server's own library is, and whether it is loaded, so
|
|
2391
|
+
// there can be a way back to it that is not retyping a path.
|
|
2392
|
+
home: options.homeSource ?? "",
|
|
2393
|
+
root: engine.snapshot(false).root,
|
|
2333
2394
|
});
|
|
2334
2395
|
return;
|
|
2335
2396
|
}
|
|
@@ -2550,7 +2611,13 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
2550
2611
|
return;
|
|
2551
2612
|
}
|
|
2552
2613
|
watch(request, response, "stream", current.tracks?.[current.index]?.title ?? "live");
|
|
2553
|
-
liveAudio(
|
|
2614
|
+
await liveAudio(
|
|
2615
|
+
request,
|
|
2616
|
+
response,
|
|
2617
|
+
engine,
|
|
2618
|
+
options.ffmpeg ?? ["ffmpeg"],
|
|
2619
|
+
options.ffprobe ?? ["ffprobe"],
|
|
2620
|
+
);
|
|
2554
2621
|
return;
|
|
2555
2622
|
}
|
|
2556
2623
|
|
|
@@ -2629,13 +2696,39 @@ const LIVE_IDLE_MS = 2000;
|
|
|
2629
2696
|
* seconds and then sit waiting for the player to catch up, so the thing that
|
|
2630
2697
|
* decides what plays next would be minutes behind what the listener hears.
|
|
2631
2698
|
*/
|
|
2632
|
-
|
|
2699
|
+
/**
|
|
2700
|
+
* The server's own output, as one address that keeps playing.
|
|
2701
|
+
*
|
|
2702
|
+
* This is the watch party: everybody pointed at it hears and sees whatever the
|
|
2703
|
+
* server is playing, and somebody joining halfway through joins halfway
|
|
2704
|
+
* through rather than starting the film again on their own.
|
|
2705
|
+
*
|
|
2706
|
+
* A film comes with its picture. It used to be `-vn` and MP3 whatever it was,
|
|
2707
|
+
* so inviting people to watch a film got them its soundtrack -- which is not
|
|
2708
|
+
* an invitation anybody wants. The container is decided when the connection
|
|
2709
|
+
* opens, because a response has one content type and MP4 and MP3 cannot be
|
|
2710
|
+
* spliced; going from a film to a song ends the stream, and a client that
|
|
2711
|
+
* wants to keep listening asks again and gets the right one.
|
|
2712
|
+
*/
|
|
2713
|
+
async function liveAudio(
|
|
2633
2714
|
request: IncomingMessage,
|
|
2634
2715
|
response: ServerResponse,
|
|
2635
2716
|
engine: Engine,
|
|
2636
2717
|
ffmpeg: string[],
|
|
2637
|
-
|
|
2718
|
+
ffprobe: string[],
|
|
2719
|
+
): Promise<void> {
|
|
2638
2720
|
const [command, ...prefix] = ffmpeg as [string, ...string[]];
|
|
2721
|
+
|
|
2722
|
+
/** Whether this track is something to watch rather than only to hear. */
|
|
2723
|
+
const looksLikeVideo = async (source: string): Promise<boolean> => {
|
|
2724
|
+
if (hasPicture(source)) return true;
|
|
2725
|
+
if (!nameSaysNothing(source)) return false;
|
|
2726
|
+
const codecs = await codecsOf({ ffmpeg: [], ffprobe, play: null }, source);
|
|
2727
|
+
return codecs.video !== "";
|
|
2728
|
+
};
|
|
2729
|
+
|
|
2730
|
+
const first = engine.trackPath(engine.snapshot().index);
|
|
2731
|
+
const asVideo = first === undefined ? false : await looksLikeVideo(first);
|
|
2639
2732
|
let child: ReturnType<typeof spawn> | null = null;
|
|
2640
2733
|
let waiting: ReturnType<typeof setTimeout> | null = null;
|
|
2641
2734
|
let closed = false;
|
|
@@ -2649,7 +2742,7 @@ function liveAudio(
|
|
|
2649
2742
|
started = true;
|
|
2650
2743
|
response.writeHead(200, {
|
|
2651
2744
|
...CORS,
|
|
2652
|
-
"content-type": "audio/mpeg",
|
|
2745
|
+
"content-type": asVideo ? "video/mp4" : "audio/mpeg",
|
|
2653
2746
|
"cache-control": "no-store",
|
|
2654
2747
|
"transfer-encoding": "chunked",
|
|
2655
2748
|
});
|
|
@@ -2688,6 +2781,13 @@ function liveAudio(
|
|
|
2688
2781
|
}
|
|
2689
2782
|
|
|
2690
2783
|
playing = snapshot.index;
|
|
2784
|
+
// Joined where the server is, not where the track begins. Somebody
|
|
2785
|
+
// arriving forty minutes into a film should arrive forty minutes in;
|
|
2786
|
+
// starting it again for them is not a watch party, it is two people
|
|
2787
|
+
// watching the same film separately.
|
|
2788
|
+
//
|
|
2789
|
+
// Before -i, so ffmpeg seeks rather than decoding its way there.
|
|
2790
|
+
const from = Math.max(0, Math.floor(snapshot.position));
|
|
2691
2791
|
const spawned = spawn(
|
|
2692
2792
|
command,
|
|
2693
2793
|
[
|
|
@@ -2695,11 +2795,16 @@ function liveAudio(
|
|
|
2695
2795
|
"-hide_banner",
|
|
2696
2796
|
"-loglevel", "error",
|
|
2697
2797
|
...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
|
|
2798
|
+
// A live source has no beginning to seek from.
|
|
2799
|
+
...(from > 1 && !isRemote(source) ? ["-ss", String(from)] : []),
|
|
2698
2800
|
"-re",
|
|
2699
2801
|
"-i", source,
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2802
|
+
...(asVideo
|
|
2803
|
+
// Copied where it can be, because a room full of viewers is a room
|
|
2804
|
+
// full of encoders otherwise.
|
|
2805
|
+
? ["-c:v", "copy", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
|
|
2806
|
+
"-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
|
|
2807
|
+
: ["-vn", "-f", "mp3", "-b:a", "192k"]),
|
|
2703
2808
|
"-",
|
|
2704
2809
|
],
|
|
2705
2810
|
{ stdio: ["ignore", "pipe", "pipe"] },
|
|
@@ -2728,7 +2833,21 @@ function liveAudio(
|
|
|
2728
2833
|
// A track change that lands while we are between songs is the signal to go
|
|
2729
2834
|
// now rather than wait out the poll.
|
|
2730
2835
|
const unsubscribe = engine.subscribe(() => {
|
|
2731
|
-
if (
|
|
2836
|
+
if (closed || child !== null) return;
|
|
2837
|
+
if (engine.snapshot().index === playing) return;
|
|
2838
|
+
// The kind changed under us -- a film after a song, or the other way --
|
|
2839
|
+
// and one response cannot carry both. Ending it is how the client is told
|
|
2840
|
+
// to ask again, which it does.
|
|
2841
|
+
const source = engine.trackPath(engine.snapshot().index);
|
|
2842
|
+
if (source !== undefined) {
|
|
2843
|
+
void looksLikeVideo(source).then((wants) => {
|
|
2844
|
+
if (closed) return;
|
|
2845
|
+
if (wants !== asVideo) stop();
|
|
2846
|
+
else next();
|
|
2847
|
+
});
|
|
2848
|
+
return;
|
|
2849
|
+
}
|
|
2850
|
+
next();
|
|
2732
2851
|
});
|
|
2733
2852
|
|
|
2734
2853
|
response.on("close", stop);
|
|
@@ -3174,6 +3293,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
3174
3293
|
channels,
|
|
3175
3294
|
publishUrls: () => publishUrls,
|
|
3176
3295
|
serverName: options.name || hostname(),
|
|
3296
|
+
homeSource: root,
|
|
3177
3297
|
live: {
|
|
3178
3298
|
status: () => ({
|
|
3179
3299
|
live: publisher !== null,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-Z0E29INh.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
:root{--bg:#080c09;--panel:#0c120e;--edge:#1d2c22;--green:#4af689;--green-dim:#227a4a;--fg:#cfe8d8;--muted:#6d8a79;--warn:#e8c35a;--accent:#7ef0c4;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}*{box-sizing:border-box}[hidden]{display:none!important}html,body{background:var(--bg);min-height:100%;color:var(--fg);margin:0;font:14px/1.45 ui-monospace,SFMono-Regular,SF Mono,Menlo,Consolas,Liberation Mono,monospace}body{background-image:repeating-linear-gradient(#4af68906 0 1px,#0000 1px 3px)}#app{max-width:1100px;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(16px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));flex-direction:column;gap:10px;margin:0 auto;display:flex}.bar{flex-wrap:wrap;align-items:center;gap:10px;display:flex}.brand{color:var(--green);letter-spacing:.14em;font-weight:700}.status{color:var(--muted)}.status[data-playing=true]{color:var(--green)}.chip{color:var(--muted);border:1px solid var(--edge);text-overflow:ellipsis;white-space:nowrap;border-radius:999px;max-width:46vw;margin-left:auto;padding:1px 10px;font-size:12px;overflow:hidden}.panel{border:1px solid var(--edge);background:var(--panel);border-radius:6px;min-width:0;padding:14px 12px 12px;position:relative}.panel:before{content:attr(data-title);background:var(--panel);color:var(--green-dim);letter-spacing:.06em;padding:0 6px;font-size:12px;position:absolute;top:-.72em;left:10px}.split{grid-template-columns:1.3fr 1fr;gap:10px;display:grid}.split>.col-a{grid-column:1}.split>.col-b{grid-column:2}@media (max-width:720px){.split{grid-template-columns:1fr}.split>.col-a,.split>.col-b{grid-column:auto}}.track-title{color:var(--accent);text-overflow:ellipsis;white-space:nowrap;font-weight:700;overflow:hidden}.track-sub{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;overflow:hidden}#video{border:1px solid var(--edge);background:#000;border-radius:4px;width:100%;max-height:46vh;margin-bottom:8px}.scrub{align-items:center;gap:10px;margin-top:6px;display:flex}.time{color:var(--fg);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}input[type=range]{appearance:none;cursor:pointer;background:0 0;flex:1;min-width:0;height:14px}input[type=range]::-webkit-slider-runnable-track{background:linear-gradient(var(--edge), var(--edge));border:1px solid var(--edge);border-radius:3px;height:6px}input[type=range]::-moz-range-track{background:var(--edge);border-radius:3px;height:6px}input[type=range]::-webkit-slider-thumb{appearance:none;background:var(--green);border-radius:2px;width:10px;height:16px;margin-top:-6px}input[type=range]::-moz-range-thumb{background:var(--green);border:0;border-radius:2px;width:10px;height:16px}input[type=range]:disabled{opacity:.45;cursor:default}#spectrum{border:1px solid var(--edge);background:#060a07;border-radius:4px;width:100%;height:190px;display:block}.meters{align-items:baseline;gap:10px;margin-top:6px;display:flex;overflow:hidden}.glyphs{color:var(--green);letter-spacing:1px;white-space:nowrap;min-height:1.4em;overflow:hidden}.levelmeter{color:var(--accent);white-space:nowrap;margin-left:auto}.filter{border:1px solid var(--edge);width:100%;color:var(--fg);font:inherit;background:#0a100c;border-radius:4px;margin-bottom:6px;padding:4px 8px}.filter:focus{border-color:var(--accent);outline:none}.crumbs{color:var(--muted);flex-wrap:wrap;align-items:center;gap:4px;margin-bottom:6px;font-size:12px;display:flex}.crumbs button{font:inherit;color:var(--accent);cursor:pointer;background:0 0;border:0;padding:0 2px}.crumbs button:hover{text-decoration:underline}.crumbs .here{color:var(--fg)}.folder{cursor:pointer;white-space:nowrap;color:var(--accent);border-radius:3px;gap:8px;padding:2px 6px;display:flex}.folder:hover{background:#142019}.folder .name{text-overflow:ellipsis;flex:1;overflow:hidden}.folder .count{color:var(--muted);flex:none}.playlist{scrollbar-color:var(--green-dim) transparent;max-height:214px;margin:0;padding:0;list-style:none;overflow-y:auto}.row{cursor:pointer;white-space:nowrap;border-radius:3px;gap:8px;padding:2px 6px;display:flex}.row:hover{background:#142019}.row.selected{background:#16241c}.row.selected .name{color:var(--accent)}.row.playing .name{color:var(--green)}.group{color:var(--muted);letter-spacing:.08em;text-transform:uppercase;border-top:1px solid var(--edge);align-items:center;gap:8px;margin-top:4px;padding:6px 6px 2px;font-size:11px;display:flex}.group:first-child{border-top:0;margin-top:0}.group-name{text-overflow:ellipsis;white-space:nowrap;flex:1;overflow:hidden}.group-remove{border:1px solid var(--edge);color:var(--muted);cursor:pointer;background:0 0;border-radius:3px;flex:none;padding:0 6px;line-height:1.4}.group-remove:hover{color:var(--accent);border-color:var(--accent)}.row .n{color:var(--muted);text-align:right;flex:none;width:2.4em}.row .name{text-overflow:ellipsis;flex:1;overflow:hidden}.row .time{color:var(--muted);flex:none}.transport{border:1px solid var(--edge);background:var(--panel);border-radius:6px;flex-wrap:wrap;align-items:center;gap:8px;padding:8px 10px;display:flex}button{font:inherit;color:var(--fg);border:1px solid var(--edge);cursor:pointer;background:#121a15;border-radius:4px;padding:6px 12px}button:hover{border-color:var(--green-dim);color:var(--green)}button:active{transform:translateY(1px)}button:focus-visible,input:focus-visible,a:focus-visible{outline:2px solid var(--green);outline-offset:2px}button.primary{color:var(--green);border-color:var(--green-dim);min-width:64px}button.ghost{color:var(--muted);background:0 0}.volume{flex:1;align-items:center;gap:8px;min-width:120px;max-width:220px;margin-left:auto;display:flex}.vol{color:var(--muted);letter-spacing:.08em;font-size:12px}.picker{flex-wrap:wrap;align-items:center;gap:8px;display:flex}.button{border:1px solid var(--edge);cursor:pointer;color:var(--fg);background:#121a15;border-radius:4px;padding:6px 12px;display:inline-block}.button:hover{border-color:var(--green-dim);color:var(--green)}.button input[type=file]{display:none}#remote-url{font:inherit;min-width:12ch;color:var(--fg);border:1px solid var(--edge);background:#060a07;border-radius:4px;flex:1;padding:6px 10px}.check{color:var(--muted);align-items:center;gap:8px;margin-top:10px;display:flex}.check input{accent-color:var(--green)}.hint{color:var(--muted);margin:0 0 8px;font-size:13px}.hint code{color:var(--accent)}#remote-state[data-status=live]{color:var(--green)}#remote-state[data-status=connecting],#remote-state[data-status=error]{color:var(--warn)}.note{color:var(--warn);border-left:2px solid var(--warn);margin:0;padding-left:8px}.statusbar{color:var(--muted);border-top:1px solid var(--edge);flex-wrap:wrap;gap:14px;padding-top:8px;font-size:12px;display:flex}.statusbar b{color:var(--green);font-weight:700}.statusbar .spacer{flex:1}.statusbar a{color:var(--muted)}.directory{border-top:1px solid var(--line);margin-top:.6rem;padding-top:.6rem}.directory-list{max-height:12rem;margin:0;padding:0;list-style:none;overflow-y:auto}.directory-list li+li{margin-top:.3rem}.directory-list button{border:1px solid var(--line);color:inherit;font:inherit;cursor:pointer;background:0 0;border-radius:4px;padding:.4rem .5rem}.directory-list button:hover,.directory-list button:focus-visible{border-color:var(--accent);background:#ffffff0a}.directory-list .name{color:var(--accent);display:block}.directory-list .detail{opacity:.7;text-overflow:ellipsis;white-space:nowrap;font-size:.85em;display:block;overflow:hidden}.publish-list{margin:.3rem 0 0;padding:0;list-style:none}.publish-list li{align-items:center;gap:8px;margin-top:.3rem;display:flex}.publish-list li.in-use .slot{color:var(--green)}.publish-list .slot{color:var(--muted);flex:none;min-width:4.5em;font-size:12px}.publish-list input{flex:auto;min-width:0}.said{color:var(--accent);margin:.3rem 0 0}.directory-list li.offline .recent-label{opacity:.55}.directory-list li.offline .detail{color:var(--warn)}.share-line{align-items:center;gap:8px;margin:.4rem 0;display:flex}.share-what{color:var(--muted);letter-spacing:.06em;text-transform:uppercase;flex:none;font-size:12px}.share-line input{flex:auto;min-width:0}#share-phone{color:var(--fg);font-size:1.05em}#share-phone b{color:var(--accent)}.admin-table{border-collapse:collapse;width:100%;min-height:5.5em;max-height:14rem;margin:.4rem 0;font-size:.85em;display:block;overflow-y:auto}.admin-table th{text-align:left;opacity:.6;padding:.2rem .4rem .2rem 0;font-weight:400}.admin-table td{white-space:nowrap;text-overflow:ellipsis;max-width:12rem;padding:.2rem .4rem .2rem 0;overflow:hidden}.admin-table td.network-public{color:var(--warning,#e0b341)}.admin-table td.network-private{color:var(--success,#7fd18b)}.admin-table tr.ended{opacity:.45}body.route-directory .player-only{display:none}.directory-list li{align-items:stretch;gap:.4rem;display:flex}.directory-list li>:first-child{flex:auto;min-width:0}.directory-list li>button:first-child{text-align:left;width:100%}.directory-list li>.button,.directory-list li>.ghost{white-space:nowrap;flex:none;align-items:center;display:flex}.directory-list .follow{white-space:nowrap;width:auto;color:var(--muted);flex:none;padding-inline:.6rem}.directory-list .follow[data-following=yes]{border-color:var(--accent);color:var(--accent)}.toggle{color:var(--muted);cursor:pointer;align-items:center;gap:.35rem;display:inline-flex}.toggle input{accent-color:var(--accent)}.recent-label{border:1px solid var(--line);border-radius:4px;flex:auto;min-width:0;padding:.4rem .5rem}
|