nixamp 0.46.0 → 0.47.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.
Files changed (32) hide show
  1. package/dist/break-silence.d.ts +5 -5
  2. package/dist/break-silence.js +5 -5
  3. package/dist/channels.d.ts +21 -0
  4. package/dist/channels.js +82 -0
  5. package/dist/playlist-input.js +26 -10
  6. package/dist/station-routes.js +5 -5
  7. package/package.json +1 -1
  8. package/src/break-silence.ts +5 -5
  9. package/src/channels.ts +78 -0
  10. package/src/playlist-input.ts +24 -10
  11. package/src/station-routes.ts +5 -5
  12. package/web/dist/assets/{ar-BlY2yL5Y.js → ar-BOy98BLI.js} +1 -1
  13. package/web/dist/assets/{cs-BimNH85J.js → cs-BbbskosY.js} +1 -1
  14. package/web/dist/assets/{da-B3IBhGVZ.js → da-DHrF2OuF.js} +1 -1
  15. package/web/dist/assets/{de-CH-pHqCQ.js → de-Dx2W0spi.js} +1 -1
  16. package/web/dist/assets/{es-BI5-s8Fs.js → es-qnp4IRsp.js} +1 -1
  17. package/web/dist/assets/{fi-BQL91oQN.js → fi-CfPSgEs-.js} +1 -1
  18. package/web/dist/assets/{fr-CkzjINxM.js → fr-LKBu7Tgy.js} +1 -1
  19. package/web/dist/assets/{hi-DN8JNzbF.js → hi-BzyRQPpa.js} +1 -1
  20. package/web/dist/assets/{hu-Cgi1qJdP.js → hu-AmxPDNs7.js} +1 -1
  21. package/web/dist/assets/{id-7NL6kLrr.js → id-CxY-xwNQ.js} +1 -1
  22. package/web/dist/assets/{it-jg9ppgTc.js → it-eoKuPzEe.js} +1 -1
  23. package/web/dist/assets/main-DYATKV1O.js +22 -0
  24. package/web/dist/assets/{nl-Cyz8HiXs.js → nl-Ceg4F0rC.js} +1 -1
  25. package/web/dist/assets/{ru-BzkaVvIz.js → ru-BRNZUbKs.js} +1 -1
  26. package/web/dist/assets/{sv-CqTcD1de.js → sv-k-A6hu8-.js} +1 -1
  27. package/web/dist/assets/{uk-CvHTXcNv.js → uk-C9owZ3Lc.js} +1 -1
  28. package/web/dist/assets/{vi-BYQnyW2Q.js → vi-CtcFEtmC.js} +1 -1
  29. package/web/dist/assets/{zh-VSRcivxg.js → zh-DQrNE8sv.js} +1 -1
  30. package/web/dist/index.html +1 -1
  31. package/web/dist/sw.js +19 -19
  32. package/web/dist/assets/main-BXsE3ZHI.js +0 -22
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Silence in place of a break, for one listener of a station.
2
+ * Silence in place of a break, for one listener of a station: the last resort.
3
3
  *
4
- * A station's breaks are in its one stream (playlist-input.ts), so a listener
5
- * with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
6
- * only the spot: while the channel says a break is on, what goes to them is
7
- * MP3 silence lasting as long as the bytes it stands in for. Audio only; a
4
+ * A listener with a listen pass (listen-pass.ts) skips breaks out of a head
5
+ * start of music (Channel.listenWithoutBreaks). Only when that is spent is a
6
+ * break replaced, by MP3 silence lasting as long as the bytes it stands in
7
+ * for, rather than a gap that stalls the player. Audio only; a
8
8
  * fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
9
9
  * everybody's.
10
10
  *
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Silence in place of a break, for one listener of a station.
2
+ * Silence in place of a break, for one listener of a station: the last resort.
3
3
  *
4
- * A station's breaks are in its one stream (playlist-input.ts), so a listener
5
- * with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
6
- * only the spot: while the channel says a break is on, what goes to them is
7
- * MP3 silence lasting as long as the bytes it stands in for. Audio only; a
4
+ * A listener with a listen pass (listen-pass.ts) skips breaks out of a head
5
+ * start of music (Channel.listenWithoutBreaks). Only when that is spent is a
6
+ * break replaced, by MP3 silence lasting as long as the bytes it stands in
7
+ * for, rather than a gap that stalls the player. Audio only; a
8
8
  * fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
9
9
  * everybody's.
10
10
  *
@@ -168,6 +168,12 @@ export declare const BACKLOG_AUDIO: number;
168
168
  * until the channel ended, however long that was.
169
169
  */
170
170
  export declare const LISTENER_QUEUE: number;
171
+ /**
172
+ * How much break-free radio a listener who skips breaks is handed on arrival:
173
+ * two minutes at 192k. Their player holds it, and each break they skip spends
174
+ * the length of the spot from it, so they never hear a gap.
175
+ */
176
+ export declare const MUSIC_BACKLOG: number;
171
177
  /** Media-time window for complete MP4 fragments. Byte limits are a separate
172
178
  * memory ceiling; the measured byte rate is only a fallback for inputs with
173
179
  * no usable fragment timestamps. It must never make low-bitrate video old. */
