@xibosignage/xibo-layout-renderer 1.0.28 → 1.0.29
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/src/Modules/ActionController/ActionController.d.ts +4 -1
- package/dist/xibo-layout-renderer.cjs.js +108 -35
- package/dist/xibo-layout-renderer.cjs.js.map +1 -1
- package/dist/xibo-layout-renderer.d.ts +4 -1
- package/dist/xibo-layout-renderer.esm.js +108 -35
- package/dist/xibo-layout-renderer.esm.js.map +1 -1
- package/dist/xibo-layout-renderer.js +108 -35
- package/dist/xibo-layout-renderer.min.js +7 -7
- package/dist/xibo-layout-renderer.min.js.map +1 -1
- package/package.json +1 -1
|
@@ -21,12 +21,13 @@ export default class ActionController {
|
|
|
21
21
|
$actionControllerTitle: HTMLElement | null;
|
|
22
22
|
$actionsContainer: HTMLElement | null;
|
|
23
23
|
translations: any;
|
|
24
|
+
private keyboardHandler;
|
|
24
25
|
constructor(parent: ILayout, actions: Action[], options: InactOptions);
|
|
25
26
|
init(): void;
|
|
26
27
|
openLayoutInNewTab(layoutCode: string, options: InactOptions): void;
|
|
27
28
|
openLayoutInPlayer(layoutCode: string, _options: InactOptions): void;
|
|
28
29
|
prevOrNextLayout(targetId: string, actionType: string): void;
|
|
29
|
-
/** Change media in region (next/previous) */
|
|
30
|
+
/** Change media in region (next/previous) with wrap-around at boundaries. */
|
|
30
31
|
gotoMediaInRegion(regionId: string, actionType: string): void;
|
|
31
32
|
loadMediaInRegion(regionId: string, widgetId: string): void;
|
|
32
33
|
/** Run action based on action data */
|
|
@@ -37,4 +38,6 @@ export default class ActionController {
|
|
|
37
38
|
/** Dispatch an incoming webhook trigger to any matching actions on this layout. */
|
|
38
39
|
handleWebhookTrigger(triggerCode: string, widgetId?: string): void;
|
|
39
40
|
initKeyboardActions(): void;
|
|
41
|
+
/** Remove the keydown listener registered by initKeyboardActions. Call when the layout ends or is cancelled. */
|
|
42
|
+
removeKeyboardActions(): void;
|
|
40
43
|
}
|
|
@@ -73576,7 +73576,7 @@ function createMediaElement(mediaObject) {
|
|
|
73576
73576
|
cssText += "\n visibility: hidden;\n opacity: 0;\n z-index: 0;\n ";
|
|
73577
73577
|
}
|
|
73578
73578
|
$media.style.cssText = cssText;
|
|
73579
|
-
if (self.render === 'html' || self.mediaType === 'ticker' || self.mediaType === 'webpage') {
|
|
73579
|
+
if (self.mediaType !== 'spacer' && (self.render === 'html' || self.mediaType === 'ticker' || self.mediaType === 'webpage')) {
|
|
73580
73580
|
self.checkIframeStatus = true;
|
|
73581
73581
|
self.iframe = prepareIframe(self);
|
|
73582
73582
|
} else if (self.mediaType === "image") {
|
|
@@ -73724,8 +73724,6 @@ function prepareAudioMedia(media, region) {
|
|
|
73724
73724
|
region.html.appendChild(media.html);
|
|
73725
73725
|
}
|
|
73726
73726
|
function prepareHtmlMedia(media, region) {
|
|
73727
|
-
// Set state as false ( for now )
|
|
73728
|
-
media.ready = false;
|
|
73729
73727
|
if (media.html) {
|
|
73730
73728
|
var mediaId = getMediaId(media);
|
|
73731
73729
|
// Clean up old copy of the media
|
|
@@ -73737,14 +73735,16 @@ function prepareHtmlMedia(media, region) {
|
|
|
73737
73735
|
mediaId: mediaId,
|
|
73738
73736
|
mediaInRegion: mediaInRegion
|
|
73739
73737
|
});
|
|
73740
|
-
// Append iframe
|
|
73741
|
-
media.html.innerHTML = '';
|
|
73742
|
-
media.html.appendChild(media.iframe);
|
|
73743
73738
|
if (!mediaInRegion) {
|
|
73744
|
-
//
|
|
73739
|
+
// Append iframe and insert into region only when not already in the DOM.
|
|
73740
|
+
// Calling innerHTML = '' when the element is already present detaches the
|
|
73741
|
+
// iframe, causing the browser to reload its src unnecessarily (e.g. when
|
|
73742
|
+
// a media was preloaded then skipped by a navigation action).
|
|
73743
|
+
media.html.innerHTML = '';
|
|
73744
|
+
media.html.appendChild(media.iframe);
|
|
73745
73745
|
region.html.appendChild(media.html);
|
|
73746
|
-
media.ready = true;
|
|
73747
73746
|
}
|
|
73747
|
+
media.ready = true;
|
|
73748
73748
|
}
|
|
73749
73749
|
}
|
|
73750
73750
|
exports.FaultCodes = void 0;
|
|
@@ -74052,7 +74052,12 @@ var Media = /*#__PURE__*/function () {
|
|
|
74052
74052
|
var _this$sspImpressionUr, _this$sspErrorUrls;
|
|
74053
74053
|
_this.xlr.emitter.emit('sspWidgetEnd', (_this$sspImpressionUr = _this.sspImpressionUrls) !== null && _this$sspImpressionUr !== void 0 ? _this$sspImpressionUr : [], (_this$sspErrorUrls = _this.sspErrorUrls) !== null && _this$sspErrorUrls !== void 0 ? _this$sspErrorUrls : [], _this.sspImpressionUrls ? _this.duration : 0);
|
|
74054
74054
|
}
|
|
74055
|
-
media.
|
|
74055
|
+
// Only advance the region if this media is still the active one.
|
|
74056
|
+
// A user-triggered next/prev action may have already moved currMedia
|
|
74057
|
+
// on, in which case the timer firing here would cause a double-advance.
|
|
74058
|
+
if (media === media.region.currMedia) {
|
|
74059
|
+
media.region.playNextMedia();
|
|
74060
|
+
}
|
|
74056
74061
|
});
|
|
74057
74062
|
this.on('cancelled', function (media) {
|
|
74058
74063
|
if (media.state === MediaState.CANCELLED) return;
|
|
@@ -74096,6 +74101,10 @@ var Media = /*#__PURE__*/function () {
|
|
|
74096
74101
|
key: "startMediaTimer",
|
|
74097
74102
|
value: function startMediaTimer(media) {
|
|
74098
74103
|
var _this2 = this;
|
|
74104
|
+
// Always reset the counter so a media replayed after cancellation runs
|
|
74105
|
+
// for its full duration rather than the residual time left from the
|
|
74106
|
+
// previous play.
|
|
74107
|
+
this.mediaTimeCount = 0;
|
|
74099
74108
|
var preloadTimeMs = 2000;
|
|
74100
74109
|
var preloadTimeBufferMs = media.duration * 1000 / 2 - preloadTimeMs;
|
|
74101
74110
|
var isPreparingNextMedia = false;
|
|
@@ -74971,11 +74980,19 @@ var Region = /*#__PURE__*/function () {
|
|
|
74971
74980
|
nxtMedia: (_this$nxtMedia2 = this.nxtMedia) === null || _this$nxtMedia2 === void 0 ? void 0 : _this$nxtMedia2.containerName
|
|
74972
74981
|
});
|
|
74973
74982
|
if (!this.layout.isOverlay && crossedEnd) {
|
|
74983
|
+
var _this$currMedia8;
|
|
74974
74984
|
this.finished();
|
|
74975
74985
|
if (this.layout.allEnded) {
|
|
74976
74986
|
console.debug('??? XLR.debug >> Region - playNextMedia - layout all ended');
|
|
74977
74987
|
return;
|
|
74978
74988
|
}
|
|
74989
|
+
// Freeze single-media HTML at its last state while waiting for other
|
|
74990
|
+
// regions to complete. The guard at the top only catches the second
|
|
74991
|
+
// call; this one catches the first completion when complete was just
|
|
74992
|
+
// set by finished() above.
|
|
74993
|
+
if (((_this$currMedia8 = this.currMedia) === null || _this$currMedia8 === void 0 ? void 0 : _this$currMedia8.render) === 'html' && this.totalMediaObjects === 1 && this.oldMedia === this.currMedia) {
|
|
74994
|
+
return;
|
|
74995
|
+
}
|
|
74979
74996
|
}
|
|
74980
74997
|
this.transitionNodes(this.oldMedia, this.currMedia);
|
|
74981
74998
|
}
|
|
@@ -74985,11 +75002,18 @@ var Region = /*#__PURE__*/function () {
|
|
|
74985
75002
|
if (this.currentMediaIndex <= 0 || this.ended) {
|
|
74986
75003
|
return;
|
|
74987
75004
|
}
|
|
75005
|
+
var interruptedMedia = this.currMedia;
|
|
74988
75006
|
this.oldMedia = this.currMedia;
|
|
74989
75007
|
this.currentMediaIndex -= 1;
|
|
74990
75008
|
this.currMedia = this.mediaObjects[this.currentMediaIndex];
|
|
74991
75009
|
this.nxtMedia = this.mediaObjects[(this.currentMediaIndex + 1) % this.totalMediaObjects];
|
|
74992
75010
|
this.complete = false;
|
|
75011
|
+
// Cancel the interrupted media after advancing currMedia, using the same
|
|
75012
|
+
// pattern as gotoMediaInRegion — emitting after the update ensures the
|
|
75013
|
+
// handler's (media === currMedia) guard correctly skips playNextMedia.
|
|
75014
|
+
if ((interruptedMedia === null || interruptedMedia === void 0 ? void 0 : interruptedMedia.state) === MediaState.PLAYING) {
|
|
75015
|
+
interruptedMedia.emitter.emit('cancelled', interruptedMedia);
|
|
75016
|
+
}
|
|
74993
75017
|
console.debug('region::playPreviousMedia', this);
|
|
74994
75018
|
this.transitionNodes(this.oldMedia, this.currMedia);
|
|
74995
75019
|
}
|
|
@@ -75067,6 +75091,7 @@ var ActionController = /*#__PURE__*/function () {
|
|
|
75067
75091
|
_defineProperty(this, "$actionControllerTitle", void 0);
|
|
75068
75092
|
_defineProperty(this, "$actionsContainer", void 0);
|
|
75069
75093
|
_defineProperty(this, "translations", {});
|
|
75094
|
+
_defineProperty(this, "keyboardHandler", null);
|
|
75070
75095
|
this.parent = parent;
|
|
75071
75096
|
this.actions = actions;
|
|
75072
75097
|
this.options = options;
|
|
@@ -75231,7 +75256,7 @@ var ActionController = /*#__PURE__*/function () {
|
|
|
75231
75256
|
this.parent.xlr.gotoPrevLayout();
|
|
75232
75257
|
}
|
|
75233
75258
|
}
|
|
75234
|
-
/** Change media in region (next/previous) */
|
|
75259
|
+
/** Change media in region (next/previous) with wrap-around at boundaries. */
|
|
75235
75260
|
}, {
|
|
75236
75261
|
key: "gotoMediaInRegion",
|
|
75237
75262
|
value: function gotoMediaInRegion(regionId, actionType) {
|
|
@@ -75239,15 +75264,34 @@ var ActionController = /*#__PURE__*/function () {
|
|
|
75239
75264
|
regionId: regionId,
|
|
75240
75265
|
actionType: actionType
|
|
75241
75266
|
});
|
|
75242
|
-
// Find target region
|
|
75243
75267
|
this.parent.regions.forEach(function (regionObj) {
|
|
75244
|
-
if (regionObj.id
|
|
75245
|
-
|
|
75246
|
-
|
|
75247
|
-
|
|
75248
|
-
|
|
75249
|
-
|
|
75268
|
+
if (regionObj.id !== regionId || regionObj.ended) return;
|
|
75269
|
+
var total = regionObj.totalMediaObjects;
|
|
75270
|
+
if (total === 0) return;
|
|
75271
|
+
// Snapshot the currently-playing media before updating currMedia so
|
|
75272
|
+
// we can cancel it cleanly after the region state is advanced.
|
|
75273
|
+
var interruptedMedia = regionObj.currMedia;
|
|
75274
|
+
// Compute new index with wrap-around. We do NOT delegate to
|
|
75275
|
+
// playNextMedia() / playPreviousMedia() here because those carry
|
|
75276
|
+
// normal playlist-cycle semantics (finished(), regionExpired()) that
|
|
75277
|
+
// must not fire during user-driven navigation.
|
|
75278
|
+
var newIndex = actionType === 'next' ? (regionObj.currentMediaIndex + 1) % total : (regionObj.currentMediaIndex - 1 + total) % total;
|
|
75279
|
+
regionObj.oldMedia = regionObj.currMedia;
|
|
75280
|
+
regionObj.currentMediaIndex = newIndex;
|
|
75281
|
+
regionObj.currMedia = regionObj.mediaObjects[newIndex];
|
|
75282
|
+
regionObj.nxtMedia = regionObj.mediaObjects[(newIndex + 1) % total];
|
|
75283
|
+
regionObj.complete = false;
|
|
75284
|
+
// Properly cancel the interrupted media AFTER updating currMedia.
|
|
75285
|
+
// Using 'cancelled' rather than bare clearInterval ensures state is
|
|
75286
|
+
// reset from PLAYING and mediaTimeCount is zeroed — without this,
|
|
75287
|
+
// returning to a cancelled media causes run() → 'start' to bail on
|
|
75288
|
+
// the state === PLAYING guard, leaving the region stuck indefinitely.
|
|
75289
|
+
// currMedia is already updated so the handler's guard
|
|
75290
|
+
// (media === media.region.currMedia) correctly skips playNextMedia.
|
|
75291
|
+
if ((interruptedMedia === null || interruptedMedia === void 0 ? void 0 : interruptedMedia.state) === MediaState.PLAYING) {
|
|
75292
|
+
interruptedMedia.emitter.emit('cancelled', interruptedMedia);
|
|
75250
75293
|
}
|
|
75294
|
+
regionObj.transitionNodes(regionObj.oldMedia, regionObj.currMedia);
|
|
75251
75295
|
});
|
|
75252
75296
|
}
|
|
75253
75297
|
}, {
|
|
@@ -75298,11 +75342,19 @@ var ActionController = /*#__PURE__*/function () {
|
|
|
75298
75342
|
console.debug('[ActionController::loadMediaInRegion] Target media already queued, skipping duplicate insertion');
|
|
75299
75343
|
return;
|
|
75300
75344
|
}
|
|
75301
|
-
// Cancel the
|
|
75302
|
-
//
|
|
75303
|
-
|
|
75304
|
-
|
|
75305
|
-
|
|
75345
|
+
// Cancel the interrupted media so it doesn't double-advance when the playlist
|
|
75346
|
+
// returns to it. currMedia has not been advanced yet at this point (playNextMedia
|
|
75347
|
+
// does that below), so we cannot use emitter.emit('cancelled') — the handler's
|
|
75348
|
+
// currMedia guard would fire and call playNextMedia a second time. Instead we
|
|
75349
|
+
// cancel directly: clear the timer and reset state so the 'start' handler does
|
|
75350
|
+
// not bail on the state === PLAYING guard when this media is replayed.
|
|
75351
|
+
if (((_targetRegion2 = targetRegion) === null || _targetRegion2 === void 0 || (_targetRegion2 = _targetRegion2.currMedia) === null || _targetRegion2 === void 0 ? void 0 : _targetRegion2.state) === MediaState.PLAYING) {
|
|
75352
|
+
var interruptedMedia = targetRegion.currMedia;
|
|
75353
|
+
if (interruptedMedia.mediaTimer) {
|
|
75354
|
+
clearInterval(interruptedMedia.mediaTimer);
|
|
75355
|
+
interruptedMedia.mediaTimer = undefined;
|
|
75356
|
+
}
|
|
75357
|
+
interruptedMedia.state = MediaState.CANCELLED;
|
|
75306
75358
|
}
|
|
75307
75359
|
// Reset complete so the HTML-media guard in playNextMedia() doesn't block
|
|
75308
75360
|
// the transition (that guard is for single-media loops, not navWidget injections).
|
|
@@ -75331,6 +75383,12 @@ var ActionController = /*#__PURE__*/function () {
|
|
|
75331
75383
|
}, {
|
|
75332
75384
|
key: "runAction",
|
|
75333
75385
|
value: function runAction(actionData, options) {
|
|
75386
|
+
// If this layout is no longer active (being cancelled or navigated away from),
|
|
75387
|
+
// discard the action so it doesn't interfere with the outgoing transition.
|
|
75388
|
+
// inLoop is set to false synchronously before finishAllRegions() in all nav paths.
|
|
75389
|
+
if (!this.parent.inLoop) {
|
|
75390
|
+
return;
|
|
75391
|
+
}
|
|
75334
75392
|
console.debug('[ActionController::runAction] Triggering action', {
|
|
75335
75393
|
actionData: actionData
|
|
75336
75394
|
});
|
|
@@ -75418,31 +75476,37 @@ var ActionController = /*#__PURE__*/function () {
|
|
|
75418
75476
|
key: "initKeyboardActions",
|
|
75419
75477
|
value: function initKeyboardActions() {
|
|
75420
75478
|
var self = this;
|
|
75421
|
-
// Store actions in a map
|
|
75422
75479
|
var keyActions = new Map();
|
|
75423
|
-
this.$actionController.querySelectorAll('.action[
|
|
75480
|
+
this.$actionController.querySelectorAll('.action[triggertype="keyPress"]').forEach(function ($el) {
|
|
75424
75481
|
var dataset = $el.dataset;
|
|
75425
75482
|
var code = dataset.triggercode;
|
|
75426
75483
|
if (code) {
|
|
75427
|
-
// Create an empty array, if not yet set
|
|
75428
75484
|
if (!keyActions.get(code)) {
|
|
75429
75485
|
keyActions.set(code, []);
|
|
75430
75486
|
}
|
|
75431
|
-
// Add new action to array
|
|
75432
75487
|
keyActions.get(code).push(dataset);
|
|
75433
75488
|
}
|
|
75434
75489
|
});
|
|
75435
|
-
//
|
|
75436
|
-
|
|
75490
|
+
// Nothing to do if this layout has no keyboard-triggered actions.
|
|
75491
|
+
if (keyActions.size === 0) return;
|
|
75492
|
+
this.keyboardHandler = function (ev) {
|
|
75437
75493
|
var actions = keyActions.get(ev.code);
|
|
75438
|
-
// Are there action for this key code?
|
|
75439
75494
|
if (actions) {
|
|
75440
|
-
// Run all actions associated with it
|
|
75441
75495
|
actions.forEach(function (dataset) {
|
|
75442
75496
|
self.runAction(dataset, self.options);
|
|
75443
75497
|
});
|
|
75444
75498
|
}
|
|
75445
|
-
}
|
|
75499
|
+
};
|
|
75500
|
+
document.addEventListener('keydown', this.keyboardHandler);
|
|
75501
|
+
}
|
|
75502
|
+
/** Remove the keydown listener registered by initKeyboardActions. Call when the layout ends or is cancelled. */
|
|
75503
|
+
}, {
|
|
75504
|
+
key: "removeKeyboardActions",
|
|
75505
|
+
value: function removeKeyboardActions() {
|
|
75506
|
+
if (this.keyboardHandler) {
|
|
75507
|
+
document.removeEventListener('keydown', this.keyboardHandler);
|
|
75508
|
+
this.keyboardHandler = null;
|
|
75509
|
+
}
|
|
75446
75510
|
}
|
|
75447
75511
|
}]);
|
|
75448
75512
|
}();
|
|
@@ -75677,6 +75741,7 @@ var Layout = /*#__PURE__*/function () {
|
|
|
75677
75741
|
});
|
|
75678
75742
|
this.on('end', /*#__PURE__*/function () {
|
|
75679
75743
|
var _ref = _asyncToGenerator( /*#__PURE__*/_regeneratorRuntime().mark(function _callee2(layout) {
|
|
75744
|
+
var _layout$actionControl;
|
|
75680
75745
|
var $layout, _$layout$parentElemen;
|
|
75681
75746
|
return _regeneratorRuntime().wrap(function _callee2$(_context2) {
|
|
75682
75747
|
while (1) switch (_context2.prev = _context2.next) {
|
|
@@ -75735,9 +75800,10 @@ var Layout = /*#__PURE__*/function () {
|
|
|
75735
75800
|
}
|
|
75736
75801
|
// Emit layout end event
|
|
75737
75802
|
console.debug('>>>>> XLR.debug Awaited XLR::emitSync > End - Calling layoutEnd event');
|
|
75738
|
-
|
|
75803
|
+
(_layout$actionControl = layout.actionController) === null || _layout$actionControl === void 0 || _layout$actionControl.removeKeyboardActions();
|
|
75804
|
+
_context2.next = 14;
|
|
75739
75805
|
return layout.xlr.emitSync('layoutEnd', layout);
|
|
75740
|
-
case
|
|
75806
|
+
case 14:
|
|
75741
75807
|
if (_this.xlr.config.platform !== exports.ConsumerPlatform.CMS && layout.inLoop) {
|
|
75742
75808
|
// Transition next layout to current layout and prepare next layout if exist
|
|
75743
75809
|
_this.xlr.prepareLayouts().then( /*#__PURE__*/function () {
|
|
@@ -75767,7 +75833,7 @@ var Layout = /*#__PURE__*/function () {
|
|
|
75767
75833
|
};
|
|
75768
75834
|
}());
|
|
75769
75835
|
}
|
|
75770
|
-
case
|
|
75836
|
+
case 15:
|
|
75771
75837
|
case "end":
|
|
75772
75838
|
return _context2.stop();
|
|
75773
75839
|
}
|
|
@@ -75778,9 +75844,11 @@ var Layout = /*#__PURE__*/function () {
|
|
|
75778
75844
|
};
|
|
75779
75845
|
}());
|
|
75780
75846
|
this.on('cancelled', function (layout) {
|
|
75847
|
+
var _layout$actionControl2;
|
|
75781
75848
|
console.debug('>>>>> XLR.debug / Layout cancelled > Layout ID > ', layout.id);
|
|
75782
75849
|
layout.state = exports.ELayoutState.CANCELLED;
|
|
75783
75850
|
layout.inLoop = false;
|
|
75851
|
+
(_layout$actionControl2 = layout.actionController) === null || _layout$actionControl2 === void 0 || _layout$actionControl2.removeKeyboardActions();
|
|
75784
75852
|
// Dispose video handlers immediately so their stall watchdogs and error
|
|
75785
75853
|
// callbacks can't fire against a layout whose DOM is about to be removed.
|
|
75786
75854
|
var _iterator = _createForOfIteratorHelper(layout.regions),
|
|
@@ -75939,7 +76007,8 @@ var Layout = /*#__PURE__*/function () {
|
|
|
75939
76007
|
_this2.regions.push(regionObj);
|
|
75940
76008
|
});
|
|
75941
76009
|
this.actionController.initTouchActions();
|
|
75942
|
-
|
|
76010
|
+
// Keyboard actions are registered in run() so the global document listener
|
|
76011
|
+
// is only active while the layout is actually playing, not during background preparation.
|
|
75943
76012
|
}
|
|
75944
76013
|
}, {
|
|
75945
76014
|
key: "run",
|
|
@@ -75958,6 +76027,7 @@ var Layout = /*#__PURE__*/function () {
|
|
|
75958
76027
|
shouldParse: false
|
|
75959
76028
|
});
|
|
75960
76029
|
if ($layoutContainer) {
|
|
76030
|
+
var _this$actionControlle;
|
|
75961
76031
|
$layoutContainer.style.setProperty('visibility', 'visible');
|
|
75962
76032
|
$layoutContainer.style.setProperty('opacity', '1');
|
|
75963
76033
|
$layoutContainer.style.setProperty('z-index', this.zIndex !== null ? "".concat(this.zIndex) : '1');
|
|
@@ -75966,6 +76036,9 @@ var Layout = /*#__PURE__*/function () {
|
|
|
75966
76036
|
// Also set the background color of the player window > body
|
|
75967
76037
|
document.body.style.setProperty('background-color', "".concat(this.bgColor));
|
|
75968
76038
|
}
|
|
76039
|
+
// Register keyboard actions now that the layout is active.
|
|
76040
|
+
// Done here (not in parseXlf) so the global listener is scoped to playback time.
|
|
76041
|
+
(_this$actionControlle = this.actionController) === null || _this$actionControlle === void 0 || _this$actionControlle.initKeyboardActions();
|
|
75969
76042
|
// Emit start event
|
|
75970
76043
|
this.emitter.emit('start', this);
|
|
75971
76044
|
// Play regions
|