| 1 | import AppKit |
| 2 | import 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. |
| 8 | final 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 | } |