@playruo/embed 1.3.1 → 1.3.2

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/README.md CHANGED
@@ -1,3 +1,12 @@
1
1
  # @playruo/embed
2
2
 
3
3
  React SDK to embed Playruo via iframe + postMessage.
4
+
5
+ ## Event forwarding
6
+
7
+ When the embedded Playruo iframe sends `{ type: "playruo_event", data }`, the
8
+ React package forwards the payload through `onEvent(data)`.
9
+
10
+ If the package host is itself inside another iframe, the same message is also
11
+ forwarded one level up with `window.parent.postMessage({ type:
12
+ "playruo_event", data }, "*")`.
package/dist/index.cjs CHANGED
@@ -74,14 +74,18 @@ var PlayruoEmbed = ({
74
74
  onReady,
75
75
  onStateChange,
76
76
  onDemoCompleted,
77
+ onEvent,
77
78
  onPurchaseRequest,
78
79
  onError,
80
+ forwardEventsToParent = true,
79
81
  renderState,
80
82
  iframeRef: externalIframeRef
81
83
  }) => {
82
84
  const internalIframeRef = (0, import_react.useRef)(null);
83
85
  const iframeRef = externalIframeRef || internalIframeRef;
84
86
  const wasEmbedFullscreenRef = (0, import_react.useRef)(false);
87
+ const hasNotifiedCompletionRef = (0, import_react.useRef)(false);
88
+ const suppressNextFullscreenExitCleanupRef = (0, import_react.useRef)(false);
85
89
  const [isIframeLoaded, setIsIframeLoaded] = (0, import_react.useState)(false);
86
90
  const [hasPlayedOnce, setHasPlayedOnce] = (0, import_react.useState)(false);
87
91
  const [showGameIframe, setShowGameIframe] = (0, import_react.useState)(false);
@@ -151,6 +155,149 @@ var PlayruoEmbed = ({
151
155
  console.warn("[PlayruoEmbed] Failed to release Escape key lock:", error);
152
156
  }
153
157
  }, [getKeyboardApi]);
