nixamp 0.10.1 → 0.10.3

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/src/directory.ts CHANGED
@@ -105,6 +105,12 @@ export interface Ended {
105
105
  startedAt: number;
106
106
  /** The last heartbeat we saw, which is as close to "ended" as we can know. */
107
107
  endedAt: number;
108
+ /**
109
+ * The codes its channels had, so a server that stops and comes back -- an
110
+ * update restarts the daemon, and the daemon withdraws its listing on the
111
+ * way down -- puts each channel back under the code people were given.
112
+ */
113
+ channelCodes?: Record<string, string>;
108
114
  }
109
115
 
110
116
  /** How long an ended stream is still worth telling a caller about. */
@@ -237,7 +243,14 @@ export class Directory {
237
243
  */
238
244
  seedEnded(items: readonly Ended[]): void {
239
245
  for (const item of items) {
240
- if (!this.ended.has(item.id) && !this.items.has(item.id)) this.ended.set(item.id, item);
246
+ if (!this.ended.has(item.id) && !this.items.has(item.id)) {
247
+ this.ended.set(item.id, item);
248
+ // Its channels' codes are reserved too, so a server coming back after
249
+ // the directory restarted still finds them its own.
250
+ if (item.channelCodes && Object.keys(item.channelCodes).length > 0) {
251
+ this.channelHistory.set(item.id, { ...(this.channelHistory.get(item.id) ?? {}), ...item.channelCodes });
252
+ }
253
+ }
241
254
  }
242
255
  }
243
256
 
@@ -297,7 +310,11 @@ export class Directory {
297
310
  // channel that was gone for a heartbeat -- a server restarting puts
298
311
  // its channels back a moment after it announces -- comes back to the
299
312
  // code people were given, not a new one.
300
- { ...(this.channelHistory.get(id) ?? {}), ...(existing?.channelCodes ?? {}) },
313
+ {
314
+ ...(previously && "channelCodes" in previously ? previously.channelCodes ?? {} : {}),
315
+ ...(this.channelHistory.get(id) ?? {}),
316
+ ...(existing?.channelCodes ?? {}),
317
+ },
301
318
  id,
302
319
  ),
303
320
  updatedAt: this.now(),
@@ -316,7 +333,7 @@ export class Directory {
316
333
  const item = this.items.get(id);
317
334
  if (item !== undefined) this.remember(item);
318
335
  this.items.delete(id);
319
- this.channelHistory.delete(id);
336
+ // The channel history stays with the ended record, and goes when it goes.
320
337
  }
321
338
 
322
339
  list(): Listing[] {
@@ -435,6 +452,9 @@ export class Directory {
435
452
  nowPlaying: item.nowPlaying,
436
453
  startedAt: item.startedAt,
437
454
  endedAt: item.updatedAt,
455
+ // Every code its channels have had, not only the ones on at the end:
456
+ // a restart announces before its channels are back.
457
+ channelCodes: { ...(this.channelHistory.get(item.id) ?? {}), ...item.channelCodes },
438
458
  };
439
459
  this.ended.set(item.id, record);
440
460
  this.mirror?.save(record);
@@ -457,13 +477,13 @@ export class Directory {
457
477
  if (item.updatedAt < cutoff) {
458
478
  this.remember(item);
459
479
  this.items.delete(id);
460
- this.channelHistory.delete(id);
461
480
  }
462
481
  }
463
482
  const forget = this.now() - ENDED_TTL_MS;
464
483
  for (const [id, item] of this.ended) {
465
484
  if (item.endedAt < forget) {
466
485
  this.ended.delete(id);
486
+ this.channelHistory.delete(id);
467
487
  this.mirror?.drop(id);
468
488
  }
469
489
  }
package/src/durable.ts CHANGED
@@ -35,6 +35,8 @@ export interface StoredEnded {
35
35
  nowPlaying: string;
36
36
  startedAt: number;
37
37
  endedAt: number;
38
+ /** Each channel's phone code, by name, so a server coming back keeps them. */
39
+ channelCodes?: Record<string, string>;
38
40
  }
39
41
 
40
42
  const SCHEMA = `
@@ -48,6 +50,7 @@ const SCHEMA = `
48
50
  started_at BIGINT NOT NULL,
49
51
  ended_at BIGINT NOT NULL
50
52
  );
53
+ ALTER TABLE ended_streams ADD COLUMN IF NOT EXISTS channel_codes JSONB NOT NULL DEFAULT '{}'::jsonb;
51
54
  CREATE INDEX IF NOT EXISTS ended_streams_code ON ended_streams (code);
52
55
 
53
56
  CREATE TABLE IF NOT EXISTS stream_reminders (
@@ -58,6 +61,24 @@ const SCHEMA = `
58
61
  );
59
62
  `;
60
63
 
64
+ /** A JSONB column, as pg hands it back: an object already, or a string on an odd driver. */
65
+ function codesFrom(value: unknown): Record<string, string> {
66
+ let parsed: unknown = value;
67
+ if (typeof value === "string") {
68
+ try {
69
+ parsed = JSON.parse(value);
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
75
+ const codes: Record<string, string> = {};
76
+ for (const [name, code] of Object.entries(parsed as Record<string, unknown>)) {
77
+ if (typeof code === "string" && /^\d{6}$/.test(code)) codes[name] = code;
78
+ }
79
+ return codes;
80
+ }
81
+
61
82
  export class Durable {
62
83
  private ready: Promise<void> | null = null;
63
84
 
@@ -91,8 +112,8 @@ export class Durable {
91
112
  await this.quietly("an ended stream", () =>
92
113
  this.db.query(
93
114
  `INSERT INTO ended_streams
94
- (id, code, name, owner_id, url, now_playing, started_at, ended_at)
95
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
115
+ (id, code, name, owner_id, url, now_playing, started_at, ended_at, channel_codes)
116
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
96
117
  ON CONFLICT (id) DO UPDATE
97
118
  SET code = EXCLUDED.code,
98
119
  name = EXCLUDED.name,
@@ -100,7 +121,8 @@ export class Durable {
100
121
  url = EXCLUDED.url,
101
122
  now_playing = EXCLUDED.now_playing,
102
123
  started_at = EXCLUDED.started_at,
103
- ended_at = EXCLUDED.ended_at`,
124
+ ended_at = EXCLUDED.ended_at,
125
+ channel_codes = EXCLUDED.channel_codes`,
104
126
  [
105
127
  stream.id,
106
128
  stream.code,
@@ -110,6 +132,7 @@ export class Durable {
110
132
  stream.nowPlaying,
111
133
  stream.startedAt,
112
134
  stream.endedAt,
135
+ JSON.stringify(stream.channelCodes ?? {}),
113
136
  ],
114
137
  ),
115
138
  );
@@ -141,6 +164,7 @@ export class Durable {
141
164
  // nothing like a number.
142
165
  startedAt: Number(r["started_at"] ?? 0),
143
166
  endedAt: Number(r["ended_at"] ?? 0),
167
+ channelCodes: codesFrom(r["channel_codes"]),
144
168
  }));
145
169
  } catch (error) {
146
170
  this.onEvent(` could not read ended streams: ${(error as Error).message}`);
package/src/hls.ts ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * A channel as HLS, for the browsers that will not take it any other way.
3
+ *
4
+ * A channel is sent as one endless fragmented MP4 down a chunked response,
5
+ * which Chrome and Firefox play as they would a file. Safari on an iPhone
6
+ * will not: it asks for a byte range, gets a stream with no length, and gives
7
+ * up before the first picture -- a spinner that flashes and a player that
8
+ * never starts. What Safari plays live is HLS, a playlist of short files.
9
+ *
10
+ * So this packages the same bytes into HLS on demand: one ffmpeg per channel,
11
+ * copying (never re-encoding) the fragments it is fed into two-second
12
+ * MPEG-TS segments in a temporary directory, started when the first playlist
13
+ * is asked for and stopped a minute after the last. The channel itself is
14
+ * untouched; this is one more listener on it.
15
+ */
16
+ import { spawn, type ChildProcess } from "node:child_process";
17
+ import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
19
+ import { join } from "node:path";
20
+
21
+ /** Seconds of video per segment. Short, so joining is quick; long enough that a playlist is not churn. */
22
+ export const SEGMENT_SECONDS = 2;
23
+ /** How many segments the playlist offers. About twelve seconds of catch-up. */
24
+ export const PLAYLIST_SEGMENTS = 6;
25
+ /** How long a packager runs with nobody asking for its playlist. */
26
+ export const IDLE_MS = 60_000;
27
+ /** How long the first playlist may take to appear before it is a failure. */
28
+ export const FIRST_PLAYLIST_MS = 20_000;
29
+
30
+ const SEGMENT = /^seg\d{5}\.ts$/;
31
+
32
+ /** A segment file name, or "" for anything that is not one. Never a path. */
33
+ export function segmentName(requested: string): string {
34
+ return SEGMENT.test(requested) ? requested : "";
35
+ }
36
+
37
+ /**
38
+ * The playlist with the key on every segment.
39
+ *
40
+ * A browser resolves segment names against the playlist's URL and drops its
41
+ * query, so the key that opened the playlist never reaches the segments and
42
+ * each one answers 401. The key travels on every line instead.
43
+ */
44
+ export function withKey(playlist: string, key: string): string {
45
+ if (key === "") return playlist;
46
+ return playlist
47
+ .split("\n")
48
+ .map((line) => (line !== "" && !line.startsWith("#") ? `${line}?k=${encodeURIComponent(key)}` : line))
49
+ .join("\n");
50
+ }
51
+
52
+ /** The ffmpeg arguments: copy what arrives on stdin into a rolling playlist. */
53
+ export function packagerArgs(dir: string): string[] {
54
+ return [
55
+ "-hide_banner",
56
+ "-loglevel", "error",
57
+ "-i", "pipe:0",
58
+ "-c", "copy",
59
+ "-f", "hls",
60
+ "-hls_time", String(SEGMENT_SECONDS),
61
+ "-hls_list_size", String(PLAYLIST_SEGMENTS),
62
+ // Old segments go; the playlist never ends; each segment starts on a
63
+ // keyframe so a joiner can begin anywhere; written whole then renamed so
64
+ // a request never reads half a file.
65
+ "-hls_flags", "delete_segments+omit_endlist+independent_segments+temp_file",
66
+ "-hls_segment_type", "mpegts",
67
+ "-hls_segment_filename", join(dir, "seg%05d.ts"),
68
+ join(dir, "index.m3u8"),
69
+ ];
70
+ }
71
+
72
+ export interface Packaged {
73
+ /** Feed it the channel's bytes: the header first, then every fragment. */
74
+ write(chunk: Buffer): boolean;
75
+ /** The channel ended; finish and stop. */
76
+ end(): void;
77
+ }
78
+
79
+ /** One channel's packager. */
80
+ class Packager implements Packaged {
81
+ readonly dir: string;
82
+ private child: ChildProcess | null = null;
83
+ private idle: ReturnType<typeof setTimeout> | null = null;
84
+ private stopped = false;
85
+ private detach: (() => void) | null = null;
86
+
87
+ constructor(
88
+ private readonly id: string,
89
+ private readonly ffmpeg: string[],
90
+ private readonly onStop: (id: string) => void,
91
+ private readonly onEvent: (message: string) => void,
92
+ ) {
93
+ this.dir = mkdtempSync(join(tmpdir(), `nixamp-hls-${id}-`));
94
+ }
95
+
96
+ start(listen: (listener: Packaged) => (() => void) | null): boolean {
97
+ const [command, ...prefix] = this.ffmpeg as [string, ...string[]];
98
+ try {
99
+ this.child = spawn(command, [...prefix, ...packagerArgs(this.dir)], { stdio: ["pipe", "ignore", "pipe"] });
100
+ } catch (error) {
101
+ this.onEvent(` HLS for "${this.id}" could not start: ${(error as Error).message}`);
102
+ this.stop();
103
+ return false;
104
+ }
105
+ let complaint = "";
106
+ this.child.stderr?.on("data", (chunk: Buffer) => {
107
+ if (complaint.length < 4000) complaint += chunk.toString("utf8");
108
+ });
109
+ this.child.stdin?.on("error", () => undefined);
110
+ this.child.on("error", (error) => {
111
+ this.onEvent(` HLS for "${this.id}" failed: ${error.message}`);
112
+ this.stop();
113
+ });
114
+ this.child.on("close", (code) => {
115
+ if (!this.stopped && code !== 0) {
116
+ this.onEvent(` HLS for "${this.id}" stopped: ${complaint.trim().split("\n").pop() ?? code}`);
117
+ }
118
+ this.stop();
119
+ });
120
+ this.detach = listen(this);
121
+ if (this.detach === null) {
122
+ this.stop();
123
+ return false;
124
+ }
125
+ this.touch();
126
+ return true;
127
+ }
128
+
129
+ write(chunk: Buffer): boolean {
130
+ const stdin = this.child?.stdin;
131
+ if (!stdin || stdin.destroyed) return false;
132
+ try {
133
+ stdin.write(chunk);
134
+ return true;
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ end(): void {
141
+ try {
142
+ this.child?.stdin?.end();
143
+ } catch {
144
+ // Already gone.
145
+ }
146
+ this.stop();
147
+ }
148
+
149
+ /** Somebody asked for the playlist: it is wanted for another minute. */
150
+ touch(): void {
151
+ if (this.idle) clearTimeout(this.idle);
152
+ this.idle = setTimeout(() => this.stop(), IDLE_MS);
153
+ this.idle.unref?.();
154
+ }
155
+
156
+ /** The playlist as it stands, or "" before the first segment is written. */
157
+ playlist(): string {
158
+ try {
159
+ const text = readFileSync(join(this.dir, "index.m3u8"), "utf8");
160
+ return text.includes("#EXTINF") ? text : "";
161
+ } catch {
162
+ return "";
163
+ }
164
+ }
165
+
166
+ /** A segment's path, or "" when it is not there (any more). */
167
+ segment(name: string): string {
168
+ const safe = segmentName(name);
169
+ if (safe === "") return "";
170
+ const path = join(this.dir, safe);
171
+ try {
172
+ return statSync(path).isFile() ? path : "";
173
+ } catch {
174
+ return "";
175
+ }
176
+ }
177
+
178
+ stop(): void {
179
+ if (this.stopped) return;
180
+ this.stopped = true;
181
+ if (this.idle) clearTimeout(this.idle);
182
+ this.idle = null;
183
+ this.detach?.();
184
+ this.detach = null;
185
+ const child = this.child;
186
+ this.child = null;
187
+ if (child && child.exitCode === null) {
188
+ try {
189
+ child.stdin?.end();
190
+ child.kill("SIGKILL");
191
+ } catch {
192
+ // Already gone.
193
+ }
194
+ }
195
+ try {
196
+ rmSync(this.dir, { recursive: true, force: true });
197
+ } catch {
198
+ // A directory that is already gone is fine.
199
+ }
200
+ this.onStop(this.id);
201
+ }
202
+ }
203
+
204
+ /**
205
+ * The packagers, one per channel that somebody is watching this way.
206
+ *
207
+ * `listen` is how a packager becomes a listener on its channel: it is handed
208
+ * the header and then every fragment, exactly as a browser would be.
209
+ */
210
+ export class HlsPackagers {
211
+ private readonly running = new Map<string, Packager>();
212
+
213
+ constructor(
214
+ private readonly options: {
215
+ ffmpeg: string[];
216
+ listen: (id: string, listener: Packaged) => (() => void) | null;
217
+ onEvent?: (message: string) => void;
218
+ /** Injected for tests: how long to wait for the first playlist. */
219
+ firstPlaylistMs?: number;
220
+ },
221
+ ) {}
222
+
223
+ /**
224
+ * The playlist for a channel, starting the packager if it is not running,
225
+ * and waiting for the first segments to exist. Null when the channel is not
226
+ * there or nothing could be packaged.
227
+ */
228
+ async playlist(id: string): Promise<string | null> {
229
+ let packager = this.running.get(id);
230
+ if (!packager) {
231
+ packager = new Packager(id, this.options.ffmpeg, (gone) => this.running.delete(gone), this.options.onEvent ?? (() => undefined));
232
+ this.running.set(id, packager);
233
+ if (!packager.start((listener) => this.options.listen(id, listener))) return null;
234
+ }
235
+ packager.touch();
236
+ const deadline = Date.now() + (this.options.firstPlaylistMs ?? FIRST_PLAYLIST_MS);
237
+ for (;;) {
238
+ const text = packager.playlist();
239
+ if (text !== "") return text;
240
+ if (Date.now() > deadline || !this.running.has(id)) return null;
241
+ await new Promise((done) => setTimeout(done, 250));
242
+ }
243
+ }
244
+
245
+ /** A segment's path, or "" when there is no such segment. */
246
+ segment(id: string, name: string): string {
247
+ const packager = this.running.get(id);
248
+ if (!packager) return "";
249
+ packager.touch();
250
+ return packager.segment(name);
251
+ }
252
+
253
+ /** The channel went: stop packaging it. */
254
+ stop(id: string): void {
255
+ this.running.get(id)?.stop();
256
+ }
257
+
258
+ stopAll(): void {
259
+ for (const packager of [...this.running.values()]) packager.stop();
260
+ }
261
+
262
+ get count(): number {
263
+ return this.running.size;
264
+ }
265
+ }
package/src/server.ts CHANGED
@@ -51,6 +51,7 @@ import { stateDir } from "./daemon.ts";
51
51
  import { readSession } from "./session.ts";
52
52
  import { Directory, ENDED_TTL_MS, parseAnnouncement, type Listing } from "./directory.ts";
53
53
  import { PartyLine, telnyxSms } from "./partyline.ts";
54
+ import { HlsPackagers, withKey } from "./hls.ts";
54
55
  import {
55
56
  contentTypeFor, downloadArgs, fileNameFor, inputArgsFor, linkChannelId, playableLink, resolveLink, saveFormat,
56
57
  type ResolvedLink,
@@ -1163,6 +1164,8 @@ export interface HandlerOptions {
1163
1164
  ffprobe?: string[];
1164
1165
  /** yt-dlp, which turns a pasted page into a media address. Null when there is none. */
1165
1166
  ytdlp?: string[] | null;
1167
+ /** Channels as HLS, for Safari on a phone, which plays a live stream no other way. */
1168
+ hls?: HlsPackagers;
1166
1169
  /** A Netscape cookies file for sites that want a signed-in browser, when there is one. */
1167
1170
  cookies?: string;
1168
1171
  /** Who is listening, for the admin view. */
@@ -2913,9 +2916,54 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2913
2916
  if (path.startsWith("/api/channels/") && options.channels) {
2914
2917
  const channels = options.channels;
2915
2918
  const rest = path.slice("/api/channels/".length);
2916
- const [rawId, action] = rest.split("/");
2919
+ const [rawId, action, file] = rest.split("/");
2917
2920
  const id = cleanId(rawId);
2918
2921
 
2922
+ // The same channel as HLS: a playlist of short files, which is what
2923
+ // Safari on an iPhone plays live -- it will not take the endless MP4
2924
+ // below, and spun on it a few times before giving up. Packaged on
2925
+ // demand, copying the fragments the browser would have got.
2926
+ if (action === "hls" && request.method === "GET") {
2927
+ if (!options.hls) {
2928
+ json(response, 503, { error: "this server cannot package HLS" });
2929
+ return;
2930
+ }
2931
+ if (!channels.has(id)) {
2932
+ json(response, 404, { error: "nothing is playing on that channel" });
2933
+ return;
2934
+ }
2935
+ if (file === "index.m3u8") {
2936
+ const playlist = await options.hls.playlist(id);
2937
+ if (playlist === null) {
2938
+ json(response, 503, { error: "that channel could not be packaged as HLS yet; try again in a moment" });
2939
+ return;
2940
+ }
2941
+ response.writeHead(200, {
2942
+ ...CORS,
2943
+ "content-type": "application/vnd.apple.mpegurl",
2944
+ "cache-control": "no-store",
2945
+ });
2946
+ // The key rides on every segment line: a browser drops the query
2947
+ // when it resolves a segment against the playlist.
2948
+ response.end(withKey(playlist, url.searchParams.get("k") ?? ""));
2949
+ return;
2950
+ }
2951
+ const segment = options.hls.segment(id, file ?? "");
2952
+ if (segment === "") {
2953
+ json(response, 404, { error: "no such segment" });
2954
+ return;
2955
+ }
2956
+ watch(request, response, "stream", id);
2957
+ response.writeHead(200, {
2958
+ ...CORS,
2959
+ "content-type": "video/mp2t",
2960
+ "cache-control": "no-store",
2961
+ "content-length": statSync(segment).size,
2962
+ });
2963
+ createReadStream(segment).pipe(response);
2964
+ return;
2965
+ }
2966
+
2919
2967
  if (action === undefined && request.method === "GET") {
2920
2968
  // Listening. The response is the fan-out target: whatever ffmpeg
2921
2969
  // produces for this channel is written to it until one end goes away.
@@ -4019,6 +4067,13 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4019
4067
  console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
4020
4068
  onEnd: (info) => console.log(` "${info.id}" stopped.`),
4021
4069
  });
4070
+ // Channels as HLS, on demand, for Safari on a phone: one ffmpeg copying a
4071
+ // channel's fragments into short files while somebody is asking for them.
4072
+ const hls = new HlsPackagers({
4073
+ ffmpeg: tools.ffmpeg,
4074
+ listen: (id, listener) => channels.listen(id, listener),
4075
+ onEvent: (message) => console.log(message),
4076
+ });
4022
4077
 
4023
4078
  // The channels this server was carrying when it was last stopped, put back
4024
4079
  // on. A server is restarted to pick up a new version, which is often, and
@@ -4389,6 +4444,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4389
4444
  // for the sites that will not talk to a datacenter without one.
4390
4445
  ytdlp: tools.ytdlp ?? null,
4391
4446
  cookies: cookiesFile(),
4447
+ hls,
4392
4448
  ...(tls ? { tls } : {}),
4393
4449
  // Untagged, so a directory of five thousand files answers at once; the
4394
4450
  // tags follow through `tag` below.
@@ -4833,6 +4889,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
4833
4889
 
4834
4890
  const shutdown = (): void => {
4835
4891
  rtmp?.stop();
4892
+ hls.stopAll();
4836
4893
  channels.stopAll();
4837
4894
  ingest?.stopRtmp();
4838
4895
  ingest?.close();
@@ -1 +1 @@
1
- import{t as e}from"./index-Bqujphpd.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};
1
+ import{t as e}from"./index-g9xRD0Ln.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};