nixamp 0.23.3 → 0.23.5

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/handles.ts CHANGED
@@ -22,8 +22,47 @@ const SCHEMA = `
22
22
  updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
23
23
  );
24
24
  CREATE UNIQUE INDEX IF NOT EXISTS ${TABLE}_lower ON ${TABLE} (lower(handle));
25
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS voice TEXT NOT NULL DEFAULT '';
26
+ ALTER TABLE ${TABLE} ADD COLUMN IF NOT EXISTS profile TEXT NOT NULL DEFAULT '';
25
27
  `;
26
28
 
29
+ /** What the room knows about somebody: the name, how they sound, where the rest is written. */
30
+ export interface Persona {
31
+ handle: string;
32
+ /** female, male, "" for whichever, or a provider voice id they named. */
33
+ voice: string;
34
+ /** The URL of their OpenProfile.md, or "". */
35
+ profile: string;
36
+ }
37
+
38
+ /** A voice as it may be kept: a sex, nothing, or a provider's voice id. */
39
+ export function cleanVoice(value: unknown): { voice: string; error: string } {
40
+ if (value === undefined || value === null) return { voice: "", error: "" };
41
+ if (typeof value !== "string") return { voice: "", error: "a voice is a word" };
42
+ const word = value.trim();
43
+ if (word === "" || word === "any") return { voice: "", error: "" };
44
+ const lower = word.toLowerCase();
45
+ if (lower === "female" || lower === "male") return { voice: lower, error: "" };
46
+ if (/^[A-Za-z][A-Za-z0-9_.-]{2,80}$/.test(word) && word.includes(".")) return { voice: word, error: "" };
47
+ return { voice: "", error: "female, male, any, or a voice id like Telnyx.KokoroTTS.am_adam" };
48
+ }
49
+
50
+ /** An OpenProfile address as it may be kept: an http(s) URL, or nothing. */
51
+ export function cleanProfile(value: unknown): { profile: string; error: string } {
52
+ if (value === undefined || value === null) return { profile: "", error: "" };
53
+ if (typeof value !== "string") return { profile: "", error: "a profile is a URL" };
54
+ const text = value.trim();
55
+ if (text === "") return { profile: "", error: "" };
56
+ try {
57
+ const url = new URL(text);
58
+ if (url.protocol !== "https:" && url.protocol !== "http:") return { profile: "", error: "a profile is an http(s) URL" };
59
+ if (url.href.length > 500) return { profile: "", error: "that URL is too long" };
60
+ return { profile: url.href, error: "" };
61
+ } catch {
62
+ return { profile: "", error: "a profile is a URL, like https://you.example/.well-known/openprofile.md" };
63
+ }
64
+ }
65
+
27
66
  /**
28
67
  * What a handle may be.
29
68
  *
@@ -87,6 +126,39 @@ export class Handles {
87
126
  return rows[0] ? String(rows[0]["handle"] ?? "") : "";
88
127
  }
89
128
 
129
+ /** Everything the room knows about somebody. Empty strings for whatever they never said. */
130
+ async persona(userId: string): Promise<Persona> {
131
+ await this.ensure();
132
+ const { rows } = await this.db.query(`SELECT handle, voice, profile FROM ${TABLE} WHERE user_id = $1`, [userId]);
133
+ const row = rows[0];
134
+ return {
135
+ handle: row ? String(row["handle"] ?? "") : "",
136
+ voice: row ? String(row["voice"] ?? "") : "",
137
+ profile: row ? String(row["profile"] ?? "") : "",
138
+ };
139
+ }
140
+
141
+ /**
142
+ * How somebody sounds, and where their profile is. Either may be given
143
+ * alone; what is not given is kept. A row exists only once a handle does,
144
+ * so somebody who never picked one is given the fallback handle first.
145
+ */
146
+ async describe(userId: string, fallbackHandle: string, wanted: { voice?: unknown; profile?: unknown }): Promise<{ persona: Persona | null; error: string }> {
147
+ const voice = wanted.voice === undefined ? null : cleanVoice(wanted.voice);
148
+ if (voice?.error) return { persona: null, error: voice.error };
149
+ const profile = wanted.profile === undefined ? null : cleanProfile(wanted.profile);
150
+ if (profile?.error) return { persona: null, error: profile.error };
151
+ await this.ensure();
152
+ const had = await this.persona(userId);
153
+ const handle = had.handle || cleanHandle(fallbackHandle) || fallbackHandle;
154
+ await this.db.query(
155
+ `INSERT INTO ${TABLE} (user_id, handle, voice, profile) VALUES ($1, $2, $3, $4)
156
+ ON CONFLICT (user_id) DO UPDATE SET voice = EXCLUDED.voice, profile = EXCLUDED.profile, updated_at = NOW()`,
157
+ [userId, handle, voice ? voice.voice : had.voice, profile ? profile.profile : had.profile],
158
+ );
159
+ return { persona: await this.persona(userId), error: "" };
160
+ }
161
+
90
162
  /**
91
163
  * Handles for a page of rows, in one query.
92
164
  *
package/src/links.ts CHANGED
@@ -145,13 +145,15 @@ export const PLAYLIST_FETCH_TIMEOUT_MS = 15_000;
145
145
  * all -- a segment list with #EXT-X- tags -- and those are one stream for
146
146
  * ffmpeg to read as it is, not a list of streams.
147
147
  */
