|
1 | 1 | import React, { type PropsWithChildren } from 'react' |
2 | 2 | import { Meteor } from 'meteor/meteor' |
| 3 | +import _ from 'underscore' |
3 | 4 | import { withTracker } from '../../../lib/ReactMeteorData/react-meteor-data.js' |
4 | 5 | import { protectString } from '@sofie-automation/shared-lib/dist/lib/protectedString' |
5 | 6 | import type { DBRundownPlaylist } from '@sofie-automation/corelib/dist/dataModel/RundownPlaylist/RundownPlaylist' |
@@ -32,6 +33,103 @@ const LOW_RESOLUTION_TIMING_DECIMATOR = 15 |
32 | 33 |
|
33 | 34 | const CURRENT_TIME_GRANULARITY = 1000 / 60 |
34 | 35 |
|
| 36 | +/** |
| 37 | + * Metadata-only fields that don't affect timing calculations or visual display. |
| 38 | + * This is used by the UI to filter unnecessary timing recalculations when the database updates. |
| 39 | + * If only these fields change in the database, we skip timing recalculation on the client. |
| 40 | + * Any other field changes will trigger a timing update and visual refresh. |
| 41 | + * |
| 42 | + * Note: The database writes ALL fields (this filtering is UI-side only). |
| 43 | + * |
| 44 | + * RATIONALE: Non-timing ingest changes (like segment.identifier or rundownPlaylist.modified) |
| 45 | + * trigger CommitIngestOperation which rewrites the entire RundownPlaylist document via .replace(). |
| 46 | + * This causes Meteor subscriptions to fire, updating the 'partInstances' prop reference, which |
| 47 | + * was previously causing cascading recalculations even when part durations hadn't changed. |
| 48 | + * This cascading effect created "visual noise" - rapid position shifts in the segment timeline. |
| 49 | + * By filtering out metadata-only changes, we prevent those unnecessary recalculations while |
| 50 | + * preserving database integrity (complete state always persisted). |
| 51 | + */ |
| 52 | +const TIMING_METADATA_ONLY_FIELDS = new Set<keyof DBRundownPlaylist>([ |
| 53 | + // Identification |
| 54 | + '_id', |
| 55 | + 'externalId', |
| 56 | + 'studioId', |
| 57 | + 'restoredFromSnapshotId', |
| 58 | + |
| 59 | + // Timestamps and tracking |
| 60 | + 'created', |
| 61 | + 'modified', |
| 62 | + 'resetTime', |
| 63 | + 'lastIncorrectPartPlaybackReported', |
| 64 | + 'lastTakeTime', |
| 65 | + |
| 66 | + // Display configuration (doesn't affect timing calculations) |
| 67 | + 'name', |
| 68 | + 'rehearsal', |
| 69 | + 'holdState', |
| 70 | + 'timeOfDayCountdowns', |
| 71 | + |
| 72 | + // Data storage |
| 73 | + 'privateData', |
| 74 | + 'publicData', |
| 75 | + 'notes', |
| 76 | + 'privatePlayoutPersistentState', |
| 77 | + 'publicPlayoutPersistentState', |
| 78 | + |
| 79 | + // AB session tracking |
| 80 | + 'trackedAbSessions', |
| 81 | + 'assignedAbSessions', |
| 82 | + |
| 83 | + // Offset (used in logic but not timing display) |
| 84 | + 'nextTimeOffset', |
| 85 | + 'queuedSegmentId', |
| 86 | + |
| 87 | + // UI editing state |
| 88 | + 'rundownRanksAreSetInSofie', |
| 89 | + |
| 90 | + // T-timers (internal state, not display) |
| 91 | + 'tTimers', |
| 92 | +]) |
| 93 | + |
| 94 | +/** |
| 95 | + * Check if timing-relevant fields in the playlist have changed. |
| 96 | + * This is used to prevent unnecessary timing recalculations when only metadata fields change. |
| 97 | + * |
| 98 | + * Fields in TIMING_METADATA_ONLY_FIELDS are ignored. |
| 99 | + * Any other field changes will return true. |
| 100 | + * |
| 101 | + * @param oldPlaylist The previous playlist state |
| 102 | + * @param newPlaylist The new playlist state |
| 103 | + * @returns true if any non-metadata fields changed, false if only metadata changed |
| 104 | + */ |
| 105 | +function didTimingFieldsChange( |
| 106 | + oldPlaylist: DBRundownPlaylist | undefined, |
| 107 | + newPlaylist: DBRundownPlaylist | undefined |
| 108 | +): boolean { |
| 109 | + // If either is undefined, they're different |
| 110 | + if (!oldPlaylist || !newPlaylist) { |
| 111 | + return oldPlaylist !== newPlaylist |
| 112 | + } |
| 113 | + |
| 114 | + // Check all fields except metadata-only fields |
| 115 | + for (const field of Object.keys(oldPlaylist) as Array<keyof DBRundownPlaylist>) { |
| 116 | + // Skip metadata-only fields |
| 117 | + if (TIMING_METADATA_ONLY_FIELDS.has(field)) { |
| 118 | + continue |
| 119 | + } |
| 120 | + |
| 121 | + const oldValue = oldPlaylist[field] |
| 122 | + const newValue = newPlaylist[field] |
| 123 | + |
| 124 | + // Use deep equality comparison |
| 125 | + if (!_.isEqual(oldValue, newValue)) { |
| 126 | + return true |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + return false |
| 131 | +} |
| 132 | + |
35 | 133 | /** |
36 | 134 | * RundownTimingProvider properties. |
37 | 135 | * @interface IRundownTimingProviderProps |
@@ -230,11 +328,13 @@ export const RundownTimingProvider = withTracker< |
230 | 328 | Meteor.clearInterval(this.refreshTimer) |
231 | 329 | this.refreshTimer = Meteor.setInterval(this.onRefreshTimer, this.refreshTimerInterval) |
232 | 330 | } |
233 | | - if ( |
234 | | - prevProps.partInstances !== this.props.partInstances || |
235 | | - prevProps.playlist?.nextPartInfo?.partInstanceId !== this.props.playlist?.nextPartInfo?.partInstanceId || |
236 | | - prevProps.playlist?.currentPartInfo?.partInstanceId !== this.props.playlist?.currentPartInfo?.partInstanceId |
237 | | - ) { |
| 331 | + |
| 332 | + // Only recalculate timing if timing-relevant fields actually changed |
| 333 | + // This prevents unnecessary recalculations when only metadata (modified, notes, etc.) changes |
| 334 | + const partInstancesChanged = prevProps.partInstances !== this.props.partInstances |
| 335 | + const timingFieldsChanged = didTimingFieldsChange(prevProps.playlist, this.props.playlist) |
| 336 | + |
| 337 | + if (partInstancesChanged || timingFieldsChanged) { |
238 | 338 | this.refreshDecimator = 0 // Force LR update |
239 | 339 | this.lastSyncedTime = 0 // Force synced update |
240 | 340 | this.onRefreshTimer() |
|
0 commit comments