158
+ const getFullscreenElement = (0, import_react.useCallback)(() => {
159
+ const doc = document;
160
+ return doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement || null;
161
+ }, []);
162
+ const notifyDemoCompletedOnce = (0, import_react.useCallback)(
163
+ (payload) => {
164
+ if (hasNotifiedCompletionRef.current) {
165
+ return;
166
+ }
167
+ hasNotifiedCompletionRef.current = true;
168
+ onDemoCompleted?.(
169
+ payload ?? {
170
+ reason: "Demo session ended",
171
+ storeUrl
172
+ }
173
+ );
174
+ },
175
+ [onDemoCompleted, storeUrl]
176
+ );
177
+ const postExitFullscreenToIframe = (0, import_react.useCallback)(() => {
178
+ const iframe = iframeRef.current;
179
+ if (!iframe) {
180
+ return;
181
+ }
182
+ try {
183
+ iframe.contentWindow?.postMessage({
184
+ type: "playruo",
185
+ action: "exitFullscreen",
186
+ fromEmbed: true,
187
+ isFullscreen: false,
188
+ parentWillHandleFullscreen: true
189
+ }, "*");
190
+ } catch {
191
+ console.error("[PlayruoEmbed] Failed to exit fullscreen");
192
+ }
193
+ }, [iframeRef]);
194
+ const postExitFullscreenAckToIframe = (0, import_react.useCallback)(() => {
195
+ const iframe = iframeRef.current;
196
+ if (!iframe) {
197
+ return;
198
+ }
199
+ try {
200
+ iframe.contentWindow?.postMessage({ type: "ACK_EXIT_FULLSCREEN" }, "*");
201
+ } catch {
202
+ console.error("[PlayruoEmbed] Failed to acknowledge fullscreen exit");
203
+ }
204
+ }, [iframeRef]);
205
+ const exitEmbedFullscreen = (0, import_react.useCallback)(
206
+ async ({
207
+ reason = "cleanup",
208
+ suppressFullscreenExitCleanup = true
209
+ } = {}) => {
210
+ const fullscreenElement = getFullscreenElement();
211
+ const doc = document;
212
+ const exitFullscreen = doc.exitFullscreen?.bind(doc) ?? doc.webkitExitFullscreen?.bind(doc) ?? doc.mozCancelFullScreen?.bind(doc) ?? doc.msExitFullscreen?.bind(doc);
213
+ const shouldAttemptExit = Boolean(fullscreenElement || wasEmbedFullscreenRef.current);
214
+ if (!shouldAttemptExit || !exitFullscreen) {
215
+ console.log("[PlayruoEmbed] Parent fullscreen exit skipped", {
216
+ hasFullscreenElement: Boolean(fullscreenElement),
217
+ hasExitFullscreen: Boolean(exitFullscreen),
218
+ reason,
219
+ wasEmbedFullscreen: wasEmbedFullscreenRef.current
220
+ });
221
+ return false;
222
+ }
223
+ console.log("[PlayruoEmbed] Parent fullscreen exit requested", {
224
+ fullscreenElement: fullscreenElement?.tagName,
225
+ reason,
226
+ wasEmbedFullscreen: wasEmbedFullscreenRef.current
227
+ });
228
+ const shouldSuppressFullscreenExitCleanup = Boolean(
229
+ suppressFullscreenExitCleanup && fullscreenElement
230
+ );
231
+ if (shouldSuppressFullscreenExitCleanup) {
232
+ suppressNextFullscreenExitCleanupRef.current = true;
233
+ wasEmbedFullscreenRef.current = false;
234
+ }
235
+ try {
236
+ await exitFullscreen();
237
+ if (shouldSuppressFullscreenExitCleanup) {
238
+ window.setTimeout(() => {
239
+ suppressNextFullscreenExitCleanupRef.current = false;
240
+ }, 1e3);
241
+ }
242
+ return true;
243
+ } catch (error) {
244
+ if (shouldSuppressFullscreenExitCleanup) {
245
+ suppressNextFullscreenExitCleanupRef.current = false;
246
+ wasEmbedFullscreenRef.current = Boolean(getFullscreenElement());
247
+ }
248
+ console.warn("[PlayruoEmbed] Failed to leave fullscreen:", error);
249
+ return false;
250
+ }
251
+ },
252
+ [getFullscreenElement]
253
+ );
254
+ const cleanupEmbedSession = (0, import_react.useCallback)(
255
+ ({
256
+ payload,
257
+ notifyCompletion = true,
258
+ requestFullscreenExit = true,
259
+ notifyIframe = false,
260
+ markPlayed = true
261
+ } = {}) => {
262
+ setShowGameIframe(false);
263
+ if (markPlayed) {
264
+ setHasPlayedOnce(true);
265
+ }
266
+ releaseEscapeKeyLock();
267
+ if (notifyIframe) {
268
+ postExitFullscreenToIframe();
269
+ }
270
+ if (requestFullscreenExit) {
271
+ void exitEmbedFullscreen();
272
+ }
273
+ if (notifyCompletion) {
274
+ notifyDemoCompletedOnce(payload);
275
+ }
276
+ },
277
+ [
278
+ exitEmbedFullscreen,
279
+ notifyDemoCompletedOnce,
280
+ postExitFullscreenToIframe,
281
+ releaseEscapeKeyLock
282
+ ]
283
+ );
284
+ const markStreamActive = (0, import_react.useCallback)(() => {
285
+ hasNotifiedCompletionRef.current = false;
286
+ setShowGameIframe(true);
287
+ }, []);
288
+ const forwardEventToParent = (0, import_react.useCallback)(
289
+ (message) => {
290
+ if (!forwardEventsToParent || typeof window === "undefined" || window.parent === window) {
291
+ return;
292
+ }
293
+ try {
294
+ window.parent.postMessage(message, "*");
295
+ } catch (error) {
296
+ console.warn("[PlayruoEmbed] Failed to forward event to parent:", error);
297
+ }
298
+ },
299
+ [forwardEventsToParent]
300
+ );
154
301
  const lockEscapeKey = (0, import_react.useCallback)(async () => {
155
302
  const keyboard = getKeyboardApi();
156
303
  if (!keyboard?.lock) {
@@ -187,6 +334,7 @@ var PlayruoEmbed = ({
187
334
  console.warn("[PlayruoEmbed] Iframe not ready");
188
335
  return;
189
336
  }
337
+ hasNotifiedCompletionRef.current = false;
190
338
  setShowGameIframe(true);
191
339
  try {
192
340
  try {
@@ -210,6 +358,7 @@ var PlayruoEmbed = ({
210
358
  setHasPlayedOnce(true);
211
359
  } catch (error) {
212
360
  console.error("[PlayruoEmbed] Failed to start game:", error);
361
+ setShowGameIframe(false);
213
362
  setErrorMessage("Failed to start game");
214
363
  onError?.("Failed to start game");
215
364
  }
@@ -233,7 +382,8 @@ var PlayruoEmbed = ({
233
382
  }, [isReady, onReady]);
234
383
  (0, import_react.useEffect)(() => {
235
384
  const handleGameMessage = (event) => {
236
- const { type, data } = event.data || {};
385
+ const message = event.data || {};
386
+ const { type, data } = message;
237
387
  const iframe = iframeRef.current;
238
388
  if (!iframe || event.source !== iframe.contentWindow) {
239
389
  return;
@@ -254,8 +404,7 @@ var PlayruoEmbed = ({
254
404
  onPurchaseRequest?.(data);
255
405
  } else if (type === "playruo_demo_completed" && data) {
256
406
  console.log("\u{1F537} [Embed] playruo_demo_completed received:", data);
257
- setHasPlayedOnce(true);
258
- onDemoCompleted?.(data);
407
+ cleanupEmbedSession({ payload: data });
259
408
  } else if (type === "playruo_error") {
260
409
  const errorMessage2 = typeof data === "string" ? data : data?.message || "Unknown error";
261
410
  console.log("\u{1F537} [Embed] playruo_error received:", errorMessage2);
@@ -276,25 +425,65 @@ var PlayruoEmbed = ({
276
425
  onError?.(errorMessage2);
277
426
  } else if (type === "playruo_session_over" && data) {
278
427
  console.log("\u{1F537} [Embed] playruo_session_over received:", data);
279
- setHasPlayedOnce(true);
428
+ void exitEmbedFullscreen({ reason: "playruo_session_over" });
280
429
  setIframeState(data);
281
430
  onStateChange?.(data);
431
+ cleanupEmbedSession({ requestFullscreenExit: false });
282
432
  } else if (type === "playruo_stream_start") {
283
433
  console.log("\u{1F537} [Embed] playruo_stream_start received");
434
+ markStreamActive();
435
+ } else if (type === "playruo_stream_stop") {
436
+ console.log("\u{1F537} [Embed] playruo_stream_stop received");
437
+ void exitEmbedFullscreen({ reason: "playruo_stream_stop" });
438
+ cleanupEmbedSession({
439
+ markPlayed: false,
440
+ notifyCompletion: false,
441
+ requestFullscreenExit: false
442
+ });
443
+ } else if (type === "REQUEST_EXIT_FULLSCREEN") {
444
+ console.log("\u{1F537} [Embed] REQUEST_EXIT_FULLSCREEN received");
445
+ void exitEmbedFullscreen({
446
+ reason: "REQUEST_EXIT_FULLSCREEN",
447
+ suppressFullscreenExitCleanup: false
448
+ }).finally(postExitFullscreenAckToIframe);
449
+ } else if (type === "playruo_event" && data && typeof data === "object") {
450
+ console.log("\u{1F537} [Embed] playruo_event received:", data);
451
+ const eventPayload = data;
452
+ onEvent?.(eventPayload);
453
+ forwardEventToParent({ type: "playruo_event", data: eventPayload });
454
+ } else if (type === "playruo" && message.action === "event" && message.payload && typeof message.payload === "object") {
455
+ console.log("\u{1F537} [Embed] legacy playruo event received:", message.payload);
456
+ const eventPayload = message.payload;
457
+ onEvent?.(eventPayload);
458
+ forwardEventToParent({ type: "playruo_event", data: eventPayload });
284
459
  }
285
460
  if (type === "error" || type === "closeForBuy" || type === "goToStore") {
286
461
  console.log("\u{1F537} [Embed] Legacy message received:", type);
287
- setHasPlayedOnce(true);
288
- const payload = {
289
- reason: "Demo session ended",
290
- storeUrl
291
- };
292
- onDemoCompleted?.(payload);
462
+ cleanupEmbedSession({
463
+ payload: {
464
+ reason: "Demo session ended",
465
+ storeUrl
466
+ }
467
+ });
293
468
  }
294
469
  };
295
470
  window.addEventListener("message", handleGameMessage);
296
471
  return () => window.removeEventListener("message", handleGameMessage);
297
- }, [storeUrl, onDemoCompleted, onReady, onStateChange, onPurchaseRequest, onError, iframeRef]);
472
+ }, [
473
+ cleanupEmbedSession,
474
+ exitEmbedFullscreen,
475
+ forwardEventToParent,
476
+ hasPlayedOnce,
477
+ iframeRef,
478
+ markStreamActive,
479
+ onEvent,
480
+ onError,
481
+ onPurchaseRequest,
482
+ onReady,
483
+ onStateChange,
484
+ postExitFullscreenAckToIframe,
485
+ storeUrl
486
+ ]);
298
487
  const generateUUID = () => {
299
488
  if (self.crypto.randomUUID) return self.crypto.randomUUID();
300
489
  const bytes = new Uint8Array(16);
@@ -341,9 +530,8 @@ var PlayruoEmbed = ({
341
530
  }, [iframeRef]);
342
531
  (0, import_react.useEffect)(() => {
343
532
  const handleFullscreenChange = () => {
344
- const doc = document;
345
533
  const iframe = iframeRef.current;
346
- const fullscreenElement = doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement || null;
534
+ const fullscreenElement = getFullscreenElement();
347
535
  const isEmbedFullscreen = Boolean(iframe && fullscreenElement === iframe);
348
536
  if (isEmbedFullscreen) {
349
537
  wasEmbedFullscreenRef.current = true;
@@ -360,26 +548,15 @@ var PlayruoEmbed = ({
360
548
  return;
361
549
  }
362
550
  releaseEscapeKeyLock();
551
+ if (suppressNextFullscreenExitCleanupRef.current) {
552
+ suppressNextFullscreenExitCleanupRef.current = false;
553
+ return;
554
+ }
363
555
  if (wasEmbedFullscreenRef.current) {
364
556
  wasEmbedFullscreenRef.current = false;
365
- if (iframe) {
366
- try {
367
- iframe.contentWindow?.postMessage({
368
- type: "playruo",
369
- action: "exitFullscreen",
370
- fromEmbed: true,
371
- isFullscreen: false,
372
- parentWillHandleFullscreen: true
373
- }, "*");
374
- } catch {
375
- console.error("[PlayruoEmbed] Failed to exit fullscreen");
376
- }
377
- }
378
- setShowGameIframe(false);
379
- setHasPlayedOnce(true);
380
- onDemoCompleted?.({
381
- reason: "Demo session ended",
382
- storeUrl
557
+ cleanupEmbedSession({
558
+ notifyIframe: true,
559
+ requestFullscreenExit: false
383
560
  });
384
561
  }
385
562
  };
@@ -393,7 +570,13 @@ var PlayruoEmbed = ({
393
570
  document.removeEventListener("mozfullscreenchange", handleFullscreenChange);
394
571
  document.removeEventListener("MSFullscreenChange", handleFullscreenChange);
395
572
  };
396
- }, [storeUrl, onDemoCompleted, iframeRef, lockEscapeKey, releaseEscapeKeyLock]);
573
+ }, [
574
+ cleanupEmbedSession,
575
+ getFullscreenElement,
576
+ iframeRef,
577
+ lockEscapeKey,
578
+ releaseEscapeKeyLock
579
+ ]);
397
580
  (0, import_react.useEffect)(() => {
398
581
  return () => {
399
582
  releaseEscapeKeyLock();
@@ -478,6 +661,8 @@ var PlayruoEmbed = ({
478
661
  }
479
662
  }, [isReady]);
480
663
  const isAvailable = Boolean(isReady && isCompatible && hasIframeConfirmed);
664
+ const shouldDisplayGameIframe = showGameIframe;
665
+ const shouldShowIframeMisuseMessage = Boolean(showIframe && !showGameIframe);
481
666
  const renderContent = () => {
482
667
  if (!renderState) return null;
483
668
  const canTryNow = Boolean(
@@ -507,6 +692,26 @@ var PlayruoEmbed = ({
507
692
  };
508
693
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className, children: [
509
694
  renderContent(),
695
+ isCompatible && shouldShowIframeMisuseMessage && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
696
+ "div",
697
+ {
698
+ role: "status",
699
+ style: {
700
+ alignItems: "center",
701
+ background: "#000",
702
+ color: "#fff",
703
+ display: "flex",
704
+ fontFamily: "system-ui, sans-serif",
705
+ fontSize: 14,
706
+ height: "100%",
707
+ justifyContent: "center",
708
+ padding: 24,
709
+ textAlign: "center",
710
+ width: "100%"
711
+ },
712
+ children: "This iframe is only meant to be displayed through PlayruoEmbed during an active stream."
713
+ }
714
+ ),
510
715
  isCompatible && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
511
716
  "iframe",
512
717
  {
@@ -519,9 +724,9 @@ var PlayruoEmbed = ({
519
724
  title: "Playruo",
520
725
  referrerPolicy: "strict-origin-when-cross-origin",
521
726
  style: {
522
- // Only mounted when compatible; visible after click (internal) or if parent explicitly asks
523
- opacity: showGameIframe || showIframe ? 1 : 0,
524
- pointerEvents: showGameIframe || showIframe ? "auto" : "none",
727
+ // The iframe stays mounted for readiness checks, visible only during launch/play, and hidden after terminal events.
728
+ opacity: shouldDisplayGameIframe ? 1 : 0,
729
+ pointerEvents: shouldDisplayGameIframe ? "auto" : "none",
525
730
  width: "100%",
526
731
  height: "100%",
527
732
  border: 0
@@ -680,6 +885,13 @@ var EmbedTester = (props = {}) => {
680
885
  console.error("Embed error:", error);
681
886
  }
682
887
  }, [addEvent, enableConsoleLog]);
888
+ const handleEvent = (0, import_react2.useCallback)((data) => {
889
+ const eventName = typeof data.event === "string" ? data.event : typeof data.name === "string" ? data.name : "unknown";
890
+ addEvent(`Event received: ${eventName}`);
891
+ if (enableConsoleLog) {
892
+ console.log("Embed event:", data);
893
+ }
894
+ }, [addEvent, enableConsoleLog]);
683
895
  const handlePurchaseRequest = (0, import_react2.useCallback)((data) => {
684
896
  addEvent(`\u{1F4B3} Explicit purchase request from Buy Now button`);
685
897
  addEvent(`Opening store: ${data.storeUrl}`);
@@ -1627,6 +1839,7 @@ var EmbedTester = (props = {}) => {
1627
1839
  iframeRef,
1628
1840
  onDemoCompleted: handleDemoCompleted,
1629
1841
  onError: handleError,
1842
+ onEvent: handleEvent,
1630
1843
  onPurchaseRequest: handlePurchaseRequest,
1631
1844
  onReady: handleReady,
1632
1845
  onStateChange: handleStateChange,
@@ -1754,7 +1967,7 @@ var isPlayruoEmbedMessage = (data) => {
1754
1967
  if (!data || typeof data !== "object") return false;
1755
1968
  const msg = data;
1756
1969
  if (typeof msg.type !== "string") return false;
1757
- return msg.type === "playruo_ready" || msg.type === "playruo_state_update" || msg.type === "playruo_purchase_request" || msg.type === "playruo_demo_completed" || msg.type === "playruo_error" || msg.type === "playruo_session_over" || msg.type === "playruo_stream_start" || msg.type === "playruo_stream_stop";
1970
+ return msg.type === "playruo_ready" || msg.type === "playruo_state_update" || msg.type === "playruo_purchase_request" || msg.type === "playruo_demo_completed" || msg.type === "playruo_error" || msg.type === "playruo_session_over" || msg.type === "playruo_stream_start" || msg.type === "playruo_stream_stop" || msg.type === "REQUEST_EXIT_FULLSCREEN" || msg.type === "playruo_event";
1758
1971
  };
1759
1972
  var isPlayruoInternalMessage = (data) => {
1760
1973
  if (!data || typeof data !== "object") return false;
package/dist/index.d.cts CHANGED
@@ -17,6 +17,12 @@ interface DemoCompletedPayload {
17
17
  reason?: string;
18
18
  storeUrl?: string;
19
19
  }
20
+ type PlayruoEventPayload = Record<string, unknown> & {
21
+ action?: string;
22
+ category?: string;
23
+ event?: string;
24
+ name?: string;
25
+ };
20
26
 
21
27
  interface PlayruoEmbedActions {
22
28
  /** Start the demo session */
@@ -44,10 +50,17 @@ interface PlayruoEmbedProps {
44
50
  onReady?: () => void;
45
51
  onStateChange?: (state: PlayruoEmbedState) => void;
46
52
  onDemoCompleted?: (data: DemoCompletedPayload) => void;
53
+ onEvent?: (data: PlayruoEventPayload) => void;
47
54
  onPurchaseRequest?: (data: {
48
55
  storeUrl: string;
49
56
  }) => void;
50
57
  onError?: (error: string) => void;
58
+ /**
59
+ * Forward playruo_event messages to the parent window when the package host
60
+ * itself is embedded in another iframe. Direct host pages already receive the
61
+ * original iframe postMessage.
62
+ */
63
+ forwardEventsToParent?: boolean;
51
64
  /**
52
65
  * Single render entry point representing the current status.
53
66
  * Provides status, message, and available actions based on current state.
@@ -103,7 +116,14 @@ interface PlayruoStreamStartMessage {
103
116
  interface PlayruoStreamStopMessage {
104
117
  type: "playruo_stream_stop";
105
118
  }
106
- type PlayruoEmbedMessage = PlayruoReadyMessage | PlayruoStateUpdateMessage | PlayruoPurchaseRequestMessage | PlayruoDemoCompletedMessage | PlayruoErrorMessage | PlayruoSessionOverMessage | PlayruoStreamStartMessage | PlayruoStreamStopMessage;
119
+ interface PlayruoRequestExitFullscreenMessage {
120
+ type: "REQUEST_EXIT_FULLSCREEN";
121
+ }
122
+ interface PlayruoEventMessage {
123
+ type: "playruo_event";
124
+ data: PlayruoEventPayload;
125
+ }
126
+ type PlayruoEmbedMessage = PlayruoReadyMessage | PlayruoStateUpdateMessage | PlayruoPurchaseRequestMessage | PlayruoDemoCompletedMessage | PlayruoErrorMessage | PlayruoSessionOverMessage | PlayruoStreamStartMessage | PlayruoStreamStopMessage | PlayruoRequestExitFullscreenMessage | PlayruoEventMessage;
107
127
  interface PlayruoInternalMessage {
108
128
  type: "playruo";
109
129
  action: "tryNowClick" | "exitFullscreen" | "mute" | "unmute";
@@ -136,4 +156,4 @@ interface BuildUrlParams {
136
156
  */
137
157
  declare const buildEmbedUrl: ({ operation, storeUrl }: BuildUrlParams) => string;
138
158
 
139
- export { type DemoCompletedPayload, EmbedTester, type EmbedTesterProps, type PlayruoDemoCompletedMessage, PlayruoEmbed, type PlayruoEmbedActions, type PlayruoEmbedMessage, type PlayruoEmbedProps, type PlayruoEmbedRenderState, type PlayruoEmbedState, type PlayruoErrorMessage, type PlayruoInternalMessage, type PlayruoPurchaseRequestMessage, type PlayruoReadyMessage, type PlayruoSessionOverMessage, type PlayruoStateUpdateMessage, type PlayruoStreamStartMessage, buildEmbedUrl, isPlayruoEmbedMessage, isPlayruoInternalMessage };
159
+ export { type DemoCompletedPayload, EmbedTester, type EmbedTesterProps, type PlayruoDemoCompletedMessage, PlayruoEmbed, type PlayruoEmbedActions, type PlayruoEmbedMessage, type PlayruoEmbedProps, type PlayruoEmbedRenderState, type PlayruoEmbedState, type PlayruoErrorMessage, type PlayruoEventMessage, type PlayruoEventPayload, type PlayruoInternalMessage, type PlayruoPurchaseRequestMessage, type PlayruoReadyMessage, type PlayruoRequestExitFullscreenMessage, type PlayruoSessionOverMessage, type PlayruoStateUpdateMessage, type PlayruoStreamStartMessage, type PlayruoStreamStopMessage, buildEmbedUrl, isPlayruoEmbedMessage, isPlayruoInternalMessage };
package/dist/index.d.ts CHANGED
@@ -17,6 +17,12 @@ interface DemoCompletedPayload {
17
17
  reason?: string;
18
18
  storeUrl?: string;
19
19
  }
20
+ type PlayruoEventPayload = Record<string, unknown> & {
21
+ action?: string;
22
+ category?: string;
23
+ event?: string;
24
+ name?: string;
25
+ };
20
26
 
21
27
  interface PlayruoEmbedActions {
22
28
  /** Start the demo session */
@@ -44,10 +50,17 @@ interface PlayruoEmbedProps {
44
50
  onReady?: () => void;
45
51
  onStateChange?: (state: PlayruoEmbedState) => void;
46
52
  onDemoCompleted?: (data: DemoCompletedPayload) => void;
53
+ onEvent?: (data: PlayruoEventPayload) => void;
47
54
  onPurchaseRequest?: (data: {
48
55
  storeUrl: string;
49
56
  }) => void;
50
57
  onError?: (error: string) => void;
58
+ /**
59
+ * Forward playruo_event messages to the parent window when the package host
60
+ * itself is embedded in another iframe. Direct host pages already receive the
61
+ * original iframe postMessage.
62
+ */
63
+ forwardEventsToParent?: boolean;
51
64
  /**
52
65
  * Single render entry point representing the current status.
53
66
  * Provides status, message, and available actions based on current state.
@@ -103,7 +116,14 @@ interface PlayruoStreamStartMessage {
103
116
  interface PlayruoStreamStopMessage {
104
117
  type: "playruo_stream_stop";
105
118
  }
106
- type PlayruoEmbedMessage = PlayruoReadyMessage | PlayruoStateUpdateMessage | PlayruoPurchaseRequestMessage | PlayruoDemoCompletedMessage | PlayruoErrorMessage | PlayruoSessionOverMessage | PlayruoStreamStartMessage | PlayruoStreamStopMessage;
119
+ interface PlayruoRequestExitFullscreenMessage {
120
+ type: "REQUEST_EXIT_FULLSCREEN";
121
+ }
122
+ interface PlayruoEventMessage {
123
+ type: "playruo_event";
124
+ data: PlayruoEventPayload;
125
+ }
126
+ type PlayruoEmbedMessage = PlayruoReadyMessage | PlayruoStateUpdateMessage | PlayruoPurchaseRequestMessage | PlayruoDemoCompletedMessage | PlayruoErrorMessage | PlayruoSessionOverMessage | PlayruoStreamStartMessage | PlayruoStreamStopMessage | PlayruoRequestExitFullscreenMessage | PlayruoEventMessage;
107
127
  interface PlayruoInternalMessage {
108
128
  type: "playruo";
109
129
  action: "tryNowClick" | "exitFullscreen" | "mute" | "unmute";
@@ -136,4 +156,4 @@ interface BuildUrlParams {
136
156
  */
137
157
  declare const buildEmbedUrl: ({ operation, storeUrl }: BuildUrlParams) => string;
138
158
 
139
- export { type DemoCompletedPayload, EmbedTester, type EmbedTesterProps, type PlayruoDemoCompletedMessage, PlayruoEmbed, type PlayruoEmbedActions, type PlayruoEmbedMessage, type PlayruoEmbedProps, type PlayruoEmbedRenderState, type PlayruoEmbedState, type PlayruoErrorMessage, type PlayruoInternalMessage, type PlayruoPurchaseRequestMessage, type PlayruoReadyMessage, type PlayruoSessionOverMessage, type PlayruoStateUpdateMessage, type PlayruoStreamStartMessage, buildEmbedUrl, isPlayruoEmbedMessage, isPlayruoInternalMessage };
159
+ export { type DemoCompletedPayload, EmbedTester, type EmbedTesterProps, type PlayruoDemoCompletedMessage, PlayruoEmbed, type PlayruoEmbedActions, type PlayruoEmbedMessage, type PlayruoEmbedProps, type PlayruoEmbedRenderState, type PlayruoEmbedState, type PlayruoErrorMessage, type PlayruoEventMessage, type PlayruoEventPayload, type PlayruoInternalMessage, type PlayruoPurchaseRequestMessage, type PlayruoReadyMessage, type PlayruoRequestExitFullscreenMessage, type PlayruoSessionOverMessage, type PlayruoStateUpdateMessage, type PlayruoStreamStartMessage, type PlayruoStreamStopMessage, buildEmbedUrl, isPlayruoEmbedMessage, isPlayruoInternalMessage };
package/dist/index.js CHANGED
@@ -34,14 +34,18 @@ var PlayruoEmbed = ({
34
34
  onReady,
35
35
  onStateChange,
36
36
  onDemoCompleted,
37
+ onEvent,
37
38
  onPurchaseRequest,
38
39
  onError,
40
+ forwardEventsToParent = true,
39
41
  renderState,
40
42
  iframeRef: externalIframeRef
41
43
  }) => {
42
44
  const internalIframeRef = useRef(null);
43
45
  const iframeRef = externalIframeRef || internalIframeRef;
44
46
  const wasEmbedFullscreenRef = useRef(false);
47
+ const hasNotifiedCompletionRef = useRef(false);
48
+ const suppressNextFullscreenExitCleanupRef = useRef(false);
45
49
  const [isIframeLoaded, setIsIframeLoaded] = useState(false);
46
50
  const [hasPlayedOnce, setHasPlayedOnce] = useState(false);
47
51
  const [showGameIframe, setShowGameIframe] = useState(false);
@@ -111,6 +115,149 @@ var PlayruoEmbed = ({
111
115
  console.warn("[PlayruoEmbed] Failed to release Escape key lock:", error);
112
116
  }
113
117
  }, [getKeyboardApi]);
118
+ const getFullscreenElement = useCallback(() => {
119
+ const doc = document;
120
+ return doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement || null;
121
+ }, []);
122
+ const notifyDemoCompletedOnce = useCallback(
123
+ (payload) => {
124
+ if (hasNotifiedCompletionRef.current) {
125
+ return;
126
+ }
127
+ hasNotifiedCompletionRef.current = true;
128
+ onDemoCompleted?.(
129
+ payload ?? {
130
+ reason: "Demo session ended",
131
+ storeUrl
132
+ }
133
+ );
134
+ },
135
+ [onDemoCompleted, storeUrl]
136
+ );
137
+ const postExitFullscreenToIframe = useCallback(() => {
138
+ const iframe = iframeRef.current;
139
+ if (!iframe) {
140
+ return;
141
+ }
142
+ try {
143
+ iframe.contentWindow?.postMessage({
144
+ type: "playruo",
145
+ action: "exitFullscreen",
146
+ fromEmbed: true,
147
+ isFullscreen: false,
148
+ parentWillHandleFullscreen: true
149
+ }, "*");
150
+ } catch {
151
+ console.error("[PlayruoEmbed] Failed to exit fullscreen");
152
+ }
153
+ }, [iframeRef]);
154
+ const postExitFullscreenAckToIframe = useCallback(() => {
155
+ const iframe = iframeRef.current;
156
+ if (!iframe) {
157
+ return;
158
+ }
159
+ try {
160
+ iframe.contentWindow?.postMessage({ type: "ACK_EXIT_FULLSCREEN" }, "*");
161
+ } catch {
162
+ console.error("[PlayruoEmbed] Failed to acknowledge fullscreen exit");
163
+ }
164
+ }, [iframeRef]);
165
+ const exitEmbedFullscreen = useCallback(
166
+ async ({
167
+ reason = "cleanup",
168
+ suppressFullscreenExitCleanup = true
169
+ } = {}) => {
170
+ const fullscreenElement = getFullscreenElement();
171
+ const doc = document;
172
+ const exitFullscreen = doc.exitFullscreen?.bind(doc) ?? doc.webkitExitFullscreen?.bind(doc) ?? doc.mozCancelFullScreen?.bind(doc) ?? doc.msExitFullscreen?.bind(doc);
173
+ const shouldAttemptExit = Boolean(fullscreenElement || wasEmbedFullscreenRef.current);
174
+ if (!shouldAttemptExit || !exitFullscreen) {
175
+ console.log("[PlayruoEmbed] Parent fullscreen exit skipped", {
176
+ hasFullscreenElement: Boolean(fullscreenElement),
177
+ hasExitFullscreen: Boolean(exitFullscreen),
178
+ reason,
179
+ wasEmbedFullscreen: wasEmbedFullscreenRef.current
180
+ });
181
+ return false;
182
+ }
183
+ console.log("[PlayruoEmbed] Parent fullscreen exit requested", {
184
+ fullscreenElement: fullscreenElement?.tagName,
185
+ reason,
186
+ wasEmbedFullscreen: wasEmbedFullscreenRef.current
187
+ });
188
+ const shouldSuppressFullscreenExitCleanup = Boolean(
189
+ suppressFullscreenExitCleanup && fullscreenElement
190
+ );
191
+ if (shouldSuppressFullscreenExitCleanup) {
192
+ suppressNextFullscreenExitCleanupRef.current = true;
193
+ wasEmbedFullscreenRef.current = false;
194
+ }
195
+ try {
196
+ await exitFullscreen();
197
+ if (shouldSuppressFullscreenExitCleanup) {
198
+ window.setTimeout(() => {
199
+ suppressNextFullscreenExitCleanupRef.current = false;
200
+ }, 1e3);
201
+ }
202
+ return true;
203
+ } catch (error) {
204
+ if (shouldSuppressFullscreenExitCleanup) {
205
+ suppressNextFullscreenExitCleanupRef.current = false;
206
+ wasEmbedFullscreenRef.current = Boolean(getFullscreenElement());
207
+ }
208
+ console.warn("[PlayruoEmbed] Failed to leave fullscreen:", error);
209
+ return false;
210
+ }
211
+ },
212
+ [getFullscreenElement]
213
+ );
214
+ const cleanupEmbedSession = useCallback(
215
+ ({
216
+ payload,
217
+ notifyCompletion = true,
218
+ requestFullscreenExit = true,
219
+ notifyIframe = false,
220
+ markPlayed = true
221
+ } = {}) => {
222
+ setShowGameIframe(false);
223
+ if (markPlayed) {
224
+ setHasPlayedOnce(true);
225
+ }
226
+ releaseEscapeKeyLock();
227
+ if (notifyIframe) {
228
+ postExitFullscreenToIframe();
229
+ }
230
+ if (requestFullscreenExit) {
231
+ void exitEmbedFullscreen();
232
+ }
233
+ if (notifyCompletion) {
234
+ notifyDemoCompletedOnce(payload);
235
+ }
236
+ },
237
+ [
238
+ exitEmbedFullscreen,
239
+ notifyDemoCompletedOnce,
240
+ postExitFullscreenToIframe,
241
+ releaseEscapeKeyLock
242
+ ]
243
+ );
244
+ const markStreamActive = useCallback(() => {
245
+ hasNotifiedCompletionRef.current = false;
246
+ setShowGameIframe(true);
247
+ }, []);
248
+ const forwardEventToParent = useCallback(
249
+ (message) => {
250
+ if (!forwardEventsToParent || typeof window === "undefined" || window.parent === window) {
251
+ return;
252
+ }
253
+ try {
254
+ window.parent.postMessage(message, "*");
255
+ } catch (error) {
256
+ console.warn("[PlayruoEmbed] Failed to forward event to parent:", error);
257
+ }
258
+ },
259
+ [forwardEventsToParent]
260
+ );
114
261
  const lockEscapeKey = useCallback(async () => {
115
262
  const keyboard = getKeyboardApi();
116
263
  if (!keyboard?.lock) {
@@ -147,6 +294,7 @@ var PlayruoEmbed = ({
147
294
  console.warn("[PlayruoEmbed] Iframe not ready");
148
295
  return;
149
296
  }
297
+ hasNotifiedCompletionRef.current = false;
150
298
  setShowGameIframe(true);
151
299
  try {
152
300
  try {
@@ -170,6 +318,7 @@ var PlayruoEmbed = ({
170
318
  setHasPlayedOnce(true);
171
319
  } catch (error) {
172
320
  console.error("[PlayruoEmbed] Failed to start game:", error);
321
+ setShowGameIframe(false);
173
322
  setErrorMessage("Failed to start game");
174
323
  onError?.("Failed to start game");
175
324
  }
@@ -193,7 +342,8 @@ var PlayruoEmbed = ({
193
342
  }, [isReady, onReady]);
194
343
  useEffect(() => {
195
344
  const handleGameMessage = (event) => {
196
- const { type, data } = event.data || {};
345
+ const message = event.data || {};
346
+ const { type, data } = message;
197
347
  const iframe = iframeRef.current;
198
348
  if (!iframe || event.source !== iframe.contentWindow) {
199
349
  return;
@@ -214,8 +364,7 @@ var PlayruoEmbed = ({
214
364
  onPurchaseRequest?.(data);
215
365
  } else if (type === "playruo_demo_completed" && data) {
216
366
  console.log("\u{1F537} [Embed] playruo_demo_completed received:", data);
217
- setHasPlayedOnce(true);
218
- onDemoCompleted?.(data);
367
+ cleanupEmbedSession({ payload: data });
219
368
  } else if (type === "playruo_error") {
220
369
  const errorMessage2 = typeof data === "string" ? data : data?.message || "Unknown error";
221
370
  console.log("\u{1F537} [Embed] playruo_error received:", errorMessage2);
@@ -236,25 +385,65 @@ var PlayruoEmbed = ({
236
385
  onError?.(errorMessage2);
237
386
  } else if (type === "playruo_session_over" && data) {
238
387
  console.log("\u{1F537} [Embed] playruo_session_over received:", data);
239
- setHasPlayedOnce(true);
388
+ void exitEmbedFullscreen({ reason: "playruo_session_over" });
240
389
  setIframeState(data);
241
390
  onStateChange?.(data);
391
+ cleanupEmbedSession({ requestFullscreenExit: false });
242
392
  } else if (type === "playruo_stream_start") {
243
393
  console.log("\u{1F537} [Embed] playruo_stream_start received");
394
+ markStreamActive();
395
+ } else if (type === "playruo_stream_stop") {
396
+ console.log("\u{1F537} [Embed] playruo_stream_stop received");
397
+ void exitEmbedFullscreen({ reason: "playruo_stream_stop" });
398
+ cleanupEmbedSession({
399
+ markPlayed: false,
400
+ notifyCompletion: false,
401
+ requestFullscreenExit: false
402
+ });
403
+ } else if (type === "REQUEST_EXIT_FULLSCREEN") {
404
+ console.log("\u{1F537} [Embed] REQUEST_EXIT_FULLSCREEN received");
405
+ void exitEmbedFullscreen({
406
+ reason: "REQUEST_EXIT_FULLSCREEN",
407
+ suppressFullscreenExitCleanup: false
408
+ }).finally(postExitFullscreenAckToIframe);
409
+ } else if (type === "playruo_event" && data && typeof data === "object") {
410
+ console.log("\u{1F537} [Embed] playruo_event received:", data);
411
+ const eventPayload = data;
412
+ onEvent?.(eventPayload);
413
+ forwardEventToParent({ type: "playruo_event", data: eventPayload });
414
+ } else if (type === "playruo" && message.action === "event" && message.payload && typeof message.payload === "object") {
415
+ console.log("\u{1F537} [Embed] legacy playruo event received:", message.payload);
416
+ const eventPayload = message.payload;
417
+ onEvent?.(eventPayload);
418
+ forwardEventToParent({ type: "playruo_event", data: eventPayload });
244
419
  }
245
420
  if (type === "error" || type === "closeForBuy" || type === "goToStore") {
246
421
  console.log("\u{1F537} [Embed] Legacy message received:", type);
247
- setHasPlayedOnce(true);
248
- const payload = {
249
- reason: "Demo session ended",
250
- storeUrl
251
- };
252
- onDemoCompleted?.(payload);
422
+ cleanupEmbedSession({
423
+ payload: {
424
+ reason: "Demo session ended",
425
+ storeUrl
426
+ }
427
+ });
253
428
  }
254
429
  };
255
430
  window.addEventListener("message", handleGameMessage);
256
431
  return () => window.removeEventListener("message", handleGameMessage);
257
- }, [storeUrl, onDemoCompleted, onReady, onStateChange, onPurchaseRequest, onError, iframeRef]);
432
+ }, [
433
+ cleanupEmbedSession,
434
+ exitEmbedFullscreen,
435
+ forwardEventToParent,
436
+ hasPlayedOnce,
437
+ iframeRef,
438
+ markStreamActive,
439
+ onEvent,
440
+ onError,
441
+ onPurchaseRequest,
442
+ onReady,
443
+ onStateChange,
444
+ postExitFullscreenAckToIframe,
445
+ storeUrl
446
+ ]);
258
447
  const generateUUID = () => {
259
448
  if (self.crypto.randomUUID) return self.crypto.randomUUID();
260
449
  const bytes = new Uint8Array(16);
@@ -301,9 +490,8 @@ var PlayruoEmbed = ({
301
490
  }, [iframeRef]);
302
491
  useEffect(() => {
303
492
  const handleFullscreenChange = () => {
304
- const doc = document;
305
493
  const iframe = iframeRef.current;
306
- const fullscreenElement = doc.fullscreenElement || doc.webkitFullscreenElement || doc.mozFullScreenElement || doc.msFullscreenElement || null;
494
+ const fullscreenElement = getFullscreenElement();
307
495
  const isEmbedFullscreen = Boolean(iframe && fullscreenElement === iframe);
308
496
  if (isEmbedFullscreen) {
309
497
  wasEmbedFullscreenRef.current = true;
@@ -320,26 +508,15 @@ var PlayruoEmbed = ({
320
508
  return;
321
509
  }
322
510
  releaseEscapeKeyLock();
511
+ if (suppressNextFullscreenExitCleanupRef.current) {
512
+ suppressNextFullscreenExitCleanupRef.current = false;
513
+ return;
514
+ }
323
515
  if (wasEmbedFullscreenRef.current) {
324
516
  wasEmbedFullscreenRef.current = false;
325
- if (iframe) {
326
- try {
327
- iframe.contentWindow?.postMessage({
328
- type: "playruo",
329
- action: "exitFullscreen",
330
- fromEmbed: true,
331
- isFullscreen: false,
332
- parentWillHandleFullscreen: true
333
- }, "*");
334
- } catch {
335
- console.error("[PlayruoEmbed] Failed to exit fullscreen");
336
- }
337
- }
338
- setShowGameIframe(false);
339
- setHasPlayedOnce(true);
340
- onDemoCompleted?.({
341
- reason: "Demo session ended",
342
- storeUrl
517
+ cleanupEmbedSession({
518
+ notifyIframe: true,
519
+ requestFullscreenExit: false
343
520
  });
344
521
  }
345
522
  };
@@ -353,7 +530,13 @@ var PlayruoEmbed = ({
353
530
  document.removeEventListener("mozfullscreenchange", handleFullscreenChange);
354
531
  document.removeEventListener("MSFullscreenChange", handleFullscreenChange);
355
532
  };
356
- }, [storeUrl, onDemoCompleted, iframeRef, lockEscapeKey, releaseEscapeKeyLock]);
533
+ }, [
534
+ cleanupEmbedSession,
535
+ getFullscreenElement,
536
+ iframeRef,
537
+ lockEscapeKey,
538
+ releaseEscapeKeyLock
539
+ ]);
357
540
  useEffect(() => {
358
541
  return () => {
359
542
  releaseEscapeKeyLock();
@@ -438,6 +621,8 @@ var PlayruoEmbed = ({
438
621
  }
439
622
  }, [isReady]);
440
623
  const isAvailable = Boolean(isReady && isCompatible && hasIframeConfirmed);
624
+ const shouldDisplayGameIframe = showGameIframe;
625
+ const shouldShowIframeMisuseMessage = Boolean(showIframe && !showGameIframe);
441
626
  const renderContent = () => {
442
627
  if (!renderState) return null;
443
628
  const canTryNow = Boolean(
@@ -467,6 +652,26 @@ var PlayruoEmbed = ({
467
652
  };
468
653
  return /* @__PURE__ */ jsxs("div", { className, children: [
469
654
  renderContent(),
655
+ isCompatible && shouldShowIframeMisuseMessage && /* @__PURE__ */ jsx(
656
+ "div",
657
+ {
658
+ role: "status",
659
+ style: {
660
+ alignItems: "center",
661
+ background: "#000",
662
+ color: "#fff",
663
+ display: "flex",
664
+ fontFamily: "system-ui, sans-serif",
665
+ fontSize: 14,
666
+ height: "100%",
667
+ justifyContent: "center",
668
+ padding: 24,
669
+ textAlign: "center",
670
+ width: "100%"
671
+ },
672
+ children: "This iframe is only meant to be displayed through PlayruoEmbed during an active stream."
673
+ }
674
+ ),
470
675
  isCompatible && /* @__PURE__ */ jsx(
471
676
  "iframe",
472
677
  {
@@ -479,9 +684,9 @@ var PlayruoEmbed = ({
479
684
  title: "Playruo",
480
685
  referrerPolicy: "strict-origin-when-cross-origin",
481
686
  style: {
482
- // Only mounted when compatible; visible after click (internal) or if parent explicitly asks
483
- opacity: showGameIframe || showIframe ? 1 : 0,
484
- pointerEvents: showGameIframe || showIframe ? "auto" : "none",
687
+ // The iframe stays mounted for readiness checks, visible only during launch/play, and hidden after terminal events.
688
+ opacity: shouldDisplayGameIframe ? 1 : 0,
689
+ pointerEvents: shouldDisplayGameIframe ? "auto" : "none",
485
690
  width: "100%",
486
691
  height: "100%",
487
692
  border: 0
@@ -640,6 +845,13 @@ var EmbedTester = (props = {}) => {
640
845
  console.error("Embed error:", error);
641
846
  }
642
847
  }, [addEvent, enableConsoleLog]);
848
+ const handleEvent = useCallback2((data) => {
849
+ const eventName = typeof data.event === "string" ? data.event : typeof data.name === "string" ? data.name : "unknown";
850
+ addEvent(`Event received: ${eventName}`);
851
+ if (enableConsoleLog) {
852
+ console.log("Embed event:", data);
853
+ }
854
+ }, [addEvent, enableConsoleLog]);
643
855
  const handlePurchaseRequest = useCallback2((data) => {
644
856
  addEvent(`\u{1F4B3} Explicit purchase request from Buy Now button`);
645
857
  addEvent(`Opening store: ${data.storeUrl}`);
@@ -1587,6 +1799,7 @@ var EmbedTester = (props = {}) => {
1587
1799
  iframeRef,
1588
1800
  onDemoCompleted: handleDemoCompleted,
1589
1801
  onError: handleError,
1802
+ onEvent: handleEvent,
1590
1803
  onPurchaseRequest: handlePurchaseRequest,
1591
1804
  onReady: handleReady,
1592
1805
  onStateChange: handleStateChange,
@@ -1714,7 +1927,7 @@ var isPlayruoEmbedMessage = (data) => {
1714
1927
  if (!data || typeof data !== "object") return false;
1715
1928
  const msg = data;
1716
1929
  if (typeof msg.type !== "string") return false;
1717
- return msg.type === "playruo_ready" || msg.type === "playruo_state_update" || msg.type === "playruo_purchase_request" || msg.type === "playruo_demo_completed" || msg.type === "playruo_error" || msg.type === "playruo_session_over" || msg.type === "playruo_stream_start" || msg.type === "playruo_stream_stop";
1930
+ return msg.type === "playruo_ready" || msg.type === "playruo_state_update" || msg.type === "playruo_purchase_request" || msg.type === "playruo_demo_completed" || msg.type === "playruo_error" || msg.type === "playruo_session_over" || msg.type === "playruo_stream_start" || msg.type === "playruo_stream_stop" || msg.type === "REQUEST_EXIT_FULLSCREEN" || msg.type === "playruo_event";
1718
1931
  };
1719
1932
  var isPlayruoInternalMessage = (data) => {
1720
1933
  if (!data || typeof data !== "object") return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playruo/embed",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "React SDK to embed Playruo with postMessage API.",
5
5
  "license": "MIT",
6
6
  "type": "module",