nixamp 0.5.8 → 0.5.10

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/audio.d.ts CHANGED
@@ -58,3 +58,28 @@ export declare function peaks(pcm: Float32Array): [number, number];
58
58
  /** Interleaved stereo down to mono, for the analyser. */
59
59
  export declare function toMono(pcm: Float32Array): Float32Array;
60
60
  export declare function formatTime(seconds: number): string;
61
+ /** What is actually inside a container, as opposed to what the name suggests. */
62
+ export interface Codecs {
63
+ /** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
64
+ video: string;
65
+ /** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
66
+ audio: string;
67
+ }
68
+ /**
69
+ * Ask ffprobe what the streams are, without holding the event loop.
70
+ *
71
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
72
+ * answering other requests, and a synchronous probe per media request is how
73
+ * the whole library came to be tagged with the process wedged solid.
74
+ */
75
+ export declare function codecsOf(tools: Tools, path: string): Promise<Codecs>;
76
+ /**
77
+ * How to get this file into a browser, given what is inside it.
78
+ *
79
+ * A container a browser will not open says nothing about the streams within:
80
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
81
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
82
+ * would cost a core per viewer and look worse. So the streams decide, one part
83
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
84
+ */
85
+ export declare function videoArgs(codecs: Codecs): string[];
package/dist/audio.js CHANGED
@@ -204,3 +204,68 @@ export function formatTime(seconds) {
204
204
  const s = total % 60;
205
205
  return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
206
206
  }
207
+ /**
208
+ * Ask ffprobe what the streams are, without holding the event loop.
209
+ *
210
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
211
+ * answering other requests, and a synchronous probe per media request is how
212
+ * the whole library came to be tagged with the process wedged solid.
213
+ */
214
+ export async function codecsOf(tools, path) {
215
+ const [cmd, ...rest] = tools.ffprobe;
216
+ const empty = { video: "", audio: "" };
217
+ if (!cmd)
218
+ return empty;
219
+ return new Promise((done) => {
220
+ const child = spawn(cmd, [
221
+ ...rest,
222
+ "-v", "quiet",
223
+ "-print_format", "json",
224
+ "-show_entries", "stream=codec_type,codec_name",
225
+ path,
226
+ ], { stdio: ["ignore", "pipe", "ignore"] });
227
+ let out = "";
228
+ child.stdout.on("data", (chunk) => {
229
+ out += chunk.toString("utf8");
230
+ });
231
+ child.on("error", () => done(empty));
232
+ child.on("close", () => {
233
+ try {
234
+ const parsed = JSON.parse(out);
235
+ const streams = parsed.streams ?? [];
236
+ return done({
237
+ video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
238
+ audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
239
+ });
240
+ }
241
+ catch {
242
+ return done(empty);
243
+ }
244
+ });
245
+ });
246
+ }
247
+ /**
248
+ * How to get this file into a browser, given what is inside it.
249
+ *
250
+ * A container a browser will not open says nothing about the streams within:
251
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
252
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
253
+ * would cost a core per viewer and look worse. So the streams decide, one part
254
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
255
+ */
256
+ export function videoArgs(codecs) {
257
+ // What a browser can play inside MP4 without help.
258
+ const keepVideo = codecs.video === "h264";
259
+ const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
260
+ return [
261
+ "-c:v", keepVideo ? "copy" : "libx264",
262
+ ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
263
+ "-c:a", keepAudio ? "copy" : "aac",
264
+ ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
265
+ "-f", "mp4",
266
+ // Fragmented, because this is a pipe: a normal MP4 writes its index at the
267
+ // end, which for a stream never arrives and for a browser means nothing
268
+ // plays at all.
269
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
270
+ ];
271
+ }
@@ -12,6 +12,14 @@ export interface RemoteTrack {
12
12
  album: string;
13
13
  /** Seconds; 0 when ffprobe could not tell us. */
14
14
  duration: number;
15
+ /**
16
+ * Whether this is a film rather than a song.
17
+ *
18
+ * The server knows, because it has the path; a remote had been guessing, and
19
+ * guessing wrong -- every remote track went to the audio element, so a video
20
+ * a browser could show played its soundtrack over a blank panel.
21
+ */
22
+ video?: boolean;
15
23
  }
16
24
  /** Everything a remote needs to draw the player. */
17
25
  export interface Snapshot {
package/dist/server.d.ts CHANGED
@@ -135,6 +135,7 @@ export interface Engine {
135
135
  stop(): void;
136
136
  }
137
137
  export declare function toRemoteTracks(tracks: Track[]): RemoteTrack[];
138
+ export declare function hasPicture(path: string): boolean;
138
139
  /**
139
140
  * The headless player: the terminal app's engine without the terminal.
140
141
  * One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
@@ -204,6 +205,8 @@ export interface HandlerOptions {
204
205
  listenKey?: string | null;
205
206
  /** How to run ffmpeg, for the sources a browser cannot play by itself. */
206
207
  ffmpeg?: string[];
208
+ /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
209
+ ffprobe?: string[];
207
210
  /** Who is listening, for the admin view. */
208
211
  connections?: Connections;
209
212
  /**
package/dist/server.js CHANGED
@@ -35,6 +35,7 @@ import { notifyAll, resendEmail, webPush } from "./notify.js";
35
35
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
36
36
  import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
37
37
  import { isRemote, playsInBrowser } from "./sources.js";
38
+ import { codecsOf, videoArgs } from "./audio.js";
38
39
  import { allowedForListening, elevate, firewallInUse, keyCookie, keyFrom, lookupPublicIp, newKey, portCommands, reachableAddresses, scopeOf, shareLink, audioLink, } from "./share.js";
39
40
  import { extname, join, normalize, resolve, sep } from "node:path";
40
41
  import { fileURLToPath } from "node:url";
@@ -269,8 +270,18 @@ export function toRemoteTracks(tracks) {
269
270
  artist: t.artist,
270
271
  album: t.album,
271
272
  duration: t.duration,
273
+ // Said out loud, because a remote cannot see the path and had been sending
274
+ // every track to the audio element -- a film's soundtrack over a blank
275
+ // panel, which is exactly what it looked like.
276
+ ...(hasPicture(t.path) ? { video: true } : {}),
272
277
  }));
273
278
  }
279
+ /** Video containers, as opposed to the songs that are most of a library. */
280
+ const PICTURE = new Set([".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".wmv", ".flv"]);
281
+ export function hasPicture(path) {
282
+ const dot = path.lastIndexOf(".");
283
+ return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
284
+ }
274
285
  /**
275
286
  * The headless player: the terminal app's engine without the terminal.
276
287
  * One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
@@ -1534,10 +1545,19 @@ export function createHandler(engine, options) {
1534
1545
  // to it raw is bytes it cannot play. Seeking is what this route is for
1535
1546
  // and transcoding gives it up, but an unseekable film beats a silent
1536
1547
  // one -- and the seekable formats are untouched.
1537
- if (playsInBrowser(file))
1548
+ if (playsInBrowser(file)) {
1538
1549
  sendFile(request, response, file);
1539
- else
1550
+ }
1551
+ else if (hasPicture(file)) {
1552
+ // A film. It used to arrive as MP3 with `-vn`, which is to say as a
1553
+ // soundtrack over a blank panel; what ffprobe finds inside decides how
1554
+ // little work it takes to keep the picture.
1555
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
1556
+ pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
1557
+ }
1558
+ else {
1540
1559
  transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1560
+ }
1541
1561
  return;
1542
1562
  }
1543
1563
  // Whatever the source is, this comes back as MP3 a browser will play:
@@ -1738,7 +1758,18 @@ function liveAudio(request, response, engine, ffmpeg) {
1738
1758
  * player falls back to /api/media for a local file it can seek, and uses this
1739
1759
  * for everything else.
1740
1760
  */
1761
+ /** Audio, from whatever this is: the shape every non-browser source took. */
1741
1762
  function transcode(request, response, source, ffmpeg) {
1763
+ pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
1764
+ }
1765
+ /**
1766
+ * Run ffmpeg and hand its output straight to the caller.
1767
+ *
1768
+ * The output arguments belong to the caller, because the same plumbing carries
1769
+ * a film and a song and the only difference is what ffmpeg is asked to write --
1770
+ * which for a film is decided by what ffprobe found inside it.
1771
+ */
1772
+ function pipeFfmpeg(request, response, source, ffmpeg, outputArgs, contentType) {
1742
1773
  const [command, ...prefix] = ffmpeg;
1743
1774
  const child = spawn(command, [
1744
1775
  ...prefix,
@@ -1749,9 +1780,7 @@ function transcode(request, response, source, ffmpeg) {
1749
1780
  // when they are handed to it for a file on disk.
1750
1781
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
1751
1782
  "-i", source,
1752
- "-vn",
1753
- "-f", "mp3",
1754
- "-b:a", "192k",
1783
+ ...outputArgs,
1755
1784
  "-",
1756
1785
  ], { stdio: ["ignore", "pipe", "pipe"] });
1757
1786
  let failed = "";
@@ -1766,7 +1795,7 @@ function transcode(request, response, source, ffmpeg) {
1766
1795
  started = true;
1767
1796
  response.writeHead(200, {
1768
1797
  ...CORS,
1769
- "content-type": "audio/mpeg",
1798
+ "content-type": contentType,
1770
1799
  "cache-control": "no-store",
1771
1800
  // Length is unknowable up front, and a browser is happy without it.
1772
1801
  "transfer-encoding": "chunked",
@@ -2084,6 +2113,7 @@ export async function serve(argv, version = "0.1.0") {
2084
2113
  connections,
2085
2114
  paywall,
2086
2115
  ffmpeg: tools.ffmpeg,
2116
+ ffprobe: tools.ffprobe,
2087
2117
  load: (next) => loadSource(tools, next),
2088
2118
  ...(directory ? { directory } : {}),
2089
2119
  ...(follows ? { follows, vapidPublicKey } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nixamp",
3
- "version": "0.5.8",
3
+ "version": "0.5.10",
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",
package/src/audio.ts CHANGED
@@ -231,3 +231,81 @@ export function formatTime(seconds: number): string {
231
231
  const s = total % 60;
232
232
  return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
233
233
  }
234
+
235
+ /** What is actually inside a container, as opposed to what the name suggests. */
236
+ export interface Codecs {
237
+ /** e.g. "h264", "hevc", "vp9". Empty when there is no video stream. */
238
+ video: string;
239
+ /** e.g. "aac", "ac3", "dts". Empty when there is no audio stream. */
240
+ audio: string;
241
+ }
242
+
243
+ /**
244
+ * Ask ffprobe what the streams are, without holding the event loop.
245
+ *
246
+ * Deliberately not the spawnSync `probe` above: this one runs while a server is
247
+ * answering other requests, and a synchronous probe per media request is how
248
+ * the whole library came to be tagged with the process wedged solid.
249
+ */
250
+ export async function codecsOf(tools: Tools, path: string): Promise<Codecs> {
251
+ const [cmd, ...rest] = tools.ffprobe;
252
+ const empty: Codecs = { video: "", audio: "" };
253
+ if (!cmd) return empty;
254
+
255
+ return new Promise<Codecs>((done) => {
256
+ const child = spawn(
257
+ cmd,
258
+ [
259
+ ...rest,
260
+ "-v", "quiet",
261
+ "-print_format", "json",
262
+ "-show_entries", "stream=codec_type,codec_name",
263
+ path,
264
+ ],
265
+ { stdio: ["ignore", "pipe", "ignore"] },
266
+ );
267
+ let out = "";
268
+ child.stdout.on("data", (chunk: Buffer) => {
269
+ out += chunk.toString("utf8");
270
+ });
271
+ child.on("error", () => done(empty));
272
+ child.on("close", () => {
273
+ try {
274
+ const parsed = JSON.parse(out) as { streams?: { codec_type?: string; codec_name?: string }[] };
275
+ const streams = parsed.streams ?? [];
276
+ return done({
277
+ video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
278
+ audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
279
+ });
280
+ } catch {
281
+ return done(empty);
282
+ }
283
+ });
284
+ });
285
+ }
286
+
287
+ /**
288
+ * How to get this file into a browser, given what is inside it.
289
+ *
290
+ * A container a browser will not open says nothing about the streams within:
291
+ * most Matroska holds H.264, which every browser decodes, and only the wrapper
292
+ * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
293
+ * would cost a core per viewer and look worse. So the streams decide, one part
294
+ * at a time -- a film can have its video copied and only its DTS re-encoded.
295
+ */
296
+ export function videoArgs(codecs: Codecs): string[] {
297
+ // What a browser can play inside MP4 without help.
298
+ const keepVideo = codecs.video === "h264";
299
+ const keepAudio = codecs.audio === "aac" || codecs.audio === "mp3";
300
+ return [
301
+ "-c:v", keepVideo ? "copy" : "libx264",
302
+ ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p"]),
303
+ "-c:a", keepAudio ? "copy" : "aac",
304
+ ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
305
+ "-f", "mp4",
306
+ // Fragmented, because this is a pipe: a normal MP4 writes its index at the
307
+ // end, which for a stream never arrives and for a browser means nothing
308
+ // plays at all.
309
+ "-movflags", "frag_keyframe+empty_moov+default_base_moof",
310
+ ];
311
+ }
package/src/protocol.ts CHANGED
@@ -13,6 +13,14 @@ export interface RemoteTrack {
13
13
  album: string;
14
14
  /** Seconds; 0 when ffprobe could not tell us. */
15
15
  duration: number;
16
+ /**
17
+ * Whether this is a film rather than a song.
18
+ *
19
+ * The server knows, because it has the path; a remote had been guessing, and
20
+ * guessing wrong -- every remote track went to the audio element, so a video
21
+ * a browser could show played its soundtrack over a blank panel.
22
+ */
23
+ video?: boolean;
16
24
  }
17
25
 
18
26
  /** Everything a remote needs to draw the player. */
package/src/server.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  paywallFromEnv,
56
56
  } from "./paywall.ts";
57
57
  import { isRemote, playsInBrowser } from "./sources.ts";
58
+ import { codecsOf, videoArgs } from "./audio.ts";
58
59
  import {
59
60
  allowedForListening,
60
61
  elevate,
@@ -391,9 +392,21 @@ export function toRemoteTracks(tracks: Track[]): RemoteTrack[] {
391
392
  artist: t.artist,
392
393
  album: t.album,
393
394
  duration: t.duration,
395
+ // Said out loud, because a remote cannot see the path and had been sending
396
+ // every track to the audio element -- a film's soundtrack over a blank
397
+ // panel, which is exactly what it looked like.
398
+ ...(hasPicture(t.path) ? { video: true } : {}),
394
399
  }));
395
400
  }
396
401
 
402
+ /** Video containers, as opposed to the songs that are most of a library. */
403
+ const PICTURE = new Set([".mp4", ".mkv", ".avi", ".mov", ".m4v", ".webm", ".mpg", ".mpeg", ".wmv", ".flv"]);
404
+
405
+ export function hasPicture(path: string): boolean {
406
+ const dot = path.lastIndexOf(".");
407
+ return dot > 0 && PICTURE.has(path.slice(dot).toLowerCase());
408
+ }
409
+
397
410
  /**
398
411
  * The headless player: the terminal app's engine without the terminal.
399
412
  * One ffmpeg decodes, ffplay makes the sound, and every sample is measured on
@@ -681,6 +694,8 @@ export interface HandlerOptions {
681
694
  listenKey?: string | null;
682
695
  /** How to run ffmpeg, for the sources a browser cannot play by itself. */
683
696
  ffmpeg?: string[];
697
+ /** Where ffprobe is, for asking what is inside a file before re-encoding it. */
698
+ ffprobe?: string[];
684
699
  /** Who is listening, for the admin view. */
685
700
  connections?: Connections;
686
701
  /**
@@ -1801,8 +1816,17 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
1801
1816
  // to it raw is bytes it cannot play. Seeking is what this route is for
1802
1817
  // and transcoding gives it up, but an unseekable film beats a silent
1803
1818
  // one -- and the seekable formats are untouched.
1804
- if (playsInBrowser(file)) sendFile(request, response, file);
1805
- else transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1819
+ if (playsInBrowser(file)) {
1820
+ sendFile(request, response, file);
1821
+ } else if (hasPicture(file)) {
1822
+ // A film. It used to arrive as MP3 with `-vn`, which is to say as a
1823
+ // soundtrack over a blank panel; what ffprobe finds inside decides how
1824
+ // little work it takes to keep the picture.
1825
+ const codecs = await codecsOf({ ffmpeg: [], ffprobe: options.ffprobe ?? ["ffprobe"], play: null }, file);
1826
+ pipeFfmpeg(request, response, file, options.ffmpeg ?? ["ffmpeg"], videoArgs(codecs), "video/mp4");
1827
+ } else {
1828
+ transcode(request, response, file, options.ffmpeg ?? ["ffmpeg"]);
1829
+ }
1806
1830
  return;
1807
1831
  }
1808
1832
 
@@ -2017,11 +2041,30 @@ function liveAudio(
2017
2041
  * player falls back to /api/media for a local file it can seek, and uses this
2018
2042
  * for everything else.
2019
2043
  */
2044
+ /** Audio, from whatever this is: the shape every non-browser source took. */
2020
2045
  function transcode(
2021
2046
  request: IncomingMessage,
2022
2047
  response: ServerResponse,
2023
2048
  source: string,
2024
2049
  ffmpeg: string[],
2050
+ ): void {
2051
+ pipeFfmpeg(request, response, source, ffmpeg, ["-vn", "-f", "mp3", "-b:a", "192k"], "audio/mpeg");
2052
+ }
2053
+
2054
+ /**
2055
+ * Run ffmpeg and hand its output straight to the caller.
2056
+ *
2057
+ * The output arguments belong to the caller, because the same plumbing carries
2058
+ * a film and a song and the only difference is what ffmpeg is asked to write --
2059
+ * which for a film is decided by what ffprobe found inside it.
2060
+ */
2061
+ function pipeFfmpeg(
2062
+ request: IncomingMessage,
2063
+ response: ServerResponse,
2064
+ source: string,
2065
+ ffmpeg: string[],
2066
+ outputArgs: string[],
2067
+ contentType: string,
2025
2068
  ): void {
2026
2069
  const [command, ...prefix] = ffmpeg as [string, ...string[]];
2027
2070
  const child = spawn(
@@ -2035,9 +2078,7 @@ function transcode(
2035
2078
  // when they are handed to it for a file on disk.
2036
2079
  ...(isRemote(source) ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
2037
2080
  "-i", source,
2038
- "-vn",
2039
- "-f", "mp3",
2040
- "-b:a", "192k",
2081
+ ...outputArgs,
2041
2082
  "-",
2042
2083
  ],
2043
2084
  { stdio: ["ignore", "pipe", "pipe"] },
@@ -2055,7 +2096,7 @@ function transcode(
2055
2096
  started = true;
2056
2097
  response.writeHead(200, {
2057
2098
  ...CORS,
2058
- "content-type": "audio/mpeg",
2099
+ "content-type": contentType,
2059
2100
  "cache-control": "no-store",
2060
2101
  // Length is unknowable up front, and a browser is happy without it.
2061
2102
  "transfer-encoding": "chunked",
@@ -2392,6 +2433,7 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
2392
2433
  connections,
2393
2434
  paywall,
2394
2435
  ffmpeg: tools.ffmpeg,
2436
+ ffprobe: tools.ffprobe,
2395
2437
  load: (next) => loadSource(tools, next),
2396
2438
  ...(directory ? { directory } : {}),
2397
2439
  ...(follows ? { follows, vapidPublicKey } : {}),
@@ -0,0 +1 @@
1
+ import{t as e}from"./index-ABOb54bx.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};