@xiboplayer/schedule 0.1.3 → 0.2.0

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.1.3",
3
+ "version": "0.2.0",
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.1.3"
14
+ "@xiboplayer/utils": "0.2.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "vitest": "^2.0.0"
package/src/index.js CHANGED
@@ -18,3 +18,9 @@ export { InterruptScheduler } from './interrupts.js';
18
18
  * @module @xiboplayer/schedule/overlays
19
19
  */
20
20
  export { OverlayScheduler } from './overlays.js';
21
+
22
+ /**
23
+ * Offline timeline calculator — duration parser + timeline simulator
24
+ * @module @xiboplayer/schedule/timeline
25
+ */
26
+ export { calculateTimeline, parseLayoutDuration } from './timeline.js';
package/src/schedule.js CHANGED
@@ -2,8 +2,11 @@
2
2
  * Schedule manager - determines which layouts to show
3
3
  */
4
4
 
5
+ import { createLogger } from '@xiboplayer/utils';
5
6
  import { evaluateCriteria } from './criteria.js';
6
7
 
8
+ const log = createLogger('Schedule');
9
+
7
10
  export class ScheduleManager {
8
11
  constructor(options = {}) {
9
12
  this.schedule = null;
@@ -131,11 +134,86 @@ export class ScheduleManager {
131
134
  * - Interrupts are interleaved with normal layouts
132
135
  */
133
136
  getCurrentLayouts() {
137
+ return this._getLayoutsAt(new Date());
138
+ }
139
+
140
+ /**
141
+ * Get layouts active at a specific time.
142
+ * Skips rate limiting and interrupt processing (those depend on real-time state).
143
+ * Used by timeline calculator to predict future playback.
144
+ * @param {Date} time - The time to evaluate
145
+ * @returns {string[]} Layout files active at that time
146
+ */
147
+ getLayoutsAtTime(time) {
148
+ return this._getLayoutsAt(time, { skipRateLimiting: true, skipInterrupts: true, quiet: true });
149
+ }
150
+
151
+ /**
152
+ * Get ALL time-active layouts with metadata, without priority or rate-limit filtering.
153
+ * Used by calculateTimeline() to simulate real playback with rate limiting and
154
+ * priority fallback (e.g., when high-priority layouts hit maxPlaysPerHour, lower
155
+ * priority layouts fill the gap).
156
+ *
157
+ * @param {Date} time - The time to evaluate
158
+ * @returns {Array<{file: string, priority: number, maxPlaysPerHour: number}>}
159
+ */
160
+ getAllLayoutsAtTime(time) {
161
+ if (!this.schedule) return [];
162
+
163
+ const now = time;
164
+ const results = [];
165
+
166
+ // Standalone layouts
167
+ if (this.schedule.layouts) {
168
+ for (const layout of this.schedule.layouts) {
169
+ if (!this.isRecurringScheduleActive(layout, now)) continue;
170
+ if (!this.isTimeActive(layout, now)) continue;
171
+ if (layout.criteria && layout.criteria.length > 0) {
172
+ if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties })) continue;
173
+ }
174
+ if (layout.isGeoAware && layout.geoLocation) {
175
+ if (!this.isWithinGeoFence(layout.geoLocation)) continue;
176
+ }
177
+ results.push({
178
+ file: layout.file,
179
+ priority: layout.priority || 0,
180
+ maxPlaysPerHour: layout.maxPlaysPerHour || 0,
181
+ });
182
+ }
183
+ }
184
+
185
+ // Campaign layouts
186
+ if (this.schedule.campaigns) {
187
+ for (const campaign of this.schedule.campaigns) {
188
+ if (!this.isRecurringScheduleActive(campaign, now)) continue;
189
+ if (!this.isTimeActive(campaign, now)) continue;
190
+ for (const layout of campaign.layouts) {
191
+ results.push({
192
+ file: layout.file,
193
+ priority: campaign.priority || 0,
194
+ maxPlaysPerHour: layout.maxPlaysPerHour || 0,
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ return results;
201
+ }
202
+
203
+ /**
204
+ * Internal: evaluate schedule at a given time.
205
+ * @param {Date} now - Time to evaluate
206
+ * @param {Object} [options] - Options
207
+ * @param {boolean} [options.skipRateLimiting] - Skip maxPlaysPerHour checks
208
+ * @param {boolean} [options.skipInterrupts] - Skip interrupt/shareOfVoice processing
209
+ */
210
+ _getLayoutsAt(now, options = {}) {
134
211
  if (!this.schedule) {
135
212
  return [];
136
213
  }
137
214
 
138
- const now = new Date();
215
+ const { skipRateLimiting = false, skipInterrupts = false, quiet = false } = options;
216
+ const _log = quiet ? () => {} : (...args) => log.info(...args);
139
217
  const activeItems = []; // Mix of campaign objects and standalone layouts
140
218
 
141
219
  // Track the highest priority of any time-active layout BEFORE rate-limit
@@ -181,7 +259,7 @@ export class ScheduleManager {
181
259
  // Check criteria conditions (date/time, display properties)
182
260
  if (layout.criteria && layout.criteria.length > 0) {
183
261
  if (!evaluateCriteria(layout.criteria, { now, displayProperties: this.displayProperties })) {
184
- console.log('[Schedule] Layout', layout.id, 'filtered by criteria');
262
+ _log('[Schedule] Layout', layout.id, 'filtered by criteria');
185
263
  continue;
186
264
  }
187
265
  }
@@ -189,7 +267,7 @@ export class ScheduleManager {
189
267
  // Check geo-fencing
190
268
  if (layout.isGeoAware && layout.geoLocation) {
191
269
  if (!this.isWithinGeoFence(layout.geoLocation)) {
192
- console.log('[Schedule] Layout', layout.id, 'filtered by geofence');
270
+ _log('[Schedule] Layout', layout.id, 'filtered by geofence');
193
271
  continue;
194
272
  }
195
273
  }
@@ -197,9 +275,9 @@ export class ScheduleManager {
197
275
  // Track priority before rate-limit filtering
198
276
  this._maxActivePriority = Math.max(this._maxActivePriority, layout.priority || 0);
199
277
 
200
- // Check max plays per hour - but track that we filtered it
201
- if (!this.canPlayLayout(layout.id, layout.maxPlaysPerHour)) {
202
- console.log('[Schedule] Layout', layout.id, 'filtered by maxPlaysPerHour (limit:', layout.maxPlaysPerHour, ')');
278
+ // Check max plays per hour (skip for future time queries)
279
+ if (!skipRateLimiting && !this.canPlayLayout(layout.id, layout.maxPlaysPerHour)) {
280
+ _log('[Schedule] Layout', layout.id, 'filtered by maxPlaysPerHour (limit:', layout.maxPlaysPerHour, ')');
203
281
  // Continue to check other layouts, but don't add this one
204
282
  continue;
205
283
  }
@@ -220,17 +298,17 @@ export class ScheduleManager {
220
298
 
221
299
  // Find maximum priority across all items (campaigns and layouts)
222
300
  let maxPriority = Math.max(...activeItems.map(item => item.priority));
223
- console.log('[Schedule] Max priority:', maxPriority, 'from', activeItems.length, 'active items');
301
+ _log('[Schedule] Max priority:', maxPriority, 'from', activeItems.length, 'active items');
224
302
 
225
303
  // Collect all layouts from items with max priority
226
304
  let allLayouts = [];
227
305
  for (const item of activeItems) {
228
306
  if (item.priority === maxPriority) {
229
- console.log('[Schedule] Including priority', item.priority, 'layouts:', item.layouts.map(l => l.file));
307
+ _log('[Schedule] Including priority', item.priority, 'layouts:', item.layouts.map(l => l.file));
230
308
  // Add all layouts from this campaign or standalone layout
231
309
  allLayouts.push(...item.layouts);
232
310
  } else {
233
- console.log('[Schedule] Skipping priority', item.priority, '< max', maxPriority);
311
+ _log('[Schedule] Skipping priority', item.priority, '< max', maxPriority);
234
312
  }
235
313
  }
236
314
 
@@ -245,23 +323,23 @@ export class ScheduleManager {
245
323
  });
246
324
  }
247
325
 
248
- // Process interrupts if interrupt scheduler is available
249
- if (this.interruptScheduler) {
326
+ // Process interrupts if interrupt scheduler is available (skip for future time queries)
327
+ if (!skipInterrupts && this.interruptScheduler) {
250
328
  const { normalLayouts, interruptLayouts } = this.interruptScheduler.separateLayouts(allLayouts);
251
329
 
252
330
  if (interruptLayouts.length > 0) {
253
- console.log('[Schedule] Found', interruptLayouts.length, 'interrupt layouts with shareOfVoice');
331
+ _log('[Schedule] Found', interruptLayouts.length, 'interrupt layouts with shareOfVoice');
254
332
  const processedLayouts = this.interruptScheduler.processInterrupts(normalLayouts, interruptLayouts);
255
333
  // Extract file IDs from processed layouts
256
334
  const result = processedLayouts.map(l => l.file);
257
- console.log('[Schedule] Final layouts (with interrupts):', result);
335
+ _log('[Schedule] Final layouts (with interrupts):', result);
258
336
  return result;
259
337
  }
260
338
  }
261
339
 
262
340
  // No interrupts, return layout files
263
341
  const result = allLayouts.map(l => l.file);
264
- console.log('[Schedule] Final layouts:', result);
342
+ _log('[Schedule] Final layouts:', result);
265
343
  return result;
266
344
  }
267
345
 
@@ -307,7 +385,7 @@ export class ScheduleManager {
307
385
 
308
386
  // Check 1: Total plays in last hour must be under limit
309
387
  if (playsInLastHour.length >= maxPlaysPerHour) {
310
- console.log(`[Schedule] Layout ${layoutId} has reached max plays per hour (${playsInLastHour.length}/${maxPlaysPerHour})`);
388
+ log.info(`Layout ${layoutId} has reached max plays per hour (${playsInLastHour.length}/${maxPlaysPerHour})`);
311
389
  return false;
312
390
  }
313
391
 
@@ -320,7 +398,7 @@ export class ScheduleManager {
320
398
 
321
399
  if (elapsed < minGapMs) {
322
400
  const remainingMin = ((minGapMs - elapsed) / 60000).toFixed(1);
323
- console.log(`[Schedule] Layout ${layoutId} spacing: next play in ${remainingMin} min (${playsInLastHour.length}/${maxPlaysPerHour} plays, ${Math.round(minGapMs/60000)} min gap)`);
401
+ log.info(`Layout ${layoutId} spacing: next play in ${remainingMin} min (${playsInLastHour.length}/${maxPlaysPerHour} plays, ${Math.round(minGapMs/60000)} min gap)`);
324
402
  return false;
325
403
  }
326
404
  }
@@ -345,7 +423,7 @@ export class ScheduleManager {
345
423
  const cleaned = history.filter(timestamp => timestamp > oneHourAgo);
346
424
  this.playHistory.set(layoutId, cleaned);
347
425
 
348
- console.log(`[Schedule] Recorded play for layout ${layoutId} (${cleaned.length} plays in last hour)`);
426
+ log.info(`Recorded play for layout ${layoutId} (${cleaned.length} plays in last hour)`);
349
427
  }
350
428
 
351
429
  /**
@@ -421,7 +499,7 @@ export class ScheduleManager {
421
499
  */
422
500
  clearPlayHistory() {
423
501
  this.playHistory.clear();
424
- console.log('[Schedule] Play history cleared');
502
+ log.info('Play history cleared');
425
503
  }
426
504
 
427
505
  /**
@@ -431,7 +509,7 @@ export class ScheduleManager {
431
509
  */
432
510
  setLocation(latitude, longitude) {
433
511
  this.playerLocation = { latitude, longitude };
434
- console.log(`[Schedule] Location set: ${latitude}, ${longitude}`);
512
+ log.info(`Location set: ${latitude}, ${longitude}`);
435
513
  }
436
514
 
437
515
  /**
@@ -456,7 +534,7 @@ export class ScheduleManager {
456
534
  isWithinGeoFence(geoLocation, defaultRadius = 500) {
457
535
  if (!this.playerLocation) {
458
536
  // No location available — be permissive, show the content
459
- console.log('[Schedule] No player location, skipping geofence check');
537
+ log.debug('No player location, skipping geofence check');
460
538
  return true;
461
539
  }
462
540
 
@@ -465,7 +543,7 @@ export class ScheduleManager {
465
543
  // Parse "lat,lng" format
466
544
  const parts = geoLocation.split(',').map(s => parseFloat(s.trim()));
467
545
  if (parts.length < 2 || isNaN(parts[0]) || isNaN(parts[1])) {
468
- console.log('[Schedule] Invalid geoLocation format:', geoLocation);
546
+ log.warn('Invalid geoLocation format:', geoLocation);
469
547
  return true; // Invalid format, be permissive
470
548
  }
471
549
 
@@ -479,7 +557,7 @@ export class ScheduleManager {
479
557
  );
480
558
 
481
559
  const within = distance <= radius;
482
- console.log(`[Schedule] Geofence: ${distance.toFixed(0)}m from (${fenceLat},${fenceLng}), radius ${radius}m → ${within ? 'WITHIN' : 'OUTSIDE'}`);
560
+ log.info(`Geofence: ${distance.toFixed(0)}m from (${fenceLat},${fenceLng}), radius ${radius}m → ${within ? 'WITHIN' : 'OUTSIDE'}`);
483
561
  return within;
484
562
  }
485
563
 
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Offline Schedule Timeline Calculator
3
+ *
4
+ * Calculates deterministic playback timelines by parsing layout XLF durations
5
+ * and simulating round-robin scheduling. Enables the player to answer
6
+ * "what's the playback plan for the next N hours?" while offline.
7
+ */
8
+
9
+ /**
10
+ * Parse layout duration from XLF XML string.
11
+ * Lightweight parser — uses DOMParser, no rendering.
12
+ *
13
+ * Duration resolution order:
14
+ * 1. Explicit <layout duration="60"> attribute
15
+ * 2. Sum of widget <media duration="X"> per region (max across regions)
16
+ * 3. Fallback: 60s
17
+ *
18
+ * @param {string} xlfXml - Raw XLF XML string
19
+ * @returns {number} Duration in seconds
20
+ */
21
+ export function parseLayoutDuration(xlfXml) {
22
+ const doc = new DOMParser().parseFromString(xlfXml, 'text/xml');
23
+ const layoutEl = doc.querySelector('layout');
24
+ if (!layoutEl) return 60;
25
+
26
+ // 1. Explicit layout duration attribute
27
+ const explicit = parseInt(layoutEl.getAttribute('duration') || '0', 10);
28
+ if (explicit > 0) return explicit;
29
+
30
+ // 2. Calculate from widget durations (max region wins — regions play in parallel)
31
+ let maxDuration = 0;
32
+ for (const regionEl of layoutEl.querySelectorAll('region')) {
33
+ let regionDuration = 0;
34
+ for (const mediaEl of regionEl.querySelectorAll('media')) {
35
+ const dur = parseInt(mediaEl.getAttribute('duration') || '0', 10);
36
+ const useDuration = parseInt(mediaEl.getAttribute('useDuration') || '1', 10);
37
+ if (dur > 0 && useDuration !== 0) {
38
+ regionDuration += dur;
39
+ } else {
40
+ // Video with useDuration=0 means "play to end" — estimate 60s,
41
+ // corrected later via recordLayoutDuration() when video metadata loads
42
+ regionDuration += 60;
43
+ }
44
+ }
45
+ maxDuration = Math.max(maxDuration, regionDuration);
46
+ }
47
+
48
+ return maxDuration > 0 ? maxDuration : 60;
49
+ }
50
+
51
+ /**
52
+ * Compare two arrays of layout files for equality.
53
+ * @param {string[]} a
54
+ * @param {string[]} b
55
+ * @returns {boolean}
56
+ */
57
+ function arraysEqual(a, b) {
58
+ if (a.length !== b.length) return false;
59
+ for (let i = 0; i < a.length; i++) {
60
+ if (a[i] !== b[i]) return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ /**
66
+ * Check if a layout can play at a given time based on simulated play history.
67
+ * Replicates ScheduleManager.canPlayLayout() logic for timeline prediction.
68
+ *
69
+ * Even-distribution rules:
70
+ * 1. Total plays in sliding 1-hour window < maxPlaysPerHour
71
+ * 2. Time since last play >= (60 / maxPlaysPerHour) minutes
72
+ *
73
+ * @param {number[]} history - Simulated play timestamps (ms) for this layout
74
+ * @param {number} maxPlaysPerHour - Max plays per hour (0 = unlimited)
75
+ * @param {number} timeMs - Current simulated time in ms
76
+ * @returns {boolean}
77
+ */
78
+ function canSimulatedPlay(history, maxPlaysPerHour, timeMs) {
79
+ if (!maxPlaysPerHour || maxPlaysPerHour === 0) return true;
80
+
81
+ const oneHourAgo = timeMs - 3600000;
82
+ const playsInLastHour = history.filter(t => t > oneHourAgo);
83
+
84
+ // Check 1: under hourly limit
85
+ if (playsInLastHour.length >= maxPlaysPerHour) return false;
86
+
87
+ // Check 2: minimum gap for even distribution
88
+ if (playsInLastHour.length > 0) {
89
+ const minGapMs = 3600000 / maxPlaysPerHour;
90
+ const lastPlay = Math.max(...playsInLastHour);
91
+ if (timeMs - lastPlay < minGapMs) return false;
92
+ }
93
+
94
+ return true;
95
+ }
96
+
97
+ /**
98
+ * Seed simulated play history from real play history.
99
+ * Maps layoutId-based history to layoutFile-based history.
100
+ * @param {Map<string, number[]>} realHistory - schedule.playHistory (layoutId → [timestamps])
101
+ * @returns {Map<string, number[]>} layoutFile → [timestamps]
102
+ */
103
+ function seedPlayHistory(realHistory) {
104
+ const simulated = new Map();
105
+ if (!realHistory) return simulated;
106
+
107
+ for (const [layoutId, timestamps] of realHistory) {
108
+ const file = `${layoutId}.xlf`;
109
+ simulated.set(file, [...timestamps]);
110
+ }
111
+ return simulated;
112
+ }
113
+
114
+ /**
115
+ * From a list of layout metadata, apply simulated rate limiting and priority
116
+ * filtering to determine which layouts can actually play at the given time.
117
+ * Mirrors the real player logic: filter rate-limited layouts first, then
118
+ * pick highest remaining priority.
119
+ *
120
+ * @param {Array<{file: string, priority: number, maxPlaysPerHour: number}>} allLayouts
121
+ * @param {Map<string, number[]>} simPlays - Simulated play history
122
+ * @param {number} timeMs - Current simulated time in ms
123
+ * @returns {string[]} Layout files that can play, highest priority first
124
+ */
125
+ function getPlayableLayouts(allLayouts, simPlays, timeMs) {
126
+ // Step 1: Filter out rate-limited layouts
127
+ const eligible = allLayouts.filter(l => {
128
+ if (!l.maxPlaysPerHour || l.maxPlaysPerHour === 0) return true;
129
+ const history = simPlays.get(l.file) || [];
130
+ return canSimulatedPlay(history, l.maxPlaysPerHour, timeMs);
131
+ });
132
+
133
+ if (eligible.length === 0) return [];
134
+
135
+ // Step 2: Pick highest priority from remaining layouts
136
+ const maxPriority = Math.max(...eligible.map(l => l.priority));
137
+ return eligible
138
+ .filter(l => l.priority === maxPriority)
139
+ .map(l => l.file);
140
+ }
141
+
142
+ /**
143
+ * Calculate a deterministic playback timeline by simulating round-robin scheduling
144
+ * with rate limiting (maxPlaysPerHour) and priority fallback. Produces a real
145
+ * schedule prediction that matches actual player behavior.
146
+ *
147
+ * When high-priority layouts hit their maxPlaysPerHour limit, the simulation
148
+ * falls back to lower-priority scheduled layouts before using the CMS default.
149
+ *
150
+ * @param {Object} schedule - ScheduleManager instance (needs getAllLayoutsAtTime(), schedule.default, playHistory)
151
+ * @param {Map<string, number>} durations - Map of layoutFile → duration in seconds
152
+ * @param {Object} [options]
153
+ * @param {Date} [options.from] - Start time (default: now)
154
+ * @param {number} [options.hours] - Hours to simulate (default: 2)
155
+ * @param {number} [options.defaultDuration] - Fallback duration in seconds (default: 60)
156
+ * @returns {Array<{layoutFile: string, startTime: Date, endTime: Date, duration: number, isDefault: boolean}>}
157
+ */
158
+ export function calculateTimeline(schedule, durations, options = {}) {
159
+ const from = options.from || new Date();
160
+ const hours = options.hours || 2;
161
+ const to = new Date(from.getTime() + hours * 3600000);
162
+ const defaultDuration = options.defaultDuration || 60;
163
+ const timeline = [];
164
+ let currentTime = new Date(from);
165
+
166
+ // Use getAllLayoutsAtTime if available (new API), fall back to getLayoutsAtTime (old API)
167
+ const hasFullApi = typeof schedule.getAllLayoutsAtTime === 'function';
168
+
169
+ // Seed simulated play history from real plays
170
+ const simPlays = seedPlayHistory(schedule.playHistory);
171
+
172
+ const maxEntries = 500;
173
+
174
+ while (currentTime < to && timeline.length < maxEntries) {
175
+ const timeMs = currentTime.getTime();
176
+ let playable;
177
+
178
+ if (hasFullApi) {
179
+ // Full simulation: get ALL active layouts, apply rate limiting + priority
180
+ const allLayouts = schedule.getAllLayoutsAtTime(currentTime);
181
+ playable = allLayouts.length > 0
182
+ ? getPlayableLayouts(allLayouts, simPlays, timeMs)
183
+ : [];
184
+ } else {
185
+ // Legacy fallback: no rate limiting simulation
186
+ playable = schedule.getLayoutsAtTime(currentTime);
187
+ }
188
+
189
+ if (playable.length === 0) {
190
+ // No playable layouts — use CMS default or skip ahead
191
+ const defaultFile = schedule.schedule?.default;
192
+ if (defaultFile) {
193
+ const dur = durations.get(defaultFile) || defaultDuration;
194
+ timeline.push({
195
+ layoutFile: defaultFile,
196
+ startTime: new Date(currentTime),
197
+ endTime: new Date(timeMs + dur * 1000),
198
+ duration: dur,
199
+ isDefault: true,
200
+ });
201
+ currentTime = new Date(timeMs + dur * 1000);
202
+ } else {
203
+ currentTime = new Date(timeMs + 60000);
204
+ }
205
+ continue;
206
+ }
207
+
208
+ // Round-robin through playable layouts
209
+ for (let i = 0; i < playable.length && currentTime < to && timeline.length < maxEntries; i++) {
210
+ const file = playable[i];
211
+ const dur = durations.get(file) || defaultDuration;
212
+ const endMs = currentTime.getTime() + dur * 1000;
213
+
214
+ timeline.push({
215
+ layoutFile: file,
216
+ startTime: new Date(currentTime),
217
+ endTime: new Date(endMs),
218
+ duration: dur,
219
+ isDefault: false,
220
+ });
221
+
222
+ // Record simulated play
223
+ if (hasFullApi) {
224
+ if (!simPlays.has(file)) simPlays.set(file, []);
225
+ simPlays.get(file).push(currentTime.getTime());
226
+ }
227
+
228
+ currentTime = new Date(endMs);
229
+
230
+ // Re-evaluate: if playable set changed, re-enter outer loop
231
+ if (hasFullApi) {
232
+ const nextAll = schedule.getAllLayoutsAtTime(currentTime);
233
+ const nextPlayable = nextAll.length > 0
234
+ ? getPlayableLayouts(nextAll, simPlays, currentTime.getTime())
235
+ : [];
236
+ if (!arraysEqual(playable, nextPlayable)) break;
237
+ } else {
238
+ const next = schedule.getLayoutsAtTime(currentTime);
239
+ if (!arraysEqual(playable, next)) break;
240
+ }
241
+ }
242
+ }
243
+
244
+ return timeline;
245
+ }