| 1 | import AppKit |
| 2 | import UniformTypeIdentifiers |
| 3 | |
| 4 | extension 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. |
| 11 | enum 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 | |
| 26 | final 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. |
| 30 | final 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 | |
| 43 | final 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 | } |