@powly/sdk 0.4.1 → 0.8.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/powly.js CHANGED
@@ -1,8 +1,8 @@
1
- /* Powly SDK 0.4.1: defines window.Powly */
1
+ /* Powly SDK 0.8.0: defines window.Powly */
2
2
  (function () {
3
3
  "use strict";
4
4
  /** This SDK's version; Powly's Studio shows it next to the downloads. */
5
- const SDK_VERSION = "0.4.1";
5
+ const SDK_VERSION = "0.8.0";
6
6
 
7
7
  // The messages between a game (this SDK, inside Powly's iframe) and the Powly page that framed it.
8
8
  // Shared with the host side (apps/web/components/player/sdk-host.ts).
@@ -14,16 +14,69 @@ const SDK_VERSION = "0.4.1";
14
14
  // 3. From then on the game also reports to Powly Anti-Cheat over the port (`PacSample`, src/pac.ts).
15
15
  const PROTOCOL = "powly-sdk";
16
16
  const PROTOCOL_VERSION = 1;
17
+ /** The corners Powly's options button (its mascot, which opens the player's options) can be in. */
18
+ const OPTIONS_BUTTON_CORNERS = [
19
+ "top-left",
20
+ "top-right",
21
+ "bottom-left",
22
+ "bottom-right",
23
+ ];
24
+ const isOptionsButtonCorner = (value) => OPTIONS_BUTTON_CORNERS.includes(value);
25
+ /** Channel ids: letters, digits, _ and -, starting with a letter, up to 32. */
26
+ const AUDIO_CHANNEL_ID = /^[A-Za-z][A-Za-z0-9_-]{0,31}$/;
27
+ const MAX_AUDIO_CHANNELS = 8;
28
+ const MAX_AUDIO_LABEL = 32;
29
+ /** The channels a game sent, cleaned up: valid ids, short labels, no repeats, at most 8. */
30
+ function audioChannelsOf(value) {
31
+ if (!Array.isArray(value))
32
+ return [];
33
+ const channels = [];
34
+ for (const entry of value) {
35
+ if (typeof entry !== "object" || entry === null)
36
+ continue;
37
+ const { id, label, volume, toggle } = entry;
38
+ if (typeof id !== "string" || !AUDIO_CHANNEL_ID.test(id))
39
+ continue;
40
+ if (channels.some((channel) => channel.id === id))
41
+ continue;
42
+ const text = typeof label === "string" ? label.trim().slice(0, MAX_AUDIO_LABEL) : "";
43
+ const slider = volume !== false;
44
+ // Neither a slider nor a switch would show nothing: both, then.
45
+ const onOff = toggle !== false || !slider;
46
+ channels.push({ id, label: text || id, volume: slider, toggle: onOff });
47
+ if (channels.length === MAX_AUDIO_CHANNELS)
48
+ break;
49
+ }
50
+ return channels;
51
+ }
52
+ /** A volume between 0 and 1, or null. */
53
+ const volumeOf = (value) => typeof value === "number" && Number.isFinite(value) ? Math.min(Math.max(value, 0), 1) : null;
54
+ /** What a game may ask the Powly page for. */
55
+ const HOST_METHODS = [
56
+ "getSessionToken",
57
+ "purchases.open",
58
+ "fullscreen.toggle",
59
+ "invites.open",
60
+ "players.open",
61
+ "twitch.connect",
62
+ "audio.set",
63
+ "audio.channels",
64
+ "quit",
65
+ ];
66
+ /** "players.open": what Powly's player dialog opens on. */
67
+ const PLAYER_VIEWS = ["profile", "report"];
17
68
  const isInitMessage = (data) => typeof data === "object" &&
18
69
  data !== null &&
19
70
  data.protocol === PROTOCOL &&
20
71
  data.type === "init" &&
21
72
  data.version === PROTOCOL_VERSION;
22
- const isRequestMessage = (data) => typeof data === "object" &&
73
+ /** A request with a method this Powly doesn't know (from a newer SDK): answered with an error. */
74
+ const isRequestShaped = (data) => typeof data === "object" &&
23
75
  data !== null &&
24
76
  data.type === "request" &&
25
- Number.isInteger(data.id) &&
26
- ["getSessionToken", "purchases.open", "fullscreen.toggle", "invites.open"].includes(data.method);
77
+ Number.isInteger(data.id);
78
+ const isRequestMessage = (data) => isRequestShaped(data) &&
79
+ HOST_METHODS.includes(data.method);
27
80
 
28
81
  // The Powly origins this build of the SDK trusts by default: a game only handshakes with a parent
29
82
  // page on one of them (unless it passes `powlyOrigin` to `Powly.init()` itself). Set at build
@@ -49,8 +102,9 @@ class PowlyError extends Error {
49
102
  // the server's to catch speed hacks), browser functions that were replaced (speed hack scripts and
50
103
  // extensions patch timers), traces of automated browsers, and how input arrives (scripted events,
51
104
  // clicks without pointer movement as AI agents make them, machine-regular timing as macros and
52
- // auto-clickers make them). Nothing is sent anywhere else, and nothing here reads what the player
53
- // types: only event counts and timings.
105
+ // auto-clickers make them), and whether the player plays at all (any trusted input, gamepads
106
+ // included: Powly closes games nobody plays). Nothing is sent anywhere else, and nothing here
107
+ // reads what the player types: only event counts and timings.
54
108
  //
55
109
  // In real players' runs (not developer mode) the SDK also guards the game's code against being
56
110
  // copied (`guard`): the browser's context menu and its view-source, save-page and developer-tools
@@ -62,6 +116,8 @@ class PowlyError extends Error {
62
116
  // `tamperedFunctions` are shared with the Powly page (apps/web/components/player/pac-sensor.ts).
63
117
  /** How often the SDK reports (ms). */
64
118
  const PAC_SAMPLE_MS = 5000;
119
+ /** A gamepad axis past this counts as input (sticks rest a little off centre). */
120
+ const GAMEPAD_DEADZONE = 0.3;
65
121
  const isPacSample = (data) => typeof data === "object" &&
66
122
  data !== null &&
67
123
  data.type === "pac" &&
@@ -210,6 +266,7 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
210
266
  synthetic: 0,
211
267
  clicks: 0,
212
268
  teleportClicks: 0,
269
+ activity: 0,
213
270
  };
214
271
  const keyTimes = [];
215
272
  const clickTimes = [];
@@ -227,6 +284,9 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
227
284
  input.synthetic += 1;
228
285
  return;
229
286
  }
287
+ input.activity += 1;
288
+ if (event.type === "wheel")
289
+ return;
230
290
  if (event.type === "pointermove" || event.type === "pointerover") {
231
291
  const move = event;
232
292
  pointer = { x: move.clientX, y: move.clientY };
@@ -252,7 +312,26 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
252
312
  }
253
313
  pointer = { x: down.clientX, y: down.clientY };
254
314
  };
255
- const types = ["keydown", "pointerdown", "pointermove", "pointerover"];
315
+ const types = ["keydown", "pointerdown", "pointermove", "pointerover", "wheel"];
316
+ // Gamepads send no events: a pressed button or a pushed stick, looked at a few times a second.
317
+ const watchGamepads = () => {
318
+ let pads;
319
+ try {
320
+ pads = [...(win.navigator.getGamepads?.() ?? [])];
321
+ }
322
+ catch {
323
+ return;
324
+ }
325
+ for (const pad of pads) {
326
+ if (!pad?.connected)
327
+ continue;
328
+ if (pad.buttons.some((button) => button.pressed) ||
329
+ pad.axes.some((axis) => Math.abs(axis) > GAMEPAD_DEADZONE)) {
330
+ input.activity += 1;
331
+ return;
332
+ }
333
+ }
334
+ };
256
335
  for (const type of types) {
257
336
  win.addEventListener(type, onInput, { capture: true, passive: true });
258
337
  }
@@ -320,6 +399,7 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
320
399
  input.synthetic = 0;
321
400
  input.clicks = 0;
322
401
  input.teleportClicks = 0;
402
+ input.activity = 0;
323
403
  dateJump = 0;
324
404
  devtools = false;
325
405
  };
