1import Foundation
2import AVFoundation
3import QuartzCore
4
5/// Master timeline clock. Playhead is derived from a host-time anchor while
6/// playing, so all track players chase one authoritative time.
7final class PlaybackController {
8 /// The document context that owns this controller. Set at construction.
9 unowned var ctx: DocumentContext!
10
11 private(set) var rate: Double = 0
12 /// Last non-zero rate we played at, so Space (play/pause) resumes at the
13 /// speed you left off — including a J/K/L shuttle speed. J/L themselves
14 /// ignore this and always start from ±1x.
15 private var lastRate: Double = 1
16 /// The normal forward speed Space / play uses, settable from the transport's
17 /// speed dropdown (default 1×). This is separate from the J/K/L shuttle,
18 /// which always walks its own 1,2,4…64 ladder — so choosing, say, 1.5× normal
19 /// playback leaves scrubbing untouched.
20 private(set) var playSpeed: Double = 1
21 private var anchorHost: Double = 0
22 private var anchorTime: Double = 0
23 private var pausedPlayhead: Double = 0
24 /// Where the playhead sat when the CURRENT continuous playback run began,
25 /// so Escape can snap it back. Captured on the 0→moving transition (so a
26 /// mid-run shuttle speed change doesn't move it) and cleared on stop.
27 private var playbackStartPlayhead: Double?
28 private var timer: Timer?
29
30 /// Loop (cycle) range. Non-undoable session state, so it lives here rather
31 /// than in the model. When `loops` is on, playback wraps within
32 /// `[inPoint ?? 0, outPoint ?? timelineDuration]`.
33 private(set) var inPoint: Double?
34 private(set) var outPoint: Double?
35 private(set) var loops = false
36
37 var isPlaying: Bool { rate != 0 }
38
39 var playhead: Double {
40 guard rate != 0 else { return pausedPlayhead }
41 return max(0, anchorTime + (CACurrentMediaTime() - anchorHost) * rate)
42 }
43
44 func start() {
45 let t = Timer(timeInterval: 1.0 / 60.0, repeats: true) { [weak self] _ in self?.tick() }
46 RunLoop.main.add(t, forMode: .common)
47 timer = t
48 }
49
50 /// Stop the clock when the document closes — otherwise a closed window's
51 /// 60 Hz timer keeps running for the app's lifetime.
52 deinit { timer?.invalidate() }
53
54 /// Called from `DocumentContext.shutdown()` on document close: kill the timer
55 /// now (while `ctx` is still alive) so no `tick()` fires against a context
56 /// that's mid-teardown.
57 func stop() {
58 rate = 0
59 timer?.invalidate()
60 timer = nil
61 }
62
63 private func tick() {
64 // While paused nothing moves; edits and proxy completions push their
65 // own syncs, so idle costs nothing.
66 guard rate != 0 else { return }
67 if loops {
68 enforceLoop()
69 } else if rate < 0, playhead <= 0 {
70 setRate(0); seek(to: 0)
71 }
72 // Sync (which flips the double buffer at a cut) BEFORE the redraw, so the
73 // viewer reflects the flip on the same frame — no 1-tick lag at a cut.
74 ctx.players.sync()
75 ctx.notify.post(name: .playheadChanged, object: nil)
76 }
77
78 /// Wrap the playhead back into the cycle range when it runs off the far
79 /// end (forward past out, or reverse before in). The seek re-anchors but
80 /// leaves `rate` untouched, so playback keeps rolling from the wrap point.
81 private func enforceLoop() {
82 let lo = inPoint ?? 0
83 let hi = outPoint ?? ctx.store.project.timelineDuration
84 guard hi > lo else { return }
85 if rate > 0, playhead >= hi { seek(to: lo) }
86 else if rate < 0, playhead <= lo { seek(to: hi) }
87 }
88
89 func setRate(_ newRate: Double) {
90 let now = playhead
91 let wasStopped = rate == 0
92 rate = newRate
93 HangMonitor.playbackRate = newRate
94 if newRate == 0 {
95 pausedPlayhead = now
96 playbackStartPlayhead = nil
97 } else {
98 if wasStopped { playbackStartPlayhead = now }
99 lastRate = newRate
100 anchorHost = CACurrentMediaTime()
101 anchorTime = now
102 ctx.chunks.playbackDidStart()
103 }
104 ctx.notify.post(name: .playheadChanged, object: nil)
105 ctx.players.sync(force: true)
106 }
107
108 func togglePlay() { setRate(isPlaying ? 0 : lastRate) }
109
110 /// Escape while playing: stop and snap the playhead back to where this
111 /// playback run began. Returns false when already paused (nothing to revert),
112 /// so the caller can fall back to a plain stop.
113 @discardableResult
114 func stopAndRevert() -> Bool {
115 guard isPlaying, let origin = playbackStartPlayhead else { return false }
116 setRate(0)
117 seek(to: origin)
118 return true
119 }
120
121 /// Set the normal play speed (from the transport dropdown). Applies live if
122 /// already playing — keeping the current direction — otherwise it becomes the
123 /// speed the next Space starts at. Clamped to a sane 0.1×–64× range.
124 func setPlaySpeed(_ speed: Double) {
125 playSpeed = max(0.1, min(64, speed))
126 if rate != 0 {
127 setRate(rate < 0 ? -playSpeed : playSpeed) // live; also updates lastRate
128 } else {
129 lastRate = playSpeed // so the next Space uses it
130 ctx.notify.post(name: .playheadChanged, object: nil)
131 }
132 let text = playSpeed == playSpeed.rounded()
133 ? String(format: "%.0f×", playSpeed) : String(format: "%g×", playSpeed)
134 NotificationCenter.default.post(name: .transientStatus, object: nil,
135 userInfo: ["text": "Playback speed: \(text)"])
136 }
137
138 /// J/K/L: each press in the moving direction doubles the rate (capped at
139 /// 64x); pressing the opposite direction halves it until it stops.
140 func shuttle(_ direction: Double) {
141 if direction == 0 { setRate(0); return }
142 if rate == 0 {
143 setRate(direction)
144 } else if rate.sign == direction.sign {
145 setRate(max(-64, min(64, rate * 2)))
146 } else {
147 let slowed = rate / 2
148 setRate(abs(slowed) < 1 ? 0 : slowed)
149 }
150 }
151
152 func seek(to time: Double) {
153 let t = max(0, time)
154 pausedPlayhead = t
155 anchorHost = CACurrentMediaTime()
156 anchorTime = t
157 ctx.notify.post(name: .playheadChanged, object: nil)
158 ctx.players.sync(force: true)
159 }
160
161 func step(by seconds: Double) {
162 setRate(0)
163 seek(to: playhead + seconds)
164 }
165
166 /// Shift the playhead by `delta` seconds, preserving playback continuity.
167 /// Unlike `seek`, this re-anchors WITHOUT forcing a hard resync — used by
168 /// ripple edits where content slides under the playhead by the same amount,
169 /// so every player is already on the right frame and must not hiccup.
170 func shift(by delta: Double) {
171 let target = max(0, playhead + delta)
172 pausedPlayhead = target
173 anchorHost = CACurrentMediaTime()
174 anchorTime = target
175 ctx.notify.post(name: .playheadChanged, object: nil)
176 ctx.players.sync(force: false)
177 }
178
179 // MARK: - Loop / cycle range (I / O / C)
180
181 /// Mark the in point at the playhead. A collapsed range (out ≤ in) drops
182 /// the stale out point. Pressing In again at the same spot clears it.
183 func setIn() {
184 let t = playhead
185 if let i = inPoint, abs(i - t) < 0.5 / max(1, ctx.store.project.fps) {
186 inPoint = nil
187 inOutChanged("Cleared in point")
188 return
189 }
190 inPoint = t
191 if let o = outPoint, o <= t { outPoint = nil }
192 inOutChanged("In point \(timecode(t))")
193 }
194
195 /// Mark the out point at the playhead, dropping a now-stale in point.
196 /// Pressing Out again at the same spot clears it.
197 func setOut() {
198 let t = playhead
199 if let o = outPoint, abs(o - t) < 0.5 / max(1, ctx.store.project.fps) {
200 outPoint = nil
201 inOutChanged("Cleared out point")
202 return
203 }
204 outPoint = t
205 if let i = inPoint, i >= t { inPoint = nil }
206 inOutChanged("Out point \(timecode(t))")
207 }
208
209 func clearInOut() {
210 inPoint = nil
211 outPoint = nil
212 inOutChanged("Cleared in / out")
213 }
214
215 func toggleLoop() {
216 loops.toggle()
217 inOutChanged(loops ? "Loop on" : "Loop off")
218 }
219
220 var hasInOut: Bool { inPoint != nil || outPoint != nil }
221
222 private func inOutChanged(_ status: String) {
223 // Redraws the timeline (it observes .playheadChanged) without moving
224 // the playhead, and shows a brief HUD note.
225 ctx.notify.post(name: .playheadChanged, object: nil)
226 NotificationCenter.default.post(name: .transientStatus, object: nil,
227 userInfo: ["text": status])
228 }
229
230 private func timecode(_ t: Double) -> String {
231 let fps = ctx.store.project.fps
232 let total = Int((t * fps).rounded())
233 let f = total % Int(fps.rounded())
234 let s = Int(t) % 60
235 let m = Int(t) / 60
236 return String(format: "%02d:%02d:%02d", m, s, f)
237 }
238}
239
240/// One AVPlayer per track, kept in sync with the master clock. Plays the
241/// proxy when it exists, falls back to the original file otherwise.
242final class TrackPlayer {
243 /// The document context (set by PlayerManager when the player is created).
244 unowned var ctx: DocumentContext!
245 let player = AVPlayer()
246 var currentClipId: UUID?
247 var currentSourceURL: URL?
248 /// Audio players set this: audible glitches from hard resyncs are much
249 /// worse than a few frames of drift, so they get wide thresholds and a
250 /// deep buffer (their originals live on the NAS).
251 var lenientSync = false
252 private var currentChunkVersion = -1
253 private var lastChunkSwap: Double = 0
254 var itemFailed = false
255 private var seekInFlight = false
256 private var pendingSeek: Double?
257 private var lastResync: Double = 0
258
259 init() {
260 player.automaticallyWaitsToMinimizeStalling = false
261 player.actionAtItemEnd = .pause
262 }
263
264 private var currentMediaKey: String?
265 /// Per-chunk proxy width baked into the CURRENT item's composition. A swap
266 /// is only worth its hiccup when it improves the frame UNDER THE PLAYHEAD —
267 /// either newly covered, or a sharper proxy than this item already has.
268 private var itemChunkWidths: [Int: Int] = [:]
269
270 func setClip(_ clip: Clip?, media: MediaItem?, sourceTime: Double = 0) {
271 guard let clip, let media else {
272 if currentClipId != nil {
273 player.replaceCurrentItem(with: nil)
274 currentClipId = nil
275 currentSourceURL = nil
276 currentMediaKey = nil
277 currentChunkVersion = -1
278 }
279 return
280 }
281 // Audio files play their original directly (no chunked proxies).
282 if media.isAudio {
283 let url = media.url
284 if currentSourceURL != url || player.currentItem == nil {
285 replaceItem(AVPlayerItem(url: url), media: media, url: url, version: -1)
286 }
287 currentClipId = clip.id
288 if player.currentItem?.status == .failed { itemFailed = true }
289 player.isMuted = clip.muted
290 return
291 }
292 // Legacy whole-file proxy when present; otherwise the chunked
293 // composition. Crossing into another clip of the SAME media keeps the
294 // item — swapping it flashes black, and a seek is all that's needed.
295 if let proxy = MediaPipeline.shared.proxyURL(for: media) {
296 if currentSourceURL != proxy || player.currentItem == nil {
297 replaceItem(AVPlayerItem(url: proxy), media: media, url: proxy, version: -1)
298 }
299 } else {
300 let (asset, version) = ctx.chunks.composition(for: media)
301 let now = CACurrentMediaTime()
302 let ready = version != -2 // the real composition has assembled
303 let sameAsset = currentMediaKey == media.cacheKey && currentSourceURL == nil
304 && player.currentItem != nil
305 var swap = false
306 if !ready {
307 // Composition still assembling: do NOT install the empty
308 // placeholder item — it shows black, can go .failed, and then
309 // the failure-retry below flashes it every couple of seconds.
310 // Leave the current item; the viewer stands in meanwhile.
311 swap = false
312 } else if !sameAsset {
313 swap = true // first real item, or the media changed
314 } else if currentChunkVersion != version {
315 // Same media, composition upgraded. Only reload when the frame
316 // UNDER THE PLAYHEAD actually changes what we can show — a chunk
317 // finishing elsewhere must not reset (and flash) the item.
318 let idx = ChunkManager.chunkIndex(forSource: sourceTime)
319 let have = ctx.chunks.builtWidths(media: media)[idx]
320 let had = itemChunkWidths[idx]
321 let sharperProxy = have != nil && (had ?? 0) < have!
322 if player.rate == 0 {
323 // PAUSED: a swap is a pure visual flash with no playback
324 // benefit, so swap only to REVEAL a frame we otherwise
325 // can't show at all — an unplayable original (DNx) whose
326 // covering chunk has just landed, or a playable original
327 // that still hasn't managed to park on the frame (a slow
328 // NAS seek — the cell is showing a stand-in or spinner
329 // while the proxy could show the frame now). Never swap to
330 // merely sharpen a still that's already on screen.
331 // Pending quality upgrades take effect on the next play/seek.
332 let cur = player.currentTime().seconds
333 let parkedOff = !cur.isFinite || abs(cur - sourceTime) > 0.5
334 swap = sharperProxy && had == nil
335 && (!ctx.chunks.originalPlayable(media: media) || parkedOff)
336 } else {
337 // PLAYING: adopt the proxy the moment it covers the playhead
338 // (a NAS original may stutter), throttled so rapid
339 // completions don't hiccup the stream.
340 swap = sharperProxy && now - lastChunkSwap > 3
341 }
342 }
343 // A failed stream is only worth re-swapping to recover DURING
344 // playback; while paused the retry just flashes with nothing gained.
345 if itemFailed, ready, player.rate != 0 { swap = now - lastChunkSwap > 2 }
346 if swap {
347 replaceItem(AVPlayerItem(asset: asset), media: media, url: nil, version: version)
348 itemChunkWidths = ctx.chunks.builtWidths(media: media)
349 lastChunkSwap = now
350 }
351 }
352 currentClipId = clip.id
353 if player.currentItem?.status == .failed { itemFailed = true }
354 player.isMuted = clip.muted || !media.hasAudio
355 }
356
357 private func replaceItem(_ item: AVPlayerItem, media: MediaItem, url: URL?, version: Int) {
358 installItem(item)
359 currentSourceURL = url
360 currentMediaKey = media.cacheKey
361 currentChunkVersion = version
362 }
363
364 /// Install a fresh item, holding it silent if we're mid-playback. A new item
365 /// starts at t=0; the syncTime call that always follows a swap (same sync
366 /// pass) seeks it to the live position and resumes. Without the hold it
367 /// blips wrong content from the file's head — audibly so on the audio track.
368 private func installItem(_ item: AVPlayerItem) {
369 // Video buffer depth scales with the RAM budget (Settings → Global):
370 // the default 2 GB keeps the old 1s; a workstation-sized budget buys
371 // deeper read-ahead, which is what smooths NAS originals.
372 item.preferredForwardBufferDuration = lenientSync ? 8
373 : Double(min(8, max(1, FrameCache.ramGB / 2)))
374 let wasPlaying = player.rate != 0
375 player.replaceCurrentItem(with: item)
376 if wasPlaying { player.rate = 0 }
377 itemFailed = false
378 seekInFlight = false
379 pendingSeek = nil
380 }
381
382 func syncTime(expected: Double, rate: Double, force: Bool) {
383 guard player.currentItem != nil else { return }
384 if rate == 0 {
385 if player.rate != 0 { player.rate = 0 }
386 coalescedSeek(to: expected)
387 } else {
388 let actual = player.currentTime().seconds
389 let now = CACurrentMediaTime()
390 // A hard zero-tolerance seek is an audible/visible hiccup, so it
391 // only fires on real drift — and audio (lenient) tolerates much
392 // more drift before interrupting a smooth stream.
393 let drifted = abs(actual - expected) > (lenientSync ? 0.30 : 0.08)
394 let resyncGap = lenientSync ? 2.0 : 0.5
395 if force || player.rate != Float(rate) || (drifted && now - lastResync > resyncGap) {
396 lastResync = now
397 player.seek(to: time(expected), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
398 guard let self, ctx.playback.rate == rate else { return }
399 if rate > 0 {
400 self.player.playImmediately(atRate: Float(rate))
401 } else {
402 self.player.rate = Float(rate)
403 }
404 }
405 }
406 }
407 }
408
409 private func coalescedSeek(to t: Double) {
410 let current = player.currentTime().seconds
411 if abs(current - t) < 0.004 { return }
412 if seekInFlight { pendingSeek = t; return }
413 seekInFlight = true
414 player.seek(to: time(t), toleranceBefore: .zero, toleranceAfter: .zero) { [weak self] _ in
415 DispatchQueue.main.async {
416 guard let self else { return }
417 self.seekInFlight = false
418 if let p = self.pendingSeek {
419 self.pendingSeek = nil
420 self.coalescedSeek(to: p)
421 } else {
422 // Landed on the target frame. While paused nothing else
423 // ticks the viewer, so tell it to re-evaluate: the player
424 // is now showing the right frame and can replace the
425 // stand-in filmstrip.
426 NotificationCenter.default.post(name: .viewerNeedsRefresh, object: nil)
427 }
428 }
429 }
430 }
431
432 private func time(_ seconds: Double) -> CMTime {
433 CMTime(seconds: max(0, seconds), preferredTimescale: 60000)
434 }
435}
436
437/// Double-buffered video track: two `TrackPlayer`s so a cut to a DIFFERENT clip
438/// is gapless. The **front** player shows the clip under the playhead; as a cut
439/// approaches, the **back** player is prerolled to the next clip's first frame
440/// (loaded, decoded, parked, muted). At the cut the roles flip — the viewer just
441/// swaps which of its two layers is visible, and the already-warm back player
442/// resumes instantly, so there's no `replaceCurrentItem` black-frame / audio gap.
443///
444/// Only cuts to a different *media* need this; crossing into another clip of the
445/// same media keeps the item and just seeks (already gapless), so the back
446/// buffer is only spun up for a genuine media change.
447final class VideoTrackPlayer {
448 unowned var ctx: DocumentContext! {
449 didSet { a.ctx = ctx; b.ctx = ctx }
450 }
451 let a = TrackPlayer()
452 let b = TrackPlayer()
453 private(set) var frontIsA = true
454 var front: TrackPlayer { frontIsA ? a : b }
455 var back: TrackPlayer { frontIsA ? b : a }
456 var currentClipId: UUID? { front.currentClipId }
457
458 /// Spin up the next clip's buffer this many seconds before its cut — enough
459 /// to load + decode the first frame even off a NAS original or a stitched
460 /// composition whose seek has to open a fresh chunk file.
461 private static let preroll = 4.0
462 /// Warm exact cut frames (heads AND tails) into the RAM frame cache when
463 /// their boundary is within this many seconds of the playhead, so even a
464 /// swap that outruns the preroll has the right frame to stand in.
465 private static let warmRadius = 10.0
466
467 func sync(ref: TrackRef, playhead: Double, rate: Double,
468 playheadMoved: Bool, force: Bool) {
469 let project = ctx.store.project
470 let cur = project.clipAt(track: ref, time: playhead, kind: .video)
471 let media = cur.flatMap { project.media($0.mediaId) }
472
473 // FLIP: the front is showing the wrong (previous) clip, but the back was
474 // prerolled to exactly the clip now under the playhead → hand over.
475 if let cur, front.currentClipId != cur.id, back.currentClipId == cur.id {
476 frontIsA.toggle()
477 }
478
479 // FRONT: the clip under the playhead, audible.
480 front.setClip(cur, media: media, sourceTime: cur?.sourceTime(at: playhead) ?? 0)
481 if let cur, media != nil {
482 let expected = cur.sourceTime(at: playhead)
483 if rate != 0 || playheadMoved || force {
484 front.syncTime(expected: expected, rate: rate * cur.speed, force: force)
485 }
486 }
487
488 // BACK: preroll the boundary clip when its cut is imminent, parked
489 // (rate 0) and muted at the frame the cut lands on: forward playback
490 // prerolls the NEXT different-media clip at its first frame; reverse
491 // prerolls the PREVIOUS one at its last, so a backwards cut is just as
492 // gapless.
493 var buffering = false
494 if rate >= 0, let next = nextDifferentClip(on: ref, after: playhead, current: cur),
495 next.start - playhead <= Self.preroll,
496 let nextMedia = project.media(next.mediaId) {
497 if back.currentClipId != next.id {
498 back.setClip(next, media: nextMedia, sourceTime: next.srcIn)
499 }
500 back.player.isMuted = true
501 back.syncTime(expected: next.srcIn, rate: 0, force: false)
502 buffering = true
503 } else if rate < 0, let prev = prevDifferentClip(on: ref, before: playhead, current: cur),
504 playhead - prev.end <= Self.preroll,
505 let prevMedia = project.media(prev.mediaId) {
506 let tail = max(prev.srcIn, prev.sourceTime(at: prev.end) - 0.05)
507 if back.currentClipId != prev.id {
508 back.setClip(prev, media: prevMedia, sourceTime: tail)
509 }
510 back.player.isMuted = true
511 back.syncTime(expected: tail, rate: 0, force: false)
512 buffering = true
513 }
514 if !buffering, back.currentClipId != nil {
515 back.setClip(nil, media: nil) // release the idle buffer
516 }
517
518 warmBoundaryFrames(ref: ref, playhead: playhead, project: project)
519 }
520
521 /// Warm the exact frames every nearby cut will need into the RAM frame
522 /// cache — clip heads for forward crossings, clip tails for reverse — so
523 /// the beat between "boundary crossed" and "player ready" shows the real
524 /// frame instead of a filmstrip thumb or a spinner. Self-deduping (the
525 /// cache remembers, decodes are backgrounded), so per-tick calls are cheap.
526 private func warmBoundaryFrames(ref: TrackRef, playhead: Double, project: ProjectModel) {
527 for clip in project.clips where clip.track == ref && clip.kind == .video {
528 guard abs(clip.start - playhead) <= Self.warmRadius
529 || abs(clip.end - playhead) <= Self.warmRadius,
530 let media = project.media(clip.mediaId), !media.isAudio else { continue }
531 if abs(clip.start - playhead) <= Self.warmRadius {
532 FrameCache.shared.warm(
533 mediaKey: media.cacheKey, at: clip.srcIn,
534 source: ctx.chunks.frameSource(media: media, sourceTime: clip.srcIn))
535 }
536 if abs(clip.end - playhead) <= Self.warmRadius {
537 let tail = max(clip.srcIn, clip.sourceTime(at: clip.end) - 0.05)
538 FrameCache.shared.warm(
539 mediaKey: media.cacheKey, at: tail,
540 source: ctx.chunks.frameSource(media: media, sourceTime: tail))
541 }
542 }
543 }
544
545 /// The next clip on this track (in time) whose media differs from `current`
546 /// — i.e. the next cut that would otherwise flash. Same-media continuations
547 /// don't need a buffer.
548 private func nextDifferentClip(on ref: TrackRef, after t: Double,
549 current: Clip?) -> Clip? {
550 ctx.store.project.clips
551 .filter { $0.track == ref && $0.kind == .video && $0.start > t + 1e-6
552 && $0.mediaId != current?.mediaId }
553 .min { $0.start < $1.start }
554 }
555
556 /// The mirror for reverse playback: the previous clip (in time) whose media
557 /// differs — the cut a rewinding playhead will cross next.
558 private func prevDifferentClip(on ref: TrackRef, before t: Double,
559 current: Clip?) -> Clip? {
560 ctx.store.project.clips
561 .filter { $0.track == ref && $0.kind == .video && $0.end < t + 1e-6
562 && $0.mediaId != current?.mediaId }
563 .max { $0.end < $1.end }
564 }
565
566 func clear() {
567 a.player.replaceCurrentItem(with: nil)
568 b.player.replaceCurrentItem(with: nil)
569 }
570}
571
572final class PlayerManager {
573 /// The document context that owns this manager. Set at construction.
574 unowned var ctx: DocumentContext!
575 private(set) var players: [TrackRef: VideoTrackPlayer] = [:]
576 /// Audio clips get one player per CLIP (not per track) so overlapping
577 /// audio layers all sound at once.
578 private(set) var audioPlayers: [UUID: TrackPlayer] = [:]
579 private var lastSyncedPlayhead: Double = -1
580
581 init() {
582 // Paused-state updates: clip edits move content under the playhead,
583 // and finished proxies should replace original/filmstrip playback.
584 NotificationCenter.default.addObserver(
585 forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in
586 guard let self else { return }
587 // While playing, the 60 Hz tick already keeps players synced. Forcing
588 // a hard resync on every edit reseeks every player and audibly stutters
589 // the audio; only edits that actually move content under the playhead
590 // then drift into a normal (throttled) resync. Force only when paused,
591 // where nothing else refreshes the frame under the playhead.
592 self.sync(force: self.ctx.playback.rate == 0)
593 }
594 NotificationCenter.default.addObserver(
595 forName: .mediaStatusChanged, object: nil, queue: .main) { [weak self] _ in
596 guard let self else { return }
597 self.sync(force: self.ctx.playback.rate == 0)
598 }
599 }
600
601 func videoTrack(for ref: TrackRef) -> VideoTrackPlayer {
602 if let p = players[ref] { return p }
603 let p = VideoTrackPlayer()
604 p.ctx = ctx
605 players[ref] = p
606 return p
607 }
608
609 func sync(force: Bool = false) {
610 let store = ctx.store
611 let pc = ctx.playback
612 let project = store.project
613 let playhead = pc.playhead
614 let rate = pc.rate
615
616 // Drop players for removed tracks.
617 let liveRefs = Set(project.tracks.indices.map { TrackRef.video($0) })
618 for (ref, p) in players where !liveRefs.contains(ref) {
619 p.clear()
620 players.removeValue(forKey: ref)
621 }
622
623 let playheadMoved = playhead != lastSyncedPlayhead
624 lastSyncedPlayhead = playhead
625
626 for i in project.tracks.indices {
627 let ref = TrackRef.video(i)
628 videoTrack(for: ref).sync(ref: ref, playhead: playhead, rate: rate,
629 playheadMoved: playheadMoved, force: force)
630 }
631
632 // Recompute the proxy build order from the (moved) playhead + direction;
633 // self-throttled, so calling it every tick is cheap.
634 ctx.chunks.updateDemand()
635
636 syncAudio(project: project, playhead: playhead, rate: rate,
637 playheadMoved: playheadMoved, force: force)
638 }
639
640 /// How far ahead of a clip's start we spin up (and preroll) its player, and
641 /// how long we keep a just-ended one. The lookahead is the key to gapless
642 /// audio cuts: the next clip is already playing (silently — its fade gain is
643 /// 0 until the playhead reaches it) and synced to the master clock BEFORE its
644 /// cut, so crossing the boundary is a pure volume handover with no cold-start
645 /// gap. The linger avoids tearing a player down the instant it ends.
646 private static let audioLookahead = 1.0
647 private static let audioLinger = 0.5
648
649 /// Layered audio: every audio clip near the playhead plays through its own
650 /// player, with the fade envelope applied as volume. Players for clips just
651 /// ahead of the playhead are kept running (silent) so cuts are seamless.
652 private func syncAudio(project: ProjectModel, playhead: Double, rate: Double,
653 playheadMoved: Bool, force: Bool) {
654 let windowed = project.clips.filter {
655 $0.kind == .audio
656 && playhead + Self.audioLookahead >= $0.start
657 && playhead - Self.audioLinger < $0.end
658 }
659 let windowedIds = Set(windowed.map(\.id))
660 for (id, p) in audioPlayers where !windowedIds.contains(id) {
661 p.player.replaceCurrentItem(with: nil)
662 audioPlayers.removeValue(forKey: id)
663 }
664 for clip in windowed {
665 guard let media = project.media(clip.mediaId) else { continue }
666 let ap: TrackPlayer
667 if let existing = audioPlayers[clip.id] {
668 ap = existing
669 } else {
670 ap = TrackPlayer()
671 ap.ctx = ctx
672 ap.lenientSync = true
673 audioPlayers[clip.id] = ap
674 }
675 let expected = clip.srcIn + (playhead - clip.start)
676 ap.setClip(clip, media: media, sourceTime: expected)
677 // audioGain is 0 outside [start, end], so a prerolled upcoming clip
678 // stays silent until its cut, then hands over with no gap.
679 ap.player.volume = Float(audioGain(clip, at: playhead))
680 if rate != 0 || playheadMoved || force {
681 ap.syncTime(expected: expected, rate: rate, force: force)
682 }
683 }
684 }
685}