1import AppKit
2
3/// Per-document service bag. Everything that is per-project — the model store,
4/// playback clock, players, proxy builder, comps scanner, storyboard rasters,
5/// and the view/session state — hangs off one of these. Views reach their
6/// state through `ctx.*`; each open `.sq` document owns exactly one context.
7final class DocumentContext {
8 /// The document that owns this context (nil for the headless harness
9 /// context). Undo/dirty flow back through it via `updateChangeCount`.
10 weak var document: ProjectDocument?
11
12 /// Per-document bus for the high-frequency `.playheadChanged` signal, so a
13 /// window playing at 60 Hz only redraws its OWN timeline/viewer/transport,
14 /// not every other open project's. (Lower-frequency notifications stay on
15 /// `.default`: they're correct app-wide because every handler reads its own
16 /// `ctx`, and the redundant redraw is cheap.)
17 let notify = NotificationCenter()
18
19 let store = Store()
20 let playback = PlaybackController()
21 let players = PlayerManager()
22 let chunks = ChunkManager()
23 let comps = FusionComps()
24 let boards = BoardStore()
25 /// Per-window view/session state (hide/focus/zoom/tool/color).
26 let session = SessionState()
27
28 private var reconcileObserver: NSObjectProtocol?
29
30 init() {
31 // Wire each service's back-reference to this context. Deferred work
32 // (timers, observers) reads `ctx.*`, so this must run before any fires.
33 store.ctx = self
34 playback.ctx = self
35 players.ctx = self
36 chunks.ctx = self
37 comps.ctx = self
38 boards.ctx = self
39 session.ctx = self
40 // Keep this document's per-track session state (focus/hide/height) in
41 // sync with its model, so deleting a focused track unfocuses it instead
42 // of blanking every surviving lane.
43 reconcileObserver = NotificationCenter.default.addObserver(
44 forName: .projectChanged, object: nil, queue: .main) { [weak self] _ in
45 guard let self else { return }
46 self.session.reconcileTracks(self.store.project)
47 }
48 }
49
50 private var didShutdown = false
51
52 /// Tear the document's services down while `self` is still alive — called
53 /// from `ProjectDocument.close()`. Services hold `unowned var ctx`; an
54 /// in-flight chunk build or the 60 Hz clock landing a callback AFTER the
55 /// context deallocs would trap on that dangling reference. Stopping them here
56 /// (before dealloc) makes every such late callback a no-op.
57 func shutdown() {
58 guard !didShutdown else { return }
59 didShutdown = true
60 playback.stop()
61 chunks.stop()
62 comps.stopWatching()
63 if let reconcileObserver {
64 NotificationCenter.default.removeObserver(reconcileObserver)
65 self.reconcileObserver = nil
66 }
67 }
68
69 deinit {
70 if let reconcileObserver { NotificationCenter.default.removeObserver(reconcileObserver) }
71 comps.stopWatching()
72 }
73
74 /// Start the per-document services (playback clock, comps folder watch,
75 /// derived-asset warmup). Called once by the window controller after load.
76 func startServices() {
77 // Trim the shared media cache under its byte cap now that this project's
78 // media is registered (so its own cache is protected). Opening a
79 // document is also the natural moment to re-measure the cache from
80 // disk (reconcile) so the incremental ledger can't drift for long.
81 MediaPipeline.shared.evictIfNeeded(reconcile: true)
82 MediaPipeline.shared.ensureDerivedAssets(for: store.project)
83 // NOTE: the whole-project proxy pre-build (`chunks.ensure`) is NOT kicked
84 // here. macOS state restoration reopens *every* previously-open project on
85 // launch, and each one running `ensure` would transcode its full ProRes
86 // proxy set in parallel — every open project's media counts as "in use", so
87 // eviction can't trim any of it and the shared cache blows past its cap and
88 // fills the disk. Instead the fill is triggered when a document's window
89 // becomes main (SequencerWindowController.windowDidBecomeMain): the
90 // frontmost project fills immediately, a background/restored project fills
91 // only once you switch to it. On-demand playhead builds (`want`) still run
92 // for whatever is actually playing.
93 comps.rescan()
94 comps.startWatching()
95 playback.start()
96 players.sync(force: true)
97 }
98
99 // MARK: - Resolving the "current" context
100
101 /// The front document's context — for app-level actions and singletons
102 /// (Settings, Export, cache eviction) that operate on whichever project is
103 /// frontmost. Falls back to the headless context when nothing is open.
104 static var current: DocumentContext {
105 // `NSApp` is nil in the headless `--selftest` harness (no NSApplication);
106 // guard it so callers on the build path can ask "am I frontmost?" without
107 // crashing — with no app, the headless context is by definition current.
108 guard let app = NSApp else { return headless }
109 if let wc = app.keyWindow?.windowController as? SequencerWindowController {
110 return wc.ctx
111 }
112 if let wc = app.mainWindow?.windowController as? SequencerWindowController {
113 return wc.ctx
114 }
115 if let doc = NSDocumentController.shared.currentDocument as? ProjectDocument {
116 return doc.ctx
117 }
118 return headless
119 }
120
121 /// Fallback context for the `--uitest`/`--selftest` harnesses and any
122 /// moment with no open document.
123 static let headless = DocumentContext()
124
125 /// Every live context: one per open document, plus the headless one. Used
126 /// by the global `MediaPipeline` cache so eviction considers all windows'
127 /// media, not just the front document's.
128 static var allLive: [DocumentContext] {
129 var ctxs = NSDocumentController.shared.documents.compactMap {
130 ($0 as? ProjectDocument)?.ctx
131 }
132 ctxs.append(headless)
133 return ctxs
134 }
135}