@thestatic-tv/dcl-sdk 2.5.17 → 2.5.18

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.mts CHANGED
@@ -320,6 +320,25 @@ interface SceneTabDefinition {
320
320
  /** React-ECS component that renders the tab content */
321
321
  render: () => JSX.Element | null;
322
322
  }
323
+ /**
324
+ * Scene boundary information calculated from parcels
325
+ */
326
+ interface SceneBounds$1 {
327
+ /** Minimum X coordinate (always 0 in local coords) */
328
+ minX: number;
329
+ /** Maximum X coordinate (width in meters) */
330
+ maxX: number;
331
+ /** Minimum Z coordinate (always 0 in local coords) */
332
+ minZ: number;
333
+ /** Maximum Z coordinate (depth in meters) */
334
+ maxZ: number;
335
+ /** Scene width in meters */
336
+ width: number;
337
+ /** Scene depth in meters */
338
+ depth: number;
339
+ /** Original parcel strings from scene.json */
340
+ parcels: string[];
341
+ }
323
342
  /**
324
343
  * Configuration options for AdminPanelModule
325
344
  */
@@ -428,6 +447,9 @@ declare class GuideModule {
428
447
  *
429
448
  * Works with both channel keys (dclk_) and scene keys (dcls_).
430
449
  * Scene keys use /scene-session endpoint, channel keys use /session.
450
+ *
451
+ * Includes automatic boundary detection to ensure metrics only track
452
+ * players who are actually within the scene's parcel boundaries.
431
453
  */
432
454
 
433
455
  declare class SessionModule {
@@ -437,7 +459,19 @@ declare class SessionModule {
437
459
  private heartbeatTimerId;
438
460
  private isActive;
439
461
  private _tier;
462
+ private _bounds;
463
+ private _boundsInitialized;
464
+ private _outOfBoundsCount;
440
465
  constructor(client: StaticTVClient);
466
+ /**
467
+ * Get the scene bounds (auto-detected from parcels)
468
+ * Returns null if bounds haven't been initialized yet
469
+ */
470
+ get bounds(): SceneBounds$1 | null;
471
+ /**
472
+ * Check if player is currently within scene bounds
473
+ */
474
+ isPlayerInScene(): boolean;
441
475
  /**
442
476
  * Get the API key ID (used as default sceneId for Pro users)
443
477
  */
@@ -466,6 +500,11 @@ declare class SessionModule {
466
500
  * Get player display name - prioritize config, fallback to runtime detection
467
501
  */
468
502
  private getDisplayName;
503
+ /**
504
+ * Initialize scene bounds from DCL Runtime
505
+ * Called once on session start
506
+ */
507
+ private initializeBounds;
469
508
  /**
470
509
  * Start a new session
471
510
  * Called automatically if autoStartSession is true
@@ -477,6 +516,7 @@ declare class SessionModule {
477
516
  private startHeartbeat;
478
517
  /**
479
518
  * Send a session heartbeat
519
+ * Always sends heartbeat with inScene status so dashboard can track location
480
520
  */
481
521
  private sendHeartbeat;
482
522
  /**
@@ -500,6 +540,8 @@ declare class SessionModule {
500
540
 
501
541
  /**
502
542
  * Heartbeat module - track video watching metrics
543
+ *
544
+ * Only tracks when player is within scene bounds.
503
545
  */
504
546
 
505
547
  declare class HeartbeatModule {
@@ -525,6 +567,7 @@ declare class HeartbeatModule {
525
567
  private getWallet;
526
568
  /**
527
569
  * Send a watching heartbeat (1 heartbeat = 1 minute watched)
570
+ * Only sends if player is within scene bounds
528
571
  */
529
572
  private sendHeartbeat;
530
573
  /**
@@ -1301,4 +1344,32 @@ declare function getPlayerWallet(): string | null;
1301
1344
  */
1302
1345
  declare function getPlayerDisplayName(): string | null;
1303
1346
 
1304
- export { type AdminPanelConfig, AdminPanelUIModule, type Channel, type ChatChannel, type ChatMessage, type ChatUIConfig, ChatUIModule, type CommandHandler, type GuideFeaturedPlaylist, GuideModule, type GuideResponse, type GuideUIConfig, GuideUIModule, type GuideVideo, HeartbeatModule, type HeartbeatResponse, type InteractionResponse, InteractionsModule, KEY_TYPE_CHANNEL, KEY_TYPE_SCENE, NotificationBanner, type PlayerData, type SDKTier, type SceneStats, type SceneStatsResponse, type SceneTabDefinition, SessionModule, type SessionResponse, StaticTVClient, type StaticTVConfig, type StreamData, type VideoSlot, type Vod, fetchUserData, getPlayerDisplayName, getPlayerWallet, hideNotification, setupStaticUI, showNotification };
1347
+ interface SceneBounds {
1348
+ minX: number;
1349
+ maxX: number;
1350
+ minZ: number;
1351
+ maxZ: number;
1352
+ width: number;
1353
+ depth: number;
1354
+ parcels: string[];
1355
+ }
1356
+ /**
1357
+ * Get scene bounds from DCL Runtime
1358
+ * Auto-detects parcels and calculates local coordinate bounds
1359
+ */
1360
+ declare function getSceneBounds(): Promise<SceneBounds | null>;
1361
+ /**
1362
+ * Check if player is within scene boundaries
1363
+ * Returns true if in bounds, false if out of bounds or can't determine
1364
+ */
1365
+ declare function isPlayerInBounds(bounds: SceneBounds): boolean;
1366
+ /**
1367
+ * Get player's current position
1368
+ */
1369
+ declare function getPlayerPosition(): {
1370
+ x: number;
1371
+ y: number;
1372
+ z: number;
1373
+ } | null;
1374
+
1375
+ export { type AdminPanelConfig, AdminPanelUIModule, type Channel, type ChatChannel, type ChatMessage, type ChatUIConfig, ChatUIModule, type CommandHandler, type GuideFeaturedPlaylist, GuideModule, type GuideResponse, type GuideUIConfig, GuideUIModule, type GuideVideo, HeartbeatModule, type HeartbeatResponse, type InteractionResponse, InteractionsModule, KEY_TYPE_CHANNEL, KEY_TYPE_SCENE, NotificationBanner, type PlayerData, type SDKTier, type SceneBounds$1 as SceneBounds, type SceneStats, type SceneStatsResponse, type SceneTabDefinition, SessionModule, type SessionResponse, StaticTVClient, type StaticTVConfig, type StreamData, type VideoSlot, type Vod, fetchUserData, getPlayerDisplayName, getPlayerPosition, getPlayerWallet, getSceneBounds, hideNotification, isPlayerInBounds, setupStaticUI, showNotification };
package/dist/index.d.ts CHANGED
@@ -320,6 +320,25 @@ interface SceneTabDefinition {
320
320
  /** React-ECS component that renders the tab content */
321
321
  render: () => JSX.Element | null;
322
322
  }
323
+ /**
324
+ * Scene boundary information calculated from parcels
325
+ */
326
+ interface SceneBounds$1 {
327
+ /** Minimum X coordinate (always 0 in local coords) */
328
+ minX: number;
329
+ /** Maximum X coordinate (width in meters) */
330
+ maxX: number;
331
+ /** Minimum Z coordinate (always 0 in local coords) */
332
+ minZ: number;
333
+ /** Maximum Z coordinate (depth in meters) */
334
+ maxZ: number;
335
+ /** Scene width in meters */
336
+ width: number;
337
+ /** Scene depth in meters */
338
+ depth: number;
339
+ /** Original parcel strings from scene.json */
340
+ parcels: string[];
341
+ }
323
342
  /**
324
343
  * Configuration options for AdminPanelModule
325
344
  */
@@ -428,6 +447,9 @@ declare class GuideModule {
428
447
  *
429
448
  * Works with both channel keys (dclk_) and scene keys (dcls_).
430
449
  * Scene keys use /scene-session endpoint, channel keys use /session.
450
+ *
451
+ * Includes automatic boundary detection to ensure metrics only track
452
+ * players who are actually within the scene's parcel boundaries.
431
453
  */
432
454
 
433
455
  declare class SessionModule {
@@ -437,7 +459,19 @@ declare class SessionModule {
437
459
  private heartbeatTimerId;
438
460
  private isActive;
439
461
  private _tier;
462
+ private _bounds;
463
+ private _boundsInitialized;
464
+ private _outOfBoundsCount;
440
465
  constructor(client: StaticTVClient);
466
+ /**
467
+ * Get the scene bounds (auto-detected from parcels)
468
+ * Returns null if bounds haven't been initialized yet
469
+ */
470
+ get bounds(): SceneBounds$1 | null;
471
+ /**
472
+ * Check if player is currently within scene bounds
473
+ */
474
+ isPlayerInScene(): boolean;
441
475
  /**
442
476
  * Get the API key ID (used as default sceneId for Pro users)
443
477
  */
@@ -466,6 +500,11 @@ declare class SessionModule {
466
500
  * Get player display name - prioritize config, fallback to runtime detection
467
501
  */
468
502
  private getDisplayName;
503
+ /**
504
+ * Initialize scene bounds from DCL Runtime
505
+ * Called once on session start
506
+ */
507
+ private initializeBounds;
469
508
  /**
470
509
  * Start a new session
471
510
  * Called automatically if autoStartSession is true
@@ -477,6 +516,7 @@ declare class SessionModule {
477
516
  private startHeartbeat;
478
517
  /**
479
518
  * Send a session heartbeat
519
+ * Always sends heartbeat with inScene status so dashboard can track location
480
520
  */
481
521
  private sendHeartbeat;
482
522
  /**
@@ -500,6 +540,8 @@ declare class SessionModule {
500
540
 
501
541
  /**
502
542
  * Heartbeat module - track video watching metrics
543
+ *
544
+ * Only tracks when player is within scene bounds.
503
545
  */
504
546
 
505
547
  declare class HeartbeatModule {
@@ -525,6 +567,7 @@ declare class HeartbeatModule {
525
567
  private getWallet;
526
568
  /**
527
569
  * Send a watching heartbeat (1 heartbeat = 1 minute watched)
570
+ * Only sends if player is within scene bounds
528
571
  */
529
572
  private sendHeartbeat;
530
573
  /**
@@ -1301,4 +1344,32 @@ declare function getPlayerWallet(): string | null;
1301
1344
  */
1302
1345
  declare function getPlayerDisplayName(): string | null;
1303
1346
 
1304
- export { type AdminPanelConfig, AdminPanelUIModule, type Channel, type ChatChannel, type ChatMessage, type ChatUIConfig, ChatUIModule, type CommandHandler, type GuideFeaturedPlaylist, GuideModule, type GuideResponse, type GuideUIConfig, GuideUIModule, type GuideVideo, HeartbeatModule, type HeartbeatResponse, type InteractionResponse, InteractionsModule, KEY_TYPE_CHANNEL, KEY_TYPE_SCENE, NotificationBanner, type PlayerData, type SDKTier, type SceneStats, type SceneStatsResponse, type SceneTabDefinition, SessionModule, type SessionResponse, StaticTVClient, type StaticTVConfig, type StreamData, type VideoSlot, type Vod, fetchUserData, getPlayerDisplayName, getPlayerWallet, hideNotification, setupStaticUI, showNotification };
1347
+ interface SceneBounds {
1348
+ minX: number;
1349
+ maxX: number;
1350
+ minZ: number;
1351
+ maxZ: number;
1352
+ width: number;
1353
+ depth: number;
1354
+ parcels: string[];
1355
+ }
1356
+ /**
1357
+ * Get scene bounds from DCL Runtime
1358
+ * Auto-detects parcels and calculates local coordinate bounds
1359
+ */
1360
+ declare function getSceneBounds(): Promise<SceneBounds | null>;
1361
+ /**
1362
+ * Check if player is within scene boundaries
1363
+ * Returns true if in bounds, false if out of bounds or can't determine
1364
+ */
1365
+ declare function isPlayerInBounds(bounds: SceneBounds): boolean;
1366
+ /**
1367
+ * Get player's current position
1368
+ */
1369
+ declare function getPlayerPosition(): {
1370
+ x: number;
1371
+ y: number;
1372
+ z: number;
1373
+ } | null;
1374
+
1375
+ export { type AdminPanelConfig, AdminPanelUIModule, type Channel, type ChatChannel, type ChatMessage, type ChatUIConfig, ChatUIModule, type CommandHandler, type GuideFeaturedPlaylist, GuideModule, type GuideResponse, type GuideUIConfig, GuideUIModule, type GuideVideo, HeartbeatModule, type HeartbeatResponse, type InteractionResponse, InteractionsModule, KEY_TYPE_CHANNEL, KEY_TYPE_SCENE, NotificationBanner, type PlayerData, type SDKTier, type SceneBounds$1 as SceneBounds, type SceneStats, type SceneStatsResponse, type SceneTabDefinition, SessionModule, type SessionResponse, StaticTVClient, type StaticTVConfig, type StreamData, type VideoSlot, type Vod, fetchUserData, getPlayerDisplayName, getPlayerPosition, getPlayerWallet, getSceneBounds, hideNotification, isPlayerInBounds, setupStaticUI, showNotification };
package/dist/index.js CHANGED
@@ -43,8 +43,11 @@ __export(index_exports, {
43
43
  StaticTVClient: () => StaticTVClient,
44
44
  fetchUserData: () => fetchUserData,
45
45
  getPlayerDisplayName: () => getPlayerDisplayName,
46
+ getPlayerPosition: () => getPlayerPosition,
46
47
  getPlayerWallet: () => getPlayerWallet,
48
+ getSceneBounds: () => getSceneBounds,
47
49
  hideNotification: () => hideNotification,
50
+ isPlayerInBounds: () => isPlayerInBounds,
48
51
  setupStaticUI: () => setupStaticUI,
49
52
  showNotification: () => showNotification
50
53
  });
@@ -248,6 +251,93 @@ function dclClearTimeout(timeoutId) {
248
251
  }
249
252
  }
250
253
 
254
+ // src/utils/bounds.ts
255
+ var import_ecs2 = require("@dcl/sdk/ecs");
256
+ var import_Runtime = require("~system/Runtime");
257
+ var PARCEL_SIZE = 16;
258
+ var cachedBounds = null;
259
+ var boundsInitialized = false;
260
+ var boundsError = false;
261
+ function parseParcel(parcel) {
262
+ const parts = parcel.split(",");
263
+ if (parts.length !== 2) return null;
264
+ const x = parseInt(parts[0], 10);
265
+ const z = parseInt(parts[1], 10);
266
+ if (isNaN(x) || isNaN(z)) return null;
267
+ return { x, z };
268
+ }
269
+ async function getSceneBounds() {
270
+ if (boundsInitialized && cachedBounds) {
271
+ return cachedBounds;
272
+ }
273
+ if (boundsError) {
274
+ return null;
275
+ }
276
+ try {
277
+ const sceneInfo = await (0, import_Runtime.getSceneInformation)({});
278
+ let parcels = [];
279
+ let baseParcel;
280
+ if (sceneInfo.metadataJson) {
281
+ const metadata = JSON.parse(sceneInfo.metadataJson);
282
+ parcels = metadata?.scene?.parcels || [];
283
+ baseParcel = metadata?.scene?.base;
284
+ }
285
+ if (parcels.length === 0) {
286
+ boundsError = true;
287
+ return null;
288
+ }
289
+ const coords = parcels.map(parseParcel).filter((c) => c !== null);
290
+ if (coords.length === 0) {
291
+ boundsError = true;
292
+ return null;
293
+ }
294
+ const worldMinX = Math.min(...coords.map((c) => c.x));
295
+ const worldMaxX = Math.max(...coords.map((c) => c.x));
296
+ const worldMinZ = Math.min(...coords.map((c) => c.z));
297
+ const worldMaxZ = Math.max(...coords.map((c) => c.z));
298
+ const width = (worldMaxX - worldMinX + 1) * PARCEL_SIZE;
299
+ const depth = (worldMaxZ - worldMinZ + 1) * PARCEL_SIZE;
300
+ cachedBounds = {
301
+ minX: 0,
302
+ maxX: width,
303
+ minZ: 0,
304
+ maxZ: depth,
305
+ width,
306
+ depth,
307
+ parcels
308
+ };
309
+ boundsInitialized = true;
310
+ return cachedBounds;
311
+ } catch (error) {
312
+ boundsError = true;
313
+ return null;
314
+ }
315
+ }
316
+ function isPlayerInBounds(bounds) {
317
+ try {
318
+ const transform = import_ecs2.Transform.getOrNull(import_ecs2.engine.PlayerEntity);
319
+ if (!transform) return false;
320
+ const pos = transform.position;
321
+ const buffer = 0.5;
322
+ return pos.x >= bounds.minX - buffer && pos.x <= bounds.maxX + buffer && pos.z >= bounds.minZ - buffer && pos.z <= bounds.maxZ + buffer;
323
+ } catch {
324
+ return false;
325
+ }
326
+ }
327
+ function getPlayerPosition() {
328
+ try {
329
+ const transform = import_ecs2.Transform.getOrNull(import_ecs2.engine.PlayerEntity);
330
+ if (!transform) return null;
331
+ return {
332
+ x: transform.position.x,
333
+ y: transform.position.y,
334
+ z: transform.position.z
335
+ };
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
251
341
  // src/modules/session.ts
252
342
  function normalizeTier(tier) {
253
343
  if (tier === "lite") return "free";
@@ -262,8 +352,26 @@ var SessionModule = class {
262
352
  this.heartbeatTimerId = null;
263
353
  this.isActive = false;
264
354
  this._tier = "free";
355
+ // Boundary checking - auto-detected from scene parcels
356
+ this._bounds = null;
357
+ this._boundsInitialized = false;
358
+ this._outOfBoundsCount = 0;
265
359
  this.client = client;
266
360
  }
361
+ /**
362
+ * Get the scene bounds (auto-detected from parcels)
363
+ * Returns null if bounds haven't been initialized yet
364
+ */
365
+ get bounds() {
366
+ return this._bounds;
367
+ }
368
+ /**
369
+ * Check if player is currently within scene bounds
370
+ */
371
+ isPlayerInScene() {
372
+ if (!this._bounds) return true;
373
+ return isPlayerInBounds(this._bounds);
374
+ }
267
375
  /**
268
376
  * Get the API key ID (used as default sceneId for Pro users)
269
377
  */
@@ -306,6 +414,25 @@ var SessionModule = class {
306
414
  const config = this.client.getConfig();
307
415
  return config.player?.name || getPlayerDisplayName();
308
416
  }
417
+ /**
418
+ * Initialize scene bounds from DCL Runtime
419
+ * Called once on session start
420
+ */
421
+ async initializeBounds() {
422
+ if (this._boundsInitialized) return;
423
+ try {
424
+ this._bounds = await getSceneBounds();
425
+ this._boundsInitialized = true;
426
+ if (this._bounds) {
427
+ this.client.log(`Scene bounds detected: ${this._bounds.width}x${this._bounds.depth}m (${this._bounds.parcels.length} parcels)`);
428
+ } else {
429
+ this.client.log("Could not detect scene bounds - boundary checking disabled");
430
+ }
431
+ } catch (error) {
432
+ this.client.log(`Failed to detect scene bounds: ${error}`);
433
+ this._boundsInitialized = true;
434
+ }
435
+ }
309
436
  /**
310
437
  * Start a new session
311
438
  * Called automatically if autoStartSession is true
@@ -315,6 +442,7 @@ var SessionModule = class {
315
442
  this.client.log("Session already active");
316
443
  return this.sessionId;
317
444
  }
445
+ await this.initializeBounds();
318
446
  try {
319
447
  const walletAddress = this.getWallet();
320
448
  const dclDisplayName = this.getDisplayName();
@@ -356,18 +484,33 @@ var SessionModule = class {
356
484
  }
357
485
  /**
358
486
  * Send a session heartbeat
487
+ * Always sends heartbeat with inScene status so dashboard can track location
359
488
  */
360
489
  async sendHeartbeat() {
361
490
  if (!this.sessionId || !this.isActive) return;
491
+ const inScene = this.isPlayerInScene();
492
+ const pos = getPlayerPosition();
493
+ if (!inScene && this._outOfBoundsCount === 0) {
494
+ this.client.log(`Player left scene bounds (${pos?.x.toFixed(1)}, ${pos?.z.toFixed(1)})`);
495
+ } else if (inScene && this._outOfBoundsCount > 0) {
496
+ this.client.log(`Player returned to scene after ${this._outOfBoundsCount} heartbeats outside`);
497
+ }
498
+ if (!inScene) {
499
+ this._outOfBoundsCount++;
500
+ } else {
501
+ this._outOfBoundsCount = 0;
502
+ }
362
503
  try {
363
504
  await this.client.request(this.getEndpoint(), {
364
505
  method: "POST",
365
506
  body: JSON.stringify({
366
507
  action: "heartbeat",
367
- sessionId: this.sessionId
508
+ sessionId: this.sessionId,
509
+ inScene,
510
+ position: pos ? { x: Math.round(pos.x), z: Math.round(pos.z) } : null
368
511
  })
369
512
  });
370
- this.client.log("Session heartbeat sent");
513
+ this.client.log(`Session heartbeat sent (inScene: ${inScene})`);
371
514
  } catch (error) {
372
515
  this.client.log(`Session heartbeat failed: ${error}`);
373
516
  }
@@ -487,9 +630,14 @@ var HeartbeatModule = class {
487
630
  }
488
631
  /**
489
632
  * Send a watching heartbeat (1 heartbeat = 1 minute watched)
633
+ * Only sends if player is within scene bounds
490
634
  */
491
635
  async sendHeartbeat() {
492
636
  if (!this.currentChannel || !this.isWatching) return;
637
+ if (this.client.session && !this.client.session.isPlayerInScene()) {
638
+ this.client.log(`Watch heartbeat skipped - player out of scene bounds`);
639
+ return;
640
+ }
493
641
  try {
494
642
  const sessionId = this.client.session?.getSessionId();
495
643
  await this.client.request("/heartbeat", {
@@ -2315,7 +2463,7 @@ var import_RestrictedActions3 = require("~system/RestrictedActions");
2315
2463
 
2316
2464
  // src/ui/ui-renderer.tsx
2317
2465
  var import_react_ecs4 = __toESM(require("@dcl/sdk/react-ecs"));
2318
- var import_ecs2 = require("@dcl/sdk/ecs");
2466
+ var import_ecs3 = require("@dcl/sdk/ecs");
2319
2467
  var notificationText = "";
2320
2468
  var notificationVisible = false;
2321
2469
  var notificationEndTime = 0;
@@ -2336,7 +2484,7 @@ function getNotificationState() {
2336
2484
  function initNotificationSystem() {
2337
2485
  if (notificationInitialized) return;
2338
2486
  notificationInitialized = true;
2339
- import_ecs2.engine.addSystem(() => {
2487
+ import_ecs3.engine.addSystem(() => {
2340
2488
  if (notificationVisible && Date.now() > notificationEndTime) {
2341
2489
  notificationVisible = false;
2342
2490
  }
@@ -3722,7 +3870,7 @@ var AdminPanelUIModule = class {
3722
3870
  };
3723
3871
 
3724
3872
  // src/StaticTVClient.ts
3725
- var import_ecs3 = require("@dcl/sdk/ecs");
3873
+ var import_ecs4 = require("@dcl/sdk/ecs");
3726
3874
  var utils = __toESM(require("@dcl-sdk/utils"));
3727
3875
  var DEFAULT_BASE_URL = "https://thestatic.tv/api/v1/dcl";
3728
3876
  var DEFAULT_FALLBACK_VIDEO = "https://media.thestatic.tv/fallback-loop.mp4";
@@ -4011,9 +4159,9 @@ var StaticTVClient = class {
4011
4159
  const fallbackUrl = this.config.fallbackVideoUrl;
4012
4160
  const fallbackDisabled = fallbackUrl === "";
4013
4161
  if (fallbackDisabled) {
4014
- if (import_ecs3.VideoPlayer.has(screen)) {
4162
+ if (import_ecs4.VideoPlayer.has(screen)) {
4015
4163
  this.log("Stopping video (no fallback)");
4016
- import_ecs3.VideoPlayer.getMutable(screen).playing = false;
4164
+ import_ecs4.VideoPlayer.getMutable(screen).playing = false;
4017
4165
  }
4018
4166
  this._currentVideoUrl = "";
4019
4167
  } else {
@@ -4036,14 +4184,14 @@ var StaticTVClient = class {
4036
4184
  if (screen === void 0) return;
4037
4185
  this._clearVerificationTimeout();
4038
4186
  this._streamVerified = false;
4039
- if (import_ecs3.VideoPlayer.has(screen)) {
4040
- const player = import_ecs3.VideoPlayer.getMutable(screen);
4187
+ if (import_ecs4.VideoPlayer.has(screen)) {
4188
+ const player = import_ecs4.VideoPlayer.getMutable(screen);
4041
4189
  player.src = url;
4042
4190
  player.playing = true;
4043
4191
  player.loop = isFallback;
4044
4192
  player.volume = 1;
4045
4193
  } else {
4046
- import_ecs3.VideoPlayer.create(screen, {
4194
+ import_ecs4.VideoPlayer.create(screen, {
4047
4195
  src: url,
4048
4196
  playing: true,
4049
4197
  loop: isFallback,
@@ -4065,8 +4213,8 @@ var StaticTVClient = class {
4065
4213
  const screen = this.config.videoScreen;
4066
4214
  if (screen === void 0) return;
4067
4215
  this._videoEventsRegistered = true;
4068
- import_ecs3.videoEventsSystem.registerVideoEventsEntity(screen, (videoEvent) => {
4069
- if (videoEvent.state === import_ecs3.VideoState.VS_PLAYING) {
4216
+ import_ecs4.videoEventsSystem.registerVideoEventsEntity(screen, (videoEvent) => {
4217
+ if (videoEvent.state === import_ecs4.VideoState.VS_PLAYING) {
4070
4218
  const timeSinceStart = Date.now() - this._verificationStartTime;
4071
4219
  const MIN_VERIFICATION_DELAY = 2e3;
4072
4220
  if (this._pendingVideoData && !this._streamVerified && timeSinceStart >= MIN_VERIFICATION_DELAY) {
@@ -4075,7 +4223,7 @@ var StaticTVClient = class {
4075
4223
  this.log(`Stream verified: ${this._pendingVideoData.name} (after ${timeSinceStart}ms)`);
4076
4224
  }
4077
4225
  }
4078
- if (videoEvent.state === import_ecs3.VideoState.VS_ERROR) {
4226
+ if (videoEvent.state === import_ecs4.VideoState.VS_ERROR) {
4079
4227
  if (this._pendingVideoData) {
4080
4228
  this.log(`Stream error for: ${this._pendingVideoData.name}`);
4081
4229
  this._handleStreamOffline();
@@ -4496,8 +4644,11 @@ var StaticTVClient = class {
4496
4644
  StaticTVClient,
4497
4645
  fetchUserData,
4498
4646
  getPlayerDisplayName,
4647
+ getPlayerPosition,
4499
4648
  getPlayerWallet,
4649
+ getSceneBounds,
4500
4650
  hideNotification,
4651
+ isPlayerInBounds,
4501
4652
  setupStaticUI,
4502
4653
  showNotification
4503
4654
  });
package/dist/index.mjs CHANGED
@@ -203,6 +203,93 @@ function dclClearTimeout(timeoutId) {
203
203
  }
204
204
  }
205
205
 
206
+ // src/utils/bounds.ts
207
+ import { engine as engine2, Transform } from "@dcl/sdk/ecs";
208
+ import { getSceneInformation } from "~system/Runtime";
209
+ var PARCEL_SIZE = 16;
210
+ var cachedBounds = null;
211
+ var boundsInitialized = false;
212
+ var boundsError = false;
213
+ function parseParcel(parcel) {
214
+ const parts = parcel.split(",");
215
+ if (parts.length !== 2) return null;
216
+ const x = parseInt(parts[0], 10);
217
+ const z = parseInt(parts[1], 10);
218
+ if (isNaN(x) || isNaN(z)) return null;
219
+ return { x, z };
220
+ }
221
+ async function getSceneBounds() {
222
+ if (boundsInitialized && cachedBounds) {
223
+ return cachedBounds;
224
+ }
225
+ if (boundsError) {
226
+ return null;
227
+ }
228
+ try {
229
+ const sceneInfo = await getSceneInformation({});
230
+ let parcels = [];
231
+ let baseParcel;
232
+ if (sceneInfo.metadataJson) {
233
+ const metadata = JSON.parse(sceneInfo.metadataJson);
234
+ parcels = metadata?.scene?.parcels || [];
235
+ baseParcel = metadata?.scene?.base;
236
+ }
237
+ if (parcels.length === 0) {
238
+ boundsError = true;
239
+ return null;
240
+ }
241
+ const coords = parcels.map(parseParcel).filter((c) => c !== null);
242
+ if (coords.length === 0) {
243
+ boundsError = true;
244
+ return null;
245
+ }
246
+ const worldMinX = Math.min(...coords.map((c) => c.x));
247
+ const worldMaxX = Math.max(...coords.map((c) => c.x));
248
+ const worldMinZ = Math.min(...coords.map((c) => c.z));
249
+ const worldMaxZ = Math.max(...coords.map((c) => c.z));
250
+ const width = (worldMaxX - worldMinX + 1) * PARCEL_SIZE;
251
+ const depth = (worldMaxZ - worldMinZ + 1) * PARCEL_SIZE;
252
+ cachedBounds = {
253
+ minX: 0,
254
+ maxX: width,
255
+ minZ: 0,
256
+ maxZ: depth,
257
+ width,
258
+ depth,
259
+ parcels
260
+ };
261
+ boundsInitialized = true;
262
+ return cachedBounds;
263
+ } catch (error) {
264
+ boundsError = true;
265
+ return null;
266
+ }
267
+ }
268
+ function isPlayerInBounds(bounds) {
269
+ try {
270
+ const transform = Transform.getOrNull(engine2.PlayerEntity);
271
+ if (!transform) return false;
272
+ const pos = transform.position;
273
+ const buffer = 0.5;
274
+ return pos.x >= bounds.minX - buffer && pos.x <= bounds.maxX + buffer && pos.z >= bounds.minZ - buffer && pos.z <= bounds.maxZ + buffer;
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+ function getPlayerPosition() {
280
+ try {
281
+ const transform = Transform.getOrNull(engine2.PlayerEntity);
282
+ if (!transform) return null;
283
+ return {
284
+ x: transform.position.x,
285
+ y: transform.position.y,
286
+ z: transform.position.z
287
+ };
288
+ } catch {
289
+ return null;
290
+ }
291
+ }
292
+
206
293
  // src/modules/session.ts
207
294
  function normalizeTier(tier) {
208
295
  if (tier === "lite") return "free";
@@ -217,8 +304,26 @@ var SessionModule = class {
217
304
  this.heartbeatTimerId = null;
218
305
  this.isActive = false;
219
306
  this._tier = "free";
307
+ // Boundary checking - auto-detected from scene parcels
308
+ this._bounds = null;
309
+ this._boundsInitialized = false;
310
+ this._outOfBoundsCount = 0;
220
311
  this.client = client;
221
312
  }
313
+ /**
314
+ * Get the scene bounds (auto-detected from parcels)
315
+ * Returns null if bounds haven't been initialized yet
316
+ */
317
+ get bounds() {
318
+ return this._bounds;
319
+ }
320
+ /**
321
+ * Check if player is currently within scene bounds
322
+ */
323
+ isPlayerInScene() {
324
+ if (!this._bounds) return true;
325
+ return isPlayerInBounds(this._bounds);
326
+ }
222
327
  /**
223
328
  * Get the API key ID (used as default sceneId for Pro users)
224
329
  */
@@ -261,6 +366,25 @@ var SessionModule = class {
261
366
  const config = this.client.getConfig();
262
367
  return config.player?.name || getPlayerDisplayName();
263
368
  }
369
+ /**
370
+ * Initialize scene bounds from DCL Runtime
371
+ * Called once on session start
372
+ */
373
+ async initializeBounds() {
374
+ if (this._boundsInitialized) return;
375
+ try {
376
+ this._bounds = await getSceneBounds();
377
+ this._boundsInitialized = true;
378
+ if (this._bounds) {
379
+ this.client.log(`Scene bounds detected: ${this._bounds.width}x${this._bounds.depth}m (${this._bounds.parcels.length} parcels)`);
380
+ } else {
381
+ this.client.log("Could not detect scene bounds - boundary checking disabled");
382
+ }
383
+ } catch (error) {
384
+ this.client.log(`Failed to detect scene bounds: ${error}`);
385
+ this._boundsInitialized = true;
386
+ }
387
+ }
264
388
  /**
265
389
  * Start a new session
266
390
  * Called automatically if autoStartSession is true
@@ -270,6 +394,7 @@ var SessionModule = class {
270
394
  this.client.log("Session already active");
271
395
  return this.sessionId;
272
396
  }
397
+ await this.initializeBounds();
273
398
  try {
274
399
  const walletAddress = this.getWallet();
275
400
  const dclDisplayName = this.getDisplayName();
@@ -311,18 +436,33 @@ var SessionModule = class {
311
436
  }
312
437
  /**
313
438
  * Send a session heartbeat
439
+ * Always sends heartbeat with inScene status so dashboard can track location
314
440
  */
315
441
  async sendHeartbeat() {
316
442
  if (!this.sessionId || !this.isActive) return;
443
+ const inScene = this.isPlayerInScene();
444
+ const pos = getPlayerPosition();
445
+ if (!inScene && this._outOfBoundsCount === 0) {
446
+ this.client.log(`Player left scene bounds (${pos?.x.toFixed(1)}, ${pos?.z.toFixed(1)})`);
447
+ } else if (inScene && this._outOfBoundsCount > 0) {
448
+ this.client.log(`Player returned to scene after ${this._outOfBoundsCount} heartbeats outside`);
449
+ }
450
+ if (!inScene) {
451
+ this._outOfBoundsCount++;
452
+ } else {
453
+ this._outOfBoundsCount = 0;
454
+ }
317
455
  try {
318
456
  await this.client.request(this.getEndpoint(), {
319
457
  method: "POST",
320
458
  body: JSON.stringify({
321
459
  action: "heartbeat",
322
- sessionId: this.sessionId
460
+ sessionId: this.sessionId,
461
+ inScene,
462
+ position: pos ? { x: Math.round(pos.x), z: Math.round(pos.z) } : null
323
463
  })
324
464
  });
325
- this.client.log("Session heartbeat sent");
465
+ this.client.log(`Session heartbeat sent (inScene: ${inScene})`);
326
466
  } catch (error) {
327
467
  this.client.log(`Session heartbeat failed: ${error}`);
328
468
  }
@@ -442,9 +582,14 @@ var HeartbeatModule = class {
442
582
  }
443
583
  /**
444
584
  * Send a watching heartbeat (1 heartbeat = 1 minute watched)
585
+ * Only sends if player is within scene bounds
445
586
  */
446
587
  async sendHeartbeat() {
447
588
  if (!this.currentChannel || !this.isWatching) return;
589
+ if (this.client.session && !this.client.session.isPlayerInScene()) {
590
+ this.client.log(`Watch heartbeat skipped - player out of scene bounds`);
591
+ return;
592
+ }
448
593
  try {
449
594
  const sessionId = this.client.session?.getSessionId();
450
595
  await this.client.request("/heartbeat", {
@@ -2270,7 +2415,7 @@ import { movePlayerTo, openExternalUrl as openExternalUrl3 } from "~system/Restr
2270
2415
 
2271
2416
  // src/ui/ui-renderer.tsx
2272
2417
  import ReactEcs4, { ReactEcsRenderer, UiEntity as UiEntity4, Label as Label4 } from "@dcl/sdk/react-ecs";
2273
- import { engine as engine2 } from "@dcl/sdk/ecs";
2418
+ import { engine as engine3 } from "@dcl/sdk/ecs";
2274
2419
  var notificationText = "";
2275
2420
  var notificationVisible = false;
2276
2421
  var notificationEndTime = 0;
@@ -2291,7 +2436,7 @@ function getNotificationState() {
2291
2436
  function initNotificationSystem() {
2292
2437
  if (notificationInitialized) return;
2293
2438
  notificationInitialized = true;
2294
- engine2.addSystem(() => {
2439
+ engine3.addSystem(() => {
2295
2440
  if (notificationVisible && Date.now() > notificationEndTime) {
2296
2441
  notificationVisible = false;
2297
2442
  }
@@ -4450,8 +4595,11 @@ export {
4450
4595
  StaticTVClient,
4451
4596
  fetchUserData,
4452
4597
  getPlayerDisplayName,
4598
+ getPlayerPosition,
4453
4599
  getPlayerWallet,
4600
+ getSceneBounds,
4454
4601
  hideNotification,
4602
+ isPlayerInBounds,
4455
4603
  setupStaticUI,
4456
4604
  showNotification
4457
4605
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thestatic-tv/dcl-sdk",
3
- "version": "2.5.17",
3
+ "version": "2.5.18",
4
4
  "description": "Connect your Decentraland scene to thestatic.tv - full channel lineup, metrics tracking, and interactions",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",