| 1 | import Foundation |
| 2 | import AVFoundation |
| 3 | import 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. |
| 7 | final 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. |
| 242 | final 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. |
| 447 | final 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 | |
| 572 | final 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 | } |