| 1 | import AppKit |
| 2 | |
| 3 | /// Instant tooltip — no system hover delay. Shown ABOVE toolbar buttons in |
| 4 | /// the accent color the moment the pointer arrives. |
| 5 | enum InstantTip { |
| 6 | private static var panel: NSPanel? |
| 7 | |
| 8 | static func show(_ text: String, for view: NSView) { |
| 9 | hide() |
| 10 | guard !text.isEmpty, let window = view.window else { return } |
| 11 | let field = NSTextField(labelWithString: text) |
| 12 | field.font = .systemFont(ofSize: 11, weight: .medium) |
| 13 | field.textColor = .white |
| 14 | // Round the measured size UP — a fractional intrinsic width was clipping |
| 15 | // the last glyph (e.g. "Pencil" showing as "Penci"). |
| 16 | field.sizeToFit() |
| 17 | let w = ceil(field.intrinsicContentSize.width) + 1 |
| 18 | let h = ceil(field.intrinsicContentSize.height) |
| 19 | let container = NSView(frame: NSRect(x: 0, y: 0, width: w + 14, height: h + 8)) |
| 20 | container.wantsLayer = true |
| 21 | container.layer?.backgroundColor = NSColor.controlAccentColor.cgColor |
| 22 | container.layer?.cornerRadius = 5 |
| 23 | field.frame = NSRect(x: 7, y: 4, width: w, height: h) |
| 24 | container.addSubview(field) |
| 25 | let r = window.convertToScreen(view.convert(view.bounds, to: nil)) |
| 26 | var origin = NSPoint(x: r.midX - container.frame.width / 2, |
| 27 | y: r.maxY + 4) |
| 28 | if let screen = window.screen { |
| 29 | let vis = screen.visibleFrame |
| 30 | origin.x = min(max(origin.x, vis.minX + 4), |
| 31 | vis.maxX - container.frame.width - 4) |
| 32 | if origin.y + container.frame.height > vis.maxY { |
| 33 | origin.y = r.minY - container.frame.height - 4 |
| 34 | } |
| 35 | } |
| 36 | let p = NSPanel( |
| 37 | contentRect: NSRect(origin: origin, size: container.frame.size), |
| 38 | styleMask: [.borderless, .nonactivatingPanel], |
| 39 | backing: .buffered, defer: false) |
| 40 | p.isOpaque = false |
| 41 | p.backgroundColor = .clear |
| 42 | p.level = .popUpMenu |
| 43 | p.ignoresMouseEvents = true |
| 44 | p.isReleasedWhenClosed = false |
| 45 | p.contentView = container |
| 46 | p.orderFront(nil) |
| 47 | panel = p |
| 48 | } |
| 49 | |
| 50 | static func hide() { |
| 51 | panel?.orderOut(nil) |
| 52 | panel = nil |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | /// THE toolbar: tools on the left, timecode (and clickable frame rate) in the |
| 57 | /// center, toggle states and view controls on the right. Sits between viewer |
| 58 | /// and timeline in the stacked layout, across the whole top in side-by-side. |
| 59 | final class TransportBar: NSView { |
| 60 | /// Document context. Facade over the shared singletons for now; injected |
| 61 | /// per-document instance later. |
| 62 | var ctx: DocumentContext = .headless { |
| 63 | didSet { |
| 64 | guard oldValue !== ctx else { return } |
| 65 | oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil) |
| 66 | ctx.notify.addObserver(self, selector: #selector(tick), |
| 67 | name: .playheadChanged, object: nil) |
| 68 | } |
| 69 | } |
| 70 | private var store: Store { ctx.store } |
| 71 | private var project: ProjectModel { ctx.store.project } |
| 72 | private var playback: PlaybackController { ctx.playback } |
| 73 | private var chunks: ChunkManager { ctx.chunks } |
| 74 | private var session: SessionState { ctx.session } |
| 75 | |
| 76 | private let timecode = NSTextField(labelWithString: "00:00:00:00") |
| 77 | private let fpsButton = InstantButton(title: "", target: nil, action: nil) |
| 78 | private let customFpsField = NSTextField() |
| 79 | private weak var fpsMenu: NSMenu? |
| 80 | private let rateField = NSTextField(labelWithString: "1x") |
| 81 | private let customSpeedField = NSTextField() |
| 82 | private weak var speedMenu: NSMenu? |
| 83 | private let status = NSTextField(labelWithString: "") |
| 84 | private let jobs = NSTextField(labelWithString: "") |
| 85 | private let netWarn = NSTextField(labelWithString: "") |
| 86 | private let heightSlider = NSSlider() |
| 87 | private var statusClearTimer: Timer? |
| 88 | |
| 89 | private var toolButtons: [MainTool: InstantButton] = [:] |
| 90 | private let shapesButton = InstantButton(title: "", target: nil, action: nil) |
| 91 | private let colorSwatch = InstantButton(title: "", target: nil, action: nil) |
| 92 | private let snapButton = InstantButton(title: "", target: nil, action: nil) |
| 93 | private let subtitleButton = InstantButton(title: "", target: nil, action: nil) |
| 94 | private let filmstripButton = InstantButton(title: "", target: nil, action: nil) |
| 95 | private let viewerButton = InstantButton(title: "", target: nil, action: nil) |
| 96 | |
| 97 | /// SF Symbols has no magnet — draw a horseshoe magnet (template image, so |
| 98 | /// contentTintColor applies). Poles point up with banded tips, the way a |
| 99 | /// magnet is universally drawn (🧲), so it reads at a glance. |
| 100 | private static func magnetImage() -> NSImage { |
| 101 | let img = NSImage(size: NSSize(width: 15, height: 15), flipped: false) { rect in |
| 102 | let cx = rect.midX |
| 103 | let cyArc: CGFloat = 5.6 // center of the bottom bend |
| 104 | let R: CGFloat = 3.7 // centerline radius of the U |
| 105 | let top: CGFloat = 11.6 // y of the pole tips |
| 106 | let lw: CGFloat = 3.2 |
| 107 | |
| 108 | // Horseshoe body: two arms rising from a bottom semicircle. |
| 109 | let body = NSBezierPath() |
| 110 | body.move(to: NSPoint(x: cx - R, y: top)) |
| 111 | body.line(to: NSPoint(x: cx - R, y: cyArc)) |
| 112 | body.appendArc(withCenter: NSPoint(x: cx, y: cyArc), radius: R, |
| 113 | startAngle: 180, endAngle: 360, clockwise: false) |
| 114 | body.line(to: NSPoint(x: cx + R, y: top)) |
| 115 | body.lineWidth = lw |
| 116 | body.lineCapStyle = .butt |
| 117 | NSColor.black.setStroke() |
| 118 | body.stroke() |
| 119 | |
| 120 | // Banded pole tips, a touch lighter so the poles read as pole pieces. |
| 121 | NSColor.black.withAlphaComponent(0.55).setFill() |
| 122 | let bandH: CGFloat = 2.4 |
| 123 | NSRect(x: cx - R - lw/2, y: top - bandH, width: lw, height: bandH).fill() |
| 124 | NSRect(x: cx + R - lw/2, y: top - bandH, width: lw, height: bandH).fill() |
| 125 | return true |
| 126 | } |
| 127 | img.isTemplate = true |
| 128 | return img |
| 129 | } |
| 130 | |
| 131 | /// Rasterize an image (typically an SF Symbol) into a plain template image, |
| 132 | /// aspect-fit inside `box`. This strips the symbol-ness so NSButton draws it |
| 133 | /// through its cell — no NSButtonImageView subview — keeping the button square. |
| 134 | private static func flattenIcon(_ image: NSImage, box: NSSize) -> NSImage { |
| 135 | let src = image.size |
| 136 | let scale = min(box.width / src.width, box.height / src.height) |
| 137 | let sz = NSSize(width: (src.width * scale).rounded(), |
| 138 | height: (src.height * scale).rounded()) |
| 139 | let out = NSImage(size: sz, flipped: false) { rect in |
| 140 | image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) |
| 141 | return true |
| 142 | } |
| 143 | out.isTemplate = true |
| 144 | return out |
| 145 | } |
| 146 | |
| 147 | override init(frame: NSRect) { |
| 148 | super.init(frame: frame) |
| 149 | wantsLayer = true |
| 150 | layer?.backgroundColor = Theme.barBg.cgColor |
| 151 | |
| 152 | // ---- Left: tools + color ---- |
| 153 | var leftViews: [NSView] = [] |
| 154 | for tool in MainTool.allCases { |
| 155 | let b = InstantButton(image: NSImage(systemSymbolName: tool.symbol, |
| 156 | accessibilityDescription: tool.label) |
| 157 | ?? NSImage(), |
| 158 | target: self, action: #selector(pickTool(_:))) |
| 159 | styleIconButton(b, tip: tool.tip) |
| 160 | toolButtons[tool] = b |
| 161 | leftViews.append(b) |
| 162 | if tool == .slide { |
| 163 | let sep = NSBox(); sep.boxType = .separator |
| 164 | sep.heightAnchor.constraint(equalToConstant: 18).isActive = true |
| 165 | leftViews.append(sep) |
| 166 | } |
| 167 | } |
| 168 | shapesButton.image = NSImage(systemSymbolName: "square.on.circle", |
| 169 | accessibilityDescription: "shapes") |
| 170 | shapesButton.target = self |
| 171 | shapesButton.action = #selector(shapesClicked) |
| 172 | styleIconButton(shapesButton, tip: "Shapes") |
| 173 | leftViews.append(shapesButton) |
| 174 | |
| 175 | colorSwatch.target = self |
| 176 | colorSwatch.action = #selector(swatchClicked) |
| 177 | colorSwatch.isBordered = false |
| 178 | colorSwatch.wantsLayer = true |
| 179 | colorSwatch.layer?.backgroundColor = session.drawColor.cgColor |
| 180 | colorSwatch.layer?.cornerRadius = 4 |
| 181 | colorSwatch.layer?.borderWidth = 1 |
| 182 | colorSwatch.layer?.borderColor = NSColor(calibratedWhite: 0.5, alpha: 0.8).cgColor |
| 183 | colorSwatch.widthAnchor.constraint(equalToConstant: 17).isActive = true |
| 184 | colorSwatch.heightAnchor.constraint(equalToConstant: 17).isActive = true |
| 185 | colorSwatch.onHover = { [weak self] in |
| 186 | guard let self else { return } |
| 187 | ColorPickerPanel.show(under: self.colorSwatch, color: self.session.drawColor) { [weak self] c in |
| 188 | self?.session.drawColor = c |
| 189 | } |
| 190 | } |
| 191 | leftViews.append(colorSwatch) |
| 192 | |
| 193 | let leftStack = NSStackView(views: leftViews) |
| 194 | leftStack.orientation = .horizontal |
| 195 | leftStack.spacing = 2 |
| 196 | |
| 197 | // ---- Center: timecode + frame rate ---- |
| 198 | timecode.font = .monospacedDigitSystemFont(ofSize: 14, weight: .medium) |
| 199 | timecode.textColor = Theme.label |
| 200 | fpsButton.target = self |
| 201 | fpsButton.action = #selector(fpsClicked) |
| 202 | fpsButton.isBordered = false |
| 203 | fpsButton.font = .monospacedDigitSystemFont(ofSize: 10, weight: .regular) |
| 204 | fpsButton.contentTintColor = Theme.subtleLabel |
| 205 | fpsButton.tipText = "Frame Rate" |
| 206 | rateField.font = .monospacedDigitSystemFont(ofSize: 11, weight: .regular) |
| 207 | rateField.textColor = Theme.subtleLabel |
| 208 | rateField.toolTip = "Playback speed — click or right-click to change" |
| 209 | // Both buttons open the same speed editor: right-click is what was asked |
| 210 | // for, left-click makes it discoverable (like the frame-rate readout). |
| 211 | let speedLeftClick = NSClickGestureRecognizer(target: self, action: #selector(speedClicked)) |
| 212 | rateField.addGestureRecognizer(speedLeftClick) |
| 213 | let speedRightClick = NSClickGestureRecognizer(target: self, action: #selector(speedClicked)) |
| 214 | speedRightClick.buttonMask = 0x2 |
| 215 | rateField.addGestureRecognizer(speedRightClick) |
| 216 | let centerStack = NSStackView(views: [timecode, fpsButton, rateField]) |
| 217 | centerStack.orientation = .horizontal |
| 218 | centerStack.spacing = 8 |
| 219 | |
| 220 | // ---- Right: status, jobs, toggles, view controls ---- |
| 221 | status.font = .systemFont(ofSize: 11) |
| 222 | status.textColor = Theme.subtleLabel |
| 223 | status.lineBreakMode = .byTruncatingTail |
| 224 | jobs.font = .systemFont(ofSize: 11) |
| 225 | jobs.textColor = .systemOrange |
| 226 | // Clicking the readout pauses/resumes proxy optimization. |
| 227 | jobs.addGestureRecognizer( |
| 228 | NSClickGestureRecognizer(target: self, action: #selector(toggleOptimizePause))) |
| 229 | |
| 230 | // Shown next to the optimization readout when a network-mounted source |
| 231 | // is too slow to build full-quality proxies in real time — the one case |
| 232 | // where quality DOESN'T degrade (degrading can't beat the read). |
| 233 | netWarn.font = .systemFont(ofSize: 11, weight: .semibold) |
| 234 | netWarn.textColor = .systemYellow |
| 235 | netWarn.lineBreakMode = .byTruncatingTail |
| 236 | |
| 237 | snapButton.image = Self.magnetImage() |
| 238 | snapButton.target = self |
| 239 | snapButton.action = #selector(toggleSnap) |
| 240 | styleIconButton(snapButton, tip: "Snap (Y)") |
| 241 | subtitleButton.image = NSImage(systemSymbolName: "captions.bubble", |
| 242 | accessibilityDescription: "subtitles") |
| 243 | subtitleButton.target = self |
| 244 | subtitleButton.action = #selector(toggleSubtitles) |
| 245 | styleIconButton(subtitleButton, tip: "Subtitles") |
| 246 | filmstripButton.image = NSImage(systemSymbolName: "film", |
| 247 | accessibilityDescription: "clip thumbnails") |
| 248 | filmstripButton.target = self |
| 249 | filmstripButton.action = #selector(toggleFilmstrips) |
| 250 | styleIconButton(filmstripButton, tip: "Thumbnails (⌥⌘F)") |
| 251 | viewerButton.image = NSImage(systemSymbolName: "square.grid.2x2", |
| 252 | accessibilityDescription: "viewer layout") |
| 253 | viewerButton.target = self |
| 254 | viewerButton.action = #selector(viewerClicked) |
| 255 | styleIconButton(viewerButton, tip: "Viewer") |
| 256 | |
| 257 | heightSlider.minValue = 0.4 |
| 258 | heightSlider.maxValue = 2.5 |
| 259 | heightSlider.doubleValue = Double(session.laneScale) |
| 260 | heightSlider.controlSize = .small |
| 261 | heightSlider.target = self |
| 262 | heightSlider.action = #selector(heightChanged) |
| 263 | heightSlider.toolTip = "Track height (⌥⌘= / ⌥⌘- / ⌥⌘0)" |
| 264 | heightSlider.widthAnchor.constraint(equalToConstant: 80).isActive = true |
| 265 | |
| 266 | let rightStack = NSStackView(views: [netWarn, jobs, snapButton, subtitleButton, |
| 267 | filmstripButton, viewerButton, heightSlider]) |
| 268 | rightStack.orientation = .horizontal |
| 269 | rightStack.spacing = 4 |
| 270 | rightStack.setCustomSpacing(8, after: netWarn) |
| 271 | rightStack.setCustomSpacing(10, after: jobs) |
| 272 | |
| 273 | for v in [leftStack, centerStack, rightStack, status] { |
| 274 | v.translatesAutoresizingMaskIntoConstraints = false |
| 275 | addSubview(v) |
| 276 | } |
| 277 | NSLayoutConstraint.activate([ |
| 278 | leftStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 10), |
| 279 | leftStack.centerYAnchor.constraint(equalTo: centerYAnchor), |
| 280 | centerStack.centerXAnchor.constraint(equalTo: centerXAnchor), |
| 281 | centerStack.centerYAnchor.constraint(equalTo: centerYAnchor), |
| 282 | rightStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -10), |
| 283 | rightStack.centerYAnchor.constraint(equalTo: centerYAnchor), |
| 284 | status.leadingAnchor.constraint(equalTo: leftStack.trailingAnchor, constant: 12), |
| 285 | status.trailingAnchor.constraint(lessThanOrEqualTo: centerStack.leadingAnchor, |
| 286 | constant: -8), |
| 287 | status.centerYAnchor.constraint(equalTo: centerYAnchor), |
| 288 | ]) |
| 289 | status.setContentHuggingPriority(.defaultLow, for: .horizontal) |
| 290 | status.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) |
| 291 | |
| 292 | ctx.notify.addObserver(self, selector: #selector(tick), |
| 293 | name: .playheadChanged, object: nil) |
| 294 | NotificationCenter.default.addObserver(self, selector: #selector(updateJobs), |
| 295 | name: .mediaStatusChanged, object: nil) |
| 296 | NotificationCenter.default.addObserver(self, selector: #selector(updateJobs), |
| 297 | name: .projectChanged, object: nil) |
| 298 | NotificationCenter.default.addObserver(self, selector: #selector(transient(_:)), |
| 299 | name: .transientStatus, object: nil) |
| 300 | NotificationCenter.default.addObserver(self, selector: #selector(viewOptionsChanged), |
| 301 | name: .viewOptionsChanged, object: nil) |
| 302 | NotificationCenter.default.addObserver(self, selector: #selector(themeChanged), |
| 303 | name: .themeChanged, object: nil) |
| 304 | tick() |
| 305 | updateJobs() |
| 306 | syncButtons() |
| 307 | } |
| 308 | required init?(coder: NSCoder) { fatalError() } |
| 309 | |
| 310 | private func styleIconButton(_ b: InstantButton, tip: String) { |
| 311 | b.isBordered = false |
| 312 | b.setButtonType(.momentaryChange) |
| 313 | b.imageScaling = .scaleProportionallyDown |
| 314 | b.imagePosition = .imageOnly |
| 315 | b.wantsLayer = true |
| 316 | b.layer?.cornerRadius = 5 |
| 317 | b.tipText = tip |
| 318 | // Cap the glyph to a small square. SF Symbols carry a large natural |
| 319 | // cell height (~27pt, driven by the symbol's point size), which was |
| 320 | // breaking the required height constraint below and leaving these |
| 321 | // buttons taller than wide — while the custom 15x15 magnet image sat |
| 322 | // happily at 22x22. Shrinking every glyph to the magnet's footprint |
| 323 | // makes the cell fit inside 22x22, so the constraints actually hold. |
| 324 | // Flatten the glyph into a plain raster template image. An SF Symbol set |
| 325 | // directly on an NSButton renders through an internal NSButtonImageView |
| 326 | // subview whose sizing inflates the button to ~22x27 — the magnet stays a |
| 327 | // clean 22x22 precisely because it's a plain template image the cell draws |
| 328 | // itself. Rasterizing every symbol the same way removes the subview and |
| 329 | // makes all these buttons behave identically. |
| 330 | if let img = b.image { |
| 331 | let sized = img.withSymbolConfiguration( |
| 332 | NSImage.SymbolConfiguration(pointSize: 13, weight: .regular)) ?? img |
| 333 | b.image = Self.flattenIcon(sized, box: NSSize(width: 16, height: 15)) |
| 334 | } |
| 335 | // Force a square box (glyph aspect ratios vary; the button and its |
| 336 | // rounded highlight background must stay square). squareSide also makes |
| 337 | // the button's *intrinsic* size square as a backstop. |
| 338 | let side: CGFloat = 22 |
| 339 | b.squareSide = side |
| 340 | b.setContentHuggingPriority(.required, for: .horizontal) |
| 341 | b.setContentHuggingPriority(.required, for: .vertical) |
| 342 | b.setContentCompressionResistancePriority(.required, for: .horizontal) |
| 343 | b.setContentCompressionResistancePriority(.required, for: .vertical) |
| 344 | let w = b.widthAnchor.constraint(equalToConstant: side) |
| 345 | let h = b.heightAnchor.constraint(equalToConstant: side) |
| 346 | for c in [w, h] { c.priority = .required; c.isActive = true } |
| 347 | } |
| 348 | |
| 349 | // MARK: - Actions |
| 350 | |
| 351 | @objc private func pickTool(_ sender: NSButton) { |
| 352 | guard let tool = toolButtons.first(where: { $0.value === sender })?.key else { return } |
| 353 | session.mainTool = tool |
| 354 | } |
| 355 | |
| 356 | private static let shapeKinds: [(String, BoardShape.Kind)] = [ |
| 357 | ("Rectangle", .rect), ("Oval", .oval), ("Triangle", .triangle), |
| 358 | ("Star", .star), ("N-gon", .ngon), ("Text", .text), ("Image…", .image), |
| 359 | ] |
| 360 | |
| 361 | @objc private func shapesClicked() { |
| 362 | let menu = NSMenu() |
| 363 | for (title, kind) in Self.shapeKinds { |
| 364 | let mi = NSMenuItem(title: title, action: #selector(shapePicked(_:)), |
| 365 | keyEquivalent: "") |
| 366 | mi.target = self |
| 367 | mi.representedObject = kind.rawValue |
| 368 | mi.state = session.pendingShape == kind ? .on : .off |
| 369 | menu.addItem(mi) |
| 370 | } |
| 371 | if session.pendingShape != nil { |
| 372 | menu.addItem(.separator()) |
| 373 | let mi = NSMenuItem(title: "Cancel Placement", |
| 374 | action: #selector(shapeCancelled), keyEquivalent: "") |
| 375 | mi.target = self |
| 376 | menu.addItem(mi) |
| 377 | } |
| 378 | menu.popUp(positioning: nil, |
| 379 | at: NSPoint(x: 0, y: shapesButton.bounds.maxY + 4), in: shapesButton) |
| 380 | } |
| 381 | |
| 382 | @objc private func shapePicked(_ sender: NSMenuItem) { |
| 383 | guard let raw = sender.representedObject as? String, |
| 384 | let kind = BoardShape.Kind(rawValue: raw) else { return } |
| 385 | session.pendingShape = kind |
| 386 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 387 | userInfo: ["text": "Drag on the storyboard preview to place the \(sender.title.lowercased())"]) |
| 388 | } |
| 389 | |
| 390 | @objc private func shapeCancelled() { session.pendingShape = nil } |
| 391 | |
| 392 | @objc private func swatchClicked() { |
| 393 | ColorPickerPanel.show(under: colorSwatch, color: session.drawColor) { [weak self] c in |
| 394 | self?.session.drawColor = c |
| 395 | } |
| 396 | } |
| 397 | |
| 398 | @objc private func toggleSnap() { session.snapping.toggle() } |
| 399 | @objc private func toggleSubtitles() { session.subtitlesEnabled.toggle() } |
| 400 | @objc private func toggleFilmstrips() { session.showFilmstrips.toggle() } |
| 401 | |
| 402 | @objc private func viewerClicked() { |
| 403 | let menu = NSMenu() |
| 404 | // Show the same key equivalents the View menu uses (⌥⌘L / ⇧⌘P). |
| 405 | let side = NSMenuItem(title: "Previews on Left", action: #selector(toggleSide), |
| 406 | keyEquivalent: "l") |
| 407 | side.keyEquivalentModifierMask = [.option, .command] |
| 408 | side.target = self |
| 409 | side.state = session.previewsOnLeft ? .on : .off |
| 410 | menu.addItem(side) |
| 411 | let pop = NSMenuItem(title: "Pop Out Previews", action: #selector(togglePopout), |
| 412 | keyEquivalent: "P") |
| 413 | pop.keyEquivalentModifierMask = [.command, .shift] |
| 414 | pop.target = self |
| 415 | pop.state = ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false) ? .on : .off |
| 416 | menu.addItem(pop) |
| 417 | menu.popUp(positioning: nil, |
| 418 | at: NSPoint(x: 0, y: viewerButton.bounds.maxY + 4), in: viewerButton) |
| 419 | } |
| 420 | |
| 421 | @objc private func toggleSide() { |
| 422 | (window?.windowController as? SequencerWindowController)?.togglePreviewsLeft() |
| 423 | } |
| 424 | @objc private func togglePopout() { |
| 425 | (window?.windowController as? SequencerWindowController)?.togglePopout() |
| 426 | } |
| 427 | |
| 428 | @objc private func fpsClicked() { |
| 429 | let menu = NSMenu() |
| 430 | menu.delegate = self |
| 431 | fpsMenu = menu |
| 432 | let fps = store.project.fps |
| 433 | |
| 434 | // Editable entry, first in the list — auto-focused so the user can just |
| 435 | // start typing a custom rate. Prefilled with the current value, all |
| 436 | // selected, so typing replaces it. |
| 437 | customFpsField.stringValue = fps == fps.rounded() |
| 438 | ? String(format: "%.0f", fps) : String(format: "%g", fps) |
| 439 | menu.addItem(makeCustomFpsItem()) |
| 440 | menu.addItem(.separator()) |
| 441 | |
| 442 | for (title, v) in AppDelegate.frameRates { |
| 443 | let mi = NSMenuItem(title: title, action: #selector(fpsPicked(_:)), |
| 444 | keyEquivalent: "") |
| 445 | mi.target = self |
| 446 | mi.representedObject = v |
| 447 | mi.state = abs(fps - v) < 0.01 ? .on : .off |
| 448 | menu.addItem(mi) |
| 449 | } |
| 450 | |
| 451 | // Drop the menu so the field lands roughly over the frame-rate readout. |
| 452 | menu.popUp(positioning: menu.items.first, |
| 453 | at: NSPoint(x: -12, y: fpsButton.bounds.maxY + 9), in: fpsButton) |
| 454 | } |
| 455 | |
| 456 | /// A menu item hosting the editable fps field, laid out to line up with the |
| 457 | /// preset titles below it. |
| 458 | private func makeCustomFpsItem() -> NSMenuItem { |
| 459 | let item = NSMenuItem() |
| 460 | let field = customFpsField |
| 461 | field.isEditable = true |
| 462 | field.isBordered = true |
| 463 | field.bezelStyle = .roundedBezel |
| 464 | field.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular) |
| 465 | field.alignment = .left |
| 466 | field.placeholderString = "Custom fps" |
| 467 | field.target = self |
| 468 | field.action = #selector(customFpsEntered(_:)) |
| 469 | field.delegate = self |
| 470 | field.translatesAutoresizingMaskIntoConstraints = false |
| 471 | |
| 472 | let container = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: 28)) |
| 473 | container.addSubview(field) |
| 474 | NSLayoutConstraint.activate([ |
| 475 | field.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 11), |
| 476 | field.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -11), |
| 477 | field.centerYAnchor.constraint(equalTo: container.centerYAnchor), |
| 478 | ]) |
| 479 | item.view = container |
| 480 | return item |
| 481 | } |
| 482 | |
| 483 | @objc private func fpsPicked(_ sender: NSMenuItem) { |
| 484 | guard let v = sender.representedObject as? Double else { return } |
| 485 | store.mutate { $0.fps = v } |
| 486 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 487 | userInfo: ["text": "Project frame rate: \(sender.title)"]) |
| 488 | } |
| 489 | |
| 490 | @objc private func customFpsEntered(_ sender: NSTextField) { |
| 491 | let raw = sender.stringValue.trimmingCharacters(in: .whitespaces) |
| 492 | // Accept a bare number or a "24 fps"-style string. |
| 493 | let scanned = raw.split(separator: " ").first.map(String.init) ?? raw |
| 494 | guard let v = Double(scanned), v >= 1, v <= 240 else { |
| 495 | NSSound.beep() |
| 496 | return |
| 497 | } |
| 498 | fpsMenu?.cancelTracking() |
| 499 | store.mutate { $0.fps = v } |
| 500 | let text = v == v.rounded() ? String(format: "%.0f fps", v) |
| 501 | : String(format: "%g fps", v) |
| 502 | NotificationCenter.default.post(name: .transientStatus, object: nil, |
| 503 | userInfo: ["text": "Project frame rate: \(text)"]) |
| 504 | } |
| 505 | |
| 506 | // MARK: - Playback speed dropdown |
| 507 | |
| 508 | @objc private func speedClicked() { |
| 509 | let menu = NSMenu() |
| 510 | menu.delegate = self |
| 511 | speedMenu = menu |
| 512 | let speed = playback.playSpeed |
| 513 | |
| 514 | // Editable entry first, auto-focused and pre-selected so you can just |
| 515 | // type a custom speed — mirrors the frame-rate dropdown. |
| 516 | customSpeedField.stringValue = Self.speedString(speed) |
| 517 | menu.addItem(makeCustomSpeedItem()) |
| 518 | menu.addItem(.separator()) |
| 519 | |
| 520 | for (title, v) in AppDelegate.playbackSpeeds { |
| 521 | let mi = NSMenuItem(title: title, action: #selector(speedPicked(_:)), |
| 522 | keyEquivalent: "") |
| 523 | mi.target = self |
| 524 | mi.representedObject = v |
| 525 | mi.state = abs(speed - v) < 0.001 ? .on : .off |
| 526 | menu.addItem(mi) |
| 527 | } |
| 528 | |
| 529 | menu.popUp(positioning: menu.items.first, |
| 530 | at: NSPoint(x: -12, y: rateField.bounds.maxY + 9), in: rateField) |
| 531 | } |
| 532 | |
| 533 | /// A menu item hosting the editable speed field, laid out like the fps one. |
| 534 | private func makeCustomSpeedItem() -> NSMenuItem { |
| 535 | let item = NSMenuItem() |
| 536 | let field = customSpeedField |
| 537 | field.isEditable = true |
| 538 | field.isBordered = true |
| 539 | field.bezelStyle = .roundedBezel |
| 540 | field.font = .monospacedDigitSystemFont(ofSize: 13, weight: .regular) |
| 541 | field.alignment = .left |
| 542 | field.placeholderString = "Custom speed" |
| 543 | field.target = self |
| 544 | field.action = #selector(customSpeedEntered(_:)) |
| 545 | field.delegate = self |
| 546 | field.translatesAutoresizingMaskIntoConstraints = false |
| 547 | |
| 548 | let container = NSView(frame: NSRect(x: 0, y: 0, width: 210, height: 28)) |
| 549 | container.addSubview(field) |
| 550 | NSLayoutConstraint.activate([ |
| 551 | field.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 11), |
| 552 | field.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -11), |
| 553 | field.centerYAnchor.constraint(equalTo: container.centerYAnchor), |
| 554 | ]) |
| 555 | item.view = container |
| 556 | return item |
| 557 | } |
| 558 | |
| 559 | @objc private func speedPicked(_ sender: NSMenuItem) { |
| 560 | guard let v = sender.representedObject as? Double else { return } |
| 561 | playback.setPlaySpeed(v) |
| 562 | } |
| 563 | |
| 564 | @objc private func customSpeedEntered(_ sender: NSTextField) { |
| 565 | let raw = sender.stringValue.trimmingCharacters(in: .whitespaces) |
| 566 | // Accept a bare number or a "1.5x"/"1.5×" string. |
| 567 | let scanned = raw.split(whereSeparator: { $0 == "x" || $0 == "×" || $0 == " " }) |
| 568 | .first.map(String.init) ?? raw |
| 569 | guard let v = Double(scanned), v >= 0.1, v <= 64 else { |
| 570 | NSSound.beep() |
| 571 | return |
| 572 | } |
| 573 | speedMenu?.cancelTracking() |
| 574 | playback.setPlaySpeed(v) |
| 575 | } |
| 576 | |
| 577 | @objc private func heightChanged() { |
| 578 | session.laneScale = CGFloat(heightSlider.doubleValue) |
| 579 | } |
| 580 | |
| 581 | // MARK: - State sync |
| 582 | |
| 583 | @objc private func viewOptionsChanged() { |
| 584 | if abs(heightSlider.doubleValue - Double(session.laneScale)) > 0.001 { |
| 585 | heightSlider.doubleValue = Double(session.laneScale) |
| 586 | } |
| 587 | syncButtons() |
| 588 | tick() |
| 589 | } |
| 590 | |
| 591 | @objc private func themeChanged() { |
| 592 | layer?.backgroundColor = Theme.barBg.cgColor |
| 593 | timecode.textColor = Theme.label |
| 594 | rateField.textColor = Theme.subtleLabel |
| 595 | status.textColor = Theme.subtleLabel |
| 596 | fpsButton.contentTintColor = Theme.subtleLabel |
| 597 | syncButtons() |
| 598 | } |
| 599 | |
| 600 | private func highlight(_ b: NSButton, _ on: Bool) { |
| 601 | b.layer?.backgroundColor = on |
| 602 | ? NSColor.controlAccentColor.withAlphaComponent(0.85).cgColor |
| 603 | : NSColor.clear.cgColor |
| 604 | b.contentTintColor = on ? .white : .secondaryLabelColor |
| 605 | } |
| 606 | |
| 607 | private func syncButtons() { |
| 608 | let canDraw = session.panelUnderPlayhead != nil |
| 609 | for (tool, b) in toolButtons { |
| 610 | highlight(b, tool == session.mainTool) |
| 611 | if tool.isDraw { |
| 612 | b.isEnabled = canDraw |
| 613 | b.alphaValue = canDraw ? 1 : 0.3 |
| 614 | } |
| 615 | } |
| 616 | // A draw tool with no panel under the playhead falls back to select. |
| 617 | if !canDraw, session.mainTool.isDraw { session.mainTool = .select } |
| 618 | if !canDraw, session.pendingShape != nil { session.pendingShape = nil } |
| 619 | highlight(shapesButton, session.pendingShape != nil) |
| 620 | shapesButton.isEnabled = canDraw |
| 621 | shapesButton.alphaValue = canDraw ? 1 : 0.3 |
| 622 | colorSwatch.layer?.backgroundColor = session.drawColor.cgColor |
| 623 | highlight(snapButton, session.snapping) |
| 624 | highlight(subtitleButton, session.subtitlesEnabled) |
| 625 | highlight(filmstripButton, session.showFilmstrips) |
| 626 | highlight(viewerButton, session.previewsOnLeft |
| 627 | || ((window?.windowController as? SequencerWindowController)?.previewsArePopped ?? false)) |
| 628 | } |
| 629 | |
| 630 | // MARK: - Readouts |
| 631 | |
| 632 | @objc private func tick() { |
| 633 | let pc = playback |
| 634 | let fps = store.project.fps |
| 635 | let frame = Int((pc.playhead * fps).rounded()) |
| 636 | timecode.stringValue = timecodeString(frame: frame, fps: fps) |
| 637 | let fpsText = fps == fps.rounded() |
| 638 | ? String(format: "%.0f fps", fps) : String(format: "%.2f fps", fps) |
| 639 | fpsButton.attributedTitle = NSAttributedString( |
| 640 | string: fpsText, |
| 641 | attributes: [.font: NSFont.monospacedDigitSystemFont(ofSize: 10, weight: .regular), |
| 642 | .foregroundColor: Theme.subtleLabel]) |
| 643 | // Always show a speed. Stopped → the set play speed with no arrow (that |
| 644 | // absence is the "paused" cue); playing → a direction arrow + the live |
| 645 | // rate. %g keeps fractional speeds like 1.5x / 1.25x readable. |
| 646 | rateField.stringValue = pc.rate == 0 |
| 647 | ? Self.speedString(pc.playSpeed) |
| 648 | : (pc.rate < 0 ? "◀︎ " : "▶︎ ") + Self.speedString(abs(pc.rate)) |
| 649 | } |
| 650 | |
| 651 | /// Format a speed magnitude as "1x" / "1.5x" (whole numbers drop the decimal). |
| 652 | private static func speedString(_ v: Double) -> String { |
| 653 | v == v.rounded() ? String(format: "%.0fx", v) : String(format: "%gx", v) |
| 654 | } |
| 655 | |
| 656 | @objc private func updateJobs() { |
| 657 | tick() // project fps may have changed with the model |
| 658 | syncButtons() |
| 659 | if chunks.isNetworkLimited { |
| 660 | netWarn.stringValue = "⚠︎ Network I/O limiting quality" |
| 661 | netWarn.toolTip = "The media is on a network volume that can't be read fast " |
| 662 | + "enough to build full-quality proxies in real time. Reducing quality " |
| 663 | + "won't help — it's the network read, not this Mac — so playback keeps " |
| 664 | + "source quality and may stutter or fall back to the originals." |
| 665 | } else { |
| 666 | netWarn.stringValue = "" |
| 667 | netWarn.toolTip = nil |
| 668 | } |
| 669 | let (building, queued) = chunks.queueSummary() |
| 670 | let total = building + queued |
| 671 | if total == 0 { |
| 672 | jobs.stringValue = "" |
| 673 | jobs.toolTip = nil |
| 674 | } else if chunks.isPaused { |
| 675 | jobs.textColor = Theme.subtleLabel |
| 676 | jobs.stringValue = "⏸ \(total) chunk\(total == 1 ? "" : "s")" |
| 677 | jobs.toolTip = "When paused, clip optimization happens only during playback." |
| 678 | } else if chunks.budgetStarved { |
| 679 | // The cache is at its cap and the project is bigger than it: the |
| 680 | // builder is deliberately NOT trying to finish the queue — it |
| 681 | // maintains a working set around where you play and edit. Say so, |
| 682 | // instead of dangling a queue count that will never drain. |
| 683 | jobs.textColor = Theme.subtleLabel |
| 684 | jobs.stringValue = building > 0 |
| 685 | ? "cache full — optimizing \(building) near playhead" |
| 686 | : "cache full — optimizing on demand" |
| 687 | let gb = Double(MediaPipeline.shared.maxCacheBytes) / 1e9 |
| 688 | jobs.toolTip = String(format: |
| 689 | "The proxy cache is at its %.0f GB cap, so this project can't be " |
| 690 | + "fully optimized at once. Proxies are kept where you play, edit, " |
| 691 | + "and land on clips, and rebuilt on demand elsewhere. Raise the " |
| 692 | + "cap in Settings to fit more. Click to pause optimization.", gb) |
| 693 | } else if queued == 0 { |
| 694 | jobs.textColor = .systemOrange |
| 695 | jobs.stringValue = "optimizing \(building) chunk\(building == 1 ? "" : "s")" |
| 696 | jobs.toolTip = "Click to disable background optimization." |
| 697 | } else { |
| 698 | jobs.textColor = .systemOrange |
| 699 | jobs.stringValue = "optimizing \(building) chunk\(building == 1 ? "" : "s") (+\(queued) queued)" |
| 700 | jobs.toolTip = "Click to disable background optimization." |
| 701 | } |
| 702 | } |
| 703 | |
| 704 | @objc private func toggleOptimizePause() { |
| 705 | chunks.setPaused(!chunks.isPaused) |
| 706 | updateJobs() |
| 707 | } |
| 708 | |
| 709 | @objc private func transient(_ note: Notification) { |
| 710 | status.stringValue = (note.userInfo?["text"] as? String) ?? "" |
| 711 | statusClearTimer?.invalidate() |
| 712 | statusClearTimer = Timer.scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in |
| 713 | self?.status.stringValue = "" |
| 714 | } |
| 715 | } |
| 716 | } |
| 717 | |
| 718 | extension TransportBar: NSTextFieldDelegate { |
| 719 | func control(_ control: NSControl, textView: NSTextView, |
| 720 | doCommandBy selector: Selector) -> Bool { |
| 721 | // Up/down would jump the insertion point to the line ends; swallow them |
| 722 | // so the cursor stays put in the custom-fps field. |
| 723 | if selector == #selector(NSResponder.moveUp(_:)) |
| 724 | || selector == #selector(NSResponder.moveDown(_:)) { |
| 725 | return true |
| 726 | } |
| 727 | return false |
| 728 | } |
| 729 | } |
| 730 | |
| 731 | extension TransportBar: NSMenuDelegate { |
| 732 | func menuWillOpen(_ menu: NSMenu) { |
| 733 | let field: NSTextField? = menu === fpsMenu ? customFpsField |
| 734 | : menu === speedMenu ? customSpeedField : nil |
| 735 | guard let field else { return } |
| 736 | // The menu runs its own modal tracking loop, so focus has to be handed |
| 737 | // to the field in that run-loop mode — a plain async dispatch would sit |
| 738 | // idle until the menu closed. |
| 739 | RunLoop.current.perform(inModes: [.eventTracking]) { |
| 740 | guard let window = field.window else { return } |
| 741 | window.makeFirstResponder(field) |
| 742 | field.currentEditor()?.selectAll(nil) |
| 743 | } |
| 744 | } |
| 745 | } |