@@ -270,6 +276,11 @@ export declare class Channel {
270
276
  * can begin at; for MP3 any point will do, a frame announces itself.
271
277
  */
272
278
  private recent;
279
+ /** Whether what the encoder is writing now is a break (a radio list's spot). */
280
+ private breakOn;
281
+ /** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
282
+ private music;
283
+ private musicBytes;
273
284
  private fragmentClock;
274
285
  private fragmentTimes;
275
286
  private recentBytes;
@@ -420,6 +431,14 @@ export declare class Channel {
420
431
  */
421
432
  private feedThrough;
422
433
  listen(listener: Listener): () => void;
434
+ /**
435
+ * A radio list's listener who hears no breaks: handed the last couple of
436
+ * minutes of music at once, which their player holds, then everything but
437
+ * the breaks. Each skipped break spends its length of that head start, so
438
+ * there is no gap; only once it is all spent does a break become silence of
439
+ * the same length (`silence`), never the spot. Joining again refills it.
440
+ */
441
+ listenWithoutBreaks(listener: Listener, kbps: number, silence: (out: Listener) => Listener): () => void;
423
442
  /** Stay up with nobody watching: no longer on demand. */
424
443
  keep(): void;
425
444
  /** Nobody is watching an on-demand channel: give it a minute, then stop. */
@@ -485,6 +504,8 @@ export declare class Channels {
485
504
  contentType(id: string): string;
486
505
  /** Attach a listener, or null when nothing is playing on that channel. */
487
506
  listen(id: string, listener: Listener): (() => void) | null;
507
+ /** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
508
+ listenWithoutBreaks(id: string, listener: Listener, kbps: number, silence: (out: Listener) => Listener): (() => void) | null;
488
509
  /** Feed a channel that already exists, for a publisher sending chunks. */
489
510
  writeTo(id: string, chunk: Buffer): boolean;
490
511
  /**
package/dist/channels.js CHANGED
@@ -66,6 +66,12 @@ export const BACKLOG_AUDIO = 64 * 1024;
66
66
  * until the channel ended, however long that was.
67
67
  */
68
68
  export const LISTENER_QUEUE = 16 * 1024 * 1024;
69
+ /**
70
+ * How much break-free radio a listener who skips breaks is handed on arrival:
71
+ * two minutes at 192k. Their player holds it, and each break they skip spends
72
+ * the length of the spot from it, so they never hear a gap.
73
+ */
74
+ export const MUSIC_BACKLOG = 3 * 1024 * 1024;
69
75
  /** Media-time window for complete MP4 fragments. Byte limits are a separate
70
76
  * memory ceiling; the measured byte rate is only a fallback for inputs with
71
77
  * no usable fragment timestamps. It must never make low-bitrate video old. */
@@ -190,6 +196,11 @@ export class Channel {
190
196
  * can begin at; for MP3 any point will do, a frame announces itself.
191
197
  */
192
198
  recent = [];
199
+ /** Whether what the encoder is writing now is a break (a radio list's spot). */
200
+ breakOn = false;
201
+ /** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
202
+ music = [];
203
+ musicBytes = 0;
193
204
  fragmentClock = new FragmentClock();
194
205
  fragmentTimes = new WeakMap();
195
206
  recentBytes = 0;
@@ -412,6 +423,13 @@ export class Channel {
412
423
  }
413
424
  });
414
425
  child.stdio[3]?.on("error", () => undefined);
426
+ // A radio list's output is constant-bitrate MP3, so how many bytes it
427
+ // has written is exactly how far it has played: whether this chunk is
428
+ // a break, to the frame, rather than to progress's once a second.
429
+ const kbps = playlistInput && this.info.kind !== "video"
430
+ ? Number(/^(\d+)k$/.exec((playlistEncode ?? [])[playlistEncode.indexOf("-b:a") + 1] ?? "")?.[1] ?? 0)
431
+ : 0;
432
+ let written = 0;
415
433
  child.stdout?.on("data", (chunk) => {
416
434
  // An ffmpeg that was replaced can still have a chunk in the pipe.
417
435
  if (this.child !== child)
@@ -421,6 +439,12 @@ export class Channel {
421
439
  sent = true;
422
440
  this.info.bytes += chunk.byteLength;
423
441
  this.rearm(child);
442
+ if (kbps > 0 && playlistInput) {
443
+ this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
444
+ written += chunk.byteLength;
445
+ }
446
+ else
447
+ this.breakOn = this.info.playlistAt === -1;
424
448
  this.emit(chunk);
425
449
  });
426
450
  child.stdout?.on("error", () => undefined);
@@ -669,6 +693,13 @@ export class Channel {
669
693
  this.measure(chunk.byteLength);
670
694
  if (!this.fragments) {
671
695
  this.remember(chunk, BACKLOG_AUDIO, false);
696
+ if (this.info.playlist && !this.breakOn) {
697
+ this.music.push(chunk);
698
+ this.musicBytes += chunk.byteLength;
699
+ while (this.music.length > 1 && this.musicBytes - this.music[0].byteLength > MUSIC_BACKLOG) {
700
+ this.musicBytes -= this.music.shift().byteLength;
701
+ }
702
+ }
672
703
  this.send(chunk);
673
704
  return;
674
705
  }
@@ -934,6 +965,52 @@ export class Channel {
934
965
  this.idleOut();
935
966
  };
936
967
  }
968
+ /**
969
+ * A radio list's listener who hears no breaks: handed the last couple of
970
+ * minutes of music at once, which their player holds, then everything but
971
+ * the breaks. Each skipped break spends its length of that head start, so
972
+ * there is no gap; only once it is all spent does a break become silence of
973
+ * the same length (`silence`), never the spot. Joining again refills it.
974
+ */
975
+ listenWithoutBreaks(listener, kbps, silence) {
976
+ const bytesPerSecond = (kbps * 1000) / 8;
977
+ let ahead = 0;
978
+ for (const piece of this.music) {
979
+ try {
980
+ listener.write(piece);
981
+ }
982
+ catch {
983
+ break;
984
+ }
985
+ ahead += piece.byteLength / bytesPerSecond;
986
+ }
987
+ const quiet = silence(listener);
988
+ const skipping = {
989
+ write: (chunk) => {
990
+ if (!this.breakOn)
991
+ return listener.write(chunk);
992
+ const seconds = chunk.byteLength / bytesPerSecond;
993
+ if (ahead >= seconds) {
994
+ ahead -= seconds;
995
+ return true;
996
+ }
997
+ return quiet.write(chunk);
998
+ },
999
+ end: () => listener.end(),
1000
+ ...(listener.pending ? { pending: () => listener.pending() } : {}),
1001
+ };
1002
+ this.listeners.add(skipping);
1003
+ this.info.listeners = this.listeners.size;
1004
+ if (this.idle)
1005
+ clearTimeout(this.idle);
1006
+ this.idle = null;
1007
+ return () => {
1008
+ this.listeners.delete(skipping);
1009
+ this.info.listeners = this.listeners.size;
1010
+ if (this.ephemeral && this.listeners.size === 0)
1011
+ this.idleOut();
1012
+ };
1013
+ }
937
1014
  /** Stay up with nobody watching: no longer on demand. */
938
1015
  keep() {
939
1016
  this.ephemeral = false;
@@ -1140,6 +1217,11 @@ export class Channels {
1140
1217
  const channel = this.open.get(id);
1141
1218
  return channel ? channel.listen(listener) : null;
1142
1219
  }
1220
+ /** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
1221
+ listenWithoutBreaks(id, listener, kbps, silence) {
1222
+ const channel = this.open.get(id);
1223
+ return channel ? channel.listenWithoutBreaks(listener, kbps, silence) : null;
1224
+ }
1143
1225
  /** Feed a channel that already exists, for a publisher sending chunks. */
1144
1226
  writeTo(id, chunk) {
1145
1227
  return this.open.get(id)?.write(chunk) ?? false;
@@ -64,6 +64,8 @@ export class PlaylistInputStream {
64
64
  let played = 0;
65
65
  let last = "";
66
66
  let lastBreak = 0;
67
+ // A film cut for a break, to be picked up where it stopped.
68
+ let resume = null;
67
69
  for (;;) {
68
70
  if (signal.aborted)
69
71
  break;
@@ -81,7 +83,8 @@ export class PlaylistInputStream {
81
83
  step = o.order ? 0 : where >= 0 ? where + 1 : Math.min(step, files.length);
82
84
  }
83
85
  }
84
- if (step >= order.length) {
86
+ // A film cut for a break is not the end of the list: it has more to play.
87
+ if (step >= order.length && !resume) {
85
88
  if (!o.repeat)
86
89
  break;
87
90
  // A pass where everything was passed over is a list with nothing
@@ -95,7 +98,8 @@ export class PlaylistInputStream {
95
98
  }
96
99
  {
97
100
  // A break, when one is due: in place of the next entry, which stays
98
- // next. Not at the very start, and never inside an entry.
101
+ // next. Not at the very start. Never inside a song; a film is cut
102
+ // for one on the clock (below), since waiting out a film is an hour.
99
103
  let breakFile = null;
100
104
  const every = typeof o.breaks?.everySeconds === "function" ? o.breaks.everySeconds() : (o.breaks?.everySeconds ?? 0);
101
105
  if (o.breaks && every > 0 && !first && offset - lastBreak >= every) {
@@ -105,15 +109,18 @@ export class PlaylistInputStream {
105
109
  return;
106
110
  }
107
111
  const isBreak = breakFile !== null;
108
- const at = isBreak ? -1 : order[step];
109
- if (!isBreak)
112
+ const resuming = isBreak ? null : resume;
113
+ if (resuming)
114
+ resume = null;
115
+ const at = isBreak ? -1 : resuming ? resuming.at : order[step];
116
+ if (!isBreak && !resuming)
110
117
  step += 1;
111
- const file = isBreak ? breakFile : files[at];
112
- if (!isBreak)
118
+ const file = isBreak ? breakFile : resuming ? resuming.file : files[at];
119
+ if (!isBreak && !resuming)
113
120
  last = file;
114
- if (!isBreak && o.excluded?.(file))
121
+ if (!isBreak && !resuming && o.excluded?.(file))
115
122
  continue;
116
- const position = first && step - 1 === o.at ? o.position : 0;
123
+ const position = resuming ? resuming.position : first && step - 1 === o.at ? o.position : 0;
117
124
  first = false;
118
125
  let reached = 0;
119
126
  try {
@@ -125,9 +132,14 @@ export class PlaylistInputStream {
125
132
  if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
126
133
  this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
127
134
  }
128
- const duration = (codecs.duration ?? 0) - position;
129
- if (duration <= 0)
135
+ const full = (codecs.duration ?? 0) - position;
136
+ if (full <= 0)
130
137
  throw new Error(`No playable duration for playlist entry ${at + 1}`);
138
+ // A film plays up to the next break and is picked up after it;
139
+ // a tail of a few seconds is not worth a cut.
140
+ const room = o.video && !isBreak && o.breaks && every > 0 ? every - (offset - lastBreak) : Infinity;
141
+ const cut = room >= 1 && full - room > 5;
142
+ const duration = cut ? room : full;
131
143
  this.entries.push({ at, file, start: offset, position });
132
144
  const hasVideo = Boolean(codecs.video);
133
145
  const extra = o.video && !hasVideo
@@ -200,6 +212,10 @@ export class PlaylistInputStream {
200
212
  // longer than the gap cannot follow itself.
201
213
  if (isBreak)
202
214
  lastBreak = offset;
215
+ // Cut for a break, not skipped: the break is due now, and the
216
+ // film carries on from here after it.
217
+ if (cut && !skipped)
218
+ resume = { at, file, position: position + duration };
203
219
  }
204
220
  catch (error) {
205
221
  // A break that would not play is simply no break.
@@ -246,7 +246,8 @@ export async function handleStations(request, response, path, url, ctx) {
246
246
  response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
247
247
  return true;
248
248
  }
249
- // Signed in at nixamp.com: the breaks go quiet. Radio only (break-silence.ts).
249
+ // Signed in at nixamp.com: no breaks, starting a little behind live so
250
+ // they can be left out without a gap. Radio only (Channel.listenWithoutBreaks).
250
251
  const pass = url.searchParams.get(PASS_PARAM) ?? "";
251
252
  const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false;
252
253
  response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
@@ -266,10 +267,9 @@ export async function handleStations(request, response, path, url, ctx) {
266
267
  pending: () => response.writableLength,
267
268
  }
268
269
  : response;
269
- const counted = spared
270
- ? quietDuringBreaks(metered, station.bitrate, () => ctx.channels.info(channelId)?.playlistAt === -1)
271
- : metered;
272
- const detach = ctx.channels.listen(channelId, counted);
270
+ const detach = spared
271
+ ? ctx.channels.listenWithoutBreaks(channelId, metered, station.bitrate, (out) => quietDuringBreaks(out, station.bitrate, () => true))
272
+ : ctx.channels.listen(channelId, metered);
273
273
  if (detach === null) {
274
274
  response.end();
275
275
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.46.0",
3
+ "version": "0.47.0",
4
4
  "description": "It really whips the terminal's ass. A Winamp-shaped audio player for your terminal.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,10 +1,10 @@
1
1
  /**
2
- * Silence in place of a break, for one listener of a station.
2
+ * Silence in place of a break, for one listener of a station: the last resort.
3
3
  *
4
- * A station's breaks are in its one stream (playlist-input.ts), so a listener
5
- * with a listen pass (listen-pass.ts) cannot be spared the time a spot takes,
6
- * only the spot: while the channel says a break is on, what goes to them is
7
- * MP3 silence lasting as long as the bytes it stands in for. Audio only; a
4
+ * A listener with a listen pass (listen-pass.ts) skips breaks out of a head
5
+ * start of music (Channel.listenWithoutBreaks). Only when that is spent is a
6
+ * break replaced, by MP3 silence lasting as long as the bytes it stands in
7
+ * for, rather than a gap that stalls the player. Audio only; a
8
8
  * fragmented MP4 cannot have fragments swapped, so a TV station's breaks are
9
9
  * everybody's.
10
10
  *
package/src/channels.ts CHANGED
@@ -196,6 +196,12 @@ export const BACKLOG_AUDIO = 64 * 1024;
196
196
  * until the channel ended, however long that was.
197
197
  */
198
198
  export const LISTENER_QUEUE = 16 * 1024 * 1024;
199
+ /**
200
+ * How much break-free radio a listener who skips breaks is handed on arrival:
201
+ * two minutes at 192k. Their player holds it, and each break they skip spends
202
+ * the length of the spot from it, so they never hear a gap.
203
+ */
204
+ export const MUSIC_BACKLOG = 3 * 1024 * 1024;
199
205
 
200
206
  /** Media-time window for complete MP4 fragments. Byte limits are a separate
201
207
  * memory ceiling; the measured byte rate is only a fallback for inputs with
@@ -346,6 +352,11 @@ export class Channel {
346
352
  * can begin at; for MP3 any point will do, a frame announces itself.
347
353
  */
348
354
  private recent: Buffer[] = [];
355
+ /** Whether what the encoder is writing now is a break (a radio list's spot). */
356
+ private breakOn = false;
357
+ /** The last couple of minutes of a radio list without its breaks, for listenWithoutBreaks. */
358
+ private music: Buffer[] = [];
359
+ private musicBytes = 0;
349
360
  private fragmentClock = new FragmentClock();
350
361
  private fragmentTimes = new WeakMap<Buffer, number>();
351
362
  private recentBytes = 0;
@@ -582,6 +593,13 @@ export class Channel {
582
593
  }
583
594
  });
584
595
  (child.stdio[3] as Readable | null)?.on("error", () => undefined);
596
+ // A radio list's output is constant-bitrate MP3, so how many bytes it
597
+ // has written is exactly how far it has played: whether this chunk is
598
+ // a break, to the frame, rather than to progress's once a second.
599
+ const kbps = playlistInput && this.info.kind !== "video"
600
+ ? Number(/^(\d+)k$/.exec((playlistEncode ?? [])[playlistEncode!.indexOf("-b:a") + 1] ?? "")?.[1] ?? 0)
601
+ : 0;
602
+ let written = 0;
585
603
  child.stdout?.on("data", (chunk: Buffer) => {
586
604
  // An ffmpeg that was replaced can still have a chunk in the pipe.
587
605
  if (this.child !== child) return;
@@ -589,6 +607,10 @@ export class Channel {
589
607
  sent = true;
590
608
  this.info.bytes += chunk.byteLength;
591
609
  this.rearm(child);
610
+ if (kbps > 0 && playlistInput) {
611
+ this.breakOn = playlistInput.place((written * 8) / (kbps * 1000)).at === -1;
612
+ written += chunk.byteLength;
613
+ } else this.breakOn = this.info.playlistAt === -1;
592
614
  this.emit(chunk);
593
615
  });
594
616
  child.stdout?.on("error", () => undefined);
@@ -823,6 +845,13 @@ export class Channel {
823
845
  this.measure(chunk.byteLength);
824
846
  if (!this.fragments) {
825
847
  this.remember(chunk, BACKLOG_AUDIO, false);
848
+ if (this.info.playlist && !this.breakOn) {
849
+ this.music.push(chunk);
850
+ this.musicBytes += chunk.byteLength;
851
+ while (this.music.length > 1 && this.musicBytes - this.music[0]!.byteLength > MUSIC_BACKLOG) {
852
+ this.musicBytes -= this.music.shift()!.byteLength;
853
+ }
854
+ }
826
855
  this.send(chunk);
827
856
  return;
828
857
  }
@@ -1075,6 +1104,49 @@ export class Channel {
1075
1104
  };
1076
1105
  }
1077
1106
 
1107
+ /**
1108
+ * A radio list's listener who hears no breaks: handed the last couple of
1109
+ * minutes of music at once, which their player holds, then everything but
1110
+ * the breaks. Each skipped break spends its length of that head start, so
1111
+ * there is no gap; only once it is all spent does a break become silence of
1112
+ * the same length (`silence`), never the spot. Joining again refills it.
1113
+ */
1114
+ listenWithoutBreaks(listener: Listener, kbps: number, silence: (out: Listener) => Listener): () => void {
1115
+ const bytesPerSecond = (kbps * 1000) / 8;
1116
+ let ahead = 0;
1117
+ for (const piece of this.music) {
1118
+ try {
1119
+ listener.write(piece);
1120
+ } catch {
1121
+ break;
1122
+ }
1123
+ ahead += piece.byteLength / bytesPerSecond;
1124
+ }
1125
+ const quiet = silence(listener);
1126
+ const skipping: Listener = {
1127
+ write: (chunk) => {
1128
+ if (!this.breakOn) return listener.write(chunk);
1129
+ const seconds = chunk.byteLength / bytesPerSecond;
1130
+ if (ahead >= seconds) {
1131
+ ahead -= seconds;
1132
+ return true;
1133
+ }
1134
+ return quiet.write(chunk);
1135
+ },
1136
+ end: () => listener.end(),
1137
+ ...(listener.pending ? { pending: () => listener.pending!() } : {}),
1138
+ };
1139
+ this.listeners.add(skipping);
1140
+ this.info.listeners = this.listeners.size;
1141
+ if (this.idle) clearTimeout(this.idle);
1142
+ this.idle = null;
1143
+ return () => {
1144
+ this.listeners.delete(skipping);
1145
+ this.info.listeners = this.listeners.size;
1146
+ if (this.ephemeral && this.listeners.size === 0) this.idleOut();
1147
+ };
1148
+ }
1149
+
1078
1150
  /** Stay up with nobody watching: no longer on demand. */
1079
1151
  keep(): void {
1080
1152
  this.ephemeral = false;
@@ -1300,6 +1372,12 @@ export class Channels {
1300
1372
  return channel ? channel.listen(listener) : null;
1301
1373
  }
1302
1374
 
1375
+ /** Attach a listener who skips a radio list's breaks (Channel.listenWithoutBreaks). */
1376
+ listenWithoutBreaks(id: string, listener: Listener, kbps: number, silence: (out: Listener) => Listener): (() => void) | null {
1377
+ const channel = this.open.get(id);
1378
+ return channel ? channel.listenWithoutBreaks(listener, kbps, silence) : null;
1379
+ }
1380
+
1303
1381
  /** Feed a channel that already exists, for a publisher sending chunks. */
1304
1382
  writeTo(id: string, chunk: Buffer): boolean {
1305
1383
  return this.open.get(id)?.write(chunk) ?? false;
@@ -109,6 +109,8 @@ export class PlaylistInputStream {
109
109
  let played = 0;
110
110
  let last = "";
111
111
  let lastBreak = 0;
112
+ // A film cut for a break, to be picked up where it stopped.
113
+ let resume: { at: number; file: string; position: number } | null = null;
112
114
  for (;;) {
113
115
  if (signal.aborted) break;
114
116
  // Asked at every boundary, so an edit lands on the next track. The
@@ -124,7 +126,8 @@ export class PlaylistInputStream {
124
126
  step = o.order ? 0 : where >= 0 ? where + 1 : Math.min(step, files.length);
125
127
  }
126
128
  }
127
- if (step >= order.length) {
129
+ // A film cut for a break is not the end of the list: it has more to play.
130
+ if (step >= order.length && !resume) {
128
131
  if (!o.repeat) break;
129
132
  // A pass where everything was passed over is a list with nothing
130
133
  // to play, and repeating it would be the loop this exists to avoid.
@@ -136,7 +139,8 @@ export class PlaylistInputStream {
136
139
  }
137
140
  {
138
141
  // A break, when one is due: in place of the next entry, which stays
139
- // next. Not at the very start, and never inside an entry.
142
+ // next. Not at the very start. Never inside a song; a film is cut
143
+ // for one on the clock (below), since waiting out a film is an hour.
140
144
  let breakFile: string | null = null;
141
145
  const every = typeof o.breaks?.everySeconds === "function" ? o.breaks.everySeconds() : (o.breaks?.everySeconds ?? 0);
142
146
  if (o.breaks && every > 0 && !first && offset - lastBreak >= every) {
@@ -145,12 +149,14 @@ export class PlaylistInputStream {
145
149
  if (signal.aborted) return;
146
150
  }
147
151
  const isBreak = breakFile !== null;
148
- const at = isBreak ? -1 : order[step]!;
149
- if (!isBreak) step += 1;
150
- const file = isBreak ? breakFile! : files[at]!;
151
- if (!isBreak) last = file;
152
- if (!isBreak && o.excluded?.(file)) continue;
153
- const position = first && step - 1 === o.at ? o.position : 0;
152
+ const resuming: { at: number; file: string; position: number } | null = isBreak ? null : resume;
153
+ if (resuming) resume = null;
154
+ const at: number = isBreak ? -1 : resuming ? resuming.at : order[step]!;
155
+ if (!isBreak && !resuming) step += 1;
156
+ const file: string = isBreak ? breakFile! : resuming ? resuming.file : files[at]!;
157
+ if (!isBreak && !resuming) last = file;
158
+ if (!isBreak && !resuming && o.excluded?.(file)) continue;
159
+ const position: number = resuming ? resuming.position : first && step - 1 === o.at ? o.position : 0;
154
160
  first = false;
155
161
  let reached = 0;
156
162
  try {
@@ -160,8 +166,13 @@ export class PlaylistInputStream {
160
166
  if (codecs.tags && (codecs.tags.title || codecs.tags.artist)) {
161
167
  this.tags.set(file, { title: codecs.tags.title ?? "", artist: codecs.tags.artist ?? "", album: codecs.tags.album ?? "" });
162
168
  }
163
- const duration = (codecs.duration ?? 0) - position;
164
- if (duration <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`);
169
+ const full = (codecs.duration ?? 0) - position;
170
+ if (full <= 0) throw new Error(`No playable duration for playlist entry ${at + 1}`);
171
+ // A film plays up to the next break and is picked up after it;
172
+ // a tail of a few seconds is not worth a cut.
173
+ const room = o.video && !isBreak && o.breaks && every > 0 ? every - (offset - lastBreak) : Infinity;
174
+ const cut = room >= 1 && full - room > 5;
175
+ const duration = cut ? room : full;
165
176
  this.entries.push({ at, file, start: offset, position });
166
177
  const hasVideo = Boolean(codecs.video);
167
178
  const extra = o.video && !hasVideo
@@ -228,6 +239,9 @@ export class PlaylistInputStream {
228
239
  // The next break is counted from the end of this one, so a spot
229
240
  // longer than the gap cannot follow itself.
230
241
  if (isBreak) lastBreak = offset;
242
+ // Cut for a break, not skipped: the break is due now, and the
243
+ // film carries on from here after it.
244
+ if (cut && !skipped) resume = { at, file, position: position + duration };
231
245
  } catch (error) {
232
246
  // A break that would not play is simply no break.
233
247
  if (isBreak && !signal.aborted) continue;
@@ -329,7 +329,8 @@ export async function handleStations(
329
329
  response.end(JSON.stringify({ error: refused.error, code: refused.code, retryAfter: refused.retryAfter }));
330
330
  return true;
331
331
  }
332
- // Signed in at nixamp.com: the breaks go quiet. Radio only (break-silence.ts).
332
+ // Signed in at nixamp.com: no breaks, starting a little behind live so
333
+ // they can be left out without a gap. Radio only (Channel.listenWithoutBreaks).
333
334
  const pass = url.searchParams.get(PASS_PARAM) ?? "";
334
335
  const spared = method === "GET" && !station.video && pass !== "" && ctx.passes ? await ctx.passes.good(pass).catch(() => false) : false;
335
336
  response.writeHead(200, { ...ctx.headers, "content-type": ctx.channels.contentType(channelId), "cache-control": "no-store" });
@@ -349,10 +350,9 @@ export async function handleStations(
349
350
  pending: (): number => response.writableLength,
350
351
  }
351
352
  : response;
352
- const counted = spared
353
- ? quietDuringBreaks(metered, station.bitrate, () => ctx.channels.info(channelId)?.playlistAt === -1)
354
- : metered;
355
- const detach = ctx.channels.listen(channelId, counted);
353
+ const detach = spared
354
+ ? ctx.channels.listenWithoutBreaks(channelId, metered, station.bitrate, (out) => quietDuringBreaks(out, station.bitrate, () => true))
355
+ : ctx.channels.listen(channelId, metered);
356
356
  if (detach === null) {
357
357
  response.end();
358
358
  return true;
@@ -1 +1 @@
1
- import{t as e}from"./main-BXsE3ZHI.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};
1
+ import{t as e}from"./main-DYATKV1O.js";var t={...e,"No parties live right now.":`لا توجد جلسات مباشرة الآن.`,"Could not refresh parties.":`تعذر تحديث الجلسات.`,"Pick a server in the directory, or paste its link here.":`اختر خادمًا من الدليل أو الصق رابطه هنا.`,"Interface language":`لغة الواجهة`,Language:`اللغة`,"Translation language":`لغة الترجمة`,"Original (auto-detect)":`الأصل (اكتشاف تلقائي)`,Play:`تشغيل`,Pause:`إيقاف مؤقت`,Stop:`إيقاف`,Next:`التالي`,Previous:`السابق`,Select:`اختيار`,"Next/Prev":`التالي/السابق`,Quit:`خروج`,"Now Playing":`قيد التشغيل`,"Spectrum Analyser":`محلل الطيف`,Playlist:`قائمة التشغيل`,Files:`الملفات`,"Open files":`فتح ملفات`,"Open folder":`فتح مجلد`,"Add to library":`إضافة إلى المكتبة`,"Replace playlist":`استبدال قائمة التشغيل`,Filter:`تصفية`,Search:`بحث`,"Nothing loaded.":`لم يُحمّل أي محتوى.`,"Empty.":`فارغ.`,"Loading…":`جارٍ التحميل…`,"Connecting…":`جارٍ الاتصال…`,Connected:`متصل`,"Not connected":`غير متصل`,"No source":`لا يوجد مصدر`,Playing:`يعمل`,Stopped:`متوقف`,Volume:`مستوى الصوت`,Seek:`موضع التشغيل`,"Full screen":`ملء الشاشة`,Download:`تنزيل`,Share:`مشاركة`,Copy:`نسخ`,"Copy link":`نسخ الرابط`,Copied:`تم النسخ`,Save:`حفظ`,Cancel:`إلغاء`,Close:`إغلاق`,Remove:`إزالة`,Delete:`حذف`,Edit:`تعديل`,Add:`إضافة`,Send:`إرسال`,Retry:`إعادة المحاولة`,Back:`رجوع`,"Next page":`الصفحة التالية`,"Previous page":`الصفحة السابقة`,"Show more":`عرض المزيد`,Panels:`اللوحات`,"Reset layout":`إعادة ضبط التخطيط`,Show:`إظهار`,Hide:`إخفاء`,Collapse:`طي`,Expand:`توسيع`,"Move up":`نقل لأعلى`,"Move down":`نقل لأسفل`,Install:`تثبيت`,"TV mode":`وضع التلفاز`,On:`مفعّل`,Off:`معطّل`,Account:`الحساب`,"Sign in":`تسجيل الدخول`,"Sign out":`تسجيل الخروج`,"Create account":`إنشاء حساب`,Email:`البريد الإلكتروني`,Password:`كلمة المرور`,"Forgot password?":`نسيت كلمة المرور؟`,"Welcome back":`مرحبًا بعودتك`,Profile:`الملف الشخصي`,Handle:`اسم المستخدم`,"Your servers":`خوادمك`,Server:`الخادم`,Connect:`اتصال`,Disconnect:`قطع الاتصال`,Directory:`الدليل`,Favourites:`المفضلة`,Follow:`متابعة`,Unfollow:`إلغاء المتابعة`,Notifications:`الإشعارات`,"Phone number":`رقم الهاتف`,"Save number":`حفظ الرقم`,Transcript:`النص المفرغ`,Captions:`الترجمة النصية`,"Translate audio":`ترجمة الصوت`,"Audio language":`لغة الصوت`,"Audio options":`خيارات الصوت`,"Keep background sound":`الاحتفاظ بأصوات الخلفية`,"Background level":`مستوى صوت الخلفية`,"Speaker voices":`أصوات المتحدثين`,"Any voice":`أي صوت`,"Male voice":`صوت رجالي`,"Female voice":`صوت نسائي`,"Original audio.":`الصوت الأصلي.`,"Buy translated audio":`شراء الصوت المترجم`,"Audio credit":`رصيد الصوت`,"Buy pass":`شراء تصريح`,Pass:`تصريح`,"Pay with":`الدفع باستخدام`,"Check payment":`التحقق من الدفع`,"Open checkout":`الانتقال للدفع`,"Free sessions":`جلسات مجانية`,"Usage rates and expiry":`الأسعار ومدة الصلاحية`,Catalogs:`الكتالوجات`,"Add catalog":`إضافة كتالوج`,"Catalog name":`اسم الكتالوج`,Link:`الرابط`,"Play link":`تشغيل الرابط`,"Go live":`بدء بث مباشر`,Publish:`نشر`,Listen:`استماع`,"Live now":`مباشر الآن`,Upcoming:`قريبًا`,Parties:`حفلات المشاهدة`,"Join party":`الانضمام للحفلة`,"Invite code":`رمز الدعوة`,Invite:`دعوة`,Chat:`الدردشة`,"Chat message":`رسالة دردشة`,People:`الأشخاص`,Speaker:`متحدث`,Speakers:`المتحدثون`,Listener:`مستمع`,Moderator:`مشرف`,"Raise hand":`رفع اليد`,"Hand raises":`الأيدي المرفوعة`,Questions:`الأسئلة`,Resources:`الموارد`,Replay:`إعادة العرض`,Schedule:`الجدول`,Event:`الفعالية`,Host:`المضيف`,About:`حول`,Stage:`المنصة`,Title:`العنوان`,Description:`الوصف`,Topic:`الموضوع`,Public:`عام`,"Anyone with the link":`أي شخص لديه الرابط`,"Invited people":`الأشخاص المدعوون`,"Who can join":`من يمكنه الانضمام`,"Date and time":`التاريخ والوقت`,"Expected length":`المدة المتوقعة`,Repeat:`تكرار`,"Does not repeat":`لا يتكرر`,"Every day":`كل يوم`,"Every week":`كل أسبوع`,Homepage:`الموقع الإلكتروني`,"Host name":`اسم المضيف`,"Profile photo URL":`رابط صورة الملف الشخصي`,"Broadcast link":`رابط البث`,"Edit event":`تعديل الفعالية`,"Save changes":`حفظ التغييرات`,"Create live":`إنشاء بث مباشر`,"Schedule class":`جدولة درس`,"Start class":`بدء الدرس`,"End event":`إنهاء الفعالية`,"End session":`إنهاء الجلسة`,"Session ended":`انتهت الجلسة`,"Event ended":`انتهت الفعالية`,Cancelled:`ملغاة`,"Replay available":`إعادة العرض متاحة`,Join:`انضمام`,"Create invite":`إنشاء دعوة`,"Invite as":`الدعوة بصفة`,"Watch now":`شاهد الآن`,"Start a room":`إنشاء غرفة`,"Recent replays":`إعادات العرض الأخيرة`,"Your account":`حسابك`,"Write with AI":`الكتابة بالذكاء الاصطناعي`,"Suggested title":`العنوان المقترح`,"Suggested description":`الوصف المقترح`,"Suggested topic":`الموضوع المقترح`,"Use these details":`استخدام هذه التفاصيل`,"Learn something live.":`تعلّم شيئًا مباشرة.`,"Screen share":`مشاركة الشاشة`,"Upload and broadcast":`رفع وبث`,Help:`مساعدة`,"Reload Player":`إعادة تحميل المشغل`,"Copy Bundled CLI Path":`نسخ مسار أداة سطر الأوامر المضمنة`,"Developer tools":`أدوات المطور`,Undo:`تراجع`,Redo:`إعادة`,Cut:`قص`,Paste:`لصق`,"Select all":`تحديد الكل`,Status:`الحالة`,"Activity log":`سجل النشاط`,Admin:`الإدارة`,"Playback controls":`عناصر التحكم بالتشغيل`,"Tracks and folders":`المقاطع والمجلدات`,"Original-language captions.":`ترجمة نصية باللغة الأصلية.`,"Skip to playback controls":`الانتقال إلى عناصر التحكم بالتشغيل`,"Skip to playlist":`الانتقال إلى قائمة التشغيل`,"Skip to transcript and translated audio":`الانتقال إلى النص المفرغ والصوت المترجم`};export{t as default};