1import AppKit
2import UniformTypeIdentifiers
3
4extension Notification.Name {
5 static let viewOptionsChanged = Notification.Name("viewOptionsChanged")
6 static let revealClip = Notification.Name("revealClip") // userInfo["clipId"]
7}
8
9/// App-wide UI constants. Mutable per-window session state (hide/focus/zoom/
10/// tool/color) lives on `SessionState` (`ctx.session`), not here.
11enum UI {
12 /// SF Symbols for the preview/header toggles: focus = fullscreen-expand,
13 /// hide = eye with a slash.
14 static let focusSymbol = "arrow.up.left.and.arrow.down.right"
15 static let hideSymbol = "eye.slash"
16 /// Lane ref standing in for the Fusion band in pane-keyed collections.
17 static let fusionPaneKey: TrackRef = .fusion
18
19 static let videoExtensions: Set<String> =
20 ["mov", "mp4", "m4v", "mkv", "avi", "mxf", "mts", "m2ts", "webm", "mpg", "mpeg"]
21 static let audioExtensions: Set<String> =
22 ["wav", "aiff", "aif", "mp3", "m4a", "aac", "flac", "caf"]
23 static var importableExtensions: Set<String> { videoExtensions.union(audioExtensions) }
24}
25
26final class SeqApplication: NSApplication {}
27
28/// Main menu that lets unmodified key equivalents (S, N, M, …) reach text
29/// fields: while a text view has focus, plain keys are typing, not commands.
30final class AppMenu: NSMenu {
31 override func performKeyEquivalent(with event: NSEvent) -> Bool {
32 if event.modifierFlags.intersection([.command, .control]).isEmpty,
33 let window = NSApp.keyWindow {
34 // Typing beats plain-key commands.
35 if window.firstResponder is NSText { return false }
36 // The storyboard editor owns its plain keys (tools, delete, …).
37 if window.identifier == StoryboardEditor.windowID { return false }
38 }
39 return super.performKeyEquivalent(with: event)
40 }
41}
42
43final class AppDelegate: NSObject, NSApplicationDelegate {
44 func applicationDidFinishLaunching(_ notification: Notification) {
45 Theme.startObserving()
46 registerBoardFonts()
47 buildMenu()
48 NSApp.activate(ignoringOtherApps: true)
49 }
50
51 /// Closing the last project window quits, matching the app's single-purpose
52 /// (Fusion-feeding) workflow.
53 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
54
55 /// In-flight ffmpeg encodes must die with the app: orphans hold shared
56 /// VideoToolbox decode sessions, and a few accumulated ones make every
57 /// AVPlayer in the NEXT instance render black (see MediaPipeline).
58 func applicationWillTerminate(_ notification: Notification) {
59 MediaPipeline.terminateChildren()
60 }
61
62 /// On launch, reopen the most recent project instead of a blank untitled
63 /// one; fall back to a fresh untitled document when there's no history.
64 func applicationOpenUntitledFile(_ sender: NSApplication) -> Bool {
65 let dc = NSDocumentController.shared
66 if let url = dc.recentDocumentURLs.first {
67 dc.openDocument(withContentsOf: url, display: true) { _, _, _ in }
68 return true
69 }
70 return false // → AppKit opens a fresh untitled document
71 }
72
73 // MARK: - App-level actions
74
75 @objc func showSettings() { SettingsWindow.shared.show() }
76 @objc func revealCache() {
77 NSWorkspace.shared.activateFileViewerSelecting([MediaPipeline.shared.cacheRoot])
78 }
79
80 // MARK: - Menu
81
82 private func item(_ title: String, _ action: Selector, key: String = "",
83 mods: NSEvent.ModifierFlags? = nil,
84 target: AnyObject? = nil) -> NSMenuItem {
85 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
86 // Default target is nil — the action routes up the responder chain to
87 // the key window's controller (per-document commands) or the app
88 // delegate (app-level ones). Pass `target` only to pin an item.
89 mi.target = target
90 // nil mods = default ⌘ for non-empty keys; [] = plain key, shown bare.
91 if let mods { mi.keyEquivalentModifierMask = mods }
92 return mi
93 }
94
95 /// First-responder-targeted item (text fields get ⌘C/⌘V/⌘A when editing;
96 /// the timeline implements the same selectors for clips/panels).
97 private func responderItem(_ title: String, _ action: Selector, key: String) -> NSMenuItem {
98 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
99 mi.target = nil
100 return mi
101 }
102
103 private func submenu(_ menu: NSMenu, title: String) -> NSMenuItem {
104 let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "")
105 mi.submenu = menu
106 return mi
107 }
108
109 private func fkey(_ code: Int) -> String { String(UnicodeScalar(code)!) }
110
111 private func buildMenu() {
112 let main = AppMenu()
113
114 let appMenu = NSMenu()
115 appMenu.addItem(withTitle: "About Sequencer", action: nil, keyEquivalent: "")
116 appMenu.addItem(.separator())
117 appMenu.addItem(item("Settings…", #selector(showSettings), key: ","))
118 appMenu.addItem(.separator())
119 appMenu.addItem(withTitle: "Quit Sequencer",
120 action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
121 main.addItem(submenu(appMenu, title: "Sequencer"))
122
123 let file = NSMenu(title: "File")
124 file.addItem(item("New Project", #selector(NSDocumentController.newDocument(_:)), key: "n"))
125 file.addItem(item("Open Project…", #selector(NSDocumentController.openDocument(_:)), key: "o"))
126 // AppKit auto-populates this submenu from NSDocument's recent-documents
127 // list so long as it holds the standard "Clear Menu" item.
128 let openRecent = NSMenu(title: "Open Recent")
129 let clearRecent = NSMenuItem(
130 title: "Clear Menu",
131 action: #selector(NSDocumentController.clearRecentDocuments(_:)),
132 keyEquivalent: "")
133 clearRecent.target = nil
134 openRecent.addItem(clearRecent)
135 file.addItem(submenu(openRecent, title: "Open Recent"))
136 file.addItem(.separator())
137 file.addItem(item("Save", #selector(NSDocument.save(_:)), key: "s"))
138 file.addItem(item("Save As…", #selector(NSDocument.saveAs(_:)), key: "S"))
139 file.addItem(item("Duplicate", #selector(NSDocument.duplicate(_:)), key: "S", mods: [.command, .option]))
140 file.addItem(item("Revert to Saved", #selector(NSDocument.revertToSaved(_:))))
141 file.addItem(responderItem("Close", #selector(NSWindow.performClose(_:)), key: "w"))
142 file.addItem(.separator())
143 file.addItem(item("Import Media…", #selector(SequencerWindowController.importMedia), key: "i"))
144 file.addItem(.separator())
145 file.addItem(item("Export…", #selector(SequencerWindowController.showExport), key: "e"))
146 file.addItem(.separator())
147 file.addItem(item("Reveal Cache in Finder", #selector(revealCache), target: self))
148 file.addItem(item("Reveal Project in Finder", #selector(SequencerWindowController.revealProject)))
149 main.addItem(submenu(file, title: "File"))
150
151 let edit = NSMenu(title: "Edit")
152 edit.addItem(item("Undo", #selector(SequencerWindowController.undo), key: "z"))
153 edit.addItem(item("Redo", #selector(SequencerWindowController.redo), key: "Z"))
154 edit.addItem(.separator())
155 edit.addItem(responderItem("Cut", #selector(NSText.cut(_:)), key: "x"))
156 edit.addItem(responderItem("Copy", #selector(NSText.copy(_:)), key: "c"))
157 edit.addItem(responderItem("Paste", #selector(NSText.paste(_:)), key: "v"))
158 edit.addItem(.separator())
159 edit.addItem(responderItem("Select All", #selector(NSResponder.selectAll(_:)), key: "a"))
160 edit.addItem(item("Deselect All", #selector(SequencerWindowController.deselectAll), key: "\u{1b}", mods: []))
161 main.addItem(submenu(edit, title: "Edit"))
162
163 let clip = NSMenu(title: "Clip")
164 clip.addItem(item("Split", #selector(SequencerWindowController.split(_:)), key: "s", mods: []))
165 clip.addItem(item("Move Overlaps to Separate Tracks", #selector(SequencerWindowController.moveOverlaps),
166 key: "o", mods: [.option]))
167 clip.addItem(item("Mute", #selector(SequencerWindowController.muteClips), key: "m", mods: [.option]))
168 clip.addItem(.separator())
169 clip.addItem(item("Nudge Left 1 Frame", #selector(SequencerWindowController.nudgeLeft),
170 key: fkey(NSLeftArrowFunctionKey), mods: []))
171 clip.addItem(item("Nudge Right 1 Frame", #selector(SequencerWindowController.nudgeRight),
172 key: fkey(NSRightArrowFunctionKey), mods: []))
173 clip.addItem(item("Nudge Left 1 Second", #selector(SequencerWindowController.nudgeLeftSecond),
174 key: fkey(NSLeftArrowFunctionKey), mods: [.shift]))
175 clip.addItem(item("Nudge Right 1 Second", #selector(SequencerWindowController.nudgeRightSecond),
176 key: fkey(NSRightArrowFunctionKey), mods: [.shift]))
177 clip.addItem(.separator())
178 clip.addItem(item("Link Clips", #selector(SequencerWindowController.linkClips), key: "g", mods: []))
179 clip.addItem(item("Unlink Clips", #selector(SequencerWindowController.unlinkClips), key: "g", mods: [.option]))
180 clip.addItem(.separator())
181 // X deletes, ⇧X ripple-deletes; ⌫ / ⌥⌫ remain aliases (handled in
182 // TimelineView.keyDown, so they keep working without a menu equivalent).
183 clip.addItem(item("Delete", #selector(SequencerWindowController.deleteSelected), key: "x", mods: []))
184 clip.addItem(item("Ripple Delete", #selector(SequencerWindowController.rippleDeleteSelected),
185 key: "X", mods: [.shift]))
186 clip.addItem(item("Close Gap at Playhead", #selector(SequencerWindowController.closeGapAtPlayhead)))
187 clip.addItem(.separator())
188 clip.addItem(item("Ripple Trim Start to Playhead", #selector(SequencerWindowController.rippleTrimLeft),
189 key: fkey(NSLeftArrowFunctionKey), mods: [.option]))
190 clip.addItem(item("Ripple Trim End to Playhead", #selector(SequencerWindowController.rippleTrimRight),
191 key: fkey(NSRightArrowFunctionKey), mods: [.option]))
192 main.addItem(submenu(clip, title: "Clip"))
193
194 // Every storyboard command lives together here, whether the underlying
195 // op is a clip split, a panel add, or timeline navigation.
196 let storyboard = NSMenu(title: "Storyboard")
197 // "New Panel" splits the panel under the playhead in two — that's how a
198 // new panel is born, so there's no separate "add panel" command.
199 storyboard.addItem(item("New Panel", #selector(SequencerWindowController.splitStoryboard), key: "b", mods: []))
200 storyboard.addItem(item("New Shot", #selector(SequencerWindowController.splitStoryboardNewShot),
201 key: "B", mods: [.shift]))
202 storyboard.addItem(item("Mark as New Shot", #selector(SequencerWindowController.toggleNewShot), key: "n", mods: []))
203 storyboard.addItem(.separator())
204 storyboard.addItem(item("Next Panel", #selector(SequencerWindowController.nextStoryboardPanel),
205 key: "]", mods: [.command]))
206 storyboard.addItem(item("Previous Panel", #selector(SequencerWindowController.prevStoryboardPanel),
207 key: "[", mods: [.command]))
208 storyboard.addItem(.separator())
209 storyboard.addItem(item("Open Storyboard Editor…", #selector(SequencerWindowController.openStoryboardEditor)))
210 main.addItem(submenu(storyboard, title: "Storyboard"))
211
212 let track = NSMenu(title: "Track")
213 track.addItem(item("Delete Empty Tracks", #selector(SequencerWindowController.deleteEmptyTracks)))
214 track.addItem(item("Show All Tracks (Reset Hide & Focus)",
215 #selector(SequencerWindowController.resetTrackVisibility)))
216 track.addItem(.separator())
217 track.addItem(item("Set Preferred Take", #selector(SequencerWindowController.setPreferredTake), key: "t", mods: []))
218 main.addItem(submenu(track, title: "Track"))
219
220 let view = NSMenu(title: "View")
221 view.addItem(item("Snapping", #selector(SequencerWindowController.toggleSnapping), key: "y", mods: []))
222 let strips = item("Show Clip Thumbnails", #selector(SequencerWindowController.toggleFilmstrips),
223 key: "f", mods: [.option, .command])
224 view.addItem(strips)
225 view.addItem(.separator())
226 view.addItem(item("Zoom to Fit", #selector(SequencerWindowController.zoomFit), key: "f", mods: [.command]))
227 view.addItem(item("Zoom In", #selector(SequencerWindowController.zoomIn), key: "="))
228 view.addItem(item("Zoom Out", #selector(SequencerWindowController.zoomOut), key: "-"))
229 view.addItem(.separator())
230 view.addItem(item("Taller Tracks", #selector(SequencerWindowController.tallerTracks),
231 key: "=", mods: [.option, .command]))
232 view.addItem(item("Shorter Tracks", #selector(SequencerWindowController.shorterTracks),
233 key: "-", mods: [.option, .command]))
234 view.addItem(item("Reset Track Heights", #selector(SequencerWindowController.resetTrackHeights),
235 key: "0", mods: [.option, .command]))
236 view.addItem(.separator())
237 view.addItem(item("Previews on Left", #selector(SequencerWindowController.togglePreviewsLeft),
238 key: "l", mods: [.option, .command]))
239 view.addItem(item("Pop Out Previews", #selector(SequencerWindowController.togglePopout), key: "P"))
240 main.addItem(submenu(view, title: "View"))
241
242 let play = NSMenu(title: "Playback")
243 play.addItem(item("Play/Pause", #selector(SequencerWindowController.playPause), key: " ", mods: []))
244 play.addItem(item("Stop", #selector(SequencerWindowController.stopPlayback), key: "k", mods: []))
245 play.addItem(item("Shuttle Forward", #selector(SequencerWindowController.shuttleForward), key: "l", mods: []))
246 play.addItem(item("Shuttle Reverse", #selector(SequencerWindowController.shuttleReverse), key: "j", mods: []))
247 play.addItem(.separator())
248 play.addItem(item("Step Forward", #selector(SequencerWindowController.stepForward), key: "]", mods: []))
249 play.addItem(item("Step Backward", #selector(SequencerWindowController.stepBackward), key: "[", mods: []))
250 play.addItem(item("Step Forward 1 Second", #selector(SequencerWindowController.stepForwardSecond),
251 key: "]", mods: [.shift]))
252 play.addItem(item("Step Backward 1 Second", #selector(SequencerWindowController.stepBackwardSecond),
253 key: "[", mods: [.shift]))
254 play.addItem(.separator())
255 play.addItem(item("Go to Start", #selector(SequencerWindowController.goToStart),
256 key: fkey(NSHomeFunctionKey), mods: []))
257 play.addItem(item("Go to End", #selector(SequencerWindowController.goToEnd),
258 key: fkey(NSEndFunctionKey), mods: []))
259 play.addItem(.separator())
260 play.addItem(item("Set In Point", #selector(SequencerWindowController.setInPoint), key: "i", mods: []))
261 play.addItem(item("Set Out Point", #selector(SequencerWindowController.setOutPoint), key: "o", mods: []))
262 play.addItem(item("Loop (Cycle) In → Out", #selector(SequencerWindowController.toggleLoop), key: "c", mods: []))
263 play.addItem(item("Clear In / Out", #selector(SequencerWindowController.clearInOut)))
264 play.addItem(.separator())
265 play.addItem(item("Add / Remove Marker", #selector(SequencerWindowController.toggleMarker), key: "m", mods: []))
266 play.addItem(item("Previous Marker", #selector(SequencerWindowController.prevMarker), key: "[", mods: [.option]))
267 play.addItem(item("Next Marker", #selector(SequencerWindowController.nextMarker), key: "]", mods: [.option]))
268 play.addItem(item("Clear All Markers", #selector(SequencerWindowController.clearMarkers)))
269 play.addItem(.separator())
270 // Checked = proxy chunks build continuously in the background; unchecked =
271 // only build what the playhead needs while playing. Mirrors the click-to-
272 // pause control on the transport bar's chunk-progress readout.
273 play.addItem(item("Background Optimization", #selector(SequencerWindowController.toggleBackgroundOptimization)))
274 main.addItem(submenu(play, title: "Playback"))
275
276 // Project configuration — frame rate, storyboard aspect, comps folder —
277 // lives in Settings (⌘,), so there's no Project menu. See SettingsWindow.
278
279 // Standard Window menu; AppKit fills in the window list and checkmarks.
280 // Minimize/Zoom target the key window through the responder chain.
281 let windowMenu = NSMenu(title: "Window")
282 windowMenu.addItem(responderItem("Minimize",
283 #selector(NSWindow.performMiniaturize(_:)), key: "m"))
284 let zoom = NSMenuItem(title: "Zoom",
285 action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "")
286 zoom.target = nil
287 windowMenu.addItem(zoom)
288 windowMenu.addItem(.separator())
289 windowMenu.addItem(withTitle: "Bring All to Front",
290 action: #selector(NSApplication.arrangeInFront(_:)), keyEquivalent: "")
291 main.addItem(submenu(windowMenu, title: "Window"))
292 NSApp.windowsMenu = windowMenu
293
294 // Naming a menu the app's helpMenu gives macOS its standard searchable
295 // Help field (⌘? focuses it) that finds any menu command by name.
296 let help = NSMenu(title: "Help")
297 help.addItem(item("Sequencer Help", #selector(showHelp), key: "?"))
298 main.addItem(submenu(help, title: "Help"))
299 NSApp.helpMenu = help
300
301 NSApp.mainMenu = main
302 }
303
304 @objc func showHelp() {
305 // Open the README that ships beside the app; fall back to a pointer at
306 // the searchable Help field.
307 let readme = Bundle.main.bundleURL
308 .deletingLastPathComponent().appendingPathComponent("README.md")
309 if FileManager.default.fileExists(atPath: readme.path) {
310 NSWorkspace.shared.open(readme)
311 return
312 }
313 let a = NSAlert()
314 a.messageText = "Sequencer Help"
315 a.informativeText = "Every command lives in the menu bar. Use the Help "
316 + "menu's search field to find any of them by name — the keyboard "
317 + "shortcut is shown next to each item."
318 a.runModal()
319 }
320
321 static let frameRates: [(String, Double)] = [
322 ("23.976 fps", 24000.0 / 1001.0), ("24 fps", 24), ("25 fps", 25),
323 ("29.97 fps", 30000.0 / 1001.0), ("30 fps", 30), ("50 fps", 50),
324 ("59.94 fps", 60000.0 / 1001.0), ("60 fps", 60),
325 ]
326
327 /// Normal-play speed presets for the transport's speed dropdown. These set
328 /// the forward speed Space plays at; J/K/L shuttle is independent.
329 static let playbackSpeeds: [(String, Double)] = [
330 ("0.25×", 0.25), ("0.5×", 0.5), ("0.75×", 0.75), ("1×", 1),
331 ("1.25×", 1.25), ("1.5×", 1.5), ("2×", 2), ("4×", 4),
332 ]
333
334}