1import AppKit
2
3/// Instant tooltip — no system hover delay. Shown ABOVE toolbar buttons in
4/// the accent color the moment the pointer arrives.
5enum 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.
59final 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
718extension 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
731extension 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}