@pixl-app/sdk 0.1.1 → 1.0.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/CHANGELOG.md +20 -0
- package/README.md +8 -3
- package/dist/api-types/device-apps.d.ts +1 -1
- package/dist/api-types/index.d.ts +3 -0
- package/dist/api-types/modules.d.ts +1 -1
- package/dist/api-types/payloads.d.ts +389 -0
- package/dist/api-types/templates.d.ts +6 -0
- package/dist/api-types/widgets.d.ts +555 -0
- package/dist/bindings.d.ts +22 -0
- package/dist/chat.d.ts +1 -1
- package/dist/client.d.ts +127 -164
- package/dist/errors.d.ts +17 -0
- package/dist/events.d.ts +41 -0
- package/dist/feed.d.ts +21 -29
- package/dist/fonts.d.ts +19 -0
- package/dist/frame.d.ts +2 -0
- package/dist/index-nb1y5rx1.js +1932 -0
- package/dist/index.d.ts +12 -3
- package/dist/index.js +38 -873
- package/dist/live-value.d.ts +18 -0
- package/dist/pixl-ui.js +278 -0
- package/dist/pixl.js +4 -4
- package/dist/spotify.d.ts +22 -0
- package/dist/stream.d.ts +30 -0
- package/dist/streamelements.d.ts +14 -0
- package/dist/tts.d.ts +18 -9
- package/dist/twitch.d.ts +68 -0
- package/dist/ui/checkbox.d.ts +27 -0
- package/dist/ui/define.d.ts +2 -0
- package/dist/ui/dialog.d.ts +15 -0
- package/dist/ui/dropdown-helpers.d.ts +48 -0
- package/dist/ui/dropdown.d.ts +65 -0
- package/dist/ui/field.d.ts +48 -0
- package/dist/ui/index.d.ts +14 -0
- package/dist/ui/index.js +1919 -0
- package/dist/ui/inputs.d.ts +29 -0
- package/dist/ui/list.d.ts +31 -0
- package/dist/ui/runtime.d.ts +18 -0
- package/dist/ui/section.d.ts +13 -0
- package/dist/ui/styles.d.ts +6 -0
- package/dist/widget.d.ts +141 -0
- package/package.json +11 -5
|
@@ -0,0 +1,1932 @@
|
|
|
1
|
+
// src/api-types/templates.ts
|
|
2
|
+
var DEFAULT_AVATAR = "https://static-cdn.jtvnw.net/user-default-pictures-uv/cdd517fe-def4-11e9-948e-784f43822e80-profile_image-300x300.png";
|
|
3
|
+
function templateUser(name, id = "123456789") {
|
|
4
|
+
return { id, login: name.toLowerCase(), name, avatar: DEFAULT_AVATAR };
|
|
5
|
+
}
|
|
6
|
+
var frostbyte = templateUser("FrostByte", "100000001");
|
|
7
|
+
var nordlicht = templateUser("Nordlicht", "100000002");
|
|
8
|
+
var sophieplays = templateUser("SophiePlays", "100000003");
|
|
9
|
+
var mod = templateUser("JonasNRW", "100000004");
|
|
10
|
+
var hypeTrain = {
|
|
11
|
+
level: 2,
|
|
12
|
+
progress: 1200,
|
|
13
|
+
goal: 3000,
|
|
14
|
+
total: 4700,
|
|
15
|
+
expiresAt: "2026-01-01T20:05:00.000Z",
|
|
16
|
+
startedAt: "2026-01-01T20:00:00.000Z",
|
|
17
|
+
type: "regular",
|
|
18
|
+
shared: false,
|
|
19
|
+
participants: [],
|
|
20
|
+
topContributions: [
|
|
21
|
+
{ user: frostbyte, type: "bits", total: 2500 },
|
|
22
|
+
{ user: nordlicht, type: "subscription", total: 1500 }
|
|
23
|
+
],
|
|
24
|
+
record: { level: 5, total: 18000 }
|
|
25
|
+
};
|
|
26
|
+
var emote = {
|
|
27
|
+
id: "60ae958e229664e8667aea38",
|
|
28
|
+
name: "peepoHey",
|
|
29
|
+
provider: "7tv",
|
|
30
|
+
animated: true,
|
|
31
|
+
zeroWidth: false
|
|
32
|
+
};
|
|
33
|
+
var EVENT_TEMPLATES2 = {
|
|
34
|
+
"stream.online": {
|
|
35
|
+
streamId: "40000000000",
|
|
36
|
+
title: "Gemütlicher Abend mit euch",
|
|
37
|
+
category: "Minecraft",
|
|
38
|
+
url: "https://twitch.tv/pixl",
|
|
39
|
+
thumbnailUrl: null
|
|
40
|
+
},
|
|
41
|
+
"stream.offline": { durationSec: 3 * 3600 + 25 * 60 },
|
|
42
|
+
"twitch.follow": { user: frostbyte },
|
|
43
|
+
"twitch.sub": {
|
|
44
|
+
user: frostbyte,
|
|
45
|
+
tier: "1",
|
|
46
|
+
months: 7,
|
|
47
|
+
streak: 4,
|
|
48
|
+
durationMonths: 1,
|
|
49
|
+
message: "Sieben Monate schon!"
|
|
50
|
+
},
|
|
51
|
+
"twitch.sub_gift": {
|
|
52
|
+
user: nordlicht,
|
|
53
|
+
tier: "1",
|
|
54
|
+
count: 5,
|
|
55
|
+
community: true,
|
|
56
|
+
months: 1,
|
|
57
|
+
recipients: [sophieplays, frostbyte],
|
|
58
|
+
totalGifted: 42
|
|
59
|
+
},
|
|
60
|
+
"twitch.cheer": { user: sophieplays, bits: 500, message: "Cheer500 GG" },
|
|
61
|
+
"twitch.raid": { user: nordlicht, viewers: 87 },
|
|
62
|
+
"twitch.channel_point": { user: frostbyte, reward: "Hydrieren", cost: 500, input: null },
|
|
63
|
+
"twitch.channel_point_resolved": { user: frostbyte, reward: "Hydrieren", status: "fulfilled" },
|
|
64
|
+
"twitch.power_up": { user: sophieplays, title: "Konfetti", bits: 100, input: null },
|
|
65
|
+
"twitch.watch_streak": { user: sophieplays, streak: 10, channelPoints: 450 },
|
|
66
|
+
"twitch.modiversary": { user: mod, months: 24 },
|
|
67
|
+
"twitch.shoutout_sent": { user: nordlicht, viewers: 87 },
|
|
68
|
+
"twitch.poll_started": {
|
|
69
|
+
title: "Welche Welt als Nächstes?",
|
|
70
|
+
choices: [
|
|
71
|
+
{ title: "Nether", votes: 0 },
|
|
72
|
+
{ title: "End", votes: 0 }
|
|
73
|
+
]
|
|
74
|
+
},
|
|
75
|
+
"twitch.poll_progress": {
|
|
76
|
+
title: "Welche Welt als Nächstes?",
|
|
77
|
+
choices: [
|
|
78
|
+
{ title: "Nether", votes: 12 },
|
|
79
|
+
{ title: "End", votes: 9 }
|
|
80
|
+
]
|
|
81
|
+
},
|
|
82
|
+
"twitch.poll_ended": {
|
|
83
|
+
title: "Welche Welt als Nächstes?",
|
|
84
|
+
status: "completed",
|
|
85
|
+
choices: [
|
|
86
|
+
{ title: "Nether", votes: 31 },
|
|
87
|
+
{ title: "End", votes: 24 }
|
|
88
|
+
]
|
|
89
|
+
},
|
|
90
|
+
"twitch.prediction_started": {
|
|
91
|
+
title: "Schafft er den Boss?",
|
|
92
|
+
outcomes: [
|
|
93
|
+
{ title: "Ja", points: 0 },
|
|
94
|
+
{ title: "Nein", points: 0 }
|
|
95
|
+
]
|
|
96
|
+
},
|
|
97
|
+
"twitch.prediction_progress": {
|
|
98
|
+
title: "Schafft er den Boss?",
|
|
99
|
+
outcomes: [
|
|
100
|
+
{ title: "Ja", points: 12000 },
|
|
101
|
+
{ title: "Nein", points: 8000 }
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
"twitch.prediction_locked": {
|
|
105
|
+
title: "Schafft er den Boss?",
|
|
106
|
+
outcomes: [
|
|
107
|
+
{ title: "Ja", points: 25000 },
|
|
108
|
+
{ title: "Nein", points: 19000 }
|
|
109
|
+
]
|
|
110
|
+
},
|
|
111
|
+
"twitch.prediction_ended": {
|
|
112
|
+
title: "Schafft er den Boss?",
|
|
113
|
+
status: "resolved",
|
|
114
|
+
winner: "Ja",
|
|
115
|
+
outcomes: [
|
|
116
|
+
{ title: "Ja", points: 25000 },
|
|
117
|
+
{ title: "Nein", points: 19000 }
|
|
118
|
+
]
|
|
119
|
+
},
|
|
120
|
+
"twitch.hype_train_started": { ...hypeTrain, level: 1, progress: 400, goal: 2000, total: 400 },
|
|
121
|
+
"twitch.hype_train_progress": hypeTrain,
|
|
122
|
+
"twitch.hype_train_ended": { ...hypeTrain, expiresAt: null },
|
|
123
|
+
"twitch.goal_started": { type: "follower", description: "Follower-Ziel", current: 980, target: 1000 },
|
|
124
|
+
"twitch.goal_progress": { current: 995, target: 1000 },
|
|
125
|
+
"twitch.goal_ended": { type: "follower", description: "Follower-Ziel", current: 1003, target: 1000, achieved: true },
|
|
126
|
+
"twitch.charity_started": { charity: "Ärzte ohne Grenzen", target: { value: 1000, currency: "EUR" } },
|
|
127
|
+
"twitch.charity_progress": { current: { value: 420, currency: "EUR" }, target: { value: 1000, currency: "EUR" } },
|
|
128
|
+
"twitch.charity_donated": { user: nordlicht, charity: "Ärzte ohne Grenzen", amount: { value: 25, currency: "EUR" } },
|
|
129
|
+
"twitch.charity_stopped": {
|
|
130
|
+
charity: "Ärzte ohne Grenzen",
|
|
131
|
+
current: { value: 1240, currency: "EUR" },
|
|
132
|
+
target: { value: 1000, currency: "EUR" }
|
|
133
|
+
},
|
|
134
|
+
"twitch.ad_break": { durationSec: 90, automatic: true, startedAt: "2026-01-01T20:30:00.000Z" },
|
|
135
|
+
"twitch.title_changed": { title: "Endlich das End" },
|
|
136
|
+
"twitch.category_changed": { category: "Minecraft" },
|
|
137
|
+
"twitch.ban": {
|
|
138
|
+
user: nordlicht,
|
|
139
|
+
moderator: mod,
|
|
140
|
+
reason: "Spam",
|
|
141
|
+
permanent: false,
|
|
142
|
+
endsAt: "2026-01-01T20:10:00.000Z"
|
|
143
|
+
},
|
|
144
|
+
"twitch.unban": { user: nordlicht, moderator: mod },
|
|
145
|
+
"twitch.vip_added": { user: sophieplays },
|
|
146
|
+
"twitch.vip_removed": { user: sophieplays },
|
|
147
|
+
"twitch.moderator_added": { user: mod },
|
|
148
|
+
"twitch.moderator_removed": { user: mod },
|
|
149
|
+
"twitch.chat_message": {
|
|
150
|
+
user: frostbyte,
|
|
151
|
+
messageId: "00000000-0000-0000-0000-000000000001",
|
|
152
|
+
text: "Hallo zusammen peepoHey",
|
|
153
|
+
html: 'Hallo zusammen <img class="pixl-emote" src="https://cdn.7tv.app/emote/60ae958e229664e8667aea38/2x.webp" alt="peepoHey" data-provider="7tv" data-emote="60ae958e229664e8667aea38">',
|
|
154
|
+
color: "#8A5CF5",
|
|
155
|
+
emotes: { peepoHey: emote },
|
|
156
|
+
badges: [],
|
|
157
|
+
paint: null
|
|
158
|
+
},
|
|
159
|
+
"twitch.chat_cleared": {},
|
|
160
|
+
"twitch.chat_user_cleared": { user: nordlicht },
|
|
161
|
+
"twitch.chat_message_deleted": { user: nordlicht, messageId: "00000000-0000-0000-0000-000000000002" },
|
|
162
|
+
"twitch.chat_settings_updated": {
|
|
163
|
+
emoteMode: false,
|
|
164
|
+
followerMode: true,
|
|
165
|
+
followerModeDuration: 10,
|
|
166
|
+
slowMode: true,
|
|
167
|
+
slowModeWait: 5,
|
|
168
|
+
subscriberMode: false,
|
|
169
|
+
uniqueChatMode: false
|
|
170
|
+
},
|
|
171
|
+
"twitch.chat_message_held": {
|
|
172
|
+
user: nordlicht,
|
|
173
|
+
text: "kauf billige follower",
|
|
174
|
+
messageId: "00000000-0000-0000-0000-000000000003"
|
|
175
|
+
},
|
|
176
|
+
"twitch.chat_message_held_updated": {
|
|
177
|
+
user: nordlicht,
|
|
178
|
+
status: "denied",
|
|
179
|
+
messageId: "00000000-0000-0000-0000-000000000003"
|
|
180
|
+
},
|
|
181
|
+
"twitch.shared_chat_started": { sessionId: "shared-1", participants: 2 },
|
|
182
|
+
"twitch.shared_chat_updated": { sessionId: "shared-1", participants: 3 },
|
|
183
|
+
"twitch.shared_chat_ended": { sessionId: "shared-1" },
|
|
184
|
+
"twitch.guest_star_session_started": { sessionId: "guest-1" },
|
|
185
|
+
"twitch.guest_star_session_ended": { sessionId: "guest-1" },
|
|
186
|
+
"twitch.guest_star_guest_updated": { sessionId: "guest-1", user: sophieplays, state: "live", slotId: "1" },
|
|
187
|
+
"twitch.guest_star_settings_updated": { slotCount: 4, groupLayout: "tiled" },
|
|
188
|
+
"seventv.emote_added": { emote, actor: "FrostByte" },
|
|
189
|
+
"seventv.emote_removed": { emote, actor: "FrostByte" },
|
|
190
|
+
"seventv.emote_renamed": { emote, oldName: "peepoHi", actor: "FrostByte" },
|
|
191
|
+
"seventv.emote_set_changed": { setId: "01H000000000000000000000", name: "Kanal-Emotes", actor: "FrostByte" },
|
|
192
|
+
"streamelements.tip": {
|
|
193
|
+
name: "SophiePlays",
|
|
194
|
+
amount: { value: 5, currency: "EUR" },
|
|
195
|
+
message: "Für den nächsten Kaffee",
|
|
196
|
+
tipId: "tip-1"
|
|
197
|
+
},
|
|
198
|
+
"subs.updated": { total: 312, points: 340, at: "2026-01-01T20:00:00.000Z" },
|
|
199
|
+
"spotify.now_playing": {
|
|
200
|
+
playing: true,
|
|
201
|
+
progressMs: 42000,
|
|
202
|
+
at: "2026-01-01T20:00:00.000Z",
|
|
203
|
+
track: null,
|
|
204
|
+
addedBy: null
|
|
205
|
+
},
|
|
206
|
+
"tts.ready": {
|
|
207
|
+
id: "00000000-0000-0000-0000-000000000004",
|
|
208
|
+
voice: "Vicki",
|
|
209
|
+
text: "SophiePlays hat 5 Euro gespendet: Für den nächsten Kaffee",
|
|
210
|
+
source: "tip",
|
|
211
|
+
eventId: null
|
|
212
|
+
},
|
|
213
|
+
"tts.skip": { id: null },
|
|
214
|
+
"tts.clear": {}
|
|
215
|
+
};
|
|
216
|
+
// src/api-types/api-scopes.ts
|
|
217
|
+
function apiScopeForEvent(event) {
|
|
218
|
+
if (event.startsWith("twitch.") || event.startsWith("stream.") || event.startsWith("streamelements.") || event.startsWith("seventv."))
|
|
219
|
+
return "events:read";
|
|
220
|
+
if (event.startsWith("state."))
|
|
221
|
+
return "states:read";
|
|
222
|
+
if (event.startsWith("tts."))
|
|
223
|
+
return "tts:read";
|
|
224
|
+
if (event.startsWith("subs."))
|
|
225
|
+
return "subs:read";
|
|
226
|
+
if (event.startsWith("spotify."))
|
|
227
|
+
return "spotify:read";
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
// src/api-types/widgets.ts
|
|
231
|
+
var STREAMDECK_SCRIPT_LIMITS = {
|
|
232
|
+
sourceBytes: 256 * 1024,
|
|
233
|
+
callMs: 50,
|
|
234
|
+
evalMs: 200,
|
|
235
|
+
memoryBytes: 32 * 1024 * 1024,
|
|
236
|
+
stackBytes: 256 * 1024,
|
|
237
|
+
outputBytes: 512 * 1024,
|
|
238
|
+
images: 64,
|
|
239
|
+
failures: 5
|
|
240
|
+
};
|
|
241
|
+
// src/chat.ts
|
|
242
|
+
function emoteUrl2(emote, size = 1) {
|
|
243
|
+
const id = encodeURIComponent(emote.id);
|
|
244
|
+
switch (emote.provider) {
|
|
245
|
+
case "twitch":
|
|
246
|
+
return `https://static-cdn.jtvnw.net/emoticons/v2/${id}/${emote.animated ? "default" : "static"}/dark/${size === 4 ? 3 : size}.0`;
|
|
247
|
+
case "7tv":
|
|
248
|
+
return `https://cdn.7tv.app/emote/${id}/${size}x.webp`;
|
|
249
|
+
case "bttv":
|
|
250
|
+
return `https://cdn.betterttv.net/emote/${id}/${size === 4 ? 3 : size}x.webp`;
|
|
251
|
+
case "ffz":
|
|
252
|
+
return `https://cdn.frankerfacez.com/emote/${id}/${size}`;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
var ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
|
256
|
+
function escapeHtml2(text) {
|
|
257
|
+
return text.replace(/[&<>"']/g, (c) => ESCAPES[c]);
|
|
258
|
+
}
|
|
259
|
+
function emoteImg(e, size) {
|
|
260
|
+
const name = escapeHtml2(e.name);
|
|
261
|
+
return `<img class="pixl-emote" src="${escapeHtml2(emoteUrl2(e, size))}" alt="${name}" data-provider="${e.provider}" data-emote="${escapeHtml2(e.id)}">`;
|
|
262
|
+
}
|
|
263
|
+
function chatHtml2(text, emotes = {}, options = {}) {
|
|
264
|
+
const size = options.size ?? 2;
|
|
265
|
+
const parts = text.split(/(\s+)/);
|
|
266
|
+
const out = [];
|
|
267
|
+
let lastEmote = -1;
|
|
268
|
+
for (const part of parts) {
|
|
269
|
+
if (!part)
|
|
270
|
+
continue;
|
|
271
|
+
if (/^\s+$/.test(part)) {
|
|
272
|
+
out.push(escapeHtml2(part));
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
const emote = Object.prototype.hasOwnProperty.call(emotes, part) ? emotes[part] : undefined;
|
|
276
|
+
if (!emote) {
|
|
277
|
+
out.push(escapeHtml2(part));
|
|
278
|
+
lastEmote = -1;
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (emote.zeroWidth && lastEmote >= 0) {
|
|
282
|
+
out.length = lastEmote + 1;
|
|
283
|
+
const base = out[lastEmote];
|
|
284
|
+
const inner = base.startsWith('<span class="pixl-emote-stack">') ? base.slice(31, -7) : base;
|
|
285
|
+
out[lastEmote] = `<span class="pixl-emote-stack">${inner}${emoteImg(emote, size)}</span>`;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
out.push(emoteImg(emote, size));
|
|
289
|
+
lastEmote = out.length - 1;
|
|
290
|
+
}
|
|
291
|
+
return out.join("");
|
|
292
|
+
}
|
|
293
|
+
function badgesHtml2(badges = []) {
|
|
294
|
+
return badges.map((b) => `<img class="pixl-badge" src="${escapeHtml2(b.url)}" alt="${escapeHtml2(b.title)}" data-provider="${b.provider}">`).join("");
|
|
295
|
+
}
|
|
296
|
+
var CHAT_CSS2 = `.pixl-emote{height:1.75em;vertical-align:middle;margin:-.25em 0}
|
|
297
|
+
.pixl-emote-stack{display:inline-grid;vertical-align:middle}
|
|
298
|
+
.pixl-emote-stack>.pixl-emote{grid-area:1/1;justify-self:center;align-self:center;margin:0}
|
|
299
|
+
.pixl-badge{height:1.1em;vertical-align:middle;margin-right:.25em}`;
|
|
300
|
+
function layerCss(l) {
|
|
301
|
+
switch (l.type) {
|
|
302
|
+
case "linear-gradient":
|
|
303
|
+
case "radial-gradient": {
|
|
304
|
+
const stops = l.stops.map((s) => `${s.color} ${Math.round(s.at * 1000) / 10}%`).join(", ");
|
|
305
|
+
const fn = `${l.repeating ? "repeating-" : ""}${l.type}`;
|
|
306
|
+
return l.type === "linear-gradient" ? `${fn}(${l.angle}deg, ${stops})` : `${fn}(${l.shape}, ${stops})`;
|
|
307
|
+
}
|
|
308
|
+
case "color":
|
|
309
|
+
return `linear-gradient(${l.color}, ${l.color})`;
|
|
310
|
+
case "image":
|
|
311
|
+
return `url("${l.url.replace(/"/g, "%22")}")`;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
function paintCss2(paint, fallback = "inherit") {
|
|
315
|
+
const layers = paint.layers.filter((l) => l.opacity > 0);
|
|
316
|
+
if (!layers.length)
|
|
317
|
+
return `color:${fallback}`;
|
|
318
|
+
const shadows = paint.shadows.map((s) => `drop-shadow(${s.x}px ${s.y}px ${s.blur}px ${s.color})`).join(" ");
|
|
319
|
+
return [
|
|
320
|
+
`color:${fallback}`,
|
|
321
|
+
`background-image:${layers.map(layerCss).join(", ")}`,
|
|
322
|
+
"background-size:cover",
|
|
323
|
+
"-webkit-background-clip:text",
|
|
324
|
+
"background-clip:text",
|
|
325
|
+
"-webkit-text-fill-color:transparent",
|
|
326
|
+
shadows ? `filter:${shadows}` : ""
|
|
327
|
+
].filter(Boolean).join(";");
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
class PixlChat2 {
|
|
331
|
+
pixl;
|
|
332
|
+
paints = new Map;
|
|
333
|
+
queue = new Map;
|
|
334
|
+
timer = null;
|
|
335
|
+
constructor(pixl) {
|
|
336
|
+
this.pixl = pixl;
|
|
337
|
+
}
|
|
338
|
+
css = CHAT_CSS2;
|
|
339
|
+
emoteUrl(emote, size = 1) {
|
|
340
|
+
return emoteUrl2(emote, size);
|
|
341
|
+
}
|
|
342
|
+
badgesHtml(badges) {
|
|
343
|
+
return badgesHtml2(badges);
|
|
344
|
+
}
|
|
345
|
+
paintCss(paint, fallback) {
|
|
346
|
+
return paintCss2(paint, fallback);
|
|
347
|
+
}
|
|
348
|
+
html(text, emotes, options) {
|
|
349
|
+
return chatHtml2(text, emotes, options);
|
|
350
|
+
}
|
|
351
|
+
async emotes() {
|
|
352
|
+
const res = await this.pixl.request("/emotes");
|
|
353
|
+
return res.emotes;
|
|
354
|
+
}
|
|
355
|
+
paint(id) {
|
|
356
|
+
let p = this.paints.get(id);
|
|
357
|
+
if (!p) {
|
|
358
|
+
p = new Promise((resolve) => {
|
|
359
|
+
this.queue.set(id, resolve);
|
|
360
|
+
this.timer ??= setTimeout(() => this.flush(), 20);
|
|
361
|
+
});
|
|
362
|
+
this.paints.set(id, p);
|
|
363
|
+
}
|
|
364
|
+
return p;
|
|
365
|
+
}
|
|
366
|
+
async paintStyle(id, fallback = "inherit") {
|
|
367
|
+
const paint = id ? await this.paint(id) : null;
|
|
368
|
+
return paint ? paintCss2(paint, fallback) : `color:${fallback}`;
|
|
369
|
+
}
|
|
370
|
+
async flush() {
|
|
371
|
+
this.timer = null;
|
|
372
|
+
const batch = this.queue;
|
|
373
|
+
this.queue = new Map;
|
|
374
|
+
const ids = [...batch.keys()];
|
|
375
|
+
const found = new Map;
|
|
376
|
+
try {
|
|
377
|
+
for (let i = 0;i < ids.length; i += 100) {
|
|
378
|
+
const res = await this.pixl.request("/7tv/paints", {
|
|
379
|
+
query: { ids: ids.slice(i, i + 100).join(",") }
|
|
380
|
+
});
|
|
381
|
+
for (const p of res.paints)
|
|
382
|
+
found.set(p.id, p);
|
|
383
|
+
}
|
|
384
|
+
} catch (e) {
|
|
385
|
+
console.warn("[pixl] 7TV-Paints nicht geladen:", e);
|
|
386
|
+
for (const id of ids)
|
|
387
|
+
this.paints.delete(id);
|
|
388
|
+
}
|
|
389
|
+
for (const [id, resolve] of batch)
|
|
390
|
+
resolve(found.get(id) ?? null);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// src/errors.ts
|
|
395
|
+
class PixlError2 extends Error {
|
|
396
|
+
code;
|
|
397
|
+
status;
|
|
398
|
+
scope;
|
|
399
|
+
problem;
|
|
400
|
+
constructor(code, message, extra = {}) {
|
|
401
|
+
super(message);
|
|
402
|
+
this.name = "PixlError";
|
|
403
|
+
this.code = code;
|
|
404
|
+
this.status = extra.status;
|
|
405
|
+
this.scope = extra.scope;
|
|
406
|
+
this.problem = extra.problem;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
async function errorFromResponse(res) {
|
|
410
|
+
const body = await res.json().catch(() => null);
|
|
411
|
+
const message = body?.error ?? `HTTP ${res.status}`;
|
|
412
|
+
const extra = { status: res.status, problem: body?.problem, scope: body?.scope };
|
|
413
|
+
if (body?.code === "module_disabled")
|
|
414
|
+
return new PixlError2("module_disabled", message, extra);
|
|
415
|
+
if (body?.code === "missing_scope" || body?.scope)
|
|
416
|
+
return new PixlError2("missing_scope", message, extra);
|
|
417
|
+
switch (res.status) {
|
|
418
|
+
case 400:
|
|
419
|
+
case 422:
|
|
420
|
+
return new PixlError2("invalid", message, extra);
|
|
421
|
+
case 401:
|
|
422
|
+
return new PixlError2("unauthorized", message, extra);
|
|
423
|
+
case 403:
|
|
424
|
+
return new PixlError2("forbidden", message, extra);
|
|
425
|
+
case 404:
|
|
426
|
+
return new PixlError2("not_found", message, extra);
|
|
427
|
+
case 429:
|
|
428
|
+
return new PixlError2("rate_limited", message, extra);
|
|
429
|
+
default:
|
|
430
|
+
return new PixlError2("server", message, extra);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// src/live-value.ts
|
|
435
|
+
class WritableLiveValue {
|
|
436
|
+
current;
|
|
437
|
+
listeners = new Set;
|
|
438
|
+
constructor(initial) {
|
|
439
|
+
this.current = initial;
|
|
440
|
+
}
|
|
441
|
+
get value() {
|
|
442
|
+
return this.current;
|
|
443
|
+
}
|
|
444
|
+
subscribe(fn) {
|
|
445
|
+
this.listeners.add(fn);
|
|
446
|
+
safeCall(() => fn(this.current));
|
|
447
|
+
return () => {
|
|
448
|
+
this.listeners.delete(fn);
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
set(value) {
|
|
452
|
+
if (Object.is(value, this.current))
|
|
453
|
+
return;
|
|
454
|
+
this.current = value;
|
|
455
|
+
for (const fn of [...this.listeners])
|
|
456
|
+
safeCall(() => fn(value));
|
|
457
|
+
}
|
|
458
|
+
clear() {
|
|
459
|
+
this.listeners.clear();
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function safeCall(fn) {
|
|
463
|
+
try {
|
|
464
|
+
fn();
|
|
465
|
+
} catch (e) {
|
|
466
|
+
console.error("[pixl] Fehler in einem Handler:", e);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// src/feed.ts
|
|
471
|
+
var HISTORY_OVERLAP_MS = 5 * 60000;
|
|
472
|
+
var HISTORY_RETRY_DELAYS_MS = [2000, 5000, 1e4, 30000];
|
|
473
|
+
var MAX_DELIVERED_IDS = 5000;
|
|
474
|
+
|
|
475
|
+
class EventFeed2 {
|
|
476
|
+
pixl;
|
|
477
|
+
events;
|
|
478
|
+
handler;
|
|
479
|
+
ready;
|
|
480
|
+
error;
|
|
481
|
+
readyValue = new WritableLiveValue(false);
|
|
482
|
+
errorValue = new WritableLiveValue(null);
|
|
483
|
+
since;
|
|
484
|
+
types;
|
|
485
|
+
delivered = new Set;
|
|
486
|
+
buffer = null;
|
|
487
|
+
loadSeq = 0;
|
|
488
|
+
stopped = false;
|
|
489
|
+
latestAt = null;
|
|
490
|
+
retryTimer = null;
|
|
491
|
+
retryAttempt = 0;
|
|
492
|
+
unsubscribe = [];
|
|
493
|
+
constructor(pixl, events, options, handler) {
|
|
494
|
+
this.pixl = pixl;
|
|
495
|
+
this.events = events;
|
|
496
|
+
this.handler = handler;
|
|
497
|
+
if (options?.since === undefined)
|
|
498
|
+
throw new PixlError2("invalid", "pixl.events.feed: `since` fehlt");
|
|
499
|
+
this.ready = this.readyValue;
|
|
500
|
+
this.error = this.errorValue;
|
|
501
|
+
this.since = options.since instanceof Date ? options.since.toISOString() : String(options.since);
|
|
502
|
+
this.types = options.types?.length ? new Set(options.types) : null;
|
|
503
|
+
this.unsubscribe.push(pixl.on("*", (e) => this.handleLive(e)));
|
|
504
|
+
this.unsubscribe.push(pixl.status.subscribe((status) => {
|
|
505
|
+
if (status === "connected") {
|
|
506
|
+
this.retryAttempt = 0;
|
|
507
|
+
this.load();
|
|
508
|
+
}
|
|
509
|
+
}));
|
|
510
|
+
}
|
|
511
|
+
stop() {
|
|
512
|
+
this.stopped = true;
|
|
513
|
+
this.clearRetry();
|
|
514
|
+
for (const off of this.unsubscribe)
|
|
515
|
+
off();
|
|
516
|
+
this.buffer = null;
|
|
517
|
+
}
|
|
518
|
+
async load() {
|
|
519
|
+
const seq = ++this.loadSeq;
|
|
520
|
+
this.clearRetry();
|
|
521
|
+
this.buffer = [];
|
|
522
|
+
let failed = null;
|
|
523
|
+
try {
|
|
524
|
+
const history = await this.fetchHistory();
|
|
525
|
+
if (seq !== this.loadSeq || this.stopped)
|
|
526
|
+
return;
|
|
527
|
+
this.errorValue.set(null);
|
|
528
|
+
this.retryAttempt = 0;
|
|
529
|
+
for (const e of history)
|
|
530
|
+
this.deliver(e);
|
|
531
|
+
} catch (e) {
|
|
532
|
+
if (seq !== this.loadSeq || this.stopped)
|
|
533
|
+
return;
|
|
534
|
+
failed = e instanceof PixlError2 ? e : new PixlError2("network", String(e));
|
|
535
|
+
this.errorValue.set(failed);
|
|
536
|
+
}
|
|
537
|
+
const pending = this.buffer ?? [];
|
|
538
|
+
this.buffer = null;
|
|
539
|
+
this.readyValue.set(true);
|
|
540
|
+
for (const event of pending)
|
|
541
|
+
this.deliver(event);
|
|
542
|
+
if (failed)
|
|
543
|
+
this.scheduleRetry(failed);
|
|
544
|
+
}
|
|
545
|
+
async fetchHistory() {
|
|
546
|
+
const stopBefore = this.latestAt === null ? null : this.latestAt - HISTORY_OVERLAP_MS;
|
|
547
|
+
const all = [];
|
|
548
|
+
let cursor;
|
|
549
|
+
const query = { since: this.since, types: this.types ? [...this.types] : undefined };
|
|
550
|
+
do {
|
|
551
|
+
const page = await this.events.fetchPage(query, cursor);
|
|
552
|
+
all.push(...page.events);
|
|
553
|
+
cursor = page.nextCursor ?? undefined;
|
|
554
|
+
const oldest = page.events[page.events.length - 1];
|
|
555
|
+
if (stopBefore !== null && oldest && Date.parse(oldest.at) < stopBefore)
|
|
556
|
+
break;
|
|
557
|
+
} while (cursor);
|
|
558
|
+
return all.reverse();
|
|
559
|
+
}
|
|
560
|
+
scheduleRetry(error) {
|
|
561
|
+
if (error.code !== "network" && error.code !== "rate_limited" && error.code !== "server")
|
|
562
|
+
return;
|
|
563
|
+
if (this.pixl.status.value !== "connected")
|
|
564
|
+
return;
|
|
565
|
+
const base = HISTORY_RETRY_DELAYS_MS[Math.min(this.retryAttempt, HISTORY_RETRY_DELAYS_MS.length - 1)];
|
|
566
|
+
this.retryAttempt += 1;
|
|
567
|
+
this.retryTimer = setTimeout(() => {
|
|
568
|
+
this.retryTimer = null;
|
|
569
|
+
if (!this.stopped && this.pixl.status.value === "connected")
|
|
570
|
+
this.load();
|
|
571
|
+
}, base + Math.random() * base * 0.3);
|
|
572
|
+
}
|
|
573
|
+
clearRetry() {
|
|
574
|
+
if (this.retryTimer)
|
|
575
|
+
clearTimeout(this.retryTimer);
|
|
576
|
+
this.retryTimer = null;
|
|
577
|
+
}
|
|
578
|
+
handleLive(event) {
|
|
579
|
+
if (this.stopped)
|
|
580
|
+
return;
|
|
581
|
+
if (this.types && !this.types.has(event.type))
|
|
582
|
+
return;
|
|
583
|
+
if (this.buffer) {
|
|
584
|
+
this.buffer.push(event);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
this.deliver(event);
|
|
588
|
+
}
|
|
589
|
+
deliver(event) {
|
|
590
|
+
if (event.id) {
|
|
591
|
+
if (this.delivered.has(event.id))
|
|
592
|
+
return;
|
|
593
|
+
this.delivered.add(event.id);
|
|
594
|
+
if (this.delivered.size > MAX_DELIVERED_IDS) {
|
|
595
|
+
const drop = this.delivered.size - MAX_DELIVERED_IDS;
|
|
596
|
+
let i = 0;
|
|
597
|
+
for (const id of this.delivered) {
|
|
598
|
+
if (i++ >= drop)
|
|
599
|
+
break;
|
|
600
|
+
this.delivered.delete(id);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const at = Date.parse(event.at);
|
|
605
|
+
if (!Number.isNaN(at) && event.source !== "test" && (this.latestAt === null || at > this.latestAt))
|
|
606
|
+
this.latestAt = at;
|
|
607
|
+
safeCall(() => this.handler(event));
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/fonts.ts
|
|
612
|
+
var FONT_HOST2 = "https://fonts.bunny.net";
|
|
613
|
+
var STYLES = [100, 200, 300, 400, 500, 600, 700, 800, 900].flatMap((w) => [`${w}`, `${w}i`]).join(",");
|
|
614
|
+
function fontStylesheetUrl2(family) {
|
|
615
|
+
return `${FONT_HOST2}/css?family=${encodeURIComponent(family.trim())}:${STYLES}&display=swap`;
|
|
616
|
+
}
|
|
617
|
+
function fontFamily2(family, fallback = "sans-serif") {
|
|
618
|
+
const name = typeof family === "string" ? family.trim() : "";
|
|
619
|
+
if (!name)
|
|
620
|
+
return fallback;
|
|
621
|
+
return `"${name.replace(/["\\]/g, "\\$&")}", ${fallback}`;
|
|
622
|
+
}
|
|
623
|
+
var loading = new Map;
|
|
624
|
+
function loadFont2(family) {
|
|
625
|
+
const name = typeof family === "string" ? family.trim() : "";
|
|
626
|
+
if (!name || typeof document === "undefined")
|
|
627
|
+
return Promise.resolve(false);
|
|
628
|
+
let done = loading.get(name);
|
|
629
|
+
if (!done) {
|
|
630
|
+
done = new Promise((resolve) => {
|
|
631
|
+
const link = document.createElement("link");
|
|
632
|
+
link.rel = "stylesheet";
|
|
633
|
+
link.href = fontStylesheetUrl2(name);
|
|
634
|
+
link.dataset.pixlFont = name;
|
|
635
|
+
link.onload = () => {
|
|
636
|
+
const fonts = document.fonts;
|
|
637
|
+
if (!fonts)
|
|
638
|
+
return resolve(true);
|
|
639
|
+
fonts.load(`16px ${fontFamily2(name)}`).then((faces) => resolve(faces.length > 0), () => resolve(false));
|
|
640
|
+
};
|
|
641
|
+
link.onerror = () => resolve(false);
|
|
642
|
+
document.head.append(link);
|
|
643
|
+
});
|
|
644
|
+
loading.set(name, done);
|
|
645
|
+
}
|
|
646
|
+
return done;
|
|
647
|
+
}
|
|
648
|
+
function fontsOf(state) {
|
|
649
|
+
const out = new Set;
|
|
650
|
+
const m = state?.manifest;
|
|
651
|
+
if (!state || !m)
|
|
652
|
+
return [];
|
|
653
|
+
const scan = (fields, values) => {
|
|
654
|
+
for (const [name, field] of Object.entries(fields ?? {})) {
|
|
655
|
+
const value = values[name];
|
|
656
|
+
if (field.type === "font" && typeof value === "string")
|
|
657
|
+
out.add(value.trim());
|
|
658
|
+
if (field.type !== "list" || !Array.isArray(value))
|
|
659
|
+
continue;
|
|
660
|
+
const keys = Object.entries(field.item).filter(([, f]) => f.type === "font").map(([key]) => key);
|
|
661
|
+
if (!keys.length)
|
|
662
|
+
continue;
|
|
663
|
+
for (const item of value)
|
|
664
|
+
for (const key of keys)
|
|
665
|
+
if (typeof item?.[key] === "string")
|
|
666
|
+
out.add(item[key].trim());
|
|
667
|
+
}
|
|
668
|
+
};
|
|
669
|
+
scan(m.settings, state.settings);
|
|
670
|
+
scan(m.controls, state.values);
|
|
671
|
+
out.delete("");
|
|
672
|
+
return [...out];
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// src/tts.ts
|
|
676
|
+
class PixlTts2 {
|
|
677
|
+
pixl;
|
|
678
|
+
constructor(pixl) {
|
|
679
|
+
this.pixl = pixl;
|
|
680
|
+
}
|
|
681
|
+
async speak(text, options = {}) {
|
|
682
|
+
const res = await this.pixl.fetchRaw(this.base, {
|
|
683
|
+
method: "POST",
|
|
684
|
+
body: { text, voice: options.voice, filters: options.filters },
|
|
685
|
+
signal: options.signal
|
|
686
|
+
});
|
|
687
|
+
if (res.status === 204)
|
|
688
|
+
throw new PixlError2("forbidden", "Liest die Browserquelle vor, die Vorschau spielt es mit ab", {
|
|
689
|
+
status: 204,
|
|
690
|
+
problem: "preview"
|
|
691
|
+
});
|
|
692
|
+
const spoken = await res.json();
|
|
693
|
+
if (this.pixl._preview)
|
|
694
|
+
this.pixl.sendTestEvent("tts.ready", {
|
|
695
|
+
id: spoken.id,
|
|
696
|
+
text: spoken.text,
|
|
697
|
+
voice: spoken.voice,
|
|
698
|
+
source: "api",
|
|
699
|
+
eventId: null
|
|
700
|
+
});
|
|
701
|
+
return spoken;
|
|
702
|
+
}
|
|
703
|
+
get base() {
|
|
704
|
+
return this.pixl._preview ? "/widget/preview/tts" : "/tts";
|
|
705
|
+
}
|
|
706
|
+
voices(options = {}) {
|
|
707
|
+
return this.pixl.request("/tts/voices", { signal: options.signal });
|
|
708
|
+
}
|
|
709
|
+
async audio(id, options = {}) {
|
|
710
|
+
const res = await this.pixl.fetchRaw(`${this.base}/${encodeURIComponent(id)}/audio`, { signal: options.signal });
|
|
711
|
+
return res.blob();
|
|
712
|
+
}
|
|
713
|
+
async marks(id, options = {}) {
|
|
714
|
+
const res = await this.pixl.request(`${this.base}/${encodeURIComponent(id)}/marks`, {
|
|
715
|
+
signal: options.signal
|
|
716
|
+
});
|
|
717
|
+
return res.marks;
|
|
718
|
+
}
|
|
719
|
+
player(options = {}) {
|
|
720
|
+
return new TtsPlayer2(this.pixl, this, options);
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
class TtsPlayer2 {
|
|
725
|
+
pixl;
|
|
726
|
+
tts;
|
|
727
|
+
options;
|
|
728
|
+
queue = [];
|
|
729
|
+
current = null;
|
|
730
|
+
wordFrame = null;
|
|
731
|
+
volume;
|
|
732
|
+
destroyed = false;
|
|
733
|
+
warnedAutoplay = false;
|
|
734
|
+
unsubscribe;
|
|
735
|
+
constructor(pixl, tts, options) {
|
|
736
|
+
this.pixl = pixl;
|
|
737
|
+
this.tts = tts;
|
|
738
|
+
this.options = options;
|
|
739
|
+
this.volume = clampVolume(options.volume ?? 1);
|
|
740
|
+
const onReady = (event) => {
|
|
741
|
+
const item = toItem(event);
|
|
742
|
+
if (!item || item.replay && this.options.ignoreReplays)
|
|
743
|
+
return;
|
|
744
|
+
if (this.current?.item.id === item.id || this.queue.some((q) => q.id === item.id))
|
|
745
|
+
return;
|
|
746
|
+
this.queue.push(item);
|
|
747
|
+
const max = this.options.maxQueue ?? 20;
|
|
748
|
+
if (this.queue.length > max)
|
|
749
|
+
this.queue.splice(0, this.queue.length - max);
|
|
750
|
+
this.next();
|
|
751
|
+
};
|
|
752
|
+
const onSkip = (event) => {
|
|
753
|
+
const id = event.data.id;
|
|
754
|
+
if (!id || this.current?.item.id === id)
|
|
755
|
+
this.skip();
|
|
756
|
+
else
|
|
757
|
+
this.queue = this.queue.filter((q) => q.id !== id);
|
|
758
|
+
};
|
|
759
|
+
this.unsubscribe = [
|
|
760
|
+
pixl._listen("tts.ready", onReady),
|
|
761
|
+
pixl._listen("tts.skip", onSkip),
|
|
762
|
+
pixl._listen("tts.clear", () => this.clear())
|
|
763
|
+
];
|
|
764
|
+
}
|
|
765
|
+
skip() {
|
|
766
|
+
if (!this.current)
|
|
767
|
+
return;
|
|
768
|
+
this.current.skipped = true;
|
|
769
|
+
this.current.finish();
|
|
770
|
+
}
|
|
771
|
+
clear() {
|
|
772
|
+
this.queue = [];
|
|
773
|
+
this.skip();
|
|
774
|
+
}
|
|
775
|
+
setVolume(volume) {
|
|
776
|
+
this.volume = clampVolume(volume);
|
|
777
|
+
if (this.current?.audio)
|
|
778
|
+
this.current.audio.volume = this.volume;
|
|
779
|
+
}
|
|
780
|
+
get pending() {
|
|
781
|
+
return this.queue;
|
|
782
|
+
}
|
|
783
|
+
destroy() {
|
|
784
|
+
this.destroyed = true;
|
|
785
|
+
for (const off of this.unsubscribe)
|
|
786
|
+
off();
|
|
787
|
+
this.clear();
|
|
788
|
+
}
|
|
789
|
+
followWords(audio, item, marks) {
|
|
790
|
+
this.stopWords();
|
|
791
|
+
let next = 0;
|
|
792
|
+
const endOf = (i) => marks[i].end ?? marks[i + 1]?.start ?? (Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : marks[i].start);
|
|
793
|
+
const tick = () => {
|
|
794
|
+
const now = audio.currentTime * 1000;
|
|
795
|
+
while (next < marks.length && marks[next].start <= now) {
|
|
796
|
+
const i = next++;
|
|
797
|
+
const word = { word: marks[i].word, index: i, start: marks[i].start, end: endOf(i) };
|
|
798
|
+
safeCall(() => this.options.onWord?.(word, item));
|
|
799
|
+
}
|
|
800
|
+
this.wordFrame = next < marks.length && !audio.ended ? schedule(tick) : null;
|
|
801
|
+
};
|
|
802
|
+
this.wordFrame = schedule(tick);
|
|
803
|
+
}
|
|
804
|
+
stopWords() {
|
|
805
|
+
if (this.wordFrame !== null)
|
|
806
|
+
cancel(this.wordFrame);
|
|
807
|
+
this.wordFrame = null;
|
|
808
|
+
}
|
|
809
|
+
async next() {
|
|
810
|
+
if (this.current || this.destroyed)
|
|
811
|
+
return;
|
|
812
|
+
const item = this.queue.shift();
|
|
813
|
+
if (!item)
|
|
814
|
+
return;
|
|
815
|
+
let done = false;
|
|
816
|
+
let resolveDone;
|
|
817
|
+
const finished = new Promise((r) => resolveDone = r);
|
|
818
|
+
const slot = {
|
|
819
|
+
item,
|
|
820
|
+
audio: null,
|
|
821
|
+
url: null,
|
|
822
|
+
skipped: false,
|
|
823
|
+
finish: () => {
|
|
824
|
+
if (done)
|
|
825
|
+
return;
|
|
826
|
+
done = true;
|
|
827
|
+
this.stopWords();
|
|
828
|
+
slot.audio?.pause();
|
|
829
|
+
if (slot.url)
|
|
830
|
+
URL.revokeObjectURL(slot.url);
|
|
831
|
+
resolveDone();
|
|
832
|
+
}
|
|
833
|
+
};
|
|
834
|
+
this.current = slot;
|
|
835
|
+
try {
|
|
836
|
+
const [blob, marks] = await Promise.all([
|
|
837
|
+
this.tts.audio(item.id),
|
|
838
|
+
this.options.onWord ? this.tts.marks(item.id).catch((e) => {
|
|
839
|
+
console.warn("[pixl] Zeitmarken nicht verfügbar, onWord bleibt für diesen Eintrag stumm:", e);
|
|
840
|
+
return;
|
|
841
|
+
}) : Promise.resolve(undefined)
|
|
842
|
+
]);
|
|
843
|
+
if (marks)
|
|
844
|
+
item.marks = marks;
|
|
845
|
+
if (!done) {
|
|
846
|
+
slot.url = URL.createObjectURL(blob);
|
|
847
|
+
const audio = new Audio(slot.url);
|
|
848
|
+
audio.volume = this.volume;
|
|
849
|
+
audio.addEventListener("ended", slot.finish);
|
|
850
|
+
audio.addEventListener("error", slot.finish);
|
|
851
|
+
slot.audio = audio;
|
|
852
|
+
safeCall(() => this.options.onStart?.(item));
|
|
853
|
+
if (marks?.length)
|
|
854
|
+
this.followWords(audio, item, marks);
|
|
855
|
+
await audio.play().catch((e) => {
|
|
856
|
+
if (!this.warnedAutoplay && e instanceof DOMException && e.name === "NotAllowedError") {
|
|
857
|
+
this.warnedAutoplay = true;
|
|
858
|
+
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.");
|
|
859
|
+
}
|
|
860
|
+
slot.finish();
|
|
861
|
+
});
|
|
862
|
+
}
|
|
863
|
+
} catch (e) {
|
|
864
|
+
console.error("[pixl] TTS-Audio konnte nicht geladen werden:", e);
|
|
865
|
+
slot.finish();
|
|
866
|
+
}
|
|
867
|
+
await finished;
|
|
868
|
+
safeCall(() => this.options.onEnd?.(item, { skipped: slot.skipped }));
|
|
869
|
+
this.current = null;
|
|
870
|
+
if (this.queue.length && !this.destroyed) {
|
|
871
|
+
setTimeout(() => void this.next(), this.options.gapMs ?? 500);
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function schedule(fn) {
|
|
876
|
+
return typeof requestAnimationFrame === "function" ? requestAnimationFrame(fn) : setTimeout(fn, 30);
|
|
877
|
+
}
|
|
878
|
+
function cancel(handle) {
|
|
879
|
+
if (typeof cancelAnimationFrame === "function")
|
|
880
|
+
cancelAnimationFrame(handle);
|
|
881
|
+
else
|
|
882
|
+
clearTimeout(handle);
|
|
883
|
+
}
|
|
884
|
+
function toItem(event) {
|
|
885
|
+
const d = event.data;
|
|
886
|
+
if (!d.id)
|
|
887
|
+
return null;
|
|
888
|
+
return {
|
|
889
|
+
id: d.id,
|
|
890
|
+
text: d.text,
|
|
891
|
+
voice: d.voice,
|
|
892
|
+
source: d.source,
|
|
893
|
+
...d.eventId ? { eventId: d.eventId } : {},
|
|
894
|
+
...d.textTruncated === true ? { textTruncated: true } : {},
|
|
895
|
+
replay: event.source === "replay"
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
function clampVolume(v) {
|
|
899
|
+
return Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 1;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// src/bindings.ts
|
|
903
|
+
function cssVarName(name) {
|
|
904
|
+
return `--pixl-${name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/_/g, "-").toLowerCase()}`;
|
|
905
|
+
}
|
|
906
|
+
function cssString(text) {
|
|
907
|
+
return `"${text.replace(/[\\"]/g, "\\$&").replace(/\n/g, "\\a ")}"`;
|
|
908
|
+
}
|
|
909
|
+
function bindable(field) {
|
|
910
|
+
if (field.type === "list" || field.type === "trigger" || field.type === "voice")
|
|
911
|
+
return false;
|
|
912
|
+
return !(field.type === "string" && field.sensitive);
|
|
913
|
+
}
|
|
914
|
+
function cssValue(field, value, fileUrl) {
|
|
915
|
+
if (!bindable(field) || value === undefined || value === null)
|
|
916
|
+
return null;
|
|
917
|
+
switch (field.type) {
|
|
918
|
+
case "number":
|
|
919
|
+
return typeof value === "number" && Number.isFinite(value) ? String(value) : null;
|
|
920
|
+
case "boolean":
|
|
921
|
+
return value === true ? "1" : "0";
|
|
922
|
+
case "string":
|
|
923
|
+
return typeof value === "string" ? cssString(value) : null;
|
|
924
|
+
case "font":
|
|
925
|
+
return typeof value === "string" && value ? fontFamily2(value) : null;
|
|
926
|
+
case "file":
|
|
927
|
+
return typeof value === "string" && value ? `url(${cssString(fileUrl(value))})` : null;
|
|
928
|
+
case "color":
|
|
929
|
+
case "enum":
|
|
930
|
+
return typeof value === "string" && value ? value : null;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
function bindableFields(state) {
|
|
934
|
+
const out = new Map;
|
|
935
|
+
const add = (fields, values) => {
|
|
936
|
+
for (const [name, field] of Object.entries(fields ?? {}))
|
|
937
|
+
if (!out.has(name) && bindable(field))
|
|
938
|
+
out.set(name, { field, value: values[name] });
|
|
939
|
+
};
|
|
940
|
+
add(state.manifest?.controls, state.values);
|
|
941
|
+
add(state.manifest?.settings, state.settings);
|
|
942
|
+
return out;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
class PageBindings {
|
|
946
|
+
doc;
|
|
947
|
+
fileUrl;
|
|
948
|
+
vars = new Set;
|
|
949
|
+
constructor(doc, fileUrl) {
|
|
950
|
+
this.doc = doc;
|
|
951
|
+
this.fileUrl = fileUrl;
|
|
952
|
+
}
|
|
953
|
+
apply(state) {
|
|
954
|
+
const style = this.doc.documentElement.style;
|
|
955
|
+
const next = new Set;
|
|
956
|
+
for (const [name, { field, value }] of bindableFields(state)) {
|
|
957
|
+
const css = cssValue(field, value, this.fileUrl);
|
|
958
|
+
if (css === null)
|
|
959
|
+
continue;
|
|
960
|
+
const prop = cssVarName(name);
|
|
961
|
+
next.add(prop);
|
|
962
|
+
if (style.getPropertyValue(prop) !== css)
|
|
963
|
+
style.setProperty(prop, css);
|
|
964
|
+
}
|
|
965
|
+
for (const prop of this.vars)
|
|
966
|
+
if (!next.has(prop))
|
|
967
|
+
style.removeProperty(prop);
|
|
968
|
+
this.vars = next;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// src/widget.ts
|
|
973
|
+
class PixlWidget2 {
|
|
974
|
+
pixl;
|
|
975
|
+
state;
|
|
976
|
+
stateValue;
|
|
977
|
+
fieldValues = new Map;
|
|
978
|
+
actionHandlers = new Map;
|
|
979
|
+
listHandlers = new Map;
|
|
980
|
+
fields;
|
|
981
|
+
values;
|
|
982
|
+
settings;
|
|
983
|
+
files = {
|
|
984
|
+
list: () => this.pixl.request("/widget/files"),
|
|
985
|
+
upload: (file, name) => this.pixl.request("/widget/files", {
|
|
986
|
+
method: "POST",
|
|
987
|
+
query: { name: name ?? (file instanceof File ? file.name : "datei") },
|
|
988
|
+
raw: file
|
|
989
|
+
}).then((r) => ({ ...r, url: this.fileUrl(r.path) }))
|
|
990
|
+
};
|
|
991
|
+
voicesCache = null;
|
|
992
|
+
voices() {
|
|
993
|
+
const now = Date.now();
|
|
994
|
+
if (!this.voicesCache || now - this.voicesCache.at > 30000) {
|
|
995
|
+
const promise = this.pixl.request("/widget/voices");
|
|
996
|
+
this.voicesCache = { at: now, promise };
|
|
997
|
+
promise.catch(() => {
|
|
998
|
+
if (this.voicesCache?.promise === promise)
|
|
999
|
+
this.voicesCache = null;
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
return this.voicesCache.promise;
|
|
1003
|
+
}
|
|
1004
|
+
preview;
|
|
1005
|
+
bindings;
|
|
1006
|
+
constructor(pixl, initial, options = {}) {
|
|
1007
|
+
this.pixl = pixl;
|
|
1008
|
+
this.preview = options.preview === true;
|
|
1009
|
+
this.bindings = options.bind && typeof document !== "undefined" ? new PageBindings(document, (p) => this.fileUrl(p)) : null;
|
|
1010
|
+
if (initial)
|
|
1011
|
+
this.bindings?.apply(initial);
|
|
1012
|
+
this.stateValue = new WritableLiveValue(initial);
|
|
1013
|
+
this.state = this.stateValue;
|
|
1014
|
+
loadFonts(initial);
|
|
1015
|
+
this.values = this.fieldProxy("controls");
|
|
1016
|
+
this.settings = this.fieldProxy("settings");
|
|
1017
|
+
this.fields = fieldsView(() => this.stateValue.value);
|
|
1018
|
+
}
|
|
1019
|
+
fileUrl(path) {
|
|
1020
|
+
if (!path.startsWith("/files/"))
|
|
1021
|
+
return path;
|
|
1022
|
+
const origin = globalThis.__PIXL__?.widgetOrigin;
|
|
1023
|
+
return origin ? `${origin.replace(/\/+$/, "")}${path}` : path;
|
|
1024
|
+
}
|
|
1025
|
+
get manifest() {
|
|
1026
|
+
return this.stateValue.value?.manifest ?? null;
|
|
1027
|
+
}
|
|
1028
|
+
get(name) {
|
|
1029
|
+
const s = this.stateValue.value;
|
|
1030
|
+
return s && name in s.values ? s.values[name] : s?.settings[name];
|
|
1031
|
+
}
|
|
1032
|
+
on(action, handler) {
|
|
1033
|
+
let set = this.actionHandlers.get(action);
|
|
1034
|
+
if (!set)
|
|
1035
|
+
this.actionHandlers.set(action, set = new Set);
|
|
1036
|
+
set.add(handler);
|
|
1037
|
+
return () => {
|
|
1038
|
+
set.delete(handler);
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
onList(name, handler) {
|
|
1042
|
+
let set = this.listHandlers.get(name);
|
|
1043
|
+
if (!set)
|
|
1044
|
+
this.listHandlers.set(name, set = new Set);
|
|
1045
|
+
set.add(handler);
|
|
1046
|
+
return () => {
|
|
1047
|
+
set.delete(handler);
|
|
1048
|
+
};
|
|
1049
|
+
}
|
|
1050
|
+
set(name, value) {
|
|
1051
|
+
return this.batch([{ name, set: value }]);
|
|
1052
|
+
}
|
|
1053
|
+
add(name, delta) {
|
|
1054
|
+
return this.batch([{ name, add: delta }]);
|
|
1055
|
+
}
|
|
1056
|
+
toggle(name) {
|
|
1057
|
+
return this.batch([{ name, toggle: true }]);
|
|
1058
|
+
}
|
|
1059
|
+
press(name) {
|
|
1060
|
+
return this.batch([{ name, press: true }]);
|
|
1061
|
+
}
|
|
1062
|
+
list(name) {
|
|
1063
|
+
return {
|
|
1064
|
+
insert: (item, at) => this.batch([{ name, insert: item, ...at === undefined ? {} : { at } }]),
|
|
1065
|
+
update: (id, patch) => this.batch([{ name, update: id, patch }]),
|
|
1066
|
+
remove: (id) => this.batch([{ name, remove: id }]),
|
|
1067
|
+
move: (id, to) => this.batch([{ name, move: id, to }]),
|
|
1068
|
+
replace: (items) => this.batch([{ name, set: items }])
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
async batch(operations) {
|
|
1072
|
+
if (this.preview)
|
|
1073
|
+
return this.previewBatch(operations);
|
|
1074
|
+
const result = await this.pixl.request("/widget", {
|
|
1075
|
+
method: "POST",
|
|
1076
|
+
body: { operations }
|
|
1077
|
+
});
|
|
1078
|
+
if (this.pixl.status.value !== "connected")
|
|
1079
|
+
this.applyState(result.state, result.changes);
|
|
1080
|
+
}
|
|
1081
|
+
async previewBatch(operations) {
|
|
1082
|
+
const current = this.stateValue.value;
|
|
1083
|
+
if (!current)
|
|
1084
|
+
return;
|
|
1085
|
+
const result = await this.pixl.request("/widget/preview", {
|
|
1086
|
+
method: "POST",
|
|
1087
|
+
body: { operations, settings: current.settings, values: current.values }
|
|
1088
|
+
});
|
|
1089
|
+
const latest = this.stateValue.value ?? current;
|
|
1090
|
+
const next = { ...latest, values: { ...latest.values }, settings: { ...latest.settings } };
|
|
1091
|
+
for (const change of result.changes)
|
|
1092
|
+
applyChange(next, change);
|
|
1093
|
+
if (result.changes.length)
|
|
1094
|
+
this.applyState(next, result.changes);
|
|
1095
|
+
for (const name of result.actions)
|
|
1096
|
+
for (const h of [...this.actionHandlers.get(name) ?? []])
|
|
1097
|
+
safeCall(h);
|
|
1098
|
+
}
|
|
1099
|
+
fromServer(state) {
|
|
1100
|
+
const own = this.stateValue.value;
|
|
1101
|
+
if (!this.preview || !own)
|
|
1102
|
+
return state;
|
|
1103
|
+
return {
|
|
1104
|
+
...state,
|
|
1105
|
+
version: own.version,
|
|
1106
|
+
manifest: own.manifest,
|
|
1107
|
+
settings: mergeKnown(own, state, "settings"),
|
|
1108
|
+
values: mergeKnown(own, state, "controls")
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
async _refetch() {
|
|
1112
|
+
this.applyState(this.fromServer(await this.pixl.request("/widget")), null);
|
|
1113
|
+
}
|
|
1114
|
+
_setState(state) {
|
|
1115
|
+
this.applyState(this.fromServer(state), null);
|
|
1116
|
+
}
|
|
1117
|
+
_handle(message) {
|
|
1118
|
+
if (message.type === "widget.action") {
|
|
1119
|
+
for (const h of [...this.actionHandlers.get(message.name) ?? []])
|
|
1120
|
+
safeCall(h);
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
if (message.type === "widget.version")
|
|
1124
|
+
return;
|
|
1125
|
+
if (message.refetch) {
|
|
1126
|
+
this._refetch().catch((e) => console.warn("[pixl] Widget-Stand nicht geladen:", e));
|
|
1127
|
+
return;
|
|
1128
|
+
}
|
|
1129
|
+
const current = this.stateValue.value;
|
|
1130
|
+
if (!current)
|
|
1131
|
+
return;
|
|
1132
|
+
const next = { ...current, values: { ...current.values }, settings: { ...current.settings } };
|
|
1133
|
+
for (const change of message.changes)
|
|
1134
|
+
applyChange(next, change);
|
|
1135
|
+
this.applyState(next, message.changes);
|
|
1136
|
+
}
|
|
1137
|
+
applyState(state, changes) {
|
|
1138
|
+
const previous = this.stateValue.value;
|
|
1139
|
+
loadFonts(state);
|
|
1140
|
+
this.stateValue.set(state);
|
|
1141
|
+
this.bindings?.apply(state);
|
|
1142
|
+
for (const [key, live] of this.fieldValues) {
|
|
1143
|
+
const [scope, name] = splitKey(key);
|
|
1144
|
+
live.set((scope === "settings" ? state.settings : state.values)[name]);
|
|
1145
|
+
}
|
|
1146
|
+
const handled = new Set;
|
|
1147
|
+
for (const change of changes ?? []) {
|
|
1148
|
+
const handlers = this.listHandlers.get(change.name);
|
|
1149
|
+
if (!handlers?.size)
|
|
1150
|
+
continue;
|
|
1151
|
+
const before = listOf(previous, change);
|
|
1152
|
+
const event = listChange(change, before);
|
|
1153
|
+
if (!event)
|
|
1154
|
+
continue;
|
|
1155
|
+
handled.add(change.name);
|
|
1156
|
+
for (const h of [...handlers])
|
|
1157
|
+
safeCall(() => h(event));
|
|
1158
|
+
}
|
|
1159
|
+
for (const [name, handlers] of this.listHandlers) {
|
|
1160
|
+
if (handled.has(name))
|
|
1161
|
+
continue;
|
|
1162
|
+
const items = state.values[name] ?? state.settings[name];
|
|
1163
|
+
const before = previous ? previous.values[name] ?? previous.settings[name] : undefined;
|
|
1164
|
+
if (!Array.isArray(items) || before === items || JSON.stringify(before) === JSON.stringify(items))
|
|
1165
|
+
continue;
|
|
1166
|
+
for (const h of [...handlers])
|
|
1167
|
+
safeCall(() => h({ type: "replace", items }));
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
fieldProxy(scope) {
|
|
1171
|
+
return new Proxy({}, {
|
|
1172
|
+
get: (_, name) => {
|
|
1173
|
+
if (typeof name !== "string")
|
|
1174
|
+
return;
|
|
1175
|
+
const key = `${scope}:${name}`;
|
|
1176
|
+
let live = this.fieldValues.get(key);
|
|
1177
|
+
if (!live) {
|
|
1178
|
+
const s = this.stateValue.value;
|
|
1179
|
+
live = new WritableLiveValue(s ? (scope === "settings" ? s.settings : s.values)[name] : undefined);
|
|
1180
|
+
this.fieldValues.set(key, live);
|
|
1181
|
+
}
|
|
1182
|
+
return live;
|
|
1183
|
+
},
|
|
1184
|
+
ownKeys: () => {
|
|
1185
|
+
const s = this.stateValue.value;
|
|
1186
|
+
return Object.keys(s ? scope === "settings" ? s.settings : s.values : {});
|
|
1187
|
+
},
|
|
1188
|
+
getOwnPropertyDescriptor: () => ({ enumerable: true, configurable: true })
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
function fieldsView(state) {
|
|
1193
|
+
const names = () => {
|
|
1194
|
+
const s = state();
|
|
1195
|
+
return s ? [...new Set([...Object.keys(s.values), ...Object.keys(s.settings)])] : [];
|
|
1196
|
+
};
|
|
1197
|
+
const read = (name) => {
|
|
1198
|
+
const s = state();
|
|
1199
|
+
return s && name in s.values ? s.values[name] : s?.settings[name];
|
|
1200
|
+
};
|
|
1201
|
+
return new Proxy({}, {
|
|
1202
|
+
get: (_, name) => typeof name === "string" ? read(name) : undefined,
|
|
1203
|
+
has: (_, name) => typeof name === "string" && names().includes(name),
|
|
1204
|
+
ownKeys: () => names(),
|
|
1205
|
+
getOwnPropertyDescriptor: (_, name) => typeof name === "string" && names().includes(name) ? { value: read(name), writable: false, enumerable: true, configurable: true } : undefined,
|
|
1206
|
+
set: (_, name) => {
|
|
1207
|
+
throw new TypeError(`[pixl] fields ist nur zum Lesen, ändern geht mit widget.set("${String(name)}", …)`);
|
|
1208
|
+
},
|
|
1209
|
+
deleteProperty: () => false
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1212
|
+
function loadFonts(state) {
|
|
1213
|
+
for (const font of fontsOf(state))
|
|
1214
|
+
loadFont2(font);
|
|
1215
|
+
}
|
|
1216
|
+
function mergeKnown(own, server, scope) {
|
|
1217
|
+
const mine = (scope === "settings" ? own.manifest?.settings : own.manifest?.controls) ?? {};
|
|
1218
|
+
const theirs = (scope === "settings" ? server.manifest?.settings : server.manifest?.controls) ?? {};
|
|
1219
|
+
const out = { ...bucket(own, scope) };
|
|
1220
|
+
const values = bucket(server, scope);
|
|
1221
|
+
for (const [name, field] of Object.entries(mine))
|
|
1222
|
+
if (theirs[name]?.type === field.type && name in values)
|
|
1223
|
+
out[name] = values[name];
|
|
1224
|
+
return out;
|
|
1225
|
+
}
|
|
1226
|
+
function splitKey(key) {
|
|
1227
|
+
const i = key.indexOf(":");
|
|
1228
|
+
return [key.slice(0, i), key.slice(i + 1)];
|
|
1229
|
+
}
|
|
1230
|
+
function bucket(state, scope) {
|
|
1231
|
+
return scope === "settings" ? state.settings : state.values;
|
|
1232
|
+
}
|
|
1233
|
+
function listOf(state, change) {
|
|
1234
|
+
if (!state)
|
|
1235
|
+
return [];
|
|
1236
|
+
const v = bucket(state, change.scope)[change.name];
|
|
1237
|
+
return Array.isArray(v) ? v : [];
|
|
1238
|
+
}
|
|
1239
|
+
function applyChange(state, change) {
|
|
1240
|
+
const target = bucket(state, change.scope);
|
|
1241
|
+
if ("value" in change) {
|
|
1242
|
+
target[change.name] = change.value;
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
const list = Array.isArray(target[change.name]) ? [...target[change.name]] : [];
|
|
1246
|
+
if ("inserted" in change) {
|
|
1247
|
+
if (!list.some((x) => x.id === change.inserted.id))
|
|
1248
|
+
list.splice(change.at, 0, change.inserted);
|
|
1249
|
+
} else if ("updated" in change) {
|
|
1250
|
+
const i = list.findIndex((x) => x.id === change.updated.id);
|
|
1251
|
+
if (i >= 0)
|
|
1252
|
+
list[i] = change.updated;
|
|
1253
|
+
} else if ("removed" in change) {
|
|
1254
|
+
const i = list.findIndex((x) => x.id === change.removed);
|
|
1255
|
+
if (i >= 0)
|
|
1256
|
+
list.splice(i, 1);
|
|
1257
|
+
} else if ("moved" in change) {
|
|
1258
|
+
const i = list.findIndex((x) => x.id === change.moved);
|
|
1259
|
+
if (i >= 0) {
|
|
1260
|
+
const [item] = list.splice(i, 1);
|
|
1261
|
+
list.splice(Math.min(change.to, list.length), 0, item);
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
target[change.name] = list;
|
|
1265
|
+
}
|
|
1266
|
+
function listChange(change, before) {
|
|
1267
|
+
if ("inserted" in change)
|
|
1268
|
+
return { type: "insert", item: change.inserted, at: change.at };
|
|
1269
|
+
if ("updated" in change)
|
|
1270
|
+
return { type: "update", item: change.updated, previous: before.find((x) => x.id === change.updated.id) ?? null };
|
|
1271
|
+
if ("removed" in change)
|
|
1272
|
+
return { type: "remove", id: change.removed, previous: before.find((x) => x.id === change.removed) ?? null };
|
|
1273
|
+
if ("moved" in change)
|
|
1274
|
+
return { type: "move", id: change.moved, to: change.to };
|
|
1275
|
+
if (Array.isArray(change.value))
|
|
1276
|
+
return { type: "replace", items: change.value };
|
|
1277
|
+
return null;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// src/events.ts
|
|
1281
|
+
function toQueryDate(value) {
|
|
1282
|
+
return value instanceof Date ? value.toISOString() : String(value);
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
class PixlEvents {
|
|
1286
|
+
pixl;
|
|
1287
|
+
constructor(pixl) {
|
|
1288
|
+
this.pixl = pixl;
|
|
1289
|
+
}
|
|
1290
|
+
async* list(query) {
|
|
1291
|
+
let cursor;
|
|
1292
|
+
do {
|
|
1293
|
+
const page = await this.fetchPage(query, cursor);
|
|
1294
|
+
yield* page.events;
|
|
1295
|
+
cursor = page.nextCursor ?? undefined;
|
|
1296
|
+
} while (cursor);
|
|
1297
|
+
}
|
|
1298
|
+
async all(query) {
|
|
1299
|
+
const all = [];
|
|
1300
|
+
for await (const e of this.list(query))
|
|
1301
|
+
all.push(e);
|
|
1302
|
+
return all.reverse();
|
|
1303
|
+
}
|
|
1304
|
+
feed(options, handler) {
|
|
1305
|
+
return new EventFeed2(this.pixl, this, options, handler);
|
|
1306
|
+
}
|
|
1307
|
+
fetchPage(query, cursor) {
|
|
1308
|
+
if (this.pixl.demo)
|
|
1309
|
+
return Promise.resolve({ events: [], nextCursor: null });
|
|
1310
|
+
return this.pixl.request("/events", {
|
|
1311
|
+
query: {
|
|
1312
|
+
v: 2,
|
|
1313
|
+
since: toQueryDate(query.since),
|
|
1314
|
+
until: query.until !== undefined ? toQueryDate(query.until) : undefined,
|
|
1315
|
+
types: query.types?.join(","),
|
|
1316
|
+
limit: 200,
|
|
1317
|
+
cursor
|
|
1318
|
+
},
|
|
1319
|
+
signal: query.signal
|
|
1320
|
+
});
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// src/frame.ts
|
|
1325
|
+
function startFrameBridge() {
|
|
1326
|
+
if (typeof window === "undefined" || window.parent === window)
|
|
1327
|
+
return;
|
|
1328
|
+
const global = globalThis;
|
|
1329
|
+
if (global.__pixlFrame)
|
|
1330
|
+
return;
|
|
1331
|
+
global.__pixlFrame = true;
|
|
1332
|
+
window.addEventListener("message", (e) => {
|
|
1333
|
+
if (e.source !== window.parent || typeof e.data !== "object" || !e.data)
|
|
1334
|
+
return;
|
|
1335
|
+
if (e.data.type !== "pixl:theme" || typeof e.data.vars !== "object")
|
|
1336
|
+
return;
|
|
1337
|
+
const root = document.documentElement;
|
|
1338
|
+
for (const [name, value] of Object.entries(e.data.vars))
|
|
1339
|
+
if (name.startsWith("--pixl-"))
|
|
1340
|
+
root.style.setProperty(name, String(value));
|
|
1341
|
+
if (e.data.scheme)
|
|
1342
|
+
root.style.colorScheme = e.data.scheme;
|
|
1343
|
+
});
|
|
1344
|
+
const report = () => {
|
|
1345
|
+
const body = document.body;
|
|
1346
|
+
const style = getComputedStyle(body);
|
|
1347
|
+
const height = body.getBoundingClientRect().height + parseFloat(style.marginTop) + parseFloat(style.marginBottom);
|
|
1348
|
+
window.parent.postMessage({ type: "pixl:height", height: Math.ceil(height) }, "*");
|
|
1349
|
+
};
|
|
1350
|
+
const observe = () => {
|
|
1351
|
+
if (typeof ResizeObserver !== "undefined")
|
|
1352
|
+
new ResizeObserver(report).observe(document.body);
|
|
1353
|
+
report();
|
|
1354
|
+
};
|
|
1355
|
+
if (document.body)
|
|
1356
|
+
observe();
|
|
1357
|
+
else
|
|
1358
|
+
document.addEventListener("DOMContentLoaded", observe, { once: true });
|
|
1359
|
+
window.addEventListener("load", report);
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// src/spotify.ts
|
|
1363
|
+
class PixlSpotify {
|
|
1364
|
+
pixl;
|
|
1365
|
+
nowPlaying;
|
|
1366
|
+
value = new WritableLiveValue(null);
|
|
1367
|
+
constructor(pixl) {
|
|
1368
|
+
this.pixl = pixl;
|
|
1369
|
+
this.nowPlaying = this.value;
|
|
1370
|
+
pixl._onConnect(() => this.load());
|
|
1371
|
+
pixl._listen("spotify.now_playing", (e) => this.value.set(e.data));
|
|
1372
|
+
}
|
|
1373
|
+
progress(now = Date.now()) {
|
|
1374
|
+
const np = this.value.value;
|
|
1375
|
+
if (!np?.track)
|
|
1376
|
+
return 0;
|
|
1377
|
+
const ms = np.playing ? np.progressMs + (now - Date.parse(np.at)) : np.progressMs;
|
|
1378
|
+
return Math.max(0, Math.min(ms, np.track.durationMs));
|
|
1379
|
+
}
|
|
1380
|
+
async load() {
|
|
1381
|
+
if (this.pixl.demo) {
|
|
1382
|
+
this.value.set({ ...EVENT_TEMPLATES2["spotify.now_playing"], at: new Date().toISOString() });
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
if (!this.pixl.scopes?.includes("spotify:read"))
|
|
1386
|
+
return;
|
|
1387
|
+
try {
|
|
1388
|
+
this.value.set(await this.pixl.request("/spotify/now-playing"));
|
|
1389
|
+
} catch (e) {
|
|
1390
|
+
if (e instanceof PixlError2 && e.code === "module_disabled")
|
|
1391
|
+
this.value.set(null);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
_close() {
|
|
1395
|
+
this.value.clear();
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
// src/streamelements.ts
|
|
1400
|
+
class PixlStreamElements {
|
|
1401
|
+
pixl;
|
|
1402
|
+
constructor(pixl) {
|
|
1403
|
+
this.pixl = pixl;
|
|
1404
|
+
}
|
|
1405
|
+
get(path, params = {}, options = {}) {
|
|
1406
|
+
return this.pixl.request(`/streamelements/${path}`, { query: params, signal: options.signal });
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
// src/stream.ts
|
|
1411
|
+
var RECONNECT_DELAYS_MS = [500, 1000, 2000, 3000, 5000, 1e4];
|
|
1412
|
+
var WATCHDOG_MS = 45000;
|
|
1413
|
+
var CONNECT_TIMEOUT_MS = 15000;
|
|
1414
|
+
|
|
1415
|
+
class LiveStream {
|
|
1416
|
+
options;
|
|
1417
|
+
abort = null;
|
|
1418
|
+
reconnectTimer = null;
|
|
1419
|
+
watchdog = null;
|
|
1420
|
+
attempt = 0;
|
|
1421
|
+
stopped = true;
|
|
1422
|
+
everConnected = false;
|
|
1423
|
+
lostAt = null;
|
|
1424
|
+
constructor(options) {
|
|
1425
|
+
this.options = options;
|
|
1426
|
+
}
|
|
1427
|
+
start() {
|
|
1428
|
+
if (this.abort)
|
|
1429
|
+
return;
|
|
1430
|
+
this.stopped = false;
|
|
1431
|
+
this.run();
|
|
1432
|
+
}
|
|
1433
|
+
stop() {
|
|
1434
|
+
this.stopped = true;
|
|
1435
|
+
if (this.reconnectTimer)
|
|
1436
|
+
clearTimeout(this.reconnectTimer);
|
|
1437
|
+
this.reconnectTimer = null;
|
|
1438
|
+
this.clearWatchdog();
|
|
1439
|
+
this.abort?.abort();
|
|
1440
|
+
this.abort = null;
|
|
1441
|
+
this.options.onStatus("closed");
|
|
1442
|
+
}
|
|
1443
|
+
async run() {
|
|
1444
|
+
const abort = new AbortController;
|
|
1445
|
+
this.abort = abort;
|
|
1446
|
+
this.options.onStatus("connecting");
|
|
1447
|
+
let fatal = false;
|
|
1448
|
+
try {
|
|
1449
|
+
this.watchdog = setTimeout(() => abort.abort(), CONNECT_TIMEOUT_MS);
|
|
1450
|
+
const res = await (this.options.fetch ?? fetch)(this.options.url, {
|
|
1451
|
+
headers: { Authorization: `Bearer ${this.options.token()}`, Accept: "text/event-stream" },
|
|
1452
|
+
signal: abort.signal,
|
|
1453
|
+
cache: "no-store"
|
|
1454
|
+
});
|
|
1455
|
+
this.clearWatchdog();
|
|
1456
|
+
if (res.status === 401 || res.status === 403) {
|
|
1457
|
+
fatal = true;
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
if (!res.ok || !res.body)
|
|
1461
|
+
throw new Error(`HTTP ${res.status}`);
|
|
1462
|
+
this.attempt = 0;
|
|
1463
|
+
if (this.lostAt !== null)
|
|
1464
|
+
this.options.log(`Live-Verbindung wieder da nach ${((Date.now() - this.lostAt) / 1000).toFixed(1)} s`);
|
|
1465
|
+
this.lostAt = null;
|
|
1466
|
+
this.everConnected = true;
|
|
1467
|
+
await this.read(res.body, abort, () => {
|
|
1468
|
+
fatal = true;
|
|
1469
|
+
});
|
|
1470
|
+
} catch {} finally {
|
|
1471
|
+
this.clearWatchdog();
|
|
1472
|
+
if (this.abort === abort)
|
|
1473
|
+
this.abort = null;
|
|
1474
|
+
if (fatal) {
|
|
1475
|
+
this.options.log("Live-Verbindung abgelehnt (Zugang ungültig oder widerrufen), kein neuer Versuch");
|
|
1476
|
+
this.stopped = true;
|
|
1477
|
+
this.options.onStatus("unauthorized");
|
|
1478
|
+
} else if (!this.stopped) {
|
|
1479
|
+
if (this.lostAt === null) {
|
|
1480
|
+
this.lostAt = Date.now();
|
|
1481
|
+
this.options.log(this.everConnected ? "Live-Verbindung getrennt, verbinde neu" : "Live-Verbindung nicht erreichbar, versuche es weiter");
|
|
1482
|
+
}
|
|
1483
|
+
this.options.onStatus("disconnected");
|
|
1484
|
+
this.scheduleReconnect();
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
scheduleReconnect() {
|
|
1489
|
+
const base = RECONNECT_DELAYS_MS[Math.min(this.attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
1490
|
+
this.attempt += 1;
|
|
1491
|
+
const delay = base + Math.random() * base * 0.3;
|
|
1492
|
+
this.reconnectTimer = setTimeout(() => {
|
|
1493
|
+
this.reconnectTimer = null;
|
|
1494
|
+
if (!this.stopped)
|
|
1495
|
+
this.start();
|
|
1496
|
+
}, delay);
|
|
1497
|
+
}
|
|
1498
|
+
armWatchdog(abort) {
|
|
1499
|
+
this.clearWatchdog();
|
|
1500
|
+
this.watchdog = setTimeout(() => abort.abort(), WATCHDOG_MS);
|
|
1501
|
+
}
|
|
1502
|
+
clearWatchdog() {
|
|
1503
|
+
if (this.watchdog)
|
|
1504
|
+
clearTimeout(this.watchdog);
|
|
1505
|
+
this.watchdog = null;
|
|
1506
|
+
}
|
|
1507
|
+
async read(body, abort, onRevoked) {
|
|
1508
|
+
const reader = body.getReader();
|
|
1509
|
+
const decoder = new TextDecoder;
|
|
1510
|
+
let buffer = "";
|
|
1511
|
+
let dataLines = [];
|
|
1512
|
+
this.armWatchdog(abort);
|
|
1513
|
+
for (;; ) {
|
|
1514
|
+
const { value, done } = await reader.read();
|
|
1515
|
+
if (done)
|
|
1516
|
+
return;
|
|
1517
|
+
this.armWatchdog(abort);
|
|
1518
|
+
buffer += decoder.decode(value, { stream: true });
|
|
1519
|
+
let newline;
|
|
1520
|
+
while ((newline = buffer.indexOf(`
|
|
1521
|
+
`)) >= 0) {
|
|
1522
|
+
const line = buffer.slice(0, newline).replace(/\r$/, "");
|
|
1523
|
+
buffer = buffer.slice(newline + 1);
|
|
1524
|
+
if (line === "") {
|
|
1525
|
+
if (!dataLines.length)
|
|
1526
|
+
continue;
|
|
1527
|
+
const raw = dataLines.join(`
|
|
1528
|
+
`);
|
|
1529
|
+
dataLines = [];
|
|
1530
|
+
let message;
|
|
1531
|
+
try {
|
|
1532
|
+
message = JSON.parse(raw);
|
|
1533
|
+
} catch {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
if (message.type === "access_revoked") {
|
|
1537
|
+
onRevoked();
|
|
1538
|
+
await reader.cancel().catch(() => {});
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
this.options.onMessage(message);
|
|
1542
|
+
} else if (line.startsWith("data:")) {
|
|
1543
|
+
dataLines.push(line.slice(5).replace(/^ /, ""));
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// src/twitch.ts
|
|
1551
|
+
class PixlTwitch {
|
|
1552
|
+
pixl;
|
|
1553
|
+
subs;
|
|
1554
|
+
hypeTrain;
|
|
1555
|
+
poll;
|
|
1556
|
+
prediction;
|
|
1557
|
+
goal;
|
|
1558
|
+
charity;
|
|
1559
|
+
adBreak;
|
|
1560
|
+
values = {
|
|
1561
|
+
subs: new WritableLiveValue(null),
|
|
1562
|
+
hypeTrain: new WritableLiveValue(null),
|
|
1563
|
+
poll: new WritableLiveValue(null),
|
|
1564
|
+
prediction: new WritableLiveValue(null),
|
|
1565
|
+
goal: new WritableLiveValue(null),
|
|
1566
|
+
charity: new WritableLiveValue(null),
|
|
1567
|
+
adBreak: new WritableLiveValue(null)
|
|
1568
|
+
};
|
|
1569
|
+
adBreakTimer = null;
|
|
1570
|
+
constructor(pixl) {
|
|
1571
|
+
this.pixl = pixl;
|
|
1572
|
+
const v = this.values;
|
|
1573
|
+
this.subs = v.subs;
|
|
1574
|
+
this.hypeTrain = v.hypeTrain;
|
|
1575
|
+
this.poll = v.poll;
|
|
1576
|
+
this.prediction = v.prediction;
|
|
1577
|
+
this.goal = v.goal;
|
|
1578
|
+
this.charity = v.charity;
|
|
1579
|
+
this.adBreak = v.adBreak;
|
|
1580
|
+
pixl._onConnect(() => this.load());
|
|
1581
|
+
pixl._listen("subs.updated", (e) => v.subs.set(e.data));
|
|
1582
|
+
pixl._listen("twitch.hype_train_started", (e) => v.hypeTrain.set(e.data));
|
|
1583
|
+
pixl._listen("twitch.hype_train_progress", (e) => v.hypeTrain.set(e.data));
|
|
1584
|
+
pixl._listen("twitch.hype_train_ended", () => v.hypeTrain.set(null));
|
|
1585
|
+
pixl._listen("twitch.poll_started", (e) => v.poll.set({ ...e.data, status: "active" }));
|
|
1586
|
+
pixl._listen("twitch.poll_progress", (e) => v.poll.set({ ...e.data, status: "active" }));
|
|
1587
|
+
pixl._listen("twitch.poll_ended", (e) => v.poll.set(e.data));
|
|
1588
|
+
pixl._listen("twitch.prediction_started", (e) => v.prediction.set({ ...e.data, status: "active", winner: null }));
|
|
1589
|
+
pixl._listen("twitch.prediction_progress", (e) => v.prediction.set({ ...e.data, status: "active", winner: null }));
|
|
1590
|
+
pixl._listen("twitch.prediction_locked", (e) => v.prediction.set({ ...e.data, status: "locked", winner: null }));
|
|
1591
|
+
pixl._listen("twitch.prediction_ended", (e) => v.prediction.set(e.data));
|
|
1592
|
+
pixl._listen("twitch.goal_started", (e) => v.goal.set({ ...e.data, active: true, achieved: false }));
|
|
1593
|
+
pixl._listen("twitch.goal_progress", (e) => {
|
|
1594
|
+
const prev = v.goal.value;
|
|
1595
|
+
v.goal.set({
|
|
1596
|
+
type: prev?.type ?? "",
|
|
1597
|
+
description: prev?.description ?? "",
|
|
1598
|
+
...e.data,
|
|
1599
|
+
active: true,
|
|
1600
|
+
achieved: false
|
|
1601
|
+
});
|
|
1602
|
+
});
|
|
1603
|
+
pixl._listen("twitch.goal_ended", (e) => v.goal.set({ ...e.data, active: false }));
|
|
1604
|
+
pixl._listen("twitch.charity_started", (e) => v.charity.set({ ...e.data, current: { value: 0, currency: e.data.target.currency }, active: true }));
|
|
1605
|
+
pixl._listen("twitch.charity_progress", (e) => v.charity.set({ charity: v.charity.value?.charity ?? "", ...e.data, active: true }));
|
|
1606
|
+
pixl._listen("twitch.charity_stopped", (e) => v.charity.set({ ...e.data, active: false }));
|
|
1607
|
+
pixl._listen("twitch.ad_break", (e) => {
|
|
1608
|
+
const startedAt = e.data.startedAt ?? e.at;
|
|
1609
|
+
const endsAt = new Date(Date.parse(startedAt) + e.data.durationSec * 1000);
|
|
1610
|
+
v.adBreak.set({ durationSec: e.data.durationSec, startedAt, endsAt: endsAt.toISOString() });
|
|
1611
|
+
if (this.adBreakTimer)
|
|
1612
|
+
clearTimeout(this.adBreakTimer);
|
|
1613
|
+
this.adBreakTimer = setTimeout(() => v.adBreak.set(null), Math.max(0, endsAt.getTime() - Date.now()));
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
async fetchSubs(options = {}) {
|
|
1617
|
+
const subs = await this.pixl.request("/subs", { signal: options.signal });
|
|
1618
|
+
this.values.subs.set(subs);
|
|
1619
|
+
return subs;
|
|
1620
|
+
}
|
|
1621
|
+
async load() {
|
|
1622
|
+
if (this.pixl.demo) {
|
|
1623
|
+
this.values.subs.set(EVENT_TEMPLATES2["subs.updated"]);
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
if (this.pixl.scopes?.includes("subs:read"))
|
|
1627
|
+
await this.fetchSubs().catch(() => {});
|
|
1628
|
+
}
|
|
1629
|
+
_close() {
|
|
1630
|
+
if (this.adBreakTimer)
|
|
1631
|
+
clearTimeout(this.adBreakTimer);
|
|
1632
|
+
for (const value of Object.values(this.values))
|
|
1633
|
+
value.clear();
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// src/client.ts
|
|
1638
|
+
var DEFAULT_BASE_URL = "https://pixl.lennox.zip";
|
|
1639
|
+
var DEMO_CHANNEL = { id: "demo", name: "Demo-Kanal", login: "pixl", avatar: null };
|
|
1640
|
+
var ALL_SCOPES = ["events:read", "tts:read", "tts:write", "subs:read", "spotify:read", "streamelements:read"];
|
|
1641
|
+
function injected() {
|
|
1642
|
+
const fromPage = globalThis.__PIXL__;
|
|
1643
|
+
if (fromPage)
|
|
1644
|
+
return fromPage;
|
|
1645
|
+
const hash = typeof location !== "undefined" ? location.hash.slice(1) : "";
|
|
1646
|
+
if (!hash.includes("pixl-key="))
|
|
1647
|
+
return;
|
|
1648
|
+
const params = new URLSearchParams(hash);
|
|
1649
|
+
return { key: params.get("pixl-key") ?? undefined, baseUrl: params.get("pixl-server") ?? undefined };
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
class Pixl2 {
|
|
1653
|
+
static defaultBaseUrl;
|
|
1654
|
+
static sharedInstance = null;
|
|
1655
|
+
static shared(options) {
|
|
1656
|
+
return Pixl2.sharedInstance ??= new Pixl2(options);
|
|
1657
|
+
}
|
|
1658
|
+
static loadFont = loadFont2;
|
|
1659
|
+
static fontFamily = fontFamily2;
|
|
1660
|
+
baseUrl;
|
|
1661
|
+
demo;
|
|
1662
|
+
status;
|
|
1663
|
+
channel;
|
|
1664
|
+
ready;
|
|
1665
|
+
events;
|
|
1666
|
+
twitch;
|
|
1667
|
+
spotify;
|
|
1668
|
+
tts;
|
|
1669
|
+
chat;
|
|
1670
|
+
streamElements;
|
|
1671
|
+
fields = fieldsView(() => this.widgetInstance?.state.value ?? null);
|
|
1672
|
+
key;
|
|
1673
|
+
logs;
|
|
1674
|
+
widgetInstance = null;
|
|
1675
|
+
control;
|
|
1676
|
+
previewMode;
|
|
1677
|
+
fetchImpl;
|
|
1678
|
+
stream;
|
|
1679
|
+
statusValue = new WritableLiveValue("closed");
|
|
1680
|
+
channelValue = new WritableLiveValue(null);
|
|
1681
|
+
handlers = new Map;
|
|
1682
|
+
internal = new Map;
|
|
1683
|
+
connectHandlers = new Set;
|
|
1684
|
+
scopeList = null;
|
|
1685
|
+
warnedScopes = new Set;
|
|
1686
|
+
warnedNoWidget = false;
|
|
1687
|
+
resolveReady;
|
|
1688
|
+
rejectReady;
|
|
1689
|
+
readySettled = false;
|
|
1690
|
+
constructor(options = {}) {
|
|
1691
|
+
const inj = injected();
|
|
1692
|
+
this.demo = options.demo === true;
|
|
1693
|
+
this.key = options.key ?? inj?.key ?? "";
|
|
1694
|
+
this.control = !this.key && inj?.control === true;
|
|
1695
|
+
this.previewMode = this.control && inj?.preview === true;
|
|
1696
|
+
if (!this.key && !this.demo && !this.control)
|
|
1697
|
+
throw new PixlError2("unauthorized", "pixl: `key` fehlt (API-Schlüssel aus „API & SDK“) oder `demo: true` setzen");
|
|
1698
|
+
if (inj?.widget)
|
|
1699
|
+
this.widgetInstance = new PixlWidget2(this, inj.widget, this.widgetOptions());
|
|
1700
|
+
this.logs = options.logs !== false;
|
|
1701
|
+
Pixl2.sharedInstance ??= this;
|
|
1702
|
+
this.fetchImpl = options.fetch ?? ((...args) => fetch(...args));
|
|
1703
|
+
this.baseUrl = (options.baseUrl ?? inj?.baseUrl ?? Pixl2.defaultBaseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
1704
|
+
this.status = this.statusValue;
|
|
1705
|
+
this.channel = this.channelValue;
|
|
1706
|
+
this.ready = new Promise((resolve, reject) => {
|
|
1707
|
+
this.resolveReady = resolve;
|
|
1708
|
+
this.rejectReady = reject;
|
|
1709
|
+
});
|
|
1710
|
+
this.ready.catch(() => {});
|
|
1711
|
+
this.events = new PixlEvents(this);
|
|
1712
|
+
this.twitch = new PixlTwitch(this);
|
|
1713
|
+
this.spotify = new PixlSpotify(this);
|
|
1714
|
+
this.tts = new PixlTts2(this);
|
|
1715
|
+
this.chat = new PixlChat2(this);
|
|
1716
|
+
this.streamElements = new PixlStreamElements(this);
|
|
1717
|
+
this.stream = this.demo ? null : new LiveStream({
|
|
1718
|
+
url: `${this.baseUrl}/api/v1/stream?v=2`,
|
|
1719
|
+
token: () => this.key,
|
|
1720
|
+
fetch: this.fetchImpl,
|
|
1721
|
+
onMessage: (m) => this.handleMessage(m),
|
|
1722
|
+
onStatus: (s) => this.handleStatus(s),
|
|
1723
|
+
log: (m) => this.log(m)
|
|
1724
|
+
});
|
|
1725
|
+
if (this.control) {
|
|
1726
|
+
this.listenToDashboard(options.autoConnect !== false);
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
if (options.autoConnect !== false)
|
|
1730
|
+
queueMicrotask(() => this.connect());
|
|
1731
|
+
}
|
|
1732
|
+
widget() {
|
|
1733
|
+
if (!this.widgetInstance && !this.warnedNoWidget && this.scopeList) {
|
|
1734
|
+
this.warnedNoWidget = true;
|
|
1735
|
+
console.warn("[pixl] widget() geht nur in gehosteten Widgets, nicht mit einem normalen API-Schlüssel");
|
|
1736
|
+
}
|
|
1737
|
+
return this.widgetInstance;
|
|
1738
|
+
}
|
|
1739
|
+
get scopes() {
|
|
1740
|
+
return this.scopeList;
|
|
1741
|
+
}
|
|
1742
|
+
on(type, handler) {
|
|
1743
|
+
let set = this.handlers.get(type);
|
|
1744
|
+
if (!set)
|
|
1745
|
+
this.handlers.set(type, set = new Set);
|
|
1746
|
+
set.add(handler);
|
|
1747
|
+
this.checkScope(type);
|
|
1748
|
+
return () => {
|
|
1749
|
+
set.delete(handler);
|
|
1750
|
+
};
|
|
1751
|
+
}
|
|
1752
|
+
sendTestEvent(type, overrides = {}) {
|
|
1753
|
+
const template = EVENT_TEMPLATES2[type];
|
|
1754
|
+
const event = {
|
|
1755
|
+
type,
|
|
1756
|
+
data: { ...template, ...overrides },
|
|
1757
|
+
at: new Date().toISOString(),
|
|
1758
|
+
source: "test"
|
|
1759
|
+
};
|
|
1760
|
+
this.dispatch(event);
|
|
1761
|
+
return event;
|
|
1762
|
+
}
|
|
1763
|
+
connect() {
|
|
1764
|
+
if (this.demo) {
|
|
1765
|
+
this.handleConnected({ type: "connected", protocol: 2, channel: DEMO_CHANNEL, scopes: ALL_SCOPES });
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
this.stream?.start();
|
|
1769
|
+
}
|
|
1770
|
+
close() {
|
|
1771
|
+
if (Pixl2.sharedInstance === this)
|
|
1772
|
+
Pixl2.sharedInstance = null;
|
|
1773
|
+
this.stream?.stop();
|
|
1774
|
+
if (this.demo)
|
|
1775
|
+
this.statusValue.set("closed");
|
|
1776
|
+
this.handlers.clear();
|
|
1777
|
+
this.internal.clear();
|
|
1778
|
+
this.connectHandlers.clear();
|
|
1779
|
+
this.twitch._close();
|
|
1780
|
+
this.spotify._close();
|
|
1781
|
+
this.channelValue.clear();
|
|
1782
|
+
this.statusValue.clear();
|
|
1783
|
+
}
|
|
1784
|
+
async request(path, options = {}) {
|
|
1785
|
+
const res = await this.fetchRaw(path, options);
|
|
1786
|
+
return await res.json();
|
|
1787
|
+
}
|
|
1788
|
+
async fetchRaw(path, options = {}) {
|
|
1789
|
+
if (this.demo)
|
|
1790
|
+
throw new PixlError2("demo", "Im Demo-Modus gibt es keinen Server");
|
|
1791
|
+
if (!this.key)
|
|
1792
|
+
throw new PixlError2("unauthorized", "Noch kein Zugang (Steuer-Seite wartet auf das Dashboard)");
|
|
1793
|
+
const url = new URL(`${this.baseUrl}/api/v1${path}`);
|
|
1794
|
+
for (const [k, v] of Object.entries(options.query ?? {}))
|
|
1795
|
+
if (v !== undefined && v !== "")
|
|
1796
|
+
url.searchParams.set(k, String(v));
|
|
1797
|
+
const headers = { Authorization: `Bearer ${this.key}` };
|
|
1798
|
+
if (options.body !== undefined)
|
|
1799
|
+
headers["Content-Type"] = "application/json";
|
|
1800
|
+
else if (options.raw)
|
|
1801
|
+
headers["Content-Type"] = options.raw.type || "application/octet-stream";
|
|
1802
|
+
let res;
|
|
1803
|
+
try {
|
|
1804
|
+
res = await this.fetchImpl(url, {
|
|
1805
|
+
method: options.method ?? "GET",
|
|
1806
|
+
headers,
|
|
1807
|
+
body: options.raw ?? (options.body !== undefined ? JSON.stringify(options.body) : undefined),
|
|
1808
|
+
signal: options.signal
|
|
1809
|
+
});
|
|
1810
|
+
} catch (e) {
|
|
1811
|
+
if (e instanceof DOMException && e.name === "AbortError")
|
|
1812
|
+
throw e;
|
|
1813
|
+
throw new PixlError2("network", "pixl ist gerade nicht erreichbar");
|
|
1814
|
+
}
|
|
1815
|
+
if (!res.ok)
|
|
1816
|
+
throw await errorFromResponse(res);
|
|
1817
|
+
return res;
|
|
1818
|
+
}
|
|
1819
|
+
_onConnect(fn) {
|
|
1820
|
+
this.connectHandlers.add(fn);
|
|
1821
|
+
}
|
|
1822
|
+
_listen(type, handler) {
|
|
1823
|
+
let set = this.internal.get(type);
|
|
1824
|
+
if (!set)
|
|
1825
|
+
this.internal.set(type, set = new Set);
|
|
1826
|
+
set.add(handler);
|
|
1827
|
+
return () => {
|
|
1828
|
+
set.delete(handler);
|
|
1829
|
+
};
|
|
1830
|
+
}
|
|
1831
|
+
widgetOptions() {
|
|
1832
|
+
const preview = injected()?.preview === true;
|
|
1833
|
+
return { preview, bind: !this.control || preview };
|
|
1834
|
+
}
|
|
1835
|
+
get _preview() {
|
|
1836
|
+
return this.previewMode;
|
|
1837
|
+
}
|
|
1838
|
+
_log(message) {
|
|
1839
|
+
this.log(message);
|
|
1840
|
+
}
|
|
1841
|
+
handleMessage(message) {
|
|
1842
|
+
if (message.type === "connected") {
|
|
1843
|
+
this.handleConnected(message);
|
|
1844
|
+
return;
|
|
1845
|
+
}
|
|
1846
|
+
if (message.type === "access_revoked")
|
|
1847
|
+
return;
|
|
1848
|
+
if (message.type.startsWith("widget.")) {
|
|
1849
|
+
this.handleWidgetMessage(message);
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
this.dispatch(message);
|
|
1853
|
+
}
|
|
1854
|
+
handleWidgetMessage(message) {
|
|
1855
|
+
if (message.type === "widget.version") {
|
|
1856
|
+
if (!this.control && injected()?.widget && typeof location !== "undefined")
|
|
1857
|
+
location.reload();
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1860
|
+
this.widgetInstance?._handle(message);
|
|
1861
|
+
}
|
|
1862
|
+
listenToDashboard(autoConnect) {
|
|
1863
|
+
if (typeof window === "undefined" || window.parent === window) {
|
|
1864
|
+
this.log("Steuer-Seite außerhalb des Dashboards geöffnet, kein Zugang");
|
|
1865
|
+
return;
|
|
1866
|
+
}
|
|
1867
|
+
window.addEventListener("message", (e) => {
|
|
1868
|
+
if (e.source !== window.parent || typeof e.data !== "object" || !e.data)
|
|
1869
|
+
return;
|
|
1870
|
+
if (e.data.type === "pixl:control" && typeof e.data.token === "string") {
|
|
1871
|
+
const first = !this.key;
|
|
1872
|
+
this.key = e.data.token;
|
|
1873
|
+
if (first && autoConnect)
|
|
1874
|
+
this.connect();
|
|
1875
|
+
}
|
|
1876
|
+
});
|
|
1877
|
+
startFrameBridge();
|
|
1878
|
+
window.parent.postMessage({ type: "pixl:ready" }, "*");
|
|
1879
|
+
}
|
|
1880
|
+
handleConnected(hello) {
|
|
1881
|
+
this.scopeList = hello.scopes;
|
|
1882
|
+
this.channelValue.set(hello.channel);
|
|
1883
|
+
if (hello.widget) {
|
|
1884
|
+
if (this.widgetInstance)
|
|
1885
|
+
this.widgetInstance._setState(hello.widget);
|
|
1886
|
+
else
|
|
1887
|
+
this.widgetInstance = new PixlWidget2(this, hello.widget, this.widgetOptions());
|
|
1888
|
+
}
|
|
1889
|
+
this.statusValue.set("connected");
|
|
1890
|
+
for (const type of this.handlers.keys())
|
|
1891
|
+
this.checkScope(type);
|
|
1892
|
+
const loads = [...this.connectHandlers].map((fn) => Promise.resolve().then(fn));
|
|
1893
|
+
Promise.allSettled(loads).then(() => {
|
|
1894
|
+
if (this.readySettled)
|
|
1895
|
+
return;
|
|
1896
|
+
this.readySettled = true;
|
|
1897
|
+
this.resolveReady();
|
|
1898
|
+
});
|
|
1899
|
+
}
|
|
1900
|
+
handleStatus(status) {
|
|
1901
|
+
if (status === "connected")
|
|
1902
|
+
return;
|
|
1903
|
+
this.statusValue.set(status);
|
|
1904
|
+
if (status === "unauthorized" && !this.readySettled) {
|
|
1905
|
+
this.readySettled = true;
|
|
1906
|
+
this.rejectReady(new PixlError2("unauthorized", "Zugang ungültig oder widerrufen"));
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1909
|
+
dispatch(event) {
|
|
1910
|
+
for (const h of [...this.internal.get(event.type) ?? []])
|
|
1911
|
+
safeCall(() => h(event));
|
|
1912
|
+
for (const h of [...this.handlers.get(event.type) ?? []])
|
|
1913
|
+
safeCall(() => h(event));
|
|
1914
|
+
for (const h of [...this.handlers.get("*") ?? []])
|
|
1915
|
+
safeCall(() => h(event));
|
|
1916
|
+
}
|
|
1917
|
+
checkScope(type) {
|
|
1918
|
+
if (!this.scopeList || type === "*")
|
|
1919
|
+
return;
|
|
1920
|
+
const scope = apiScopeForEvent(type);
|
|
1921
|
+
if (!scope || this.scopeList.includes(scope) || this.warnedScopes.has(scope))
|
|
1922
|
+
return;
|
|
1923
|
+
this.warnedScopes.add(scope);
|
|
1924
|
+
console.warn(`[pixl] "${type}" kommt nie an: dem Schlüssel fehlt die Erlaubnis ${scope}`);
|
|
1925
|
+
}
|
|
1926
|
+
log(message) {
|
|
1927
|
+
if (this.logs)
|
|
1928
|
+
console.info(`[pixl] ${message}`);
|
|
1929
|
+
}
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
export { EVENT_TEMPLATES2, emoteUrl2, escapeHtml2, chatHtml2, badgesHtml2, CHAT_CSS2, paintCss2, PixlChat2, PixlError2, EventFeed2, FONT_HOST2, fontStylesheetUrl2, fontFamily2, loadFont2, PixlTts2, TtsPlayer2, PixlWidget2, Pixl2 };
|