@@ -328,11 +408,13 @@ function startPacSensor(port, { guard = false } = {}, win = window) {
328
408
  const clearInterval = win.clearInterval.bind(win);
329
409
  const dateTimer = setInterval(watchDate, 1000);
330
410
  const reportTimer = setInterval(report, PAC_SAMPLE_MS);
411
+ const gamepadTimer = setInterval(watchGamepads, 250);
331
412
  const trapTimer = trap ? setInterval(springTrap, 1500) : undefined;
332
413
  report();
333
414
  return () => {
334
415
  clearInterval(dateTimer);
335
416
  clearInterval(reportTimer);
417
+ clearInterval(gamepadTimer);
336
418
  if (trapTimer !== undefined)
337
419
  clearInterval(trapTimer);
338
420
  for (const type of types)
@@ -824,6 +906,11 @@ function createRooms(options) {
824
906
  // });
825
907
  // }
826
908
  //
909
+ // A game with a Twitch mode among others (Twitch chat "optional" in Studio) passes
910
+ // `{ prompt: true }` when the player picks that mode: if they haven't connected Twitch, Powly asks
911
+ // them to in its own dialog (drawn by Powly, outside the game), and `connect()` resolves once
912
+ // they did or closed it.
913
+ //
827
914
  // Chat is Twitch IRC over a WebSocket (https://dev.twitch.tv/docs/chat/irc/). The connection comes
828
915
  // back by itself when it drops, and stops when the launch ends.
829
916
  const TWITCH_CHAT_URL = "wss://irc-ws.chat.twitch.tv:443";
@@ -1064,8 +1151,12 @@ function createTwitch(options) {
1064
1151
  };
1065
1152
  return {
1066
1153
  info: options.getInfo,
1067
- async connect() {
1068
- const info = await options.getInfo();
1154
+ async connect(connectOptions = {}) {
1155
+ let info = await options.getInfo();
1156
+ if (info.status === "not_connected" && connectOptions.prompt && options.prompt) {
1157
+ if (await options.prompt())
1158
+ info = await options.getInfo();
1159
+ }
1069
1160
  const next = info.status === "connected" ? info.channel : null;
1070
1161
  if (!next || stopped) {
1071
1162
  disconnect();
@@ -1104,6 +1195,154 @@ function createTwitch(options) {
1104
1195
  };
1105
1196
  }
1106
1197
 
1198
+ // Powly's sound menu for games (`powly.audio`): the player's overall volume, a mute (Powly's M
1199
+ // key), and the kinds of sound the game registers (channels: music, drawing sounds…), each with a
1200
+ // volume slider and an on/off switch, or one of them. The player sets them in Powly's options
1201
+ // bar; the game hears "change".
1202
+ //
1203
+ // const powly = await Powly.init({
1204
+ // audio: { channels: [{ id: "music", label: "Music" }] },
1205
+ // });
1206
+ // await powly.audio.register({ id: "drawing", label: "Drawing sounds", volume: false });
1207
+ // music.volume = powly.audio.level("music");
1208
+ // powly.audio.on("change", () => { music.volume = powly.audio.level("music"); });
1209
+ // if (powly.audio.level("drawing") > 0) playScribble();
1210
+ //
1211
+ // The overall volume and mute apply by themselves (`auto`, on by default): the SDK puts a volume
1212
+ // control in front of the page's speakers, for Web Audio (every connection to an AudioContext's
1213
+ // destination goes through it) and for <audio>/<video> (their volume is scaled). So every game
1214
+ // on this SDK follows Powly's volume, and channels only need the channel's own value:
1215
+ // `level(channel)` is what to play at either way.
1216
+ /** Volume of the page's sound overall: 0 when muted. */
1217
+ const masterLevel = (state) => (state.muted ? 0 : state.volume);
1218
+ /** Live references to objects, for walking them later without keeping them alive. */
1219
+ function weakList() {
1220
+ const seen = new WeakSet();
1221
+ const refs = new Set();
1222
+ return {
1223
+ add(item) {
1224
+ if (seen.has(item))
1225
+ return false;
1226
+ seen.add(item);
1227
+ refs.add(new WeakRef(item));
1228
+ return true;
1229
+ },
1230
+ has: (item) => seen.has(item),
1231
+ *[Symbol.iterator]() {
1232
+ for (const ref of refs) {
1233
+ const item = ref.deref();
1234
+ if (item)
1235
+ yield item;
1236
+ else
1237
+ refs.delete(ref);
1238
+ }
1239
+ },
1240
+ };
1241
+ }
1242
+ /**
1243
+ * Puts a volume control in front of the page's speakers: Web Audio connections to a real-time
1244
+ * AudioContext's destination go through a gain node per context, and media elements' volume is
1245
+ * scaled (the volume the game set still reads back unchanged; elements played through Web Audio
1246
+ * are left to the gain node). Returns how to set it.
1247
+ */
1248
+ function installMasterGain(win = window) {
1249
+ let level = 1;
1250
+ // Web Audio.
1251
+ const gainOf = new WeakMap();
1252
+ const gains = weakList();
1253
+ const Destination = win.AudioDestinationNode;
1254
+ const Offline = win.OfflineAudioContext;
1255
+ const nodeProto = win.AudioNode?.prototype;
1256
+ const connect = nodeProto?.connect;
1257
+ const disconnect = nodeProto?.disconnect;
1258
+ /** The gain node standing in for `target` when it's a real-time context's destination. */
1259
+ const standIn = (target) => {
1260
+ if (!Destination || !(target instanceof Destination))
1261
+ return null;
1262
+ const context = target.context;
1263
+ if (Offline && context instanceof Offline)
1264
+ return null;
1265
+ let gain = gainOf.get(context);
1266
+ if (!gain) {
1267
+ gain = context.createGain();
1268
+ gain.gain.value = level;
1269
+ gains.add(gain);
1270
+ connect?.call(gain, target);
1271
+ gainOf.set(context, gain);
1272
+ }
1273
+ return gain;
1274
+ };
1275
+ if (nodeProto && connect && disconnect) {
1276
+ nodeProto.connect = function (target, ...rest) {
1277
+ const gain = gains.has(this) ? null : standIn(target);
1278
+ if (!gain)
1279
+ return connect.call(this, target, ...rest);
1280
+ connect.call(this, gain, ...rest);
1281
+ // Chaining keeps working: connect() answers what it was given.
1282
+ return target;
1283
+ };
1284
+ nodeProto.disconnect = function (...args) {
1285
+ const gain = gains.has(this) ? null : standIn(args[0]);
1286
+ return gain ? disconnect.call(this, gain, ...args.slice(1)) : disconnect.apply(this, args);
1287
+ };
1288
+ }
1289
+ // Media elements: the volume the game set, and the one played (times `level`).
1290
+ const media = win.HTMLMediaElement?.prototype;
1291
+ const volume = media ? Object.getOwnPropertyDescriptor(media, "volume") : undefined;
1292
+ const wanted = new WeakMap();
1293
+ const elements = weakList();
1294
+ const routed = new WeakSet();
1295
+ const apply = (element) => {
1296
+ const own = wanted.get(element) ?? 1;
1297
+ volume?.set?.call(element, routed.has(element) ? own : own * level);
1298
+ };
1299
+ const track = (element) => {
1300
+ if (!wanted.has(element))
1301
+ wanted.set(element, volume?.get?.call(element) ?? 1);
1302
+ elements.add(element);
1303
+ apply(element);
1304
+ };
1305
+ if (media && volume?.get && volume.set) {
1306
+ const { get, set } = volume;
1307
+ Object.defineProperty(media, "volume", {
1308
+ configurable: true,
1309
+ enumerable: volume.enumerable,
1310
+ get() {
1311
+ return wanted.get(this) ?? get.call(this);
1312
+ },
1313
+ set(value) {
1314
+ // Out of range throws, like the browser's own setter.
1315
+ set.call(this, value);
1316
+ wanted.set(this, value);
1317
+ track(this);
1318
+ },
1319
+ });
1320
+ const play = media.play;
1321
+ media.play = function () {
1322
+ track(this);
1323
+ return play.call(this);
1324
+ };
1325
+ }
1326
+ const createSource = win.AudioContext?.prototype.createMediaElementSource;
1327
+ if (createSource) {
1328
+ win.AudioContext.prototype.createMediaElementSource = function (element) {
1329
+ routed.add(element);
1330
+ track(element);
1331
+ return createSource.call(this, element);
1332
+ };
1333
+ }
1334
+ return {
1335
+ set(next) {
1336
+ level = next;
1337
+ for (const gain of gains)
1338
+ gain.gain.value = level;
1339
+ for (const element of elements)
1340
+ apply(element);
1341
+ },
1342
+ };
1343
+ }
1344
+
1345
+
1107
1346
 
1108
1347
 
1109
1348
 
@@ -1121,6 +1360,8 @@ function createTwitch(options) {
1121
1360
  // const token = await powly.getSessionToken(); // send it to your backend, which verifies it
1122
1361
  // const room = await powly.rooms.create(); // realtime parties (src/rooms.ts)
1123
1362
  // await powly.twitch.connect(); // the streamer's Twitch chat (src/twitch.ts)
1363
+ // await powly.players.open(playerId); // Powly's profile dialog: add, block, report
1364
+ // powly.audio.level("music"); // Powly's sound menu (src/audio.ts)
1124
1365
  //
1125
1366
  // Nothing here is a security boundary: the game runs in the player's browser. Your backend must
1126
1367
  // verify session tokens (JWKS) or exchange them, and Powly checks everything server-side.
@@ -1165,6 +1406,12 @@ function init(options = {}) {
1165
1406
  if (origins.length === 0) {
1166
1407
  return Promise.reject(new PowlyError("This game only runs when launched from Powly."));
1167
1408
  }
1409
+ const audioChannels = audioChannelsOf(options.audio?.channels ?? []);
1410
+ // Before the game's engine makes a sound, so its connections go through the volume control.
1411
+ const auto = options.audio?.auto !== false;
1412
+ if (auto)
1413
+ masterGain ??= installMasterGain();
1414
+ const master = auto ? masterGain : null;
1168
1415
  return new Promise((resolve, reject) => {
1169
1416
  const channels = origins.map((origin) => {
1170
1417
  const channel = new MessageChannel();
@@ -1174,6 +1421,10 @@ function init(options = {}) {
1174
1421
  type: "init",
1175
1422
  sdkVersion: SDK_VERSION,
1176
1423
  pac: 1,
1424
+ ...(isOptionsButtonCorner(options.optionsButton) && {
1425
+ optionsButton: options.optionsButton,
1426
+ }),
1427
+ audio: { channels: audioChannels },
1177
1428
  };
1178
1429
  channel.port1.onmessage = (event) => {
1179
1430
  const data = event.data;
@@ -1182,7 +1433,7 @@ function init(options = {}) {
1182
1433
  for (const other of channels)
1183
1434
  if (other !== channel)
1184
1435
  other.port1.close();
1185
- resolve(createSession(channel.port1, data));
1436
+ resolve(createSession(channel.port1, data, { channels: audioChannels, master }));
1186
1437
  }
1187
1438
  else if (data?.type === "failed") {
1188
1439
  clearTimeout(timer);
@@ -1200,13 +1451,47 @@ function init(options = {}) {
1200
1451
  }, options.timeoutMs ?? 10_000);
1201
1452
  });
1202
1453
  }
1203
- function createSession(port, ready) {
1454
+ /** The page's volume control (src/audio.ts), put in once even if init runs again. */
1455
+ let masterGain = null;
1456
+ /** The sound settings Powly sent, with every channel (on, at full volume, when it sent none). */
1457
+ function audioStateOf(value, channels) {
1458
+ const sent = (typeof value === "object" && value !== null ? value : {});
1459
+ const levels = ((typeof sent.channels === "object" && sent.channels) || {});
1460
+ return {
1461
+ volume: volumeOf(sent.volume) ?? 1,
1462
+ muted: sent.muted === true,
1463
+ channels: Object.fromEntries(channels.map((channel) => {
1464
+ const level = levels[channel.id];
1465
+ return [channel.id, { volume: volumeOf(level?.volume) ?? 1, on: level?.on !== false }];
1466
+ })),
1467
+ };
1468
+ }
1469
+ function createSession(port, ready, sound) {
1204
1470
  let token = ready.token;
1205
1471
  let expiresAt = Date.now() + ready.expiresIn * 1000;
1206
1472
  let refreshing = null;
1207
1473
  let nextId = 1;
1208
1474
  const pending = new Map();
1209
1475
  const listeners = new Map();
1476
+ // "shown" comes once; games that listen for it later still hear it.
1477
+ let shown = false;
1478
+ // The player's sound settings; the page's volume follows them (auto).
1479
+ let channels = sound.channels;
1480
+ let audio = audioStateOf(ready.audio, channels);
1481
+ const audioListeners = new Set();
1482
+ sound.master?.set(masterLevel(audio));
1483
+ const setAudio = (next) => {
1484
+ audio = next;
1485
+ sound.master?.set(masterLevel(audio));
1486
+ for (const listener of audioListeners) {
1487
+ try {
1488
+ listener(audio);
1489
+ }
1490
+ catch (error) {
1491
+ console.error("[powly] an audio listener failed", error);
1492
+ }
1493
+ }
1494
+ };
1210
1495
  port.onmessage = (event) => {
1211
1496
  const data = event.data;
1212
1497
  if (data?.type === "response") {
@@ -1220,7 +1505,19 @@ function createSession(port, ready) {
1220
1505
  waiting.reject(new PowlyError(data.error));
1221
1506
  }
1222
1507
  else if (data?.type === "event") {
1223
- for (const listener of listeners.get(data.event) ?? [])
1508
+ const message = data;
1509
+ if (message.event === "audio") {
1510
+ if ("audio" in message)
1511
+ setAudio(audioStateOf(message.audio, channels));
1512
+ return;
1513
+ }
1514
+ const name = message.event;
1515
+ if (name === "shown") {
1516
+ if (shown)
1517
+ return;
1518
+ shown = true;
1519
+ }
1520
+ for (const listener of listeners.get(name) ?? [])
1224
1521
  listener();
1225
1522
  }
1226
1523
  };
@@ -1280,7 +1577,10 @@ function createSession(port, ready) {
1280
1577
  invite: sendInvite,
1281
1578
  });
1282
1579
  listeners.get("ended")?.add(() => realtime.stop());
1283
- const twitch = createTwitch({ getInfo: () => api("/twitch") });
1580
+ const twitch = createTwitch({
1581
+ getInfo: () => api("/twitch"),
1582
+ prompt: async () => (await call("twitch.connect")).connected,
1583
+ });
1284
1584
  listeners.get("ended")?.add(() => twitch.stop());
1285
1585
  const saves = {
1286
1586
  get: async (key) => {
@@ -1327,6 +1627,52 @@ function createSession(port, ready) {
1327
1627
  return (await call("invites.open", { params, members }));
1328
1628
  },
1329
1629
  },
1630
+ players: {
1631
+ open: async (playerId, options = {}) => (await call("players.open", {
1632
+ playerId,
1633
+ ...(options.view && { view: options.view }),
1634
+ })),
1635
+ },
1636
+ audio: {
1637
+ get volume() {
1638
+ return audio.volume;
1639
+ },
1640
+ get muted() {
1641
+ return audio.muted;
1642
+ },
1643
+ get channels() {
1644
+ return Object.fromEntries(Object.entries(audio.channels).map(([id, state]) => [id, { ...state }]));
1645
+ },
1646
+ level(channel) {
1647
+ const state = channel === undefined ? undefined : audio.channels[channel];
1648
+ const own = state ? (state.on ? state.volume : 0) : 1;
1649
+ return sound.master ? own : own * masterLevel(audio);
1650
+ },
1651
+ async register(channel) {
1652
+ const next = audioChannelsOf([
1653
+ ...channels.filter((existing) => existing.id !== channel?.id),
1654
+ channel,
1655
+ ]);
1656
+ if (!next.some((entry) => entry.id === channel?.id)) {
1657
+ throw new PowlyError(`A channel needs an id of letters, digits, _ and -, and a game has up to ${MAX_AUDIO_CHANNELS}.`);
1658
+ }
1659
+ // Same order as before; a changed channel keeps its place.
1660
+ channels = [
1661
+ ...channels.map((existing) => next.find((entry) => entry.id === existing.id) ?? existing),
1662
+ ...next.filter((entry) => !channels.some((existing) => existing.id === entry.id)),
1663
+ ];
1664
+ const result = await call("audio.channels", { channels });
1665
+ setAudio(audioStateOf(result, channels));
1666
+ },
1667
+ async set(changes) {
1668
+ const result = await call("audio.set", { audio: changes });
1669
+ setAudio(audioStateOf(result, channels));
1670
+ },
1671
+ on(_event, listener) {
1672
+ audioListeners.add(listener);
1673
+ return () => audioListeners.delete(listener);
1674
+ },
1675
+ },
1330
1676
  rooms: realtime.rooms,
1331
1677
  twitch: {
1332
1678
  info: twitch.info,
@@ -1362,12 +1708,24 @@ function createSession(port, ready) {
1362
1708
  await call("fullscreen.toggle");
1363
1709
  },
1364
1710
  },
1711
+ quit: async () => {
1712
+ await call("quit");
1713
+ },
1365
1714
  on(event, listener) {
1366
1715
  const set = listeners.get(event) ?? new Set();
1367
1716
  set.add(listener);
1368
1717
  listeners.set(event, set);
1718
+ if (event === "shown" && shown) {
1719
+ queueMicrotask(() => {
1720
+ if (set.has(listener))
1721
+ listener();
1722
+ });
1723
+ }
1369
1724
  return () => set.delete(listener);
1370
1725
  },
1726
+ get shown() {
1727
+ return shown;
1728
+ },
1371
1729
  };
1372
1730
  }
1373
1731
  const Powly = { init, SDK_VERSION, PowlyError };