1import AppKit
2import UniformTypeIdentifiers
3
4/// One window per open project. Owns the timeline / viewer / transport views
5/// (all bound to this document's `ctx`), the split layout, and every
6/// per-document menu action. Menu items for these actions target the first
7/// responder, so the key window's controller handles them.
8final class SequencerWindowController: NSWindowController, NSWindowDelegate,
9 NSSplitViewDelegate, NSMenuItemValidation {
10 let ctx: DocumentContext
11 let timeline = TimelineView()
12 let viewer = ViewerGridView()
13 let transport = TransportBar()
14 private let split = NSSplitView()
15 private let container = NSView()
16 private let timelinePane = NSView()
17 private var popoutWindow: NSWindow?
18 private var previewsPopped = false
19 var previewsArePopped: Bool { previewsPopped }
20 private var layoutConstraints: [NSLayoutConstraint] = []
21
22 private var session: SessionState { ctx.session }
23 private var store: Store { ctx.store }
24 private var fps: Double { ctx.store.project.fps }
25
26 init(ctx: DocumentContext) {
27 self.ctx = ctx
28 let window = NSWindow(
29 contentRect: NSRect(x: 0, y: 0, width: 1500, height: 950),
30 styleMask: [.titled, .closable, .miniaturizable, .resizable],
31 backing: .buffered, defer: false)
32 window.minSize = NSSize(width: 900, height: 600)
33 super.init(window: window)
34 window.delegate = self
35 shouldCascadeWindows = true
36
37 // Bind the three views to this document's context before they draw.
38 timeline.ctx = ctx
39 viewer.ctx = ctx
40 transport.ctx = ctx
41
42 buildContent()
43 window.makeFirstResponder(timeline)
44 }
45 required init?(coder: NSCoder) { fatalError() }
46
47 /// Warm up derived assets and start the playback clock once the document is
48 /// loaded and its window shown.
49 func startDocumentServices() {
50 ctx.startServices()
51 }
52
53 // MARK: - Window & layout
54
55 private func buildContent() {
56 transport.translatesAutoresizingMaskIntoConstraints = false
57 timeline.translatesAutoresizingMaskIntoConstraints = false
58 split.dividerStyle = .thin
59 split.delegate = self
60 split.translatesAutoresizingMaskIntoConstraints = false
61
62 timelinePane.addSubview(timeline)
63 container.addSubview(split)
64 NSLayoutConstraint.activate([
65 timeline.leadingAnchor.constraint(equalTo: timelinePane.leadingAnchor),
66 timeline.trailingAnchor.constraint(equalTo: timelinePane.trailingAnchor),
67 timeline.bottomAnchor.constraint(equalTo: timelinePane.bottomAnchor),
68 split.leadingAnchor.constraint(equalTo: container.leadingAnchor),
69 split.trailingAnchor.constraint(equalTo: container.trailingAnchor),
70 split.bottomAnchor.constraint(equalTo: container.bottomAnchor),
71 ])
72 window?.contentView = container
73 applyLayout()
74 }
75
76 // Neither pane may collapse: previews and timeline both stay usable.
77 func splitView(_ splitView: NSSplitView, constrainMinCoordinate proposed: CGFloat,
78 ofSubviewAt dividerIndex: Int) -> CGFloat {
79 max(proposed, 200)
80 }
81 func splitView(_ splitView: NSSplitView, constrainMaxCoordinate proposed: CGFloat,
82 ofSubviewAt dividerIndex: Int) -> CGFloat {
83 let total = splitView.isVertical ? splitView.bounds.width : splitView.bounds.height
84 return min(proposed, total - 240)
85 }
86
87 /// Rebuild the split for the current layout mode: previews above (default),
88 /// previews on the left, or previews popped out into their own window.
89 private func applyLayout() {
90 guard let window else { return }
91 viewer.removeFromSuperview()
92 for v in split.arrangedSubviews {
93 split.removeArrangedSubview(v)
94 v.removeFromSuperview()
95 }
96 NSLayoutConstraint.deactivate(layoutConstraints)
97 transport.removeFromSuperview()
98
99 let toolbarOnTop = previewsPopped || session.previewsOnLeft
100 if toolbarOnTop {
101 container.addSubview(transport)
102 layoutConstraints = [
103 transport.topAnchor.constraint(equalTo: container.topAnchor),
104 transport.leadingAnchor.constraint(equalTo: container.leadingAnchor),
105 transport.trailingAnchor.constraint(equalTo: container.trailingAnchor),
106 transport.heightAnchor.constraint(equalToConstant: 27),
107 split.topAnchor.constraint(equalTo: transport.bottomAnchor),
108 timeline.topAnchor.constraint(equalTo: timelinePane.topAnchor),
109 ]
110 } else {
111 timelinePane.addSubview(transport)
112 layoutConstraints = [
113 transport.topAnchor.constraint(equalTo: timelinePane.topAnchor),
114 transport.leadingAnchor.constraint(equalTo: timelinePane.leadingAnchor),
115 transport.trailingAnchor.constraint(equalTo: timelinePane.trailingAnchor),
116 transport.heightAnchor.constraint(equalToConstant: 27),
117 split.topAnchor.constraint(equalTo: container.topAnchor),
118 timeline.topAnchor.constraint(equalTo: transport.bottomAnchor),
119 ]
120 }
121 NSLayoutConstraint.activate(layoutConstraints)
122
123 if previewsPopped {
124 let pw = popoutWindow ?? {
125 let w = NSWindow(
126 contentRect: NSRect(x: 0, y: 0, width: 960, height: 560),
127 styleMask: [.titled, .closable, .resizable],
128 backing: .buffered, defer: false)
129 w.title = "Previews"
130 w.isReleasedWhenClosed = false
131 w.delegate = self
132 popoutWindow = w
133 return w
134 }()
135 pw.contentView = viewer
136 pw.makeKeyAndOrderFront(nil)
137 split.isVertical = false
138 split.addArrangedSubview(timelinePane)
139 window.makeKeyAndOrderFront(nil)
140 } else {
141 if let pw = popoutWindow {
142 pw.delegate = nil
143 pw.contentView = NSView()
144 pw.orderOut(nil)
145 pw.delegate = self
146 }
147 split.isVertical = session.previewsOnLeft
148 split.addArrangedSubview(viewer)
149 split.addArrangedSubview(timelinePane)
150 split.setHoldingPriority(.defaultLow, forSubviewAt: 0)
151 split.setHoldingPriority(.defaultHigh, forSubviewAt: 1)
152 DispatchQueue.main.async { [self] in
153 if session.previewsOnLeft {
154 split.setPosition(window.frame.width * 0.44, ofDividerAt: 0)
155 } else {
156 split.setPosition(window.frame.height * 0.62, ofDividerAt: 0)
157 }
158 }
159 }
160 window.makeFirstResponder(timeline)
161 }
162
163 func windowWillClose(_ notification: Notification) {
164 if (notification.object as? NSWindow) === popoutWindow, previewsPopped {
165 previewsPopped = false
166 applyLayout()
167 }
168 }
169
170 /// This project became frontmost — now (and only now) kick the whole-project
171 /// proxy pre-build so scrubbing anywhere is smooth. Deferring the fill to here
172 /// rather than document load is what stops state-restored *background* projects
173 /// from all transcoding their full proxy sets at once and overflowing the
174 /// shared cache (see DocumentContext.startServices). `ensure` is idempotent —
175 /// already-built chunks are skipped — so re-focusing a project is essentially
176 /// free. Ignore the popout window becoming main (it shares this ctx).
177 func windowDidBecomeMain(_ notification: Notification) {
178 guard (notification.object as? NSWindow) === window else { return }
179 ctx.chunks.ensure(for: ctx.store.project)
180 }
181
182 // MARK: - Menu validation (per-document items)
183
184 func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
185 switch menuItem.action {
186 case #selector(undo): return store.canUndo
187 case #selector(redo): return store.canRedo
188 case #selector(deleteSelected), #selector(rippleDeleteSelected):
189 return !store.selection.isEmpty
190 case #selector(deselectAll):
191 return true // Esc also stops playback, so it stays live
192 case #selector(toggleNewShot):
193 menuItem.state = (timeline.newShotMenuState ?? false) ? .on : .off
194 return true
195 case #selector(toggleSnapping):
196 menuItem.state = session.snapping ? .on : .off
197 return true
198 case #selector(toggleFilmstrips):
199 menuItem.state = session.showFilmstrips ? .on : .off
200 return true
201 case #selector(togglePreviewsLeft):
202 menuItem.state = session.previewsOnLeft ? .on : .off
203 return !previewsPopped
204 case #selector(togglePopout):
205 menuItem.state = previewsPopped ? .on : .off
206 return true
207 case #selector(openStoryboardEditor):
208 return session.panelUnderPlayhead != nil ||
209 store.selection.contains { store.project.clip($0)?.kind == .storyboard }
210 case #selector(toggleBackgroundOptimization):
211 menuItem.state = ctx.chunks.isPaused ? .off : .on
212 return true
213 case #selector(prevMarker), #selector(nextMarker), #selector(clearMarkers):
214 return !store.project.markers.isEmpty
215 case #selector(toggleLoop):
216 menuItem.state = ctx.playback.loops ? .on : .off
217 return true
218 case #selector(clearInOut):
219 return ctx.playback.hasInOut
220 default: return true
221 }
222 }
223
224 // MARK: - Edit / Clip actions
225
226 // Drawing edits now live on the same undo timeline as model edits, so ⌘Z /
227 // ⌘⇧Z route through the Store regardless of which window is focused.
228 @objc func undo() { store.undo() }
229 @objc func redo() { store.redo() }
230 @objc func deselectAll() {
231 store.selection = []
232 ctx.playback.setRate(0)
233 }
234 @objc func rippleTrimLeft() { timeline.rippleTrimToPlayhead(deleteLeft: true) }
235 @objc func rippleTrimRight() { timeline.rippleTrimToPlayhead(deleteLeft: false) }
236 @objc func closeGapAtPlayhead() { timeline.closeBlankSpaceAtPlayhead() }
237 @objc func split(_ sender: Any?) { timeline.split() }
238 @objc func splitStoryboard() { timeline.splitStoryboardAtPlayhead() }
239 @objc func splitStoryboardNewShot() { timeline.splitStoryboardAtPlayhead(newShot: true) }
240 @objc func toggleNewShot() { timeline.toggleNewShot() }
241 @objc func moveOverlaps() { timeline.moveOverlapsToSeparateTracks() }
242 @objc func nudgeLeft() { timeline.nudgeSelection(by: -1 / fps) }
243 @objc func nudgeRight() { timeline.nudgeSelection(by: 1 / fps) }
244 @objc func nudgeLeftSecond() { timeline.nudgeSelection(by: -1) }
245 @objc func nudgeRightSecond() { timeline.nudgeSelection(by: 1) }
246 @objc func showExport() { ExportDialog.shared.show() }
247 @objc func muteClips() { timeline.toggleMute() }
248 @objc func linkClips() { timeline.linkSelection() }
249 @objc func unlinkClips() { timeline.unlinkSelection() }
250 @objc func deleteSelected() { timeline.deleteSelection() }
251 @objc func rippleDeleteSelected() { timeline.rippleDelete() }
252
253 @objc func importMedia() {
254 let panel = NSOpenPanel()
255 panel.allowsMultipleSelection = true
256 panel.canChooseDirectories = true
257 guard panel.runModal() == .OK else { return }
258 let urls = panel.urls.filter {
259 UI.importableExtensions.contains($0.pathExtension.lowercased())
260 || $0.lastPathComponent == "sync.json"
261 || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
262 }
263 guard !urls.isEmpty else { return }
264 timeline.importFiles(urls, atSecond: ctx.playback.playhead, targetRow: nil)
265 }
266
267 @objc func revealProject() {
268 if let url = ctx.document?.fileURL {
269 NSWorkspace.shared.activateFileViewerSelecting([url])
270 }
271 }
272
273 // MARK: - Track actions
274
275 @objc func deleteEmptyTracks() { timeline.deleteEmptyTracks() }
276 @objc func resetTrackVisibility() { timeline.resetTrackVisibility() }
277
278 // MARK: - View actions
279
280 @objc func toggleSnapping() { session.snapping.toggle() }
281 @objc func toggleFilmstrips() { session.showFilmstrips.toggle() }
282 @objc func zoomFit() { timeline.zoomToFit() }
283 @objc func zoomIn() { timeline.zoomIn() }
284 @objc func zoomOut() { timeline.zoomOut() }
285 @objc func tallerTracks() { session.laneScale *= 1.2 }
286 @objc func shorterTracks() { session.laneScale /= 1.2 }
287 @objc func resetTrackHeights() {
288 session.laneScale = 1
289 session.trackHeights = [:]
290 }
291 @objc func togglePreviewsLeft() {
292 session.previewsOnLeft.toggle()
293 applyLayout()
294 }
295 @objc func togglePopout() {
296 previewsPopped.toggle()
297 applyLayout()
298 }
299
300 // MARK: - Playback actions
301
302 @objc func playPause() { ctx.playback.togglePlay() }
303 @objc func stopPlayback() { ctx.playback.setRate(0) }
304 @objc func shuttleForward() { ctx.playback.shuttle(1) }
305 @objc func shuttleReverse() { ctx.playback.shuttle(-1) }
306 @objc func stepForward() { ctx.playback.step(by: 1 / fps) }
307 @objc func stepBackward() { ctx.playback.step(by: -1 / fps) }
308 @objc func stepForwardSecond() { ctx.playback.step(by: 1) }
309 @objc func stepBackwardSecond() { ctx.playback.step(by: -1) }
310 @objc func goToStart() { ctx.playback.seek(to: 0) }
311 @objc func goToEnd() { ctx.playback.seek(to: store.project.timelineDuration) }
312 @objc func setInPoint() { ctx.playback.setIn() }
313 @objc func setOutPoint() { ctx.playback.setOut() }
314 @objc func toggleLoop() { ctx.playback.toggleLoop() }
315 @objc func clearInOut() { ctx.playback.clearInOut() }
316 @objc func toggleMarker() { timeline.toggleMarkerAtPlayhead() }
317 @objc func prevMarker() { timeline.goToPrevMarker() }
318 @objc func nextMarker() { timeline.goToNextMarker() }
319 @objc func prevStoryboardPanel() { timeline.goToPrevStoryboardPanel() }
320 @objc func nextStoryboardPanel() { timeline.goToNextStoryboardPanel() }
321 @objc func clearMarkers() { timeline.clearAllMarkers() }
322
323 @objc func toggleBackgroundOptimization() {
324 ctx.chunks.setPaused(!ctx.chunks.isPaused)
325 }
326
327 // MARK: - Comps & storyboard actions
328
329 @objc func setPreferredTake() { ctx.comps.togglePreferredTake() }
330
331 @objc func openStoryboardEditor() {
332 let selected = store.selection.first { store.project.clip($0)?.kind == .storyboard }
333 if let id = selected ?? session.panelUnderPlayhead?.id {
334 StoryboardEditor.shared.open(clipId: id, ctx: ctx)
335 } else {
336 NotificationCenter.default.post(name: .transientStatus, object: nil,
337 userInfo: ["text": "Move the playhead over a storyboard panel to edit it"])
338 }
339 }
340}