@xiboplayer/schedule 0.5.5 → 0.5.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiboplayer/schedule",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "description": "Complete scheduling solution: campaigns, dayparting, interrupts, and overlays",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
@@ -11,7 +11,7 @@
11
11
  "./overlays": "./src/overlays.js"
12
12
  },
13
13
  "dependencies": {
14
- "@xiboplayer/utils": "0.5.5"
14
+ "@xiboplayer/utils": "0.5.7"
15
15
  },
16
16
  "devDependencies": {
17
17
  "vitest": "^2.0.0"
package/src/index.js CHANGED
@@ -25,4 +25,4 @@ export { OverlayScheduler } from './overlays.js';
25
25
  * Offline timeline calculator — duration parser + timeline simulator
26
26
  * @module @xiboplayer/schedule/timeline
27
27
  */
28
- export { calculateTimeline, parseLayoutDuration } from './timeline.js';
28
+ export { calculateTimeline, parseLayoutDuration, buildScheduleQueue } from './timeline.js';
package/src/schedule.js CHANGED
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { createLogger } from '@xiboplayer/utils';
6
6
  import { evaluateCriteria } from './criteria.js';
7
+ import { buildScheduleQueue } from './timeline.js';
7
8
 
8
9
  const log = createLogger('Schedule');
9
10
 
@@ -16,6 +17,11 @@ export class ScheduleManager {
16
17
  this.weatherData = {}; // Weather data from GetWeather XMDS call
17
18
  this.playerLocation = null; // { latitude, longitude } from Geolocation API
18
19
  this._layoutMetadata = new Map(); // layoutFile → { syncEvent, shareOfVoice, ... }
20
+
21
+ // Pre-calculated schedule queue (LCM-based deterministic timeline)
22
+ this._scheduleQueue = null; // { queue: [{layoutId, duration}], periodSeconds }
23
+ this._queuePosition = 0; // Current position in the queue
24
+ this._queueLayoutSet = null; // Stringified active layout set (for invalidation)
19
25
  }
20
26
 
21
27
  /**
@@ -23,6 +29,7 @@ export class ScheduleManager {
23
29
  */
24
30
  setSchedule(schedule) {
25
31
  this.schedule = schedule;
32
+ this._invalidateQueue();
26
33
  }
27
34
 
28
35
  /**
@@ -606,42 +613,102 @@ export class ScheduleManager {
606
613
  }
607
614
 
608
615
  /**
609
- * Get current layouts with the default layout interleaved between scheduled layouts.
616
+ * Get (or build) the deterministic schedule queue.
610
617
  *
611
- * Xibo CMS expects the default layout (fallback) to play between each scheduled
612
- * layout in the rotation. For example, with layouts [A, B, C] and default D,
613
- * the result is [A, D, B, D, C, D].
618
+ * Uses LCM-based even distribution to pre-calculate a repeating cycle where
619
+ * each rate-limited layout plays at evenly spaced intervals and gaps are
620
+ * filled by unlimited layouts and the CMS default.
614
621
  *
615
- * No interleaving when:
616
- * - There is no default layout configured
617
- * - There are no scheduled layouts (only default plays)
618
- * - There is only one scheduled layout (no gaps to fill)
622
+ * The queue is cached and only rebuilt when:
623
+ * - The schedule changes (setSchedule)
624
+ * - The active layout set changes (time boundary crossed)
625
+ * - durations are updated
619
626
  *
620
- * @returns {string[]} Layout files with default interleaved
627
+ * @param {Map<string, number>} durations - layoutFile duration in seconds
628
+ * @param {Object} [options]
629
+ * @param {Set<string>} [options.dynamicLayouts] - Set of layout files with useDuration=0
630
+ * @returns {{ queue: Array<{layoutId: string, duration: number}>, periodSeconds: number }}
621
631
  */
622
- getInterleavedLayouts() {
623
- const layouts = this.getCurrentLayouts();
624
- const defaultLayout = this.schedule?.default;
632
+ getScheduleQueue(durations, options = {}) {
633
+ const allLayouts = this.getAllLayoutsAtTime(new Date());
634
+ const layoutSetKey = allLayouts.map(l => `${l.file}:${l.priority}:${l.maxPlaysPerHour}`).sort().join('|');
625
635
 
626
- // No interleaving needed: no default, no layouts, or single layout
627
- if (!defaultLayout || layouts.length <= 1) {
628
- return layouts;
636
+ // Return cached queue if the active layout set hasn't changed
637
+ if (this._scheduleQueue && this._queueLayoutSet === layoutSetKey) {
638
+ return this._scheduleQueue;
629
639
  }
630
640
 
631
- // If the only layout is the default itself, return as-is
632
- if (layouts.length === 1 && layouts[0] === defaultLayout) {
633
- return layouts;
634
- }
641
+ const result = buildScheduleQueue(allLayouts, durations, {
642
+ defaultLayout: this.schedule?.default || null,
643
+ defaultDuration: 60,
644
+ dynamicLayouts: options.dynamicLayouts || new Set(),
645
+ });
646
+
647
+ this._scheduleQueue = result;
648
+ this._queueLayoutSet = layoutSetKey;
649
+ // Reset position when queue is rebuilt
650
+ this._queuePosition = 0;
635
651
 
636
- // Interleave: A, D, B, D, C, D
637
- const interleaved = [];
638
- for (const layout of layouts) {
639
- interleaved.push(layout);
640
- interleaved.push(defaultLayout);
652
+ if (result.queue.length > 0) {
653
+ log.info(`[Schedule] Built queue: ${result.queue.length} entries, period ${result.periodSeconds}s`);
654
+ log.info(`[Schedule] Queue: ${result.queue.map(e => `${e.layoutId}(${e.duration}s)`).join(' → ')}`);
641
655
  }
642
656
 
643
- log.info('[Schedule] Interleaved', layouts.length, 'layouts with default', defaultLayout);
644
- return interleaved;
657
+ return result;
658
+ }
659
+
660
+ /**
661
+ * Pop the next entry from the schedule queue.
662
+ * Wraps around at the end (the LCM period guarantees the pattern repeats).
663
+ *
664
+ * @param {Map<string, number>} durations - layoutFile → duration in seconds
665
+ * @param {Object} [options]
666
+ * @param {Set<string>} [options.dynamicLayouts] - Dynamic layout set
667
+ * @returns {{ layoutId: string, duration: number } | null}
668
+ */
669
+ popNextFromQueue(durations, options = {}) {
670
+ const { queue } = this.getScheduleQueue(durations, options);
671
+ if (queue.length === 0) return null;
672
+
673
+ const entry = queue[this._queuePosition % queue.length];
674
+ this._queuePosition = (this._queuePosition + 1) % queue.length;
675
+ return entry;
676
+ }
677
+
678
+ /**
679
+ * Peek at the next entry in the schedule queue without advancing.
680
+ *
681
+ * @param {Map<string, number>} durations - layoutFile → duration in seconds
682
+ * @param {Object} [options]
683
+ * @returns {{ layoutId: string, duration: number } | null}
684
+ */
685
+ peekNextInQueue(durations, options = {}) {
686
+ const { queue } = this.getScheduleQueue(durations, options);
687
+ if (queue.length === 0) return null;
688
+ return queue[this._queuePosition % queue.length];
689
+ }
690
+
691
+ /**
692
+ * Peek at the entry after the current one (two positions ahead).
693
+ * Used for preloading.
694
+ *
695
+ * @param {Map<string, number>} durations
696
+ * @param {Object} [options]
697
+ * @returns {{ layoutId: string, duration: number } | null}
698
+ */
699
+ peekAfterNext(durations, options = {}) {
700
+ const { queue } = this.getScheduleQueue(durations, options);
701
+ if (queue.length <= 1) return null;
702
+ return queue[(this._queuePosition + 1) % queue.length];
703
+ }
704
+
705
+ /**
706
+ * Invalidate the cached queue (called on schedule change, time boundaries, etc.)
707
+ */
708
+ _invalidateQueue() {
709
+ this._scheduleQueue = null;
710
+ this._queueLayoutSet = null;
711
+ this._queuePosition = 0;
645
712
  }
646
713
 
647
714
  /**
@@ -196,107 +196,150 @@ describe('ScheduleManager - Campaigns', () => {
196
196
  });
197
197
  });
198
198
 
199
- describe('ScheduleManager - Default Layout Interleaving', () => {
199
+ describe('ScheduleManager - Schedule Queue (LCM-based)', () => {
200
200
  let manager;
201
+ const durations = new Map();
201
202
 
202
203
  beforeEach(() => {
203
204
  manager = new ScheduleManager();
205
+ durations.clear();
206
+ durations.set('100', 60);
207
+ durations.set('200', 60);
208
+ durations.set('300', 60);
209
+ durations.set('999', 60);
204
210
  });
205
211
 
206
- it('should interleave default layout between multiple scheduled layouts', () => {
212
+ it('should build a queue with unlimited layouts and default', () => {
207
213
  manager.setSchedule({
208
214
  default: '999',
209
215
  layouts: [
210
216
  { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
211
217
  { file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
212
- { file: '300', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
213
218
  ],
214
219
  campaigns: []
215
220
  });
216
221
 
217
- const layouts = manager.getInterleavedLayouts();
222
+ const { queue } = manager.getScheduleQueue(durations);
218
223
 
219
- expect(layouts).toEqual(['100', '999', '200', '999', '300', '999']);
224
+ // Should include all layouts
225
+ expect(queue.length).toBeGreaterThan(0);
226
+ const layoutIds = queue.map(e => e.layoutId);
227
+ expect(layoutIds).toContain('100');
228
+ expect(layoutIds).toContain('200');
220
229
  });
221
230
 
222
- it('should not interleave when only default layout is active', () => {
231
+ it('should return only default when no layouts are scheduled', () => {
223
232
  manager.setSchedule({
224
233
  default: '999',
225
234
  layouts: [],
226
235
  campaigns: []
227
236
  });
228
237
 
229
- const layouts = manager.getInterleavedLayouts();
238
+ const { queue } = manager.getScheduleQueue(durations);
230
239
 
231
- expect(layouts).toEqual(['999']);
240
+ expect(queue).toHaveLength(1);
241
+ expect(queue[0].layoutId).toBe('999');
232
242
  });
233
243
 
234
- it('should not interleave when only one scheduled layout', () => {
244
+ it('should return empty queue when no schedule set', () => {
245
+ const { queue } = manager.getScheduleQueue(durations);
246
+
247
+ expect(queue).toEqual([]);
248
+ });
249
+
250
+ it('should place rate-limited layouts at even intervals', () => {
251
+ durations.set('472', 219);
235
252
  manager.setSchedule({
236
253
  default: '999',
237
254
  layouts: [
238
- { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
255
+ { file: '472', priority: 10, maxPlaysPerHour: 3, fromdt: dateStr(-1), todt: dateStr(1) },
239
256
  ],
240
257
  campaigns: []
241
258
  });
242
259
 
243
- const layouts = manager.getInterleavedLayouts();
260
+ const { queue, periodSeconds } = manager.getScheduleQueue(durations);
244
261
 
245
- expect(layouts).toEqual(['100']);
262
+ // With maxPlaysPerHour=3, interval=1200s, LCM=1200s
263
+ expect(periodSeconds).toBe(1200);
264
+ // Should have exactly 1 play of 472 in a 1200s period
265
+ const plays472 = queue.filter(e => e.layoutId === '472');
266
+ expect(plays472).toHaveLength(1);
267
+ // Gaps filled by default
268
+ const defaultPlays = queue.filter(e => e.layoutId === '999');
269
+ expect(defaultPlays.length).toBeGreaterThan(0);
246
270
  });
247
271
 
248
- it('should preserve layout order when interleaving', () => {
272
+ it('should pop entries in order and wrap around', () => {
249
273
  manager.setSchedule({
250
274
  default: '999',
251
- layouts: [],
252
- campaigns: [
253
- {
254
- id: '1',
255
- priority: 10,
256
- fromdt: dateStr(-1),
257
- todt: dateStr(1),
258
- layouts: [
259
- { file: '205' },
260
- { file: '203' },
261
- { file: '204' }
262
- ]
263
- }
264
- ]
275
+ layouts: [
276
+ { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
277
+ { file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
278
+ ],
279
+ campaigns: []
265
280
  });
266
281
 
267
- const layouts = manager.getInterleavedLayouts();
268
-
269
- // Order must be preserved: 205, default, 203, default, 204, default
270
- expect(layouts).toEqual(['205', '999', '203', '999', '204', '999']);
271
- });
282
+ const { queue } = manager.getScheduleQueue(durations);
283
+ const firstPop = manager.popNextFromQueue(durations);
284
+ const secondPop = manager.popNextFromQueue(durations);
272
285
 
273
- it('should return empty array when no schedule is set', () => {
274
- const layouts = manager.getInterleavedLayouts();
286
+ expect(firstPop.layoutId).toBe(queue[0].layoutId);
287
+ expect(secondPop.layoutId).toBe(queue[1].layoutId);
275
288
 
276
- expect(layouts).toEqual([]);
289
+ // Pop through entire queue to test wrap-around
290
+ for (let i = 2; i < queue.length; i++) {
291
+ manager.popNextFromQueue(durations);
292
+ }
293
+ const wrapped = manager.popNextFromQueue(durations);
294
+ expect(wrapped.layoutId).toBe(queue[0].layoutId);
277
295
  });
278
296
 
279
- it('should not interleave when no default layout is configured', () => {
297
+ it('should invalidate queue on schedule change', () => {
280
298
  manager.setSchedule({
299
+ default: '999',
281
300
  layouts: [
282
301
  { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
283
- { file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
284
302
  ],
285
303
  campaigns: []
286
304
  });
287
305
 
288
- const layouts = manager.getInterleavedLayouts();
306
+ manager.popNextFromQueue(durations); // advance position
289
307
 
290
- // No default, so no interleaving just the scheduled layouts
291
- expect(layouts).toEqual(['100', '200']);
308
+ // Change scheduleshould reset
309
+ manager.setSchedule({
310
+ default: '999',
311
+ layouts: [
312
+ { file: '200', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
313
+ ],
314
+ campaigns: []
315
+ });
316
+
317
+ const { queue } = manager.getScheduleQueue(durations);
318
+ expect(queue.map(e => e.layoutId)).toContain('200');
319
+ expect(queue.map(e => e.layoutId)).not.toContain('100');
292
320
  });
293
321
 
294
- it('should interleave with campaign and standalone layouts at same priority', () => {
322
+ it('should cache queue and return same result for unchanged schedule', () => {
295
323
  manager.setSchedule({
296
324
  default: '999',
297
325
  layouts: [
298
- { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) }
326
+ { file: '100', priority: 10, fromdt: dateStr(-1), todt: dateStr(1) },
299
327
  ],
328
+ campaigns: []
329
+ });
330
+
331
+ const result1 = manager.getScheduleQueue(durations);
332
+ const result2 = manager.getScheduleQueue(durations);
333
+
334
+ expect(result1).toBe(result2); // Same reference (cached)
335
+ });
336
+
337
+ it('should include campaign layouts in queue', () => {
338
+ durations.set('205', 60);
339
+ durations.set('203', 60);
340
+ manager.setSchedule({
341
+ default: '999',
342
+ layouts: [],
300
343
  campaigns: [
301
344
  {
302
345
  id: '1',
@@ -304,21 +347,17 @@ describe('ScheduleManager - Default Layout Interleaving', () => {
304
347
  fromdt: dateStr(-1),
305
348
  todt: dateStr(1),
306
349
  layouts: [
307
- { file: '200' },
308
- { file: '201' }
350
+ { file: '205' },
351
+ { file: '203' },
309
352
  ]
310
353
  }
311
354
  ]
312
355
  });
313
356
 
314
- const layouts = manager.getInterleavedLayouts();
315
-
316
- // 3 scheduled layouts + 3 default insertions = 6
317
- expect(layouts).toHaveLength(6);
318
- // Every odd-indexed element should be the default
319
- expect(layouts[1]).toBe('999');
320
- expect(layouts[3]).toBe('999');
321
- expect(layouts[5]).toBe('999');
357
+ const { queue } = manager.getScheduleQueue(durations);
358
+ const layoutIds = queue.map(e => e.layoutId);
359
+ expect(layoutIds).toContain('205');
360
+ expect(layoutIds).toContain('203');
322
361
  });
323
362
  });
324
363
 
package/src/timeline.js CHANGED
@@ -16,19 +16,20 @@
16
16
  * 3. Fallback: 60s
17
17
  *
18
18
  * @param {string} xlfXml - Raw XLF XML string
19
- * @returns {number} Duration in seconds
19
+ * @returns {{ duration: number, isDynamic: boolean }} Duration in seconds and whether any widget has useDuration=0
20
20
  */
21
21
  export function parseLayoutDuration(xlfXml) {
22
22
  const doc = new DOMParser().parseFromString(xlfXml, 'text/xml');
23
23
  const layoutEl = doc.querySelector('layout');
24
- if (!layoutEl) return 60;
24
+ if (!layoutEl) return { duration: 60, isDynamic: false };
25
25
 
26
26
  // 1. Explicit layout duration attribute
27
27
  const explicit = parseInt(layoutEl.getAttribute('duration') || '0', 10);
28
- if (explicit > 0) return explicit;
28
+ if (explicit > 0) return { duration: explicit, isDynamic: false };
29
29
 
30
30
  // 2. Calculate from widget durations (max region wins — regions play in parallel)
31
31
  let maxDuration = 0;
32
+ let isDynamic = false;
32
33
  for (const regionEl of layoutEl.querySelectorAll('region')) {
33
34
  let regionDuration = 0;
34
35
  for (const mediaEl of regionEl.querySelectorAll('media')) {
@@ -40,12 +41,14 @@ export function parseLayoutDuration(xlfXml) {
40
41
  // Video with useDuration=0 means "play to end" — estimate 60s,
41
42
  // corrected later via recordLayoutDuration() when video metadata loads
42
43
  regionDuration += 60;
44
+ isDynamic = true;
43
45
  }
44
46
  }
45
47
  maxDuration = Math.max(maxDuration, regionDuration);
46
48
  }
47
49
 
48
- return maxDuration > 0 ? maxDuration : 60;
50
+ const duration = maxDuration > 0 ? maxDuration : 60;
51
+ return { duration, isDynamic };
49
52
  }
50
53
 
51
54
  /**
@@ -267,3 +270,136 @@ export function calculateTimeline(schedule, durations, options = {}) {
267
270
 
268
271
  return timeline;
269
272
  }
273
+
274
+ // ── LCM-based deterministic schedule queue ──────────────────────────────
275
+
276
+ /**
277
+ * Greatest common divisor (Euclidean algorithm).
278
+ * @param {number} a
279
+ * @param {number} b
280
+ * @returns {number}
281
+ */
282
+ function gcd(a, b) {
283
+ a = Math.abs(Math.round(a));
284
+ b = Math.abs(Math.round(b));
285
+ while (b) { [a, b] = [b, a % b]; }
286
+ return a;
287
+ }
288
+
289
+ /**
290
+ * Least common multiple of two integers.
291
+ * @param {number} a
292
+ * @param {number} b
293
+ * @returns {number}
294
+ */
295
+ function lcm(a, b) {
296
+ if (a === 0 || b === 0) return 0;
297
+ return Math.abs(Math.round(a) * Math.round(b)) / gcd(a, b);
298
+ }
299
+
300
+ /**
301
+ * LCM of an array of integers.
302
+ * @param {number[]} values
303
+ * @returns {number}
304
+ */
305
+ function lcmArray(values) {
306
+ return values.reduce((acc, v) => lcm(acc, v), 1);
307
+ }
308
+
309
+ /**
310
+ * Build a deterministic playback queue by simulating one LCM period.
311
+ *
312
+ * Uses getPlayableLayouts() (the same priority-fallback + rate-limit logic
313
+ * that calculateTimeline uses) to simulate playback for one repeating cycle.
314
+ * This ensures the queue matches the timeline overlay exactly: high-priority
315
+ * rate-limited layouts get their slots, then lower-priority layouts fill gaps.
316
+ *
317
+ * @param {Array<{file: string, priority: number, maxPlaysPerHour: number}>} allLayouts
318
+ * All time-active layouts from schedule.getAllLayoutsAtTime()
319
+ * @param {Map<string, number>} durations
320
+ * Map of layoutFile → duration in seconds
321
+ * @param {Object} [options]
322
+ * @param {string} [options.defaultLayout] - Default layout file (CMS fallback)
323
+ * @param {number} [options.defaultDuration] - Fallback duration (default: 60)
324
+ * @param {Set<string>} [options.dynamicLayouts] - Set of layout files that are dynamic (video, useDuration=0)
325
+ * @returns {{ queue: Array<{layoutId: string, duration: number}>, periodSeconds: number }}
326
+ */
327
+ export function buildScheduleQueue(allLayouts, durations, options = {}) {
328
+ const {
329
+ defaultLayout = null,
330
+ defaultDuration = 60,
331
+ } = options;
332
+
333
+ if (allLayouts.length === 0 && !defaultLayout) {
334
+ return { queue: [], periodSeconds: 0 };
335
+ }
336
+
337
+ // Step 1: Identify rate-limited layouts to calculate LCM period
338
+ const rateLimited = allLayouts.filter(l => l.maxPlaysPerHour > 0);
339
+
340
+ let periodSeconds;
341
+ if (rateLimited.length > 0) {
342
+ const intervals = rateLimited.map(l => Math.round(3600 / l.maxPlaysPerHour));
343
+ periodSeconds = lcmArray(intervals);
344
+ // Cap at 2 hours to prevent absurd periods
345
+ if (periodSeconds > 7200) periodSeconds = 7200;
346
+ } else {
347
+ // No rate-limited layouts — single round-robin cycle
348
+ const totalDuration = allLayouts.reduce((sum, l) => sum + (durations.get(l.file) || defaultDuration), 0)
349
+ + (defaultLayout && !allLayouts.some(l => l.file === defaultLayout)
350
+ ? (durations.get(defaultLayout) || defaultDuration)
351
+ : 0);
352
+ periodSeconds = totalDuration || defaultDuration;
353
+ }
354
+
355
+ // Step 2: Simulate playback for one period using getPlayableLayouts()
356
+ const queue = [];
357
+ const simPlays = new Map(); // file → [timestampMs] for rate-limit tracking
358
+ let cursorMs = 0;
359
+ const periodMs = periodSeconds * 1000;
360
+ const maxEntries = 500; // safety cap
361
+
362
+ while (cursorMs < periodMs && queue.length < maxEntries) {
363
+ // Get playable layouts at current simulated time (priority fallback + rate limits)
364
+ const playable = getPlayableLayouts(allLayouts, simPlays, cursorMs);
365
+
366
+ if (playable.length === 0) {
367
+ // All layouts exhausted — use default
368
+ if (defaultLayout) {
369
+ const dur = durations.get(defaultLayout) || defaultDuration;
370
+ queue.push({ layoutId: defaultLayout, duration: dur });
371
+ cursorMs += dur * 1000;
372
+ } else {
373
+ // No default — skip ahead 60s to avoid infinite loop
374
+ cursorMs += 60000;
375
+ }
376
+ continue;
377
+ }
378
+
379
+ // Play all playable layouts in round-robin order (one each), then re-evaluate
380
+ for (let i = 0; i < playable.length && cursorMs < periodMs && queue.length < maxEntries; i++) {
381
+ const file = playable[i];
382
+ const dur = durations.get(file) || defaultDuration;
383
+
384
+ queue.push({ layoutId: file, duration: dur });
385
+
386
+ // Record simulated play for rate-limit tracking
387
+ if (!simPlays.has(file)) simPlays.set(file, []);
388
+ simPlays.get(file).push(cursorMs);
389
+
390
+ cursorMs += dur * 1000;
391
+
392
+ // Re-evaluate after each play: if the playable set changed, break to outer loop
393
+ const nextPlayable = getPlayableLayouts(allLayouts, simPlays, cursorMs);
394
+ if (!arraysEqual(playable, nextPlayable)) break;
395
+ }
396
+ }
397
+
398
+ // Handle edge case: no layouts and only default
399
+ if (queue.length === 0 && defaultLayout) {
400
+ const defDur = durations.get(defaultLayout) || defaultDuration;
401
+ queue.push({ layoutId: defaultLayout, duration: defDur });
402
+ }
403
+
404
+ return { queue, periodSeconds };
405
+ }