@indiegems/gem-web-sdk 18.1.1 → 19.0.1
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 +18 -0
- package/dist/function.d.ts +16 -2
- package/dist/game.d.ts +16 -2
- package/dist/gem-web-sdk.conformance.js +3 -3
- package/dist/gem-web-sdk.esm.js +1 -1
- package/dist/gem-web-sdk.function.js +3 -3
- package/dist/gem-web-sdk.game.js +3 -3
- package/dist/gem-web-sdk.min.js +3 -3
- package/dist/gem-web-sdk.sdk.js +3 -3
- package/dist/gem-web-sdk.server.js +1 -1
- package/dist/sdk.d.ts +16 -2
- package/docs/api/api-reference-function.md +247 -2
- package/docs/api/api-reference-game.md +254 -3
- package/docs/api/api-reference-server.md +1 -1
- package/docs/api/reference.json +661 -5
- package/docs/arcade-and-store.md +145 -6
- package/docs/changelog.md +18 -0
- package/docs/getting-started.md +1 -1
- package/docs/inventory-and-catalogs.md +13 -0
- package/docs/manifest.json +12 -12
- package/docs/server-bundles.md +8 -2
- package/docs/what-a-game-must-do.md +22 -1
- package/package.json +1 -1
package/docs/arcade-and-store.md
CHANGED
|
@@ -40,7 +40,8 @@ itself. You get an outcome.
|
|
|
40
40
|
- [8. The Arcade Store's offers](#8-the-arcade-stores-offers)
|
|
41
41
|
- [9. Buying from the Arcade Store](#9-buying-from-the-arcade-store)
|
|
42
42
|
- [10. Letting the Arcade show one offer](#10-letting-the-arcade-show-one-offer)
|
|
43
|
-
- [11.
|
|
43
|
+
- [11. What the player holds, and a VIP door](#11-what-the-player-holds-and-a-vip-door)
|
|
44
|
+
- [12. Outcomes and reasons](#12-outcomes-and-reasons)
|
|
44
45
|
|
|
45
46
|
## 1. Two surfaces, and which one is real money
|
|
46
47
|
|
|
@@ -67,7 +68,7 @@ three and never grows:
|
|
|
67
68
|
|
|
68
69
|
`reason` is a short code that says why. The set is open: a new Arcade build may send a reason your
|
|
69
70
|
game has never seen, so branch on `outcome` and treat `reason` as a hint. The codes in use today are
|
|
70
|
-
in [§
|
|
71
|
+
in [§12](#12-outcomes-and-reasons).
|
|
71
72
|
|
|
72
73
|
## 3. What is available here
|
|
73
74
|
|
|
@@ -316,9 +317,9 @@ Pick by who can say what the offer is:
|
|
|
316
317
|
- **Do not show a price on the button.** The sheet shows the right one for this player, which a
|
|
317
318
|
sale or a pass can change, and `priceDisplay` does not say either of those. If you want a price on
|
|
318
319
|
screen, draw the offer yourself and use `purchase`.
|
|
319
|
-
- **
|
|
320
|
-
|
|
321
|
-
|
|
320
|
+
- **The sheet does not tell you what the player holds.** It tells the player, so a button that
|
|
321
|
+
stays up for a holder is fine: they see what they already have, and choose from there. To change
|
|
322
|
+
your own screen for a holder, read `gem.me.passes` ([§11](#11-what-the-player-holds-and-a-vip-door)).
|
|
322
323
|
- **Hide the button when the SKU is not in `offers()`.** A SKU that is not on sale here answers
|
|
323
324
|
`unavailable` / `not_found`, and the player would have pressed a button that does nothing.
|
|
324
325
|
- **Read `showOfferAvailable`, not `available`.** An older Arcade can sell through `purchase` and
|
|
@@ -332,7 +333,145 @@ closed checkout, shared with `purchase`: the next ask of either kind is `decline
|
|
|
332
333
|
while. The press shows nothing during a back-off, so give the player something else to do on that
|
|
333
334
|
screen.
|
|
334
335
|
|
|
335
|
-
## 11.
|
|
336
|
+
## 11. What the player holds, and a VIP door
|
|
337
|
+
|
|
338
|
+
```js
|
|
339
|
+
const passes = await gem.me.passes.list();
|
|
340
|
+
// [{ pass: 'vip', held: true, endsAt: '2026-11-29T12:34:56Z' }]
|
|
341
|
+
|
|
342
|
+
const vip = await gem.me.passes.get('vip'); // one pass by its slug
|
|
343
|
+
if (vip.held) drawVipBadge();
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
`gem.me.passes` says which store passes the player holds. **VIP is the pass `vip`**, its offering
|
|
347
|
+
slug. Key on the slug, which is stable and not localized, and never on a display name.
|
|
348
|
+
|
|
349
|
+
- **It lists every pass games may see, held or not.** An admin marks a pass as visible to games, and
|
|
350
|
+
the list has one entry for each, so its shape never says what the player bought. A pass that is not
|
|
351
|
+
marked is left out, and `get` reads a pass that is not listed as not held.
|
|
352
|
+
- **A pass is a slug, whether it is held, and when it ends.** No price, no name, no perks, nothing
|
|
353
|
+
about how the player got it, and nothing about a pass that has ended.
|
|
354
|
+
- **`endsAt` is the exact end,** grace and any queued period included, and `null` when the pass is not
|
|
355
|
+
held or is held with no end, which support can grant. Read `held` first: `null` alone never means
|
|
356
|
+
ended.
|
|
357
|
+
- **A guest reads as not holding.** Every pass is `held: false`, and that is no error.
|
|
358
|
+
- **A read that fails rejects with a `GemApiError`, as every `gem.me` read does.** That is unknown,
|
|
359
|
+
not "not held", so keep what you drew. It is the one call on this page that rejects.
|
|
360
|
+
- **It is a read over your play token,** not an ask of the Arcade. It needs no press, and works
|
|
361
|
+
wherever your game has a token, including where the store is off: holding a pass is not buying one.
|
|
362
|
+
Each call goes to the platform, so read when you draw and do not poll.
|
|
363
|
+
|
|
364
|
+
### The browser draws, the server decides
|
|
365
|
+
|
|
366
|
+
**Never decide anything from `gem.me.passes`.** It runs in the player's browser, and a player can
|
|
367
|
+
make any answer come back. It is for drawing the door. What is behind the door comes from your
|
|
368
|
+
function, which asks the platform and answers with the contents. A level that ships in your build is
|
|
369
|
+
not behind a door at all, because a player can open it by editing the page.
|
|
370
|
+
|
|
371
|
+
```js
|
|
372
|
+
// 1. DRAWING. Open or shut. Nothing here unlocks anything.
|
|
373
|
+
function drawVipDoor(passes) {
|
|
374
|
+
door.drawOpen(passes.some((p) => p.pass === 'vip' && p.held));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
await gem.awaitBridge();
|
|
378
|
+
try {
|
|
379
|
+
drawVipDoor(await gem.me.passes.list());
|
|
380
|
+
} catch {
|
|
381
|
+
door.drawShut(); // unknown: the press below still asks the function
|
|
382
|
+
}
|
|
383
|
+
gem.me.passes.on('changed', drawVipDoor); // opens after a purchase, locks when VIP ends
|
|
384
|
+
|
|
385
|
+
// 2. DECIDING. The press asks the function. The browser's reading is not part of the call.
|
|
386
|
+
door.onPress = async () => {
|
|
387
|
+
let answer;
|
|
388
|
+
try {
|
|
389
|
+
answer = (await gem.functions.invoke({ door: 'vip-lounge' })).json();
|
|
390
|
+
} catch {
|
|
391
|
+
showDoorIsStuck(); // nothing decided: not "you need VIP"
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
if (answer.allowed) enterLounge(answer.lounge);
|
|
395
|
+
else showHowToGetVip();
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
// 3. BUYING. From the press on your own "How to get VIP" button.
|
|
399
|
+
howToGetVipButton.onclick = () => gem.store.showOffer(VIP_SKU);
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
The function, in Rust, because only the platform's answer counts:
|
|
403
|
+
|
|
404
|
+
```rust
|
|
405
|
+
use gem::functions::host::{self, ApiRequest, Caller};
|
|
406
|
+
|
|
407
|
+
// The input says which door. It never says who is asking, or what they hold.
|
|
408
|
+
let context = host::get_context();
|
|
409
|
+
let Caller::Player(_) = context.caller else {
|
|
410
|
+
return Err("expected a player's call".into());
|
|
411
|
+
};
|
|
412
|
+
let read = host::call_api(&ApiRequest {
|
|
413
|
+
method: "GET".into(),
|
|
414
|
+
path: format!("/v1/games/{}/players/me/passes", context.game_id),
|
|
415
|
+
body: Vec::new(),
|
|
416
|
+
})?;
|
|
417
|
+
if read.status != 200 {
|
|
418
|
+
return Err(format!("the pass read answered {}", read.status)); // decides nothing
|
|
419
|
+
}
|
|
420
|
+
let body: serde_json::Value = serde_json::from_slice(&read.body).map_err(|e| e.to_string())?;
|
|
421
|
+
let vip = body["passes"]
|
|
422
|
+
.as_array()
|
|
423
|
+
.into_iter()
|
|
424
|
+
.flatten()
|
|
425
|
+
.any(|pass| pass["pass"] == "vip" && pass["held"] == true);
|
|
426
|
+
let answer = if vip {
|
|
427
|
+
serde_json::json!({ "allowed": true, "lounge": lounge_contents() })
|
|
428
|
+
} else {
|
|
429
|
+
serde_json::json!({ "allowed": false })
|
|
430
|
+
};
|
|
431
|
+
Ok(answer.to_string().into_bytes())
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
"Me" on that route is the player who invoked the function. A function your server invoked has no
|
|
435
|
+
player, and the route refuses it with `403`. The body is `{ pass, held, ends_at }` for each pass.
|
|
436
|
+
|
|
437
|
+
- **If the door and the function disagree, the function is right.** The player sees "How to get VIP"
|
|
438
|
+
or the lounge, and either way nobody got in by editing the page. Redraw the door when the function
|
|
439
|
+
says no.
|
|
440
|
+
- **A guest has no VIP and cannot buy it from here.** Their door is shut, and `offers()` answers
|
|
441
|
+
`unavailable` / `sign_in_required`, so offer `gem.account.requestSignIn` instead of the button.
|
|
442
|
+
|
|
443
|
+
### When the door moves by itself
|
|
444
|
+
|
|
445
|
+
While something listens to `on('changed')`, the SDK reads again at each of these, and calls you only
|
|
446
|
+
if the answer differs:
|
|
447
|
+
|
|
448
|
+
| Moment | Why you cannot rely on something else |
|
|
449
|
+
| --------------------------------------------------------- | -------------------------------------------------------------------------- |
|
|
450
|
+
| `store.purchase` or `store.showOffer` answers `ok` | `onSettled` does not fire after a plain `ok` |
|
|
451
|
+
| Either answers `unavailable` / `error` | The outcome is unknown, and the player may have paid |
|
|
452
|
+
| `gem.store.onSettled` fires | `ok` / `pending` still reads as not held |
|
|
453
|
+
| Your game resumes | A purchase in the Arcade's own store, from the Gem menu, sends no event at all |
|
|
454
|
+
| The player signs in | A guest who signs in may hold VIP |
|
|
455
|
+
| A held pass reaches `endsAt` | VIP does not renew, so the door has to lock by itself |
|
|
456
|
+
|
|
457
|
+
A read that fails changes nothing, and is tried again later. A read that was already on its way when
|
|
458
|
+
a purchase ended is never reused, so a stale answer cannot shut the door on the buyer. If you read
|
|
459
|
+
without a listener, call `list()` yourself at those moments; it is always fresh.
|
|
460
|
+
|
|
461
|
+
`on` never fires in a function, which has none of those moments.
|
|
462
|
+
|
|
463
|
+
### On a dedicated server
|
|
464
|
+
|
|
465
|
+
The platform has a read for this,
|
|
466
|
+
`GET /v1/games/{game_id}/matches/{match_id}/players/{player_id}/passes`, with the same body, for a
|
|
467
|
+
player in your live match. **A server bundle cannot call it yet**, and the SDK has no method for it,
|
|
468
|
+
because it needs the allocation nonce: the route requires `X-Alloc-Nonce`, which the platform's
|
|
469
|
+
runtime holds and never hands to your bundle. The boost read has the same limit. Reaching it takes a
|
|
470
|
+
hook in the runtime, like the one behind `gem.match.end()`.
|
|
471
|
+
|
|
472
|
+
Until then, decide in a function, or leave a VIP door out of a dedicated-server match.
|
|
473
|
+
|
|
474
|
+
## 12. Outcomes and reasons
|
|
336
475
|
|
|
337
476
|
| Call | `ok` | `declined` | `unavailable` |
|
|
338
477
|
| ---------------- | ------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
package/docs/changelog.md
CHANGED
|
@@ -16,6 +16,24 @@ Its entries are grouped under up to five headings:
|
|
|
16
16
|
- **Rebuild**: a type changed. Recompile against the new version; code that compiles today keeps compiling unless the entry says otherwise.
|
|
17
17
|
- **New capability**: something a game can now do. Nothing changes unless you use it.
|
|
18
18
|
|
|
19
|
+
## 19.0.1
|
|
20
|
+
|
|
21
|
+
Upgrading from 19.0.0. Impact: none.
|
|
22
|
+
|
|
23
|
+
Nothing in this release asks anything of a game.
|
|
24
|
+
|
|
25
|
+
## 19.0.0
|
|
26
|
+
|
|
27
|
+
Upgrading from 18.1.1. Impact: required.
|
|
28
|
+
|
|
29
|
+
### Rebuild
|
|
30
|
+
|
|
31
|
+
- The player scope on the game object is now typed as PlayerScope instead of EntityScope. Recompile your game. If you annotated that value, or passed it to code that expects EntityScope, change the annotation to PlayerScope. Otherwise nothing else needs to change.
|
|
32
|
+
|
|
33
|
+
### New capability
|
|
34
|
+
|
|
35
|
+
- A game can now read which passes its player holds. Every pass the Arcade marks as visible to games is reported, held or not, with the time it ends. The read is exposed through PassesApi on PlayerScope, and it is not available on EntityScope because a character holds no pass. The result only tells you who holds a pass; your game still decides what the pass unlocks. PassesEvent lets you listen for changes, and the SDK re-reads after a store purchase completes, on resume, after a sign-in, and when a held pass ends, so a buyer's doors open without a reload. GamePass describes one entry in the result.
|
|
36
|
+
|
|
19
37
|
## 18.1.1
|
|
20
38
|
|
|
21
39
|
Upgrading from 18.1.0. Impact: none.
|
package/docs/getting-started.md
CHANGED
|
@@ -198,6 +198,19 @@ Read every instance of a definition without walking the whole bag:
|
|
|
198
198
|
const swords = await gem.me.inventory.instances('iron-sword');
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
+
⚠️ **`instanceId` is never a key on its own.** A fungible item has one row per definition, and every
|
|
202
|
+
one of those rows carries the same placeholder, `__stack__`, so all of a player's fungible items
|
|
203
|
+
share one `instanceId`. A live game removed repeated rows while paging the bag by `instanceId`
|
|
204
|
+
alone: only the last fungible item survived, every other look the player had bought read as not
|
|
205
|
+
owned, and the game offered them for sale again. Key a row by its definition and instance
|
|
206
|
+
together, and answer "does the player hold this?" from `itemDefinitionId` and `quantity`:
|
|
207
|
+
|
|
208
|
+
```js
|
|
209
|
+
const rowKey = (item) => `${item.itemDefinitionId}:${item.instanceId}`;
|
|
210
|
+
const holds = (bag, itemDefinitionId) =>
|
|
211
|
+
bag.items.data.some((item) => item.itemDefinitionId === itemDefinitionId && item.quantity > 0);
|
|
212
|
+
```
|
|
213
|
+
|
|
201
214
|
⚠️ **An item's `properties` and `customData` are untrusted, and rendering them wrong is a
|
|
202
215
|
cross-player attack.** They are typed `unknown` **because the platform does not validate their
|
|
203
216
|
contents** — every read of them is a parse, not a trusted object. And a transfer moves an instance
|
package/docs/manifest.json
CHANGED
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
},
|
|
19
19
|
{
|
|
20
20
|
"file": "arcade-and-store.md",
|
|
21
|
-
"sha256": "
|
|
22
|
-
"updated": "2026-10-
|
|
21
|
+
"sha256": "aee6e00fa7e0f463cb09174a9afdffe10d66ed1036546e584b341e4c11bb1e27",
|
|
22
|
+
"updated": "2026-10-31T19:43:30Z"
|
|
23
23
|
},
|
|
24
24
|
{
|
|
25
25
|
"file": "arcade-avatars.md",
|
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
},
|
|
49
49
|
{
|
|
50
50
|
"file": "changelog.md",
|
|
51
|
-
"sha256": "
|
|
52
|
-
"updated": "2026-
|
|
51
|
+
"sha256": "069554f114dab51df5461c7b4e7b65339886bc0bc1c455baad83688aad3ee4fa",
|
|
52
|
+
"updated": "2026-11-02T15:26:17Z"
|
|
53
53
|
},
|
|
54
54
|
{
|
|
55
55
|
"file": "daily-challenges.md",
|
|
@@ -78,13 +78,13 @@
|
|
|
78
78
|
},
|
|
79
79
|
{
|
|
80
80
|
"file": "getting-started.md",
|
|
81
|
-
"sha256": "
|
|
82
|
-
"updated": "2026-
|
|
81
|
+
"sha256": "1175cae0795051d2b2f0d736c49e60ba455197273bcb09c3e2c565ab529be4b6",
|
|
82
|
+
"updated": "2026-11-02T15:26:17Z"
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
"file": "inventory-and-catalogs.md",
|
|
86
|
-
"sha256": "
|
|
87
|
-
"updated": "2026-
|
|
86
|
+
"sha256": "a4763fe2418c0e24639ca58c27f30ffc416f07528857402a6f9ab3fee147f4f6",
|
|
87
|
+
"updated": "2026-11-01T21:49:36Z"
|
|
88
88
|
},
|
|
89
89
|
{
|
|
90
90
|
"file": "leaderboards.md",
|
|
@@ -138,8 +138,8 @@
|
|
|
138
138
|
},
|
|
139
139
|
{
|
|
140
140
|
"file": "server-bundles.md",
|
|
141
|
-
"sha256": "
|
|
142
|
-
"updated": "2026-
|
|
141
|
+
"sha256": "e3e49043fa4c6744df9c7f4c5e901511a4e9832d7f46f6880f1995648bacbf09",
|
|
142
|
+
"updated": "2026-11-02T15:26:17Z"
|
|
143
143
|
},
|
|
144
144
|
{
|
|
145
145
|
"file": "serverless-functions-js.md",
|
|
@@ -163,8 +163,8 @@
|
|
|
163
163
|
},
|
|
164
164
|
{
|
|
165
165
|
"file": "what-a-game-must-do.md",
|
|
166
|
-
"sha256": "
|
|
167
|
-
"updated": "2026-
|
|
166
|
+
"sha256": "17df2ebdafe56ac5eafdf4eba783c97025ddd9e0ee88b7b43d25718908496b2a",
|
|
167
|
+
"updated": "2026-11-02T14:57:41Z"
|
|
168
168
|
}
|
|
169
169
|
]
|
|
170
170
|
}
|
package/docs/server-bundles.md
CHANGED
|
@@ -80,8 +80,8 @@ To check the download yourself: the package npm serves is, byte for byte, the fi
|
|
|
80
80
|
also publishes on its download host, with the hash beside it:
|
|
81
81
|
|
|
82
82
|
```bash
|
|
83
|
-
curl -fsSL -O https://downloads.gemarcade.com/sdk/
|
|
84
|
-
curl -fsSL https://downloads.gemarcade.com/sdk/
|
|
83
|
+
curl -fsSL -O https://downloads.gemarcade.com/sdk/v19.0.1/gem-web-sdk.tgz
|
|
84
|
+
curl -fsSL https://downloads.gemarcade.com/sdk/v19.0.1/gem-web-sdk.tgz.sri
|
|
85
85
|
openssl dgst -sha384 -binary gem-web-sdk.tgz | openssl base64 -A
|
|
86
86
|
```
|
|
87
87
|
|
|
@@ -829,6 +829,12 @@ The short version: the browser half is what a player's **session** can do, and t
|
|
|
829
829
|
what a **match** can do. `gem.rooms.join()` has no meaning to a process that exists because a match
|
|
830
830
|
was already allocated.
|
|
831
831
|
|
|
832
|
+
**`gem.me.passes` is browser and function only**, and that is a limit rather than a principle. The
|
|
833
|
+
platform has a match read of which passes a player in your match holds, but your bundle cannot call
|
|
834
|
+
it: it requires the allocation nonce, which the runtime never hands to a bundle, as the boost read
|
|
835
|
+
does, and it answers `403` to every caller until game servers are granted `passes.read`.
|
|
836
|
+
[arcade-and-store.md](arcade-and-store.md#on-a-dedicated-server) has what to do instead.
|
|
837
|
+
|
|
832
838
|
**Rooms are the case where those two answers differ**, so they are worth stating separately rather
|
|
833
839
|
than filed under "browser-only". `gem.rooms` — join, leave, ready up, quick-join — is a player
|
|
834
840
|
acting on their own membership, and your server has none. Reading and administering the room your
|
|
@@ -1687,7 +1687,8 @@ startMusic(ctx); // start it now: it plays at once where sound is allowed, and f
|
|
|
1687
1687
|
// context say when it is running rather than deciding from the first read.
|
|
1688
1688
|
const LIFTS = ['pointerup', 'touchend', 'click', 'keydown'];
|
|
1689
1689
|
const unlock = () => {
|
|
1690
|
-
|
|
1690
|
+
// 'interrupted' is how some WebKit versions report a context iOS is holding.
|
|
1691
|
+
if (ctx.state === 'suspended' || ctx.state === 'interrupted') void ctx.resume();
|
|
1691
1692
|
};
|
|
1692
1693
|
const settled = () => {
|
|
1693
1694
|
if (ctx.state !== 'running') return;
|
|
@@ -1703,6 +1704,26 @@ if (ctx.state !== 'running') {
|
|
|
1703
1704
|
A `<audio>` or `<video>` element works the same way. Call `play()` straight away, and only if it
|
|
1704
1705
|
rejects, call it again from the first lift.
|
|
1705
1706
|
|
|
1707
|
+
**Coming back from the background is the SDK's.** On an iPhone, a player who swipes your game away
|
|
1708
|
+
and comes back finds its sound cut off: iOS leaves the `AudioContext` `suspended` or `interrupted`,
|
|
1709
|
+
stops your media elements, and restarts neither. The snippet above has stopped listening by then, so
|
|
1710
|
+
the SDK does this part for you. After the page was hidden, or a context left `running` without your
|
|
1711
|
+
asking, it listens again for the player's next lift in your frame, resumes the contexts that were
|
|
1712
|
+
running and replays the elements that were playing when the sound was cut off, then stops
|
|
1713
|
+
listening. You write nothing for it. It stays out of your way:
|
|
1714
|
+
|
|
1715
|
+
- **A context you `suspend()` yourself is left alone** until you call `resume()`. If you pause your
|
|
1716
|
+
audio when the page is hidden and resume it when it comes back, keep doing that: the SDK steps in
|
|
1717
|
+
only when your own resume did not take, which on iOS it often does not without a tap.
|
|
1718
|
+
- **An element you `pause()` is not replayed**, and nothing is restarted while the Arcade has your
|
|
1719
|
+
game paused: your `onPause(false)` handler decides what plays when the menu closes.
|
|
1720
|
+
- **The Arcade's mute still holds.** A context the SDK resumes is still silent while the player has
|
|
1721
|
+
the Arcade muted.
|
|
1722
|
+
|
|
1723
|
+
It reaches the audio the mute does: created after `createGem`, with `manageAudio` left on. With
|
|
1724
|
+
`manageAudio: false` it is yours: listen for the lift again on `visibilitychange` and on the
|
|
1725
|
+
context's `statechange`, rather than once.
|
|
1726
|
+
|
|
1706
1727
|
Never block the first frame on audio, never show an error for it, and do not ask the player to "tap
|
|
1707
1728
|
to start" just to unlock sound.
|
|
1708
1729
|
|