@raydiant/playlist-window-manager 0.20.0 → 0.21.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import * as RE from '@raydiant/playlist-rule-engine';
2
2
  import deepEqual from 'fast-deep-equal';
3
+ import { v4 as uuidv4 } from 'uuid';
3
4
  import createEmbeddedApp from './embeddedApps';
4
5
  import PlaylistWindowManagerState from './PlaylistWindowManagerState';
5
6
  import PlaylistWindowManager from './PlaylistWindowManager';
@@ -17,7 +18,8 @@ import {
17
18
  BuilderState,
18
19
  WorkerState,
19
20
  EmbeddedAppMetadata,
20
- AnalyticsEvent
21
+ AnalyticsEvent,
22
+ PreviewState,
21
23
  } from './types';
22
24
 
23
25
  export interface PresentationWindowOptions {
@@ -53,7 +55,7 @@ export interface PresentationWindowOptions {
53
55
  }
54
56
 
55
57
  // TODO: Automatically inject latest version when building in CI.
56
- const defaultAppRendererUrl = 'https://apps.raydiant.com/3.0.0/index.html';
58
+ const defaultAppRendererUrl = 'https://apps.raydiant.com/3.0.1/index.html';
57
59
 
58
60
  const isValidNumber = (value: any) =>
59
61
  typeof value === 'number' && !isNaN(value);
@@ -74,21 +76,18 @@ export default class PresentationWindow {
74
76
  private completeCallback: (() => void) | null = null;
75
77
  private errorCallback: ((err: Error) => void) | null = null;
76
78
  private readyTimeout: ReturnType<typeof setTimeout> | null = null;
77
- // private completeThrottleTimeout: ReturnType<typeof setTimeout> | null = null;
78
79
  private windowManagers: { [windowId: string]: PlaylistWindowManager } = {};
79
80
  private focusedWindowManager: PlaylistWindowManager | null = null;
80
81
  private didEmbeddedAppLoad: boolean = false;
81
82
  private lastPlayedAt: Date | null = null;
82
- private configurableDurationTimeout: ReturnType<
83
- typeof setTimeout
84
- > | null = null;
83
+ private configurableDurationTimeout: ReturnType<typeof setTimeout> | null =
84
+ null;
85
85
  private isWindowLocked: boolean = false;
86
86
  private isChildWindowLocked: { [windowId: string]: boolean } = {};
87
87
  private enableInterruptLockCallback: (() => void) | null = null;
88
88
  private disableInterruptLockCallback: (() => void) | null = null;
89
- private analyticsEventCallback:
90
- | ((event: AnalyticsEvent) => void)
91
- | null = null;
89
+ private analyticsEventCallback: ((event: AnalyticsEvent) => void) | null =
90
+ null;
92
91
 
93
92
  private builderState: BuilderState | undefined;
94
93
  private presentation: Presentation;
@@ -101,6 +100,14 @@ export default class PresentationWindow {
101
100
  private workerState: WorkerState = {};
102
101
  private workerSubscriptions = new Set<string>();
103
102
  private runtimeSubscriptions = new Set<string>();
103
+ private previewSubscriptions = new Set<string>();
104
+ private previewState: PreviewState = { inputs: {} };
105
+
106
+ private createObjectURLResolvers: Record<string, (url: string) => void> = {};
107
+ private createObjectURLTimeouts: Record<
108
+ string,
109
+ ReturnType<typeof setTimeout>
110
+ > = {};
104
111
 
105
112
  constructor(private options: PresentationWindowOptions) {
106
113
  this.builderState = options.builderState;
@@ -138,7 +145,7 @@ export default class PresentationWindow {
138
145
 
139
146
  // Show child windows after main window, setting focus if necessary.
140
147
  let isChildWindowFocused = false;
141
- Object.values(this.windowManagers).map(wm => {
148
+ Object.values(this.windowManagers).map((wm) => {
142
149
  if (
143
150
  isFocused &&
144
151
  this.focusedWindowManager &&
@@ -161,7 +168,7 @@ export default class PresentationWindow {
161
168
  public hide(): void {
162
169
  const { windowId, renderer } = this.options;
163
170
  // Hide child windows before main window.
164
- Object.values(this.windowManagers).map(wm => wm.hide());
171
+ Object.values(this.windowManagers).map((wm) => wm.hide());
165
172
  renderer.hideWindow(windowId);
166
173
  }
167
174
 
@@ -189,7 +196,7 @@ export default class PresentationWindow {
189
196
  }
190
197
 
191
198
  // Remove any child window managers.
192
- Object.values(this.windowManagers).forEach(wm => {
199
+ Object.values(this.windowManagers).forEach((wm) => {
193
200
  wm.destroy();
194
201
  });
195
202
 
@@ -197,7 +204,7 @@ export default class PresentationWindow {
197
204
  try {
198
205
  await renderer.removeWindow(windowId);
199
206
  this.logInfo(`Removed window '${windowId}'`);
200
- } catch (err) {
207
+ } catch (err: any) {
201
208
  this.logError(
202
209
  `Failed to remove window '${windowId}' with '${err.message}'`
203
210
  );
@@ -208,7 +215,7 @@ export default class PresentationWindow {
208
215
  public reinitializePresentationWindowRuleEngineState(
209
216
  newRulesDataStoreObj: RE.RulesDataStore
210
217
  ) {
211
- Object.values(this.windowManagers).forEach(wm => {
218
+ Object.values(this.windowManagers).forEach((wm) => {
212
219
  wm.reinitializeRuleEngineState(newRulesDataStoreObj);
213
220
  });
214
221
  }
@@ -229,10 +236,10 @@ export default class PresentationWindow {
229
236
  }
230
237
 
231
238
  if (!this.isEmbeddedApp()) {
232
- this.sendMessage('play');
239
+ this.sendMessageString('play');
233
240
 
234
241
  // Play any child window managers.
235
- Object.values(this.windowManagers).forEach(wm => {
242
+ Object.values(this.windowManagers).forEach((wm) => {
236
243
  // We only want to trigger play on child windows the first play of the main window or if
237
244
  // the child window is paused.
238
245
  if (!wm.isPlaying() || wm.isPaused()) {
@@ -241,7 +248,7 @@ export default class PresentationWindow {
241
248
  });
242
249
  } else if (this.runtimeSubscriptions.has('play')) {
243
250
  // If the embedded app has subscribed to the play event, send it a play event.
244
- this.sendMessage('play');
251
+ this.sendMessageString('play');
245
252
  }
246
253
 
247
254
  this.lastPlayedAt = new Date();
@@ -249,12 +256,12 @@ export default class PresentationWindow {
249
256
 
250
257
  public pause() {
251
258
  // Pause all child window managers.
252
- Object.values(this.windowManagers).forEach(wm => wm.pause());
259
+ Object.values(this.windowManagers).forEach((wm) => wm.pause());
253
260
  }
254
261
 
255
262
  public resume() {
256
263
  // Resume all child window managers.
257
- Object.values(this.windowManagers).forEach(wm => wm.resume());
264
+ Object.values(this.windowManagers).forEach((wm) => wm.resume());
258
265
  }
259
266
 
260
267
  public reset(): void {
@@ -342,12 +349,28 @@ export default class PresentationWindow {
342
349
 
343
350
  if (this.isEmbeddedApp()) {
344
351
  if (applicationVersion.embeddedUrlFormat) {
345
- const url = this.getEmbeddedAppUrl();
352
+ if (this.previewSubscriptions.has('inputs')) {
353
+ this.logInfo(
354
+ `Reload window '${windowId}' for embedded app preview state`
355
+ );
346
356
 
347
- this.logInfo(
348
- `Reload window '${windowId}' for embedded app url '${url}'`
349
- );
350
- await this.loadWindow(url);
357
+ const updatedInputs = transformInputValues(
358
+ this.presentation.applicationVariables
359
+ );
360
+
361
+ if (!deepEqual(updatedInputs, this.previewState.inputs)) {
362
+ this.sendMessageObject('preview-inputs', updatedInputs);
363
+ }
364
+
365
+ this.previewState.inputs = updatedInputs;
366
+ } else {
367
+ const url = this.getEmbeddedAppUrl();
368
+
369
+ this.logInfo(
370
+ `Reload window '${windowId}' for embedded app url '${url}'`
371
+ );
372
+ await this.loadWindow(url);
373
+ }
351
374
  }
352
375
  } else {
353
376
  this.logInfo(`Reload window '${windowId}'`);
@@ -359,10 +382,18 @@ export default class PresentationWindow {
359
382
  // Do no support conditional properties for embedded apps. We will likely
360
383
  // change the way conditional properties work.
361
384
  if (!this.isEmbeddedApp()) {
362
- this.sendMessage('run_get_properties', builderState);
385
+ this.sendMessageString('run_get_properties', builderState);
363
386
  }
364
387
  }
365
388
 
389
+ public async createObjectURL(blob: Blob): Promise<string> {
390
+ if (this.isEmbeddedApp()) {
391
+ return this.createObjectURLForEmbeddedApp(blob);
392
+ }
393
+
394
+ return URL.createObjectURL(blob);
395
+ }
396
+
366
397
  private isEmbeddedApp() {
367
398
  const { applicationVersion, isPreview } = this.options;
368
399
 
@@ -382,7 +413,7 @@ export default class PresentationWindow {
382
413
  return true;
383
414
  }
384
415
 
385
- return Object.values(this.windowManagers).every(wm => wm.isReady());
416
+ return Object.values(this.windowManagers).every((wm) => wm.isReady());
386
417
  }
387
418
 
388
419
  private checkIsReadyCallback() {
@@ -440,7 +471,7 @@ export default class PresentationWindow {
440
471
  const {
441
472
  windowId,
442
473
  appRendererUrl = defaultAppRendererUrl,
443
- backgroundColor
474
+ backgroundColor,
444
475
  } = this.options;
445
476
 
446
477
  this.logInfo(`Create window '${windowId}'`);
@@ -467,7 +498,7 @@ export default class PresentationWindow {
467
498
  bounds,
468
499
  backgroundColor,
469
500
  receiveMessageCallback,
470
- errorCallback: this.handleError
501
+ errorCallback: this.handleError,
471
502
  });
472
503
 
473
504
  // Mute new window. This ensures preloaded windows are muted by default and
@@ -486,25 +517,36 @@ export default class PresentationWindow {
486
517
  try {
487
518
  await renderer.loadWindow(windowId, url);
488
519
  this.logInfo(`Loaded window '${windowId}'`);
489
- } catch (err) {
520
+ } catch (err: any) {
490
521
  this.handleError(
491
522
  new WindowLoadError(`Failed to load window with '${err.message}'`)
492
523
  );
493
524
  }
494
525
  }
495
526
 
496
- private sendMessage(type: string, payload?: any) {
527
+ // Send a JSON stringified message to the renderer. This is the legacy way of sending
528
+ // messages and should be deprecated in favor of sendMessageObject.
529
+ private sendMessageString(type: string, payload?: any) {
497
530
  const { windowId, renderer } = this.options;
498
531
  const message = JSON.stringify({ type, payload, windowId });
499
532
 
500
533
  this.logInfo(`Sending '${type}' to window '${windowId}'`);
501
- renderer.sendMessage(windowId, message);
534
+ renderer.sendMessageString(windowId, message);
535
+ }
536
+
537
+ // Send a message object to the renderer. This is the preferred way of sending messages
538
+ // going forward since it allows for more complex payloads like ArrayBuffers (for File Inputs).
539
+ private sendMessageObject(type: string, payload?: any) {
540
+ const { windowId, renderer } = this.options;
541
+
542
+ this.logInfo(`Sending '${type}' to window '${windowId}'`);
543
+ renderer.sendMessageObject(windowId, type, payload);
502
544
  }
503
545
 
504
546
  private waitForPesentationReady(): Promise<void> {
505
547
  const { windowId, readyTimeoutDurationSeconds = 30 } = this.options;
506
548
 
507
- return new Promise<void>(resolve => {
549
+ return new Promise<void>((resolve) => {
508
550
  if (this.didReceiveReady) {
509
551
  resolve();
510
552
  } else {
@@ -564,7 +606,7 @@ export default class PresentationWindow {
564
606
  // IFrames do not have a mute/unmute API so we are overriding any mute properties
565
607
  // to support muting MZ zones when using the IFrameRenderer (SecondScreen).
566
608
  if (this.forceMute) {
567
- this.sendMessage('render', {
609
+ this.sendMessageString('render', {
568
610
  builderState: this.builderState,
569
611
  presentation: {
570
612
  ...this.presentation,
@@ -573,22 +615,22 @@ export default class PresentationWindow {
573
615
  // TODO: We need a better way to identify mute properties (ie. PropType.mute())
574
616
  // Force mute the presentation, existing apps use either mute or muted.
575
617
  mute: true,
576
- muted: true
577
- }
618
+ muted: true,
619
+ },
578
620
  },
579
621
  theme: this.buildTheme(),
580
622
  applicationVersion,
581
623
  device,
582
- isPreview
624
+ isPreview,
583
625
  });
584
626
  } else {
585
- this.sendMessage('render', {
627
+ this.sendMessageString('render', {
586
628
  builderState: this.builderState,
587
629
  presentation: this.presentation,
588
630
  theme: this.buildTheme(),
589
631
  applicationVersion,
590
632
  device,
591
- isPreview
633
+ isPreview,
592
634
  });
593
635
  }
594
636
  }
@@ -608,7 +650,7 @@ export default class PresentationWindow {
608
650
  readyTimeoutDurationSeconds,
609
651
  appRendererUrl,
610
652
  logger,
611
- isPreview
653
+ isPreview,
612
654
  } = this.options;
613
655
  const parentWindowManager = this.options.windowManager;
614
656
 
@@ -664,31 +706,31 @@ export default class PresentationWindow {
664
706
  x: bounds.x,
665
707
  y: bounds.y,
666
708
  width: bounds.width,
667
- height: bounds.height
709
+ height: bounds.height,
668
710
  },
669
711
  backgroundColor: 'transparent',
670
712
  themeOverride,
671
713
  forceMute,
672
714
  isPreview,
673
715
  onEnableInterruptLock: () => this.enableInteruptLock(windowName),
674
- onDisableInterruptLock: () => this.disableInteruptLock(windowName)
716
+ onDisableInterruptLock: () => this.disableInteruptLock(windowName),
675
717
  });
676
718
 
677
719
  // Hook up child window manager events to bubble up to the parent.
678
720
  if (parentWindowManager) {
679
- windowManager.on('window_preload_error', metadata => {
721
+ windowManager.on('window_preload_error', (metadata) => {
680
722
  parentWindowManager.emit('window_preload_error', metadata);
681
723
  });
682
- windowManager.on('window_play', metadata => {
724
+ windowManager.on('window_play', (metadata) => {
683
725
  parentWindowManager.emit('window_play', metadata);
684
726
  });
685
- windowManager.on('window_end_success', metadata => {
727
+ windowManager.on('window_end_success', (metadata) => {
686
728
  parentWindowManager.emit('window_end_success', metadata);
687
729
  });
688
- windowManager.on('window_end_error', metadata => {
730
+ windowManager.on('window_end_error', (metadata) => {
689
731
  parentWindowManager.emit('window_end_error', metadata);
690
732
  });
691
- windowManager.on('window_analytics_event', metadata => {
733
+ windowManager.on('window_analytics_event', (metadata) => {
692
734
  parentWindowManager.emit('window_analytics_event', metadata);
693
735
  });
694
736
  }
@@ -764,7 +806,7 @@ export default class PresentationWindow {
764
806
  const { windowId } = this.options;
765
807
 
766
808
  const metadata: EmbeddedAppMetadata = {
767
- renderMode: this.options.isPreview ? 'preview' : 'live'
809
+ renderMode: this.options.isPreview ? 'preview' : 'live',
768
810
  };
769
811
 
770
812
  const url = createEmbeddedApp(
@@ -809,7 +851,7 @@ export default class PresentationWindow {
809
851
 
810
852
  // Immediately send play if the app if already playing.
811
853
  if (this.isPlaying()) {
812
- this.sendMessage('play');
854
+ this.sendMessageString('play');
813
855
  }
814
856
  } else {
815
857
  this.logWarning(
@@ -845,9 +887,9 @@ export default class PresentationWindow {
845
887
  // as the app subscribes.
846
888
  const workerState = this.workerState[payload];
847
889
  if (workerState !== undefined) {
848
- this.sendMessage('worker-state', {
890
+ this.sendMessageString('worker-state', {
849
891
  name: payload,
850
- state: workerState
892
+ state: workerState,
851
893
  });
852
894
  }
853
895
  } else {
@@ -881,6 +923,56 @@ export default class PresentationWindow {
881
923
  break;
882
924
  }
883
925
 
926
+ case 'create-object-url-result': {
927
+ if (isValidCreateObjectURLResultPayload(payload)) {
928
+ // Resolve promise with the local blob URL.
929
+ if (this.createObjectURLResolvers[payload.requestId]) {
930
+ this.createObjectURLResolvers[payload.requestId](payload.url);
931
+ delete this.createObjectURLResolvers[payload.requestId];
932
+ }
933
+ // Clear the timeout for the request.
934
+ if (this.createObjectURLTimeouts[payload.requestId]) {
935
+ clearTimeout(this.createObjectURLTimeouts[payload.requestId]);
936
+ delete this.createObjectURLTimeouts[payload.requestId];
937
+ }
938
+ } else {
939
+ this.logWarning(
940
+ `Invalid payload for create-object-url-result message: ${payload}`
941
+ );
942
+ }
943
+ break;
944
+ }
945
+
946
+ case 'preview-subscribe': {
947
+ if (typeof payload === 'string') {
948
+ this.logInfo(
949
+ `Received 'preview-subscribe' from window '${windowId}'`
950
+ );
951
+ // The payload is the event name (ex. 'inputs').
952
+ this.previewSubscriptions.add(payload);
953
+ } else {
954
+ this.logWarning(
955
+ `Invalid payload for preview-subscribe message: ${payload}`
956
+ );
957
+ }
958
+ break;
959
+ }
960
+
961
+ case 'preview-unsubscribe': {
962
+ if (typeof payload === 'string') {
963
+ this.logInfo(
964
+ `Received 'preview-unsubscribe' from window '${windowId}'`
965
+ );
966
+ // The payload is the event name (ex. 'inputs').
967
+ this.previewSubscriptions.delete(payload);
968
+ } else {
969
+ this.logWarning(
970
+ `Invalid payload for preview-subscribe message: ${payload}`
971
+ );
972
+ }
973
+ break;
974
+ }
975
+
884
976
  default: {
885
977
  this.logWarning(`Received unknown embedded message type: ${type}`);
886
978
  }
@@ -933,7 +1025,7 @@ export default class PresentationWindow {
933
1025
  x: Math.round(payload.x),
934
1026
  y: Math.round(payload.y),
935
1027
  width: Math.round(payload.width),
936
- height: Math.round(payload.height)
1028
+ height: Math.round(payload.height),
937
1029
  },
938
1030
  payload.isMuted,
939
1031
  payload.isFocused,
@@ -1037,7 +1129,7 @@ export default class PresentationWindow {
1037
1129
  publishedAt,
1038
1130
  playlistId,
1039
1131
  playlistName,
1040
- rule
1132
+ rule,
1041
1133
  } = this.options;
1042
1134
 
1043
1135
  return {
@@ -1052,7 +1144,7 @@ export default class PresentationWindow {
1052
1144
  publishedAt,
1053
1145
  playlistId,
1054
1146
  playlistName,
1055
- rule
1147
+ rule,
1056
1148
  };
1057
1149
  }
1058
1150
 
@@ -1071,9 +1163,9 @@ export default class PresentationWindow {
1071
1163
  workerState !== undefined &&
1072
1164
  !deepEqual(workerState, previousWorkerState)
1073
1165
  ) {
1074
- this.sendMessage('worker-state', {
1166
+ this.sendMessageString('worker-state', {
1075
1167
  name: workerName,
1076
- state: workerState
1168
+ state: workerState,
1077
1169
  });
1078
1170
  }
1079
1171
  }
@@ -1097,15 +1189,15 @@ export default class PresentationWindow {
1097
1189
  {
1098
1190
  type: 'equals',
1099
1191
  left: { var: ['target', 'hasPlaylists'] },
1100
- right: { const: false }
1101
- }
1102
- ]
1192
+ right: { const: false },
1193
+ },
1194
+ ],
1103
1195
  }
1104
1196
  : {
1105
1197
  // A global rule was not provided so we don't need to combine the rule with an AND.
1106
1198
  type: 'equals',
1107
1199
  left: { var: ['target', 'hasPlaylists'] },
1108
- right: { const: false }
1200
+ right: { const: false },
1109
1201
  }
1110
1202
  );
1111
1203
  }
@@ -1141,4 +1233,85 @@ export default class PresentationWindow {
1141
1233
  appVars.muteZone1 === true && appVars.muteZone2 === true;
1142
1234
  return isMutedMultizone;
1143
1235
  }
1236
+
1237
+ private createObjectURLForEmbeddedApp(blob: Blob): Promise<string> {
1238
+ return new Promise((resolve, reject) => {
1239
+ // Handle multiple concurrent createObjectURL calls by creating a request id for each
1240
+ // call. We send the request id along with the message to the running app and the app
1241
+ // sends the request id back with the result so we can resolve the correct promise.
1242
+ const requestId = uuidv4();
1243
+ this.createObjectURLResolvers[requestId] = resolve;
1244
+
1245
+ // Start a timer to reject the promise if the app doesn't respond with the create object
1246
+ // url result within 1 second. This should be more or less instant.
1247
+ this.createObjectURLTimeouts[requestId] = setTimeout(() => {
1248
+ const error = new Error(
1249
+ `Timed out waiting for the create-object-url-result message for request id ${requestId}`
1250
+ );
1251
+ reject(error);
1252
+ delete this.createObjectURLResolvers[requestId];
1253
+ delete this.createObjectURLTimeouts[requestId];
1254
+ }, 1000);
1255
+
1256
+ // Create a FileReader object to read the file data as an ArrayBuffer.
1257
+ const reader = new FileReader();
1258
+
1259
+ reader.addEventListener(
1260
+ 'load',
1261
+ () => {
1262
+ const buffer = reader.result;
1263
+
1264
+ // Check if buffer is an ArrayBuffer
1265
+ if (buffer instanceof ArrayBuffer) {
1266
+ // Send the ArrayBuffer to the running app to create the local file blob URL.
1267
+ this.sendMessageObject('create-object-url', { requestId, buffer });
1268
+ }
1269
+ },
1270
+ false
1271
+ );
1272
+
1273
+ reader.readAsArrayBuffer(blob);
1274
+
1275
+ return '';
1276
+ });
1277
+ }
1144
1278
  }
1279
+
1280
+ const isValidCreateObjectURLResultPayload = (
1281
+ payload: any
1282
+ ): payload is { requestId: string; url: string } => {
1283
+ return (
1284
+ // Ensure payload is an object.
1285
+ typeof payload === 'object' &&
1286
+ payload !== null &&
1287
+ // Ensure requestId is a string.
1288
+ 'requestId' in payload &&
1289
+ typeof payload['requestId'] === 'string' &&
1290
+ // Ensure payload.file is an object.
1291
+ 'url' in payload &&
1292
+ typeof payload['url'] === 'string'
1293
+ );
1294
+ };
1295
+
1296
+ const transformInputValues = (inputs: Record<string, any>) => {
1297
+ const transformed: Record<string, any> = {};
1298
+
1299
+ // Recursively traverse inputs, transforming any file input values to remove internal
1300
+ // properties like file_upload_id.
1301
+ for (const [key, value] of Object.entries(inputs)) {
1302
+ if (typeof value === 'object' && value !== null && 'url' in value) {
1303
+ // Value is a file input, strip out the file_upload_id property.
1304
+ transformed[key] = { url: value.url };
1305
+ } else if (Array.isArray(value)) {
1306
+ // Value is an array input, traverse each item in the array for file input values.
1307
+ transformed[key] = [];
1308
+ for (const item of value) {
1309
+ transformed[key].push(transformInputValues(item));
1310
+ }
1311
+ } else {
1312
+ transformed[key] = value;
1313
+ }
1314
+ }
1315
+
1316
+ return transformed;
1317
+ };