@wvdsh/sdk-js 1.3.12 → 1.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -627,6 +627,7 @@ declare class HeartbeatManager extends WavedashManager {
627
627
  private isFirstTick;
628
628
  private readonly TEST_CONNECTION_INTERVAL_MS;
629
629
  private readonly DISCONNECTED_TIMEOUT_MS;
630
+ private cachedPresenceData;
630
631
  constructor(sdk: WavedashSDK);
631
632
  /** Start heartbeat and connection-check intervals */
632
633
  start(): void;
@@ -638,11 +639,11 @@ declare class HeartbeatManager extends WavedashManager {
638
639
  private tickHeartbeat;
639
640
  private sendHeartbeat;
640
641
  /**
641
- * Updates user presence in the backend
642
+ * Updates user presence in the backend.
642
643
  * @param data - Data to send to the backend
643
644
  * @returns true if the presence was updated successfully
644
645
  */
645
- updateUserPresence(data?: Record<string, string | number | boolean | null>): Promise<boolean>;
646
+ updateUserPresence(data: Record<string, string | number | boolean | null>): Promise<boolean>;
646
647
  /**
647
648
  * Tests the connection to the backend
648
649
  */
@@ -713,6 +714,47 @@ declare class OverlayManager extends WavedashManager {
713
714
  private handleKeyDown;
714
715
  }
715
716
 
717
+ /**
718
+ * AudioManager
719
+ *
720
+ * Mutes & unmutes the game in response to MUTE_CHANGED iframe messages, without
721
+ * the game needing to know anything about it.
722
+ *
723
+ * Web Audio: subclass `AudioContext` so `ctx.destination` resolves to a master
724
+ * GainNode that we control. The master gain wires to the real native destination,
725
+ * so `node.connect(ctx.destination)` and any other game code is unaffected.
726
+ *
727
+ * HTML Media (`<audio>`/`<video>`): override `HTMLMediaElement.prototype.muted`
728
+ * to record the game's intended state, but write `true` to the underlying element
729
+ * whenever the SDK is muted. Tracked elements come from three sources:
730
+ * 1. Pre-existing DOM media (`querySelectorAll`)
731
+ * 2. `new Audio()` constructor shim (covers detached SFX)
732
+ * 3. MutationObserver for any media added to the DOM later (covers innerHTML,
733
+ * framework rendering, createElement + append, etc.)
734
+ */
735
+ declare class AudioManager extends WavedashManager {
736
+ private _isMuted;
737
+ private contexts;
738
+ private elements;
739
+ private intendedMuted;
740
+ private originalAudioContext;
741
+ private originalWebKitAudioContext;
742
+ private originalAudio;
743
+ private originalMutedDescriptor;
744
+ private mutationObserver;
745
+ constructor(sdk: WavedashSDK);
746
+ isMuted(): boolean;
747
+ private handleMute;
748
+ /**
749
+ * Track a media element and (if SDK is currently muted) silence it.
750
+ * Idempotent — safe to call multiple times for the same element.
751
+ */
752
+ private trackElement;
753
+ private installShims;
754
+ private shimAudioContextClass;
755
+ destroy(): void;
756
+ }
757
+
716
758
  /**
717
759
  * Friends service
718
760
  *
@@ -898,6 +940,7 @@ declare class WavedashSDK extends EventTarget {
898
940
  p2pManager: P2PManager;
899
941
  fullscreenManager: FullscreenManager;
900
942
  overlayManager: OverlayManager;
943
+ audioManager: AudioManager;
901
944
  private managers;
902
945
  private gameplayJwt;
903
946
  private gameplayJwtPromise;
@@ -1162,11 +1205,16 @@ declare class WavedashSDK extends EventTarget {
1162
1205
  inviteUserToLobby(lobbyId: GenericId<"lobbies">, userId: GenericId<"users">): Promise<WavedashResponse<boolean>>;
1163
1206
  getLobbyInviteLink(copyToClipboard?: boolean): Promise<WavedashResponse<string>>;
1164
1207
  /**
1165
- * Updates rich user presence so friends can see what the player is doing in game
1166
- * @param data Game data to send to the backend
1208
+ * Updates rich user presence so friends can see what the player is doing in game.
1209
+ * Supported keys:
1210
+ * `status` — one-line activity shown as the primary line (e.g. "Traveling in a group")
1211
+ * `details` — secondary context shown beneath the status (e.g. current zone or mode)
1212
+ *
1213
+ * Pass an empty dictionary to clear all presence fields.
1214
+ * @param data Presence fields to update.
1167
1215
  * @returns true if the presence was updated successfully
1168
1216
  */
1169
- updateUserPresence(data?: Record<string, string | number | boolean | null>): Promise<WavedashResponse<boolean>>;
1217
+ updateUserPresence(data: Record<string, string | number | boolean | null>): Promise<WavedashResponse<boolean>>;
1170
1218
  private isGodot;
1171
1219
  private formatResponse;
1172
1220
  private ensureInit;
package/dist/index.js CHANGED
@@ -2709,6 +2709,7 @@ var HeartbeatManager = class extends WavedashManager {
2709
2709
  this.isFirstTick = true;
2710
2710
  this.TEST_CONNECTION_INTERVAL_MS = 1e3;
2711
2711
  this.DISCONNECTED_TIMEOUT_MS = 9e4;
2712
+ this.cachedPresenceData = {};
2712
2713
  this.handleVisibilityChange = () => {
2713
2714
  if (document.visibilityState === "visible") {
2714
2715
  this.start();
@@ -2778,7 +2779,7 @@ var HeartbeatManager = class extends WavedashManager {
2778
2779
  this.heartbeatInFlight = true;
2779
2780
  this.sdk.convexClient.mutation(api7.sdk.presence.heartbeat, {
2780
2781
  ...reestablish ? {
2781
- data: { forceUpdate: true },
2782
+ data: this.cachedPresenceData,
2782
2783
  deviceFingerprint: this.deviceFingerprint
2783
2784
  } : {}
2784
2785
  }).then((accepted) => {
@@ -2792,15 +2793,15 @@ var HeartbeatManager = class extends WavedashManager {
2792
2793
  });
2793
2794
  }
2794
2795
  /**
2795
- * Updates user presence in the backend
2796
+ * Updates user presence in the backend.
2796
2797
  * @param data - Data to send to the backend
2797
2798
  * @returns true if the presence was updated successfully
2798
2799
  */
2799
2800
  async updateUserPresence(data) {
2800
2801
  try {
2801
- const dataToSend = data ?? { forceUpdate: true };
2802
+ this.cachedPresenceData = data;
2802
2803
  await this.sdk.convexClient.mutation(api7.sdk.presence.heartbeat, {
2803
- data: dataToSend,
2804
+ data,
2804
2805
  deviceFingerprint: this.deviceFingerprint
2805
2806
  });
2806
2807
  return true;
@@ -3028,6 +3029,211 @@ var OverlayManager = class extends WavedashManager {
3028
3029
  }
3029
3030
  };
3030
3031
 
3032
+ // src/services/audio.ts
3033
+ import { IFRAME_MESSAGE_TYPE as IFRAME_MESSAGE_TYPE5 } from "@wvdsh/api";
3034
+ var WeakRefSet = class {
3035
+ constructor() {
3036
+ this.set = /* @__PURE__ */ new Set();
3037
+ }
3038
+ add(value) {
3039
+ for (const ref of this.set) {
3040
+ if (ref.deref() === value) return;
3041
+ }
3042
+ this.set.add(new WeakRef(value));
3043
+ }
3044
+ forEach(callback) {
3045
+ for (const ref of this.set) {
3046
+ const v = ref.deref();
3047
+ if (v === void 0) this.set.delete(ref);
3048
+ else callback(v);
3049
+ }
3050
+ }
3051
+ clear() {
3052
+ this.set.clear();
3053
+ }
3054
+ };
3055
+ var AudioManager = class extends WavedashManager {
3056
+ constructor(sdk) {
3057
+ super(sdk);
3058
+ this._isMuted = false;
3059
+ // Web Audio contexts and their master gain nodes
3060
+ this.contexts = /* @__PURE__ */ new Map();
3061
+ // HTML media elements we know about + their game-intended muted state
3062
+ this.elements = new WeakRefSet();
3063
+ this.intendedMuted = /* @__PURE__ */ new WeakMap();
3064
+ // Originals (restored on destroy)
3065
+ this.originalAudioContext = null;
3066
+ this.originalWebKitAudioContext = null;
3067
+ this.originalAudio = null;
3068
+ this.originalMutedDescriptor = null;
3069
+ this.mutationObserver = null;
3070
+ this.handleMute = (data) => {
3071
+ if (this._isMuted === data.isMuted) return;
3072
+ this._isMuted = data.isMuted;
3073
+ logger.debug(`[AudioManager] muted=${this._isMuted}`);
3074
+ const target = this._isMuted ? 0 : 1;
3075
+ this.contexts.forEach((gain, ctx) => {
3076
+ const now = ctx.currentTime;
3077
+ gain.gain.cancelScheduledValues(now);
3078
+ gain.gain.setValueAtTime(gain.gain.value, now);
3079
+ gain.gain.linearRampToValueAtTime(target, now + 0.05);
3080
+ });
3081
+ const setMutedNative = this.originalMutedDescriptor?.set;
3082
+ if (setMutedNative) {
3083
+ this.elements.forEach((el) => {
3084
+ const intended = this.intendedMuted.get(el) ?? false;
3085
+ setMutedNative.call(el, this._isMuted ? true : intended);
3086
+ });
3087
+ }
3088
+ };
3089
+ this.installShims();
3090
+ this.sdk.iframeMessenger.addEventListener(
3091
+ IFRAME_MESSAGE_TYPE5.MUTE_CHANGED,
3092
+ this.handleMute
3093
+ );
3094
+ }
3095
+ isMuted() {
3096
+ return this._isMuted;
3097
+ }
3098
+ /**
3099
+ * Track a media element and (if SDK is currently muted) silence it.
3100
+ * Idempotent — safe to call multiple times for the same element.
3101
+ */
3102
+ trackElement(el) {
3103
+ if (this.intendedMuted.has(el)) return;
3104
+ const getMuted = this.originalMutedDescriptor?.get;
3105
+ const setMuted = this.originalMutedDescriptor?.set;
3106
+ const current = getMuted ? getMuted.call(el) : el.muted;
3107
+ this.intendedMuted.set(el, current);
3108
+ this.elements.add(el);
3109
+ if (this._isMuted && !current && setMuted) {
3110
+ setMuted.call(el, true);
3111
+ }
3112
+ }
3113
+ installShims() {
3114
+ if (typeof window === "undefined") return;
3115
+ if (window.AudioContext) {
3116
+ this.originalAudioContext = window.AudioContext;
3117
+ window.AudioContext = this.shimAudioContextClass(window.AudioContext);
3118
+ }
3119
+ const win = window;
3120
+ if (win.webkitAudioContext) {
3121
+ this.originalWebKitAudioContext = win.webkitAudioContext;
3122
+ win.webkitAudioContext = this.shimAudioContextClass(win.webkitAudioContext);
3123
+ }
3124
+ if (window.Audio) {
3125
+ const OriginalAudio = window.Audio;
3126
+ this.originalAudio = OriginalAudio;
3127
+ ((manager) => {
3128
+ const Shimmed = function(src) {
3129
+ const audio = new OriginalAudio(src);
3130
+ manager.trackElement(audio);
3131
+ return audio;
3132
+ };
3133
+ Shimmed.prototype = OriginalAudio.prototype;
3134
+ window.Audio = Shimmed;
3135
+ })(this);
3136
+ }
3137
+ if (typeof document !== "undefined") {
3138
+ document.querySelectorAll("audio, video").forEach((el) => {
3139
+ this.trackElement(el);
3140
+ });
3141
+ this.mutationObserver = new MutationObserver((mutations) => {
3142
+ for (const m of mutations) {
3143
+ m.addedNodes.forEach((node) => {
3144
+ if (node instanceof HTMLMediaElement) {
3145
+ this.trackElement(node);
3146
+ } else if (node instanceof HTMLElement) {
3147
+ node.querySelectorAll("audio, video").forEach((el) => {
3148
+ this.trackElement(el);
3149
+ });
3150
+ }
3151
+ });
3152
+ }
3153
+ });
3154
+ this.mutationObserver.observe(document.documentElement, {
3155
+ childList: true,
3156
+ subtree: true
3157
+ });
3158
+ }
3159
+ this.originalMutedDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, "muted") ?? null;
3160
+ const original = this.originalMutedDescriptor;
3161
+ if (original?.get && original?.set) {
3162
+ ((manager) => {
3163
+ Object.defineProperty(HTMLMediaElement.prototype, "muted", {
3164
+ configurable: true,
3165
+ get() {
3166
+ const intended = manager.intendedMuted.get(this);
3167
+ return intended !== void 0 ? intended : original.get.call(this);
3168
+ },
3169
+ set(value) {
3170
+ manager.intendedMuted.set(this, value);
3171
+ manager.elements.add(this);
3172
+ original.set.call(this, manager._isMuted ? true : value);
3173
+ }
3174
+ });
3175
+ })(this);
3176
+ }
3177
+ }
3178
+ shimAudioContextClass(Original) {
3179
+ return /* @__PURE__ */ ((manager) => class extends Original {
3180
+ constructor(opts) {
3181
+ super(opts);
3182
+ const masterGain = this.createGain();
3183
+ masterGain.connect(this.destination);
3184
+ masterGain.gain.setValueAtTime(
3185
+ manager._isMuted ? 0 : 1,
3186
+ this.currentTime
3187
+ );
3188
+ Object.defineProperty(this, "destination", {
3189
+ configurable: true,
3190
+ get() {
3191
+ return masterGain;
3192
+ }
3193
+ });
3194
+ manager.contexts.set(this, masterGain);
3195
+ }
3196
+ close() {
3197
+ manager.contexts.delete(this);
3198
+ return super.close();
3199
+ }
3200
+ })(this);
3201
+ }
3202
+ destroy() {
3203
+ this.sdk.iframeMessenger.removeEventListener(
3204
+ IFRAME_MESSAGE_TYPE5.MUTE_CHANGED,
3205
+ this.handleMute
3206
+ );
3207
+ if (this.mutationObserver) {
3208
+ this.mutationObserver.disconnect();
3209
+ this.mutationObserver = null;
3210
+ }
3211
+ if (typeof window !== "undefined") {
3212
+ if (this.originalAudioContext) {
3213
+ window.AudioContext = this.originalAudioContext;
3214
+ }
3215
+ const win = window;
3216
+ if (this.originalWebKitAudioContext && win.webkitAudioContext) {
3217
+ win.webkitAudioContext = this.originalWebKitAudioContext;
3218
+ }
3219
+ if (this.originalAudio) {
3220
+ window.Audio = this.originalAudio;
3221
+ }
3222
+ }
3223
+ if (this.originalMutedDescriptor) {
3224
+ Object.defineProperty(
3225
+ HTMLMediaElement.prototype,
3226
+ "muted",
3227
+ this.originalMutedDescriptor
3228
+ );
3229
+ }
3230
+ this.contexts.clear();
3231
+ this.elements.clear();
3232
+ this.intendedMuted = /* @__PURE__ */ new WeakMap();
3233
+ super.destroy();
3234
+ }
3235
+ };
3236
+
3031
3237
  // src/services/friends.ts
3032
3238
  import { api as api8 } from "@wvdsh/api";
3033
3239
 
@@ -3289,7 +3495,7 @@ var SwMessenger = class {
3289
3495
  };
3290
3496
 
3291
3497
  // src/index.ts
3292
- import { IFRAME_MESSAGE_TYPE as IFRAME_MESSAGE_TYPE5, UrlParams } from "@wvdsh/api";
3498
+ import { IFRAME_MESSAGE_TYPE as IFRAME_MESSAGE_TYPE6, UrlParams } from "@wvdsh/api";
3293
3499
 
3294
3500
  // src/utils/validation.ts
3295
3501
  var CONVEX_ID_REGEX = /^[0-9a-z]{31,37}$/;
@@ -3333,6 +3539,13 @@ var vRecord = (value, path) => {
3333
3539
  `${path}: expected plain object, got ${describeValue(value)}`
3334
3540
  );
3335
3541
  }
3542
+ for (const [key, val] of Object.entries(value)) {
3543
+ if (typeof val === "object" && val !== null) {
3544
+ throw new Error(
3545
+ `${path}: expected flat record with no nested objects, but key "${key}" contains ${describeValue(val)}`
3546
+ );
3547
+ }
3548
+ }
3336
3549
  return value;
3337
3550
  };
3338
3551
  function vId(tableName) {
@@ -3377,7 +3590,12 @@ function vUnion(...variants) {
3377
3590
  }
3378
3591
  function vObject(shape) {
3379
3592
  return (value, path) => {
3380
- const obj = vRecord(value, path);
3593
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
3594
+ throw new Error(
3595
+ `${path}: expected plain object, got ${describeValue(value)}`
3596
+ );
3597
+ }
3598
+ const obj = value;
3381
3599
  for (const key of Object.keys(obj)) {
3382
3600
  if (!(key in shape)) {
3383
3601
  throw new Error(`${path}: unrecognized property "${key}"`);
@@ -3468,6 +3686,7 @@ var WavedashSDK = class extends EventTarget {
3468
3686
  this.gameEventManager = new GameEventManager(this);
3469
3687
  this.fullscreenManager = new FullscreenManager(this);
3470
3688
  this.overlayManager = new OverlayManager(this);
3689
+ this.audioManager = new AudioManager(this);
3471
3690
  this.managers = [
3472
3691
  this.p2pManager,
3473
3692
  this.lobbyManager,
@@ -3479,7 +3698,8 @@ var WavedashSDK = class extends EventTarget {
3479
3698
  this.friendsManager,
3480
3699
  this.gameEventManager,
3481
3700
  this.fullscreenManager,
3482
- this.overlayManager
3701
+ this.overlayManager,
3702
+ this.audioManager
3483
3703
  ];
3484
3704
  this.friendsManager.cacheUsers([
3485
3705
  {
@@ -3611,7 +3831,7 @@ var WavedashSDK = class extends EventTarget {
3611
3831
  [progress]
3612
3832
  );
3613
3833
  this.clearSetupWarning();
3614
- iframeMessenger.postToParent(IFRAME_MESSAGE_TYPE5.PROGRESS_UPDATE, {
3834
+ iframeMessenger.postToParent(IFRAME_MESSAGE_TYPE6.PROGRESS_UPDATE, {
3615
3835
  progress
3616
3836
  });
3617
3837
  }
@@ -3620,7 +3840,7 @@ var WavedashSDK = class extends EventTarget {
3620
3840
  if (this.gameFinishedLoading) return;
3621
3841
  this.gameFinishedLoading = true;
3622
3842
  this.heartbeatManager.start();
3623
- iframeMessenger.postToParent(IFRAME_MESSAGE_TYPE5.LOADING_COMPLETE, {});
3843
+ iframeMessenger.postToParent(IFRAME_MESSAGE_TYPE6.LOADING_COMPLETE, {});
3624
3844
  this.overlayManager.takeFocus();
3625
3845
  }
3626
3846
  get gameLoaded() {
@@ -4368,15 +4588,34 @@ var WavedashSDK = class extends EventTarget {
4368
4588
  // User Presence
4369
4589
  // ==============================
4370
4590
  /**
4371
- * Updates rich user presence so friends can see what the player is doing in game
4372
- * @param data Game data to send to the backend
4591
+ * Updates rich user presence so friends can see what the player is doing in game.
4592
+ * Supported keys:
4593
+ * `status` — one-line activity shown as the primary line (e.g. "Traveling in a group")
4594
+ * `details` — secondary context shown beneath the status (e.g. current zone or mode)
4595
+ *
4596
+ * Pass an empty dictionary to clear all presence fields.
4597
+ * @param data Presence fields to update.
4373
4598
  * @returns true if the presence was updated successfully
4374
4599
  */
4375
4600
  async updateUserPresence(data) {
4601
+ if (typeof data === "string") {
4602
+ const raw = data;
4603
+ try {
4604
+ data = JSON.parse(raw);
4605
+ } catch (error) {
4606
+ const message = `updateUserPresence: invalid JSON: ${raw}`;
4607
+ logger.error(message, error);
4608
+ return this.formatResponse({
4609
+ success: false,
4610
+ data: null,
4611
+ message
4612
+ });
4613
+ }
4614
+ }
4376
4615
  return this.apiCall(
4377
4616
  this.heartbeatManager,
4378
4617
  "updateUserPresence",
4379
- [["data", vOptional(vRecord)]],
4618
+ [["data", vRecord]],
4380
4619
  data
4381
4620
  );
4382
4621
  }
@@ -4467,7 +4706,7 @@ var WavedashSDK = class extends EventTarget {
4467
4706
  throw new Error(`Failed to refresh gameplay token: ${response.status}`);
4468
4707
  }
4469
4708
  this.gameplayJwt = await response.text();
4470
- iframeMessenger.postToParent(IFRAME_MESSAGE_TYPE5.GAMEPLAY_JWT_READY, {
4709
+ iframeMessenger.postToParent(IFRAME_MESSAGE_TYPE6.GAMEPLAY_JWT_READY, {
4471
4710
  gameplayJwt: this.gameplayJwt
4472
4711
  });
4473
4712
  this.swMessenger.postToServiceWorker({
@@ -4503,7 +4742,7 @@ var WavedashSDK = class extends EventTarget {
4503
4742
  }
4504
4743
  setupSessionEndListeners() {
4505
4744
  iframeMessenger.addEventListener(
4506
- IFRAME_MESSAGE_TYPE5.END_SESSION,
4745
+ IFRAME_MESSAGE_TYPE6.END_SESSION,
4507
4746
  () => this.destroy()
4508
4747
  );
4509
4748
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wvdsh/sdk-js",
3
- "version": "1.3.12",
3
+ "version": "1.3.13",
4
4
  "type": "module",
5
5
  "description": "Wavedash JavaScript SDK",
6
6
  "main": "./dist/client.js",
@@ -49,8 +49,8 @@
49
49
  "typescript-eslint": "^8.52.0"
50
50
  },
51
51
  "dependencies": {
52
- "@wvdsh/api": "^0.1.27",
53
- "convex": "^1.38.0",
52
+ "@wvdsh/api": "^0.1.28",
53
+ "convex": "^1.39.1",
54
54
  "lodash.throttle": "^4.1.1"
55
55
  }
56
56
  }