@pixl-app/sdk 0.0.0-stage → 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,875 @@
1
+ // src/chat.ts
2
+ function emoteUrl(emote, size = 1) {
3
+ const id = encodeURIComponent(emote.id);
4
+ switch (emote.provider) {
5
+ case "twitch":
6
+ return `https://static-cdn.jtvnw.net/emoticons/v2/${id}/${emote.animated ? "default" : "static"}/dark/${size === 4 ? 3 : size}.0`;
7
+ case "7tv":
8
+ return `https://cdn.7tv.app/emote/${id}/${size}x.webp`;
9
+ case "bttv":
10
+ return `https://cdn.betterttv.net/emote/${id}/${size === 4 ? 3 : size}x.webp`;
11
+ case "ffz":
12
+ return `https://cdn.frankerfacez.com/emote/${id}/${size}`;
13
+ }
14
+ }
15
+ var ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
16
+ function escapeHtml(text) {
17
+ return text.replace(/[&<>"']/g, (c) => ESCAPES[c]);
18
+ }
19
+ function emoteImg(e, size) {
20
+ const name = escapeHtml(e.name);
21
+ return `<img class="pixl-emote" src="${escapeHtml(emoteUrl(e, size))}" alt="${name}" data-provider="${e.provider}" data-emote="${escapeHtml(e.id)}">`;
22
+ }
23
+ function chatHtml(text, emotes = {}, options = {}) {
24
+ const size = options.size ?? 2;
25
+ const parts = text.split(/(\s+)/);
26
+ const out = [];
27
+ let lastEmote = -1;
28
+ for (const part of parts) {
29
+ if (!part)
30
+ continue;
31
+ if (/^\s+$/.test(part)) {
32
+ out.push(escapeHtml(part));
33
+ continue;
34
+ }
35
+ const emote = Object.prototype.hasOwnProperty.call(emotes, part) ? emotes[part] : undefined;
36
+ if (!emote) {
37
+ out.push(escapeHtml(part));
38
+ lastEmote = -1;
39
+ continue;
40
+ }
41
+ if (emote.zeroWidth && lastEmote >= 0) {
42
+ out.length = lastEmote + 1;
43
+ const base = out[lastEmote];
44
+ const inner = base.startsWith('<span class="pixl-emote-stack">') ? base.slice(31, -7) : base;
45
+ out[lastEmote] = `<span class="pixl-emote-stack">${inner}${emoteImg(emote, size)}</span>`;
46
+ continue;
47
+ }
48
+ out.push(emoteImg(emote, size));
49
+ lastEmote = out.length - 1;
50
+ }
51
+ return out.join("");
52
+ }
53
+ function badgesHtml(badges = []) {
54
+ return badges.map((b) => `<img class="pixl-badge" src="${escapeHtml(b.url)}" alt="${escapeHtml(b.title)}" data-provider="${b.provider}">`).join("");
55
+ }
56
+ var CHAT_CSS = `.pixl-emote{height:1.75em;vertical-align:middle;margin:-.25em 0}
57
+ .pixl-emote-stack{display:inline-grid;vertical-align:middle}
58
+ .pixl-emote-stack>.pixl-emote{grid-area:1/1;justify-self:center;align-self:center;margin:0}
59
+ .pixl-badge{height:1.1em;vertical-align:middle;margin-right:.25em}`;
60
+ function layerCss(l) {
61
+ switch (l.type) {
62
+ case "linear-gradient":
63
+ case "radial-gradient": {
64
+ const stops = l.stops.map((s) => `${s.color} ${Math.round(s.at * 1000) / 10}%`).join(", ");
65
+ const fn = `${l.repeating ? "repeating-" : ""}${l.type}`;
66
+ return l.type === "linear-gradient" ? `${fn}(${l.angle}deg, ${stops})` : `${fn}(${l.shape}, ${stops})`;
67
+ }
68
+ case "color":
69
+ return `linear-gradient(${l.color}, ${l.color})`;
70
+ case "image":
71
+ return `url("${l.url.replace(/"/g, "%22")}")`;
72
+ }
73
+ }
74
+ function paintCss(paint, fallback = "inherit") {
75
+ const layers = paint.layers.filter((l) => l.opacity > 0);
76
+ if (!layers.length)
77
+ return `color:${fallback}`;
78
+ const shadows = paint.shadows.map((s) => `drop-shadow(${s.x}px ${s.y}px ${s.blur}px ${s.color})`).join(" ");
79
+ return [
80
+ `color:${fallback}`,
81
+ `background-image:${layers.map(layerCss).join(", ")}`,
82
+ "background-size:cover",
83
+ "-webkit-background-clip:text",
84
+ "background-clip:text",
85
+ "-webkit-text-fill-color:transparent",
86
+ shadows ? `filter:${shadows}` : ""
87
+ ].filter(Boolean).join(";");
88
+ }
89
+
90
+ class PixlChat {
91
+ pixl;
92
+ paints = new Map;
93
+ queue = new Map;
94
+ timer = null;
95
+ constructor(pixl) {
96
+ this.pixl = pixl;
97
+ }
98
+ css = CHAT_CSS;
99
+ emoteUrl(emote, size = 1) {
100
+ return emoteUrl(emote, size);
101
+ }
102
+ badgesHtml(badges) {
103
+ return badgesHtml(badges);
104
+ }
105
+ paintCss(paint, fallback) {
106
+ return paintCss(paint, fallback);
107
+ }
108
+ html(text, emotes, options) {
109
+ return chatHtml(text, emotes, options);
110
+ }
111
+ async emotes() {
112
+ const res = await this.pixl.request("/emotes");
113
+ return res.emotes;
114
+ }
115
+ paint(id) {
116
+ let p = this.paints.get(id);
117
+ if (!p) {
118
+ p = new Promise((resolve) => {
119
+ this.queue.set(id, resolve);
120
+ this.timer ??= setTimeout(() => this.flush(), 20);
121
+ });
122
+ this.paints.set(id, p);
123
+ }
124
+ return p;
125
+ }
126
+ async paintStyle(id, fallback = "inherit") {
127
+ const paint = id ? await this.paint(id) : null;
128
+ return paint ? paintCss(paint, fallback) : `color:${fallback}`;
129
+ }
130
+ async flush() {
131
+ this.timer = null;
132
+ const batch = this.queue;
133
+ this.queue = new Map;
134
+ const ids = [...batch.keys()];
135
+ const found = new Map;
136
+ try {
137
+ for (let i = 0;i < ids.length; i += 100) {
138
+ const res = await this.pixl.request("/7tv/paints", {
139
+ ids: ids.slice(i, i + 100).join(",")
140
+ });
141
+ for (const p of res.paints)
142
+ found.set(p.id, p);
143
+ }
144
+ } catch (e) {
145
+ console.warn("[pixl] 7TV-Paints nicht geladen:", e);
146
+ for (const id of ids)
147
+ this.paints.delete(id);
148
+ }
149
+ for (const [id, resolve] of batch)
150
+ resolve(found.get(id) ?? null);
151
+ }
152
+ }
153
+
154
+ // src/feed.ts
155
+ var HISTORY_OVERLAP_MS = 5 * 60000;
156
+ var HISTORY_RETRY_DELAYS_MS = [2000, 5000, 1e4, 30000];
157
+
158
+ class EventFeed {
159
+ ready = false;
160
+ error = null;
161
+ pixl;
162
+ since;
163
+ types;
164
+ handler;
165
+ delivered = new Set;
166
+ errorListeners = new Set;
167
+ buffer = null;
168
+ loadSeq = 0;
169
+ stopped = false;
170
+ latestAt = null;
171
+ retryTimer = null;
172
+ retryAttempt = 0;
173
+ unsubscribeStatus;
174
+ onEvent = (_, event) => this.handleLive(event);
175
+ constructor(pixl, options, handler) {
176
+ if (options?.since === undefined)
177
+ throw new Error("pixl.feed: `since` fehlt");
178
+ this.pixl = pixl;
179
+ this.since = options.since instanceof Date ? options.since.toISOString() : String(options.since);
180
+ this.types = options.types?.length ? new Set(options.types) : null;
181
+ this.handler = handler;
182
+ pixl.on("*", this.onEvent);
183
+ this.unsubscribeStatus = pixl.onStatus((status) => {
184
+ if (status === "connected") {
185
+ this.retryAttempt = 0;
186
+ this.load();
187
+ }
188
+ });
189
+ }
190
+ onError(listener) {
191
+ this.errorListeners.add(listener);
192
+ if (this.error)
193
+ listener(this.error);
194
+ return () => this.errorListeners.delete(listener);
195
+ }
196
+ stop() {
197
+ this.stopped = true;
198
+ this.clearRetry();
199
+ this.pixl.off("*", this.onEvent);
200
+ this.unsubscribeStatus();
201
+ this.errorListeners.clear();
202
+ this.buffer = null;
203
+ }
204
+ async load() {
205
+ const seq = ++this.loadSeq;
206
+ this.clearRetry();
207
+ this.buffer = [];
208
+ let failed = null;
209
+ try {
210
+ const history = await this.fetchHistory();
211
+ if (seq !== this.loadSeq || this.stopped)
212
+ return;
213
+ this.error = null;
214
+ this.retryAttempt = 0;
215
+ for (const e of history) {
216
+ this.deliver({
217
+ event: e.event,
218
+ data: e.data,
219
+ replay: !!(e.data?.replay || e.data?.test),
220
+ id: e.id,
221
+ at: e.at,
222
+ history: true
223
+ });
224
+ }
225
+ } catch (e) {
226
+ if (seq !== this.loadSeq || this.stopped)
227
+ return;
228
+ failed = e instanceof Error ? e : new Error(String(e));
229
+ this.error = failed;
230
+ for (const l of this.errorListeners)
231
+ l(failed);
232
+ }
233
+ const pending = this.buffer ?? [];
234
+ this.buffer = null;
235
+ this.ready = true;
236
+ for (const event of pending)
237
+ this.deliver(event);
238
+ if (failed)
239
+ this.scheduleRetry(failed);
240
+ }
241
+ async fetchHistory() {
242
+ const stopBefore = this.latestAt === null ? null : this.latestAt - HISTORY_OVERLAP_MS;
243
+ const all = [];
244
+ let cursor;
245
+ do {
246
+ const page = await this.pixl.events({
247
+ since: this.since,
248
+ types: this.types ? [...this.types] : undefined,
249
+ limit: 200,
250
+ cursor
251
+ });
252
+ all.push(...page.events);
253
+ cursor = page.nextCursor ?? undefined;
254
+ const oldest = page.events[page.events.length - 1];
255
+ if (stopBefore !== null && oldest && Date.parse(oldest.at) < stopBefore)
256
+ break;
257
+ } while (cursor);
258
+ return all.reverse();
259
+ }
260
+ scheduleRetry(error) {
261
+ const status = error.status;
262
+ if (status !== undefined && status >= 400 && status < 500 && status !== 429 && status !== 408)
263
+ return;
264
+ if (this.pixl.status !== "connected")
265
+ return;
266
+ const base = HISTORY_RETRY_DELAYS_MS[Math.min(this.retryAttempt, HISTORY_RETRY_DELAYS_MS.length - 1)];
267
+ this.retryAttempt += 1;
268
+ this.retryTimer = setTimeout(() => {
269
+ this.retryTimer = null;
270
+ if (!this.stopped && this.pixl.status === "connected")
271
+ this.load();
272
+ }, base + Math.random() * base * 0.3);
273
+ }
274
+ clearRetry() {
275
+ if (this.retryTimer)
276
+ clearTimeout(this.retryTimer);
277
+ this.retryTimer = null;
278
+ }
279
+ handleLive(event) {
280
+ if (this.stopped)
281
+ return;
282
+ if (this.types && !this.types.has(event.event))
283
+ return;
284
+ if (this.buffer) {
285
+ this.buffer.push(event);
286
+ return;
287
+ }
288
+ this.deliver(event);
289
+ }
290
+ deliver(event) {
291
+ if (event.id) {
292
+ if (this.delivered.has(event.id))
293
+ return;
294
+ this.delivered.add(event.id);
295
+ }
296
+ if (event.at) {
297
+ const at = Date.parse(event.at);
298
+ if (!Number.isNaN(at) && (this.latestAt === null || at > this.latestAt))
299
+ this.latestAt = at;
300
+ }
301
+ try {
302
+ this.handler(event.data, event);
303
+ } catch (e) {
304
+ console.error("[pixl] Fehler im feed()-Handler:", e);
305
+ }
306
+ }
307
+ }
308
+
309
+ // src/tts.ts
310
+ class PixlTts {
311
+ pixl;
312
+ constructor(pixl) {
313
+ this.pixl = pixl;
314
+ }
315
+ async speak(text, options = {}) {
316
+ const res = await this.pixl._fetch("/tts", {
317
+ method: "POST",
318
+ json: { text, voice: options.voice, filters: options.filters }
319
+ });
320
+ return await res.json();
321
+ }
322
+ async voices() {
323
+ const res = await this.pixl._fetch("/tts/voices");
324
+ return await res.json();
325
+ }
326
+ async audio(id) {
327
+ const res = await this.pixl._fetch(`/tts/${encodeURIComponent(id)}/audio`);
328
+ return res.blob();
329
+ }
330
+ async marks(id) {
331
+ const res = await this.pixl._fetch(`/tts/${encodeURIComponent(id)}/marks`);
332
+ return (await res.json()).marks;
333
+ }
334
+ player(options = {}) {
335
+ return new TtsPlayer(this.pixl, this, options);
336
+ }
337
+ }
338
+
339
+ class TtsPlayer {
340
+ pixl;
341
+ tts;
342
+ options;
343
+ queue = [];
344
+ current = null;
345
+ wordFrame = null;
346
+ volume;
347
+ destroyed = false;
348
+ warnedAutoplay = false;
349
+ onReady;
350
+ onSkip;
351
+ onClear;
352
+ constructor(pixl, tts, options) {
353
+ this.pixl = pixl;
354
+ this.tts = tts;
355
+ this.options = options;
356
+ this.volume = clampVolume(options.volume ?? 1);
357
+ this.onReady = (data, event) => {
358
+ const item = toItem(data, event);
359
+ if (!item || event.replay && this.options.ignoreReplays)
360
+ return;
361
+ if (this.current?.item.id === item.id || this.queue.some((q) => q.id === item.id))
362
+ return;
363
+ this.queue.push(item);
364
+ const max = this.options.maxQueue ?? 20;
365
+ if (this.queue.length > max)
366
+ this.queue.splice(0, this.queue.length - max);
367
+ this.next();
368
+ };
369
+ this.onSkip = (data) => {
370
+ const id = typeof data.id === "string" ? data.id : undefined;
371
+ if (!id || this.current?.item.id === id)
372
+ this.skip();
373
+ else
374
+ this.queue = this.queue.filter((q) => q.id !== id);
375
+ };
376
+ this.onClear = () => this.clear();
377
+ pixl.on("tts.ready", this.onReady);
378
+ pixl.on("tts.skip", this.onSkip);
379
+ pixl.on("tts.clear", this.onClear);
380
+ }
381
+ skip() {
382
+ if (!this.current)
383
+ return;
384
+ this.current.skipped = true;
385
+ this.current.finish();
386
+ }
387
+ clear() {
388
+ this.queue = [];
389
+ this.skip();
390
+ }
391
+ setVolume(volume) {
392
+ this.volume = clampVolume(volume);
393
+ if (this.current?.audio)
394
+ this.current.audio.volume = this.volume;
395
+ }
396
+ get pending() {
397
+ return this.queue;
398
+ }
399
+ destroy() {
400
+ this.destroyed = true;
401
+ this.pixl.off("tts.ready", this.onReady);
402
+ this.pixl.off("tts.skip", this.onSkip);
403
+ this.pixl.off("tts.clear", this.onClear);
404
+ this.clear();
405
+ }
406
+ followWords(audio, item, marks) {
407
+ this.stopWords();
408
+ let next = 0;
409
+ const endOf = (i) => marks[i].end ?? marks[i + 1]?.start ?? (Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : marks[i].start);
410
+ const tick = () => {
411
+ const now = audio.currentTime * 1000;
412
+ while (next < marks.length && marks[next].start <= now) {
413
+ const i = next++;
414
+ const word = { word: marks[i].word, index: i, start: marks[i].start, end: endOf(i) };
415
+ safeCall(() => this.options.onWord?.(word, item));
416
+ }
417
+ this.wordFrame = next < marks.length && !audio.ended ? schedule(tick) : null;
418
+ };
419
+ this.wordFrame = schedule(tick);
420
+ }
421
+ stopWords() {
422
+ if (this.wordFrame !== null)
423
+ cancel(this.wordFrame);
424
+ this.wordFrame = null;
425
+ }
426
+ async next() {
427
+ if (this.current || this.destroyed)
428
+ return;
429
+ const item = this.queue.shift();
430
+ if (!item)
431
+ return;
432
+ let done = false;
433
+ let resolveDone;
434
+ const finished = new Promise((r) => resolveDone = r);
435
+ const slot = {
436
+ item,
437
+ audio: null,
438
+ url: null,
439
+ skipped: false,
440
+ finish: () => {
441
+ if (done)
442
+ return;
443
+ done = true;
444
+ this.stopWords();
445
+ slot.audio?.pause();
446
+ if (slot.url)
447
+ URL.revokeObjectURL(slot.url);
448
+ resolveDone();
449
+ }
450
+ };
451
+ this.current = slot;
452
+ try {
453
+ const [blob, marks] = await Promise.all([
454
+ this.tts.audio(item.id),
455
+ this.options.onWord ? this.tts.marks(item.id).catch((e) => {
456
+ console.warn("[pixl] Zeitmarken nicht verfügbar, onWord bleibt für diesen Eintrag stumm:", e);
457
+ return;
458
+ }) : Promise.resolve(undefined)
459
+ ]);
460
+ if (marks)
461
+ item.marks = marks;
462
+ if (!done) {
463
+ slot.url = URL.createObjectURL(blob);
464
+ const audio = new Audio(slot.url);
465
+ audio.volume = this.volume;
466
+ audio.addEventListener("ended", slot.finish);
467
+ audio.addEventListener("error", slot.finish);
468
+ slot.audio = audio;
469
+ safeCall(() => this.options.onStart?.(item));
470
+ if (marks?.length)
471
+ this.followWords(audio, item, marks);
472
+ await audio.play().catch((e) => {
473
+ if (!this.warnedAutoplay && e instanceof DOMException && e.name === "NotAllowedError") {
474
+ this.warnedAutoplay = true;
475
+ console.warn("[pixl] Der Browser blockiert die Wiedergabe ohne Klick. In OBS und StreamElements passiert das nicht, im normalen Browser einmal auf die Seite klicken.");
476
+ }
477
+ slot.finish();
478
+ });
479
+ }
480
+ } catch (e) {
481
+ console.error("[pixl] TTS-Audio konnte nicht geladen werden:", e);
482
+ slot.finish();
483
+ }
484
+ await finished;
485
+ safeCall(() => this.options.onEnd?.(item, { skipped: slot.skipped }));
486
+ this.current = null;
487
+ if (this.queue.length && !this.destroyed) {
488
+ setTimeout(() => void this.next(), this.options.gapMs ?? 500);
489
+ }
490
+ }
491
+ }
492
+ function schedule(fn) {
493
+ return typeof requestAnimationFrame === "function" ? requestAnimationFrame(fn) : setTimeout(fn, 30);
494
+ }
495
+ function cancel(handle) {
496
+ if (typeof cancelAnimationFrame === "function")
497
+ cancelAnimationFrame(handle);
498
+ else
499
+ clearTimeout(handle);
500
+ }
501
+ function toItem(data, event) {
502
+ if (typeof data.id !== "string")
503
+ return null;
504
+ return {
505
+ id: data.id,
506
+ text: typeof data.text === "string" ? data.text : "",
507
+ voice: typeof data.voice === "string" ? data.voice : "",
508
+ source: typeof data.source === "string" ? data.source : "",
509
+ ...typeof data.eventId === "string" ? { eventId: data.eventId } : {},
510
+ ...data.textTruncated === true ? { textTruncated: true } : {},
511
+ replay: event.replay
512
+ };
513
+ }
514
+ function clampVolume(v) {
515
+ return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 1;
516
+ }
517
+ function safeCall(fn) {
518
+ try {
519
+ fn();
520
+ } catch (e) {
521
+ console.error("[pixl] Fehler in einem TTS-Handler:", e);
522
+ }
523
+ }
524
+
525
+ // src/client.ts
526
+ class PixlApiError extends Error {
527
+ status;
528
+ problem;
529
+ constructor(status, message, problem) {
530
+ super(message);
531
+ this.name = "PixlApiError";
532
+ this.status = status;
533
+ this.problem = problem;
534
+ }
535
+ }
536
+ var DEFAULT_BASE_URL = "https://pixl.lennox.zip";
537
+ var RECONNECT_DELAYS_MS = [500, 1000, 2000, 3000, 5000, 1e4];
538
+ var WATCHDOG_MS = 45000;
539
+ var CONNECT_TIMEOUT_MS = 15000;
540
+ function toQueryDate(value) {
541
+ if (value instanceof Date)
542
+ return value.toISOString();
543
+ return String(value);
544
+ }
545
+
546
+ class Pixl {
547
+ static defaultBaseUrl;
548
+ baseUrl;
549
+ key;
550
+ tts;
551
+ chat;
552
+ handlers = new Map;
553
+ stateHandlers = new Map;
554
+ statusHandlers = new Set;
555
+ states = new Map;
556
+ _status = "closed";
557
+ abort = null;
558
+ reconnectTimer = null;
559
+ watchdog = null;
560
+ attempt = 0;
561
+ stopped = false;
562
+ logs;
563
+ everConnected = false;
564
+ lostAt = null;
565
+ constructor(options) {
566
+ if (!options?.key)
567
+ throw new Error("pixl: `key` fehlt (API-Schlüssel aus den Einstellungen)");
568
+ this.key = options.key;
569
+ this.logs = options.logs !== false;
570
+ this.baseUrl = (options.baseUrl ?? Pixl.defaultBaseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
571
+ this.tts = new PixlTts(this);
572
+ this.chat = new PixlChat(this);
573
+ if (options.autoConnect !== false)
574
+ queueMicrotask(() => this.connect());
575
+ }
576
+ on(event, handler) {
577
+ let set = this.handlers.get(event);
578
+ if (!set)
579
+ this.handlers.set(event, set = new Set);
580
+ set.add(handler);
581
+ return this;
582
+ }
583
+ off(event, handler) {
584
+ this.handlers.get(event)?.delete(handler);
585
+ return this;
586
+ }
587
+ state(key, handler) {
588
+ let set = this.stateHandlers.get(key);
589
+ if (!set)
590
+ this.stateHandlers.set(key, set = new Set);
591
+ set.add(handler);
592
+ if (this.states.has(key))
593
+ handler(this.states.get(key), key);
594
+ return this;
595
+ }
596
+ offState(key, handler) {
597
+ this.stateHandlers.get(key)?.delete(handler);
598
+ return this;
599
+ }
600
+ getState(key) {
601
+ return this.states.get(key);
602
+ }
603
+ get status() {
604
+ return this._status;
605
+ }
606
+ onStatus(handler) {
607
+ this.statusHandlers.add(handler);
608
+ handler(this._status);
609
+ return () => this.statusHandlers.delete(handler);
610
+ }
611
+ setStatus(status) {
612
+ if (status === this._status)
613
+ return;
614
+ this._status = status;
615
+ for (const h of this.statusHandlers)
616
+ safeCall2(() => h(status));
617
+ }
618
+ async request(path, params = {}) {
619
+ const url = new URL(`${this.baseUrl}/api/v1${path}`);
620
+ for (const [k, v] of Object.entries(params))
621
+ if (v !== undefined && v !== "")
622
+ url.searchParams.set(k, v);
623
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${this.key}` } });
624
+ if (!res.ok) {
625
+ const body = await res.json().catch(() => null);
626
+ throw new PixlApiError(res.status, body?.error ?? `HTTP ${res.status}`);
627
+ }
628
+ return await res.json();
629
+ }
630
+ async _fetch(path, init = {}) {
631
+ const headers = { Authorization: `Bearer ${this.key}` };
632
+ if (init.json !== undefined)
633
+ headers["Content-Type"] = "application/json";
634
+ const res = await fetch(`${this.baseUrl}/api/v1${path}`, {
635
+ method: init.method ?? "GET",
636
+ headers,
637
+ body: init.json !== undefined ? JSON.stringify(init.json) : undefined
638
+ });
639
+ if (!res.ok) {
640
+ const body = await res.json().catch(() => null);
641
+ throw new PixlApiError(res.status, body?.error ?? `HTTP ${res.status}`, body?.problem);
642
+ }
643
+ return res;
644
+ }
645
+ events(query = {}) {
646
+ return this.request("/events", {
647
+ since: query.since !== undefined ? toQueryDate(query.since) : undefined,
648
+ until: query.until !== undefined ? toQueryDate(query.until) : undefined,
649
+ types: query.types?.join(","),
650
+ limit: query.limit !== undefined ? String(query.limit) : undefined,
651
+ cursor: query.cursor
652
+ });
653
+ }
654
+ org() {
655
+ return this.request("/org");
656
+ }
657
+ subs() {
658
+ return this.request("/subs");
659
+ }
660
+ nowPlaying() {
661
+ return this.request("/spotify/now-playing");
662
+ }
663
+ static progress(nowPlaying, now = Date.now()) {
664
+ if (!nowPlaying.track)
665
+ return 0;
666
+ const ms = nowPlaying.playing ? nowPlaying.progressMs + (now - Date.parse(nowPlaying.at)) : nowPlaying.progressMs;
667
+ return Math.max(0, Math.min(ms, nowPlaying.track.durationMs));
668
+ }
669
+ streamElements(path, params = {}) {
670
+ const query = {};
671
+ for (const [k, v] of Object.entries(params))
672
+ query[k] = v === undefined ? undefined : String(v);
673
+ return this.request(`/streamelements/${path}`, query);
674
+ }
675
+ async fetchStates() {
676
+ const res = await this.request("/states");
677
+ return res.states;
678
+ }
679
+ async fetchAllEvents(query = {}) {
680
+ const all = [];
681
+ let cursor;
682
+ do {
683
+ const page = await this.events({ ...query, limit: 200, cursor });
684
+ all.push(...page.events);
685
+ cursor = page.nextCursor ?? undefined;
686
+ } while (cursor);
687
+ return all.reverse();
688
+ }
689
+ feed(options, handler) {
690
+ return new EventFeed(this, options, handler);
691
+ }
692
+ connect() {
693
+ if (this.abort)
694
+ return;
695
+ this.stopped = false;
696
+ this.run();
697
+ }
698
+ disconnect() {
699
+ this.stopped = true;
700
+ if (this.reconnectTimer)
701
+ clearTimeout(this.reconnectTimer);
702
+ this.reconnectTimer = null;
703
+ this.abort?.abort();
704
+ this.abort = null;
705
+ this.setStatus("closed");
706
+ }
707
+ async run() {
708
+ const abort = new AbortController;
709
+ this.abort = abort;
710
+ this.setStatus("connecting");
711
+ let fatal = false;
712
+ try {
713
+ this.watchdog = setTimeout(() => abort.abort(), CONNECT_TIMEOUT_MS);
714
+ const res = await fetch(`${this.baseUrl}/api/v1/stream`, {
715
+ headers: { Authorization: `Bearer ${this.key}`, Accept: "text/event-stream" },
716
+ signal: abort.signal,
717
+ cache: "no-store"
718
+ });
719
+ this.clearWatchdog();
720
+ if (res.status === 401 || res.status === 403) {
721
+ fatal = true;
722
+ return;
723
+ }
724
+ if (!res.ok || !res.body)
725
+ throw new Error(`HTTP ${res.status}`);
726
+ this.attempt = 0;
727
+ if (this.lostAt !== null)
728
+ this.log(`Live-Verbindung wieder da nach ${((Date.now() - this.lostAt) / 1000).toFixed(1)} s`);
729
+ this.lostAt = null;
730
+ this.everConnected = true;
731
+ this.setStatus("connected");
732
+ await this.readStream(res.body, abort, () => {
733
+ fatal = true;
734
+ });
735
+ } catch {} finally {
736
+ this.clearWatchdog();
737
+ if (this.abort === abort)
738
+ this.abort = null;
739
+ if (fatal) {
740
+ this.log("Live-Verbindung abgelehnt (Schlüssel ungültig oder widerrufen), kein neuer Versuch");
741
+ this.stopped = true;
742
+ this.setStatus("unauthorized");
743
+ } else if (!this.stopped) {
744
+ if (this.lostAt === null) {
745
+ this.lostAt = Date.now();
746
+ this.log(this.everConnected ? "Live-Verbindung getrennt, verbinde neu" : "Live-Verbindung nicht erreichbar, versuche es weiter");
747
+ }
748
+ this.setStatus("disconnected");
749
+ this.scheduleReconnect();
750
+ }
751
+ }
752
+ }
753
+ log(message) {
754
+ if (this.logs)
755
+ console.info(`[pixl] ${message}`);
756
+ }
757
+ scheduleReconnect() {
758
+ const base = RECONNECT_DELAYS_MS[Math.min(this.attempt, RECONNECT_DELAYS_MS.length - 1)];
759
+ this.attempt += 1;
760
+ const delay = base + Math.random() * base * 0.3;
761
+ this.reconnectTimer = setTimeout(() => {
762
+ this.reconnectTimer = null;
763
+ if (!this.stopped)
764
+ this.connect();
765
+ }, delay);
766
+ }
767
+ armWatchdog(abort) {
768
+ this.clearWatchdog();
769
+ this.watchdog = setTimeout(() => abort.abort(), WATCHDOG_MS);
770
+ }
771
+ clearWatchdog() {
772
+ if (this.watchdog)
773
+ clearTimeout(this.watchdog);
774
+ this.watchdog = null;
775
+ }
776
+ async readStream(body, abort, onRevoked) {
777
+ const reader = body.getReader();
778
+ const decoder = new TextDecoder;
779
+ let buffer = "";
780
+ let dataLines = [];
781
+ this.armWatchdog(abort);
782
+ for (;; ) {
783
+ const { value, done } = await reader.read();
784
+ if (done)
785
+ return;
786
+ this.armWatchdog(abort);
787
+ buffer += decoder.decode(value, { stream: true });
788
+ let newline;
789
+ while ((newline = buffer.indexOf(`
790
+ `)) >= 0) {
791
+ const line = buffer.slice(0, newline).replace(/\r$/, "");
792
+ buffer = buffer.slice(newline + 1);
793
+ if (line === "") {
794
+ if (dataLines.length) {
795
+ const message = dataLines.join(`
796
+ `);
797
+ dataLines = [];
798
+ if (this.dispatch(message) === "revoked") {
799
+ onRevoked();
800
+ await reader.cancel().catch(() => {});
801
+ return;
802
+ }
803
+ }
804
+ } else if (line.startsWith("data:")) {
805
+ dataLines.push(line.slice(5).replace(/^ /, ""));
806
+ }
807
+ }
808
+ }
809
+ }
810
+ dispatch(raw) {
811
+ let msg;
812
+ try {
813
+ msg = JSON.parse(raw);
814
+ } catch {
815
+ return;
816
+ }
817
+ if (msg.event === "api_key.revoked")
818
+ return "revoked";
819
+ if (msg.event === "state.snapshot") {
820
+ const list = msg.data?.states ?? [];
821
+ for (const { key, value } of list)
822
+ this.setState(key, value);
823
+ return;
824
+ }
825
+ if (msg.event === "state.updated") {
826
+ const { key, value } = msg.data;
827
+ if (typeof key === "string")
828
+ this.setState(key, value);
829
+ return;
830
+ }
831
+ const event = {
832
+ event: msg.event,
833
+ data: msg.data ?? {},
834
+ replay: !!msg.replay,
835
+ id: msg.id,
836
+ at: msg.at
837
+ };
838
+ this.emit(event);
839
+ }
840
+ emit(event) {
841
+ if (event.event === "twitch.chat_message" && typeof event.data.text === "string" && event.data.html === undefined) {
842
+ event.data.html = chatHtml(event.data.text, event.data.emotes);
843
+ }
844
+ for (const h of this.handlers.get(event.event) ?? [])
845
+ safeCall2(() => h(event.data, event));
846
+ for (const h of this.handlers.get("*") ?? [])
847
+ safeCall2(() => h(event.data, event));
848
+ }
849
+ setState(key, value) {
850
+ this.states.set(key, value);
851
+ for (const h of this.stateHandlers.get(key) ?? [])
852
+ safeCall2(() => h(value, key));
853
+ }
854
+ }
855
+ function safeCall2(fn) {
856
+ try {
857
+ fn();
858
+ } catch (e) {
859
+ console.error("[pixl] Fehler in einem Handler:", e);
860
+ }
861
+ }
862
+ export {
863
+ CHAT_CSS,
864
+ EventFeed,
865
+ Pixl,
866
+ PixlApiError,
867
+ PixlChat,
868
+ PixlTts,
869
+ TtsPlayer,
870
+ badgesHtml,
871
+ chatHtml,
872
+ emoteUrl,
873
+ escapeHtml,
874
+ paintCss
875
+ };