148
- export function playlistFrom(text: string, base: string): { hls: boolean; sources: string[] } {
149
- if (/^#EXT-X-/m.test(text)) return { hls: true, sources: [] };
150
- const sources = parseCatalog(text, base)
151
- .map((entry) => entry.source)
152
- .filter((source) => /^(https?|rtmps?):\/\//i.test(source))
148
+ export function playlistFrom(text: string, base: string): { hls: boolean; sources: string[]; image: string } {
149
+ if (/^#EXT-X-/m.test(text)) return { hls: true, sources: [], image: "" };
150
+ const entries = parseCatalog(text, base)
151
+ .filter((entry) => /^(https?|rtmps?):\/\//i.test(entry.source))
153
152
  .slice(0, PLAYLIST_MAX_ENTRIES);
154
- return { hls: false, sources };
153
+ // The picture of the list is the first picture in it: a podcast's episodes
154
+ // all wear the show's art, and the newest is first.
155
+ const image = entries.map((entry) => pictureUrl(entry.logo)).find((one) => one !== "") ?? "";
156
+ return { hls: false, sources: entries.map((entry) => entry.source), image };
155
157
  }
156
158
 
157
159
  /**
@@ -195,7 +197,7 @@ export async function resolvePlaylist(
195
197
  extractor: "playlist",
196
198
  ext: "",
197
199
  page: url,
198
- thumbnail: "",
200
+ thumbnail: list.image,
199
201
  about: "",
200
202
  playlist: list.sources,
201
203
  };
package/src/partyline.ts CHANGED
@@ -702,6 +702,30 @@ export class PartyLine {
702
702
  });
703
703
  }
704
704
 
705
+ /**
706
+ * A line said into a room, to everybody on the phone in it. What the
707
+ * trollbox becomes on the party line: the handle and the words, in the
708
+ * voice of whoever typed them. False when nobody is on the phone there,
709
+ * which is most of the time and costs nothing.
710
+ */
711
+ async say(code: string, text: string, voice: string): Promise<boolean> {
712
+ const room = this.rooms.get(code);
713
+ if (!room || room.conferenceId === null || room.callers === 0) return false;
714
+ const said = text.replace(/\s+/g, " ").trim().slice(0, 500);
715
+ if (said === "") return false;
716
+ const answer = await this.request(`/conferences/${encodeURIComponent(room.conferenceId)}/actions/speak`, {
717
+ payload: said,
718
+ voice: voice || this.voice,
719
+ });
720
+ return answer !== null;
721
+ }
722
+
723
+ /** Whether anybody is on the phone in a room, before a line is worth reading. */
724
+ hasCallers(code: string): boolean {
725
+ const room = this.rooms.get(code);
726
+ return room !== undefined && room.conferenceId !== null && room.callers > 0;
727
+ }
728
+
705
729
  /**
706
730
  * How many people are on the phone for a stream.
707
731
  *
package/src/server.ts CHANGED
@@ -88,6 +88,7 @@ import { Rooms } from "./rooms.ts";
88
88
  import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.ts";
89
89
  import { MAX_BYTES as SPEECH_BYTES, Speech, SpeechError, isWav, languageOf } from "./speech.ts";
90
90
  import { Captions } from "./captions.ts";
91
+ import { Profiles, spokenLine, telnyxVoiceFor, voicesFromEnv } from "./voices.ts";
91
92
  import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
92
93
  import {
93
94
  applyRemoteConfig,
@@ -1733,6 +1734,8 @@ export interface HandlerOptions {
1733
1734
  trollbox?: Trollbox;
1734
1735
  /** Speech to text: a line said out loud, heard here. Needs the optional model. */
1735
1736
  speech?: Speech;
1737
+ /** Other people's OpenProfiles, for the voice their lines are read in. */
1738
+ profiles?: Profiles;
1736
1739
  /** Tickets: a paid pass to one event's room. Absent means every show is free. */
1737
1740
  tickets?: Tickets;
1738
1741
  /**
@@ -2703,6 +2706,33 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2703
2706
  // Separate from the address on purpose. The address is a credential and
2704
2707
  // a way to reach somebody; publishing it in a directory listing or an
2705
2708
  // invite would be publishing what they log in with.
2709
+ /**
2710
+ * A trollbox line, read aloud to whoever is on the phone in that room.
2711
+ * The room's code comes from the directory (one per channel, one for
2712
+ * the server's own stream), the voice from the author's account or
2713
+ * OpenProfile. Never awaited by the poster: the phone is a side effect
2714
+ * of the chat, and a slow Telnyx must not slow the box.
2715
+ */
2716
+ const readOnThePhone = (where: { server: string; channel: string }, authorId: string, handle: string, body: string): void => {
2717
+ const partyLine = options.partyLine;
2718
+ const listing = (options.directory?.list() ?? []).find((one) => {
2719
+ try {
2720
+ return new URL(one.url).origin === where.server;
2721
+ } catch {
2722
+ return false;
2723
+ }
2724
+ });
2725
+ if (!partyLine || !listing) return;
2726
+ const code = where.channel === "live" || where.channel === "main" ? listing.code : listing.channelCodes[where.channel] ?? "";
2727
+ if (!code || !partyLine.hasCallers(code)) return;
2728
+ void (async () => {
2729
+ const persona = (await options.handles?.persona(authorId)) ?? { handle, voice: "", profile: "" };
2730
+ const profile = persona.profile && options.profiles ? await options.profiles.voiceOf(persona.profile) : null;
2731
+ const voice = telnyxVoiceFor({ userId: authorId, voice: persona.voice, profile }, voicesFromEnv());
2732
+ await partyLine.say(code, spokenLine(handle, body), voice);
2733
+ })().catch(() => undefined);
2734
+ };
2735
+
2706
2736
  /*
2707
2737
  * The trollbox: the chat for one live room, keyed by the server and
2708
2738
  * the channel so every viewer of a stream is in the same box whichever
@@ -2751,6 +2781,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2751
2781
  }
2752
2782
  const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
2753
2783
  const line = await trollbox.post(where, who.id, handle, body.body);
2784
+ readOnThePhone(where, who.id, line.handle, line.body);
2754
2785
  json(response, 201, { message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true } });
2755
2786
  return;
2756
2787
  }
@@ -2819,6 +2850,7 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2819
2850
  if (where && options.trollbox && heard.text !== "") {
2820
2851
  const handle = (await options.handles?.of(who.id)) || fallbackHandle(who.id);
2821
2852
  const line = await options.trollbox.post(where, who.id, handle, heard.text);
2853
+ readOnThePhone(where, who.id, line.handle, line.body);
2822
2854
  json(response, 201, {
2823
2855
  text: heard.text, seconds: heard.seconds, model: speech.model,
2824
2856
  message: { id: line.id, handle: line.handle, body: line.body, createdAt: line.createdAt, mine: true },
@@ -2841,24 +2873,38 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
2841
2873
  return;
2842
2874
  }
2843
2875
 
2876
+ // The handle, and since 0.23.4 the voice a line is read in on the
2877
+ // phone and the OpenProfile it may be read from. Any of the three may
2878
+ // be sent alone; what is not sent is kept.
2844
2879
  if (request.method === "GET") {
2845
- json(response, 200, { handle: await handles.of(who.id) });
2880
+ const persona = await handles.persona(who.id);
2881
+ json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
2846
2882
  return;
2847
2883
  }
2848
2884
  if (request.method === "PUT" || request.method === "POST") {
2849
- let body: { handle?: unknown };
2885
+ let body: { handle?: unknown; voice?: unknown; profile?: unknown };
2850
2886
  try {
2851
2887
  body = JSON.parse(await readBody(request)) as typeof body;
2852
2888
  } catch {
2853
2889
  json(response, 400, { error: "bad JSON" });
2854
2890
  return;
2855
2891
  }
2856
- const claimed = await handles.claim(who.id, body.handle);
2857
- if (claimed.error) {
2858
- json(response, 409, { error: claimed.error });
2859
- return;
2892
+ if (body.handle !== undefined) {
2893
+ const claimed = await handles.claim(who.id, body.handle);
2894
+ if (claimed.error) {
2895
+ json(response, 409, { error: claimed.error });
2896
+ return;
2897
+ }
2860
2898
  }
2861
- json(response, 200, { handle: claimed.handle });
2899
+ if (body.voice !== undefined || body.profile !== undefined) {
2900
+ const described = await handles.describe(who.id, fallbackHandle(who.id), { voice: body.voice, profile: body.profile });
2901
+ if (described.error) {
2902
+ json(response, 422, { error: described.error });
2903
+ return;
2904
+ }
2905
+ }
2906
+ const persona = await handles.persona(who.id);
2907
+ json(response, 200, { ...persona, handle: persona.handle || fallbackHandle(who.id), chosen: persona.handle !== "" });
2862
2908
  return;
2863
2909
  }
2864
2910
  json(response, 405, { error: "GET or PUT" });
@@ -3666,6 +3712,15 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
3666
3712
  return;
3667
3713
  }
3668
3714
  links.set(link, resolved);
3715
+ // A channel already on -- put back by a restart, which forgets where
3716
+ // it came from -- learns its picture and its line from the link pasted
3717
+ // again, rather than waiting to be taken off and put on.
3718
+ const already = options.channels.info(channelId);
3719
+ if (already) {
3720
+ if (!already.art && resolved.thumbnail) already.art = resolved.thumbnail;
3721
+ if (!already.about && resolved.about) already.about = resolved.about;
3722
+ if (already.art || already.about) options.rememberChannels?.(rememberedNow(options.channels));
3723
+ }
3669
3724
  if (!options.channels.has(channelId)) {
3670
3725
  // Asked of the site first, once, with the headers yt-dlp said to
3671
3726
  // send. A media address that answers nothing is a site refusing
@@ -5815,6 +5870,8 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
5815
5870
  ...(rooms ? { rooms } : {}),
5816
5871
  ...(trollbox ? { trollbox } : {}),
5817
5872
  ...(speech ? { speech } : {}),
5873
+ // Other people's OpenProfiles, read for the voice a line is spoken in.
5874
+ ...(accounts ? { profiles: new Profiles() } : {}),
5818
5875
  ...(tickets ? { tickets } : {}),
5819
5876
  ...(authServer ? { authServer } : {}),
5820
5877
  ...(parties ? { parties } : {}),
package/src/voices.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The voice a person's words are read in, on the phone.
3
+ *
4
+ * A trollbox line is text; the party line is a phone call. Reading one into
5
+ * the other needs a voice, and the voice should be theirs as far as a
6
+ * machine can manage: a man's line in a man's voice, a woman's in a woman's,
7
+ * anybody else's in whichever they asked for. Where that comes from, in
8
+ * order: the voice they set on their nixamp account; their OpenProfile
9
+ * (`Voice`, then `Gender`, then `Pronouns`; see logicsrc.com/openprofile);
10
+ * and failing both, one picked from their id, so the same person is always
11
+ * the same voice even when nobody was told which.
12
+ *
13
+ * The voices are Telnyx's Kokoro ones: an open-weights model, no bill beyond
14
+ * the call. ElevenLabs or anything else Telnyx speaks is a configuration
15
+ * away, per sex, because "better" and "paid" are the same word there.
16
+ */
17
+ import { createHash } from "node:crypto";
18
+
19
+ export type VoiceKind = "female" | "male" | "";
20
+
21
+ /** Kokoro, through Telnyx: af_heart is the default woman, am_adam the default man. */
22
+ export const DEFAULT_VOICES: Record<Exclude<VoiceKind, "">, string> = {
23
+ female: "Telnyx.KokoroTTS.af_heart",
24
+ male: "Telnyx.KokoroTTS.am_adam",
25
+ };
26
+
27
+ /** The word a person used for themselves, as a voice: female, male, or nothing to go on. */
28
+ export function voiceKindOf(value: unknown): VoiceKind {
29
+ if (typeof value !== "string") return "";
30
+ const word = value.trim().toLowerCase();
31
+ if (word === "") return "";
32
+ if (/^(f|female|woman|women|girl|she|her|she\/her|fem|feminine|lady)$/.test(word)) return "female";
33
+ if (/^(m|male|man|men|boy|he|him|he\/him|masc|masculine|guy)$/.test(word)) return "male";
34
+ if (/^she\b/.test(word)) return "female";
35
+ if (/^he\b/.test(word)) return "male";
36
+ return "";
37
+ }
38
+
39
+ export interface ProfileVoice {
40
+ /** What the profile said outright, when it did: female, male, or a provider voice id. */
41
+ voice: string;
42
+ gender: string;
43
+ pronouns: string;
44
+ }
45
+
46
+ /**
47
+ * The parts of an OpenProfile.md that say how somebody sounds. The identity
48
+ * block is the bullets under the first `#`; `Gender` may also sit under
49
+ * `## Match`, where the spec put it first. Everything else is ignored.
50
+ */
51
+ export function readProfileVoice(markdown: string): ProfileVoice {
52
+ const found: ProfileVoice = { voice: "", gender: "", pronouns: "" };
53
+ let section = "";
54
+ let started = false;
55
+ for (const raw of markdown.split(/\r?\n/)) {
56
+ const line = raw.trim();
57
+ if (line.startsWith("# ") && !started) {
58
+ started = true;
59
+ continue;
60
+ }
61
+ if (line.startsWith("## ")) {
62
+ section = line.slice(3).trim().toLowerCase();
63
+ continue;
64
+ }
65
+ const inIdentity = started && section === "";
66
+ const inMatch = /^(match|dating|matching|partner|looking for)$/.test(section);
67
+ if (!inIdentity && !inMatch) continue;
68
+ const item = /^[-*]\s+(?:\*\*)?([A-Za-z ]+?)(?:\*\*)?\s*:\s*(.+)$/.exec(line);
69
+ if (!item) continue;
70
+ const key = (item[1] ?? "").trim().toLowerCase();
71
+ const value = (item[2] ?? "").trim();
72
+ if (key === "voice" && !found.voice) found.voice = value;
73
+ if (key === "gender" && !found.gender) found.gender = value;
74
+ if (key === "pronouns" && !found.pronouns) found.pronouns = value;
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /**
80
+ * The Telnyx voice for somebody, from what is known about them. A voice
81
+ * named outright (`ElevenLabs.…`, `Telnyx.KokoroTTS.…`, `Polly.…`) is used
82
+ * as written; a sex picks the configured voice for it; nothing at all
83
+ * picks one by the account id, so it stays the same tomorrow.
84
+ */
85
+ export function telnyxVoiceFor(
86
+ who: { userId: string; voice?: string; profile?: ProfileVoice | null },
87
+ voices: Record<Exclude<VoiceKind, "">, string> = DEFAULT_VOICES,
88
+ ): string {
89
+ const asked = (who.voice ?? "").trim();
90
+ if (asked.includes(".")) return asked;
91
+ const fromProfile = who.profile?.voice.trim() ?? "";
92
+ if (fromProfile.includes(".")) return fromProfile;
93
+ const kind = voiceKindOf(asked) || voiceKindOf(fromProfile) || voiceKindOf(who.profile?.gender) || voiceKindOf(who.profile?.pronouns);
94
+ if (kind) return voices[kind];
95
+ const digit = createHash("sha1").update(who.userId).digest()[0] as number;
96
+ return digit % 2 === 0 ? voices.female : voices.male;
97
+ }
98
+
99
+ /** The voices to use per sex, from the environment when it says so. */
100
+ export function voicesFromEnv(env: NodeJS.ProcessEnv = process.env): Record<Exclude<VoiceKind, "">, string> {
101
+ return {
102
+ female: env["NIXAMP_VOICE_FEMALE"] || DEFAULT_VOICES.female,
103
+ male: env["NIXAMP_VOICE_MALE"] || DEFAULT_VOICES.male,
104
+ };
105
+ }
106
+
107
+ /** What a line sounds like read aloud: who said it, then what. */
108
+ export function spokenLine(handle: string, body: string): string {
109
+ const said = body.replace(/\s+/g, " ").trim();
110
+ return `${handle.replace(/-/g, " ")} says: ${said}`;
111
+ }
112
+
113
+ /**
114
+ * Somebody's OpenProfile, fetched and kept an hour. A profile is read on
115
+ * every line they say, and a line a second is the trollbox's own limit.
116
+ */
117
+ export class Profiles {
118
+ private readonly kept = new Map<string, { at: number; voice: ProfileVoice }>();
119
+
120
+ constructor(
121
+ private readonly fetcher: typeof fetch = fetch,
122
+ private readonly now: () => number = () => Date.now(),
123
+ private readonly ttlMs = 60 * 60 * 1000,
124
+ ) {}
125
+
126
+ async voiceOf(url: string): Promise<ProfileVoice | null> {
127
+ let where: URL;
128
+ try {
129
+ where = new URL(url);
130
+ if (where.protocol !== "https:" && where.protocol !== "http:") return null;
131
+ } catch {
132
+ return null;
133
+ }
134
+ const had = this.kept.get(where.href);
135
+ if (had && this.now() - had.at < this.ttlMs) return had.voice;
136
+ try {
137
+ const response = await this.fetcher(where.href, {
138
+ headers: { accept: "text/markdown, text/plain;q=0.9, */*;q=0.1" },
139
+ signal: AbortSignal.timeout(5000),
140
+ });
141
+ if (!response.ok) return had?.voice ?? null;
142
+ const text = (await response.text()).slice(0, 64 * 1024);
143
+ const voice = readProfileVoice(text);
144
+ this.kept.set(where.href, { at: this.now(), voice });
145
+ if (this.kept.size > 5000) {
146
+ for (const [key, one] of this.kept) if (this.now() - one.at > this.ttlMs) this.kept.delete(key);
147
+ }
148
+ return voice;
149
+ } catch {
150
+ return had?.voice ?? null;
151
+ }
152
+ }
153
+ }
@@ -1 +1 @@
1
- import{t as e}from"./index-CjxlA6ia.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-zCUbE3U5.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};