1import AppKit
2import AVFoundation
3
4/// Multicam-style preview: one cell per track WITH something under the
5/// playhead, side by side. There is no combined view — tracks are angles,
6/// not layers. Each cell is outlined in its track's color. Hidden tracks
7/// drop out; focus shows only focused tracks (the Fusion band has its own
8/// focus/hide). Cells reflow with a short animation when the active set changes.
9final class ViewerGridView: NSView {
10 /// The document context (store, playback, comps, players), injected by the
11 /// window controller when this view is placed in a document window.
12 var ctx: DocumentContext = .headless {
13 didSet {
14 guard oldValue !== ctx else { return }
15 oldValue.notify.removeObserver(self, name: .playheadChanged, object: nil)
16 ctx.notify.addObserver(self, selector: #selector(sync),
17 name: .playheadChanged, object: nil)
18 // Forward the new context into cells already built against the old
19 // one — otherwise they keep reading the previous (or headless) store
20 // and render nothing but the black cell background.
21 for c in cells.values { c.ctx = ctx }
22 fusionCell?.ctx = ctx
23 subtitles.ctx = ctx
24 }
25 }
26 private var store: Store { ctx.store }
27 private var project: ProjectModel { ctx.store.project }
28 private var playback: PlaybackController { ctx.playback }
29 private var players: PlayerManager { ctx.players }
30 private var chunks: ChunkManager { ctx.chunks }
31 private var comps: FusionComps { ctx.comps }
32 private var boards: BoardStore { ctx.boards }
33 private var session: SessionState { ctx.session }
34
35 private var cells: [TrackRef: ViewerCell] = [:]
36 private var fusionCell: FusionViewerCell?
37 private var paneKey: [TrackRef] = [] // current pane order (fusion = .fusion)
38
39 /// The cell under the mouse right now — target of the F/H hover-shortcuts.
40 /// Set by ViewerCellBase on mouse enter/exit (weak: dying cells self-clear).
41 weak var hoveredCell: ViewerCellBase?
42 private var keyMonitor: Any?
43
44 private static let fusionKey = UI.fusionPaneKey
45
46 /// Empty-state prompt: why nothing is on screen (and, for hidden tracks, a
47 /// one-click way back). Only shown when there are no panes at all.
48 private let placeholder = ViewerPlaceholder()
49
50 /// Synchronized clover-transcript captions, drawn on top of the cells.
51 /// Positions itself (via `preferredFrame`) but hides itself unless a
52 /// transcript-bearing clip is under the playhead and captions are enabled.
53 private let subtitles = SubtitleOverlay()
54
55 override init(frame: NSRect) {
56 super.init(frame: frame)
57 wantsLayer = true
58 layer?.backgroundColor = Theme.viewerBg.cgColor
59 for name: Notification.Name in [.projectChanged, .viewOptionsChanged, .compsChanged,
60 .mediaStatusChanged, .viewerNeedsRefresh] {
61 NotificationCenter.default.addObserver(self, selector: #selector(sync),
62 name: name, object: nil)
63 }
64 // Per-document: bound against the current (headless) ctx here, re-bound
65 // when a real ctx is injected (see `ctx.didSet`).
66 ctx.notify.addObserver(self, selector: #selector(sync),
67 name: .playheadChanged, object: nil)
68 NotificationCenter.default.addObserver(self, selector: #selector(themeChanged),
69 name: .themeChanged, object: nil)
70
71 placeholder.onUnhide = { [weak self] in
72 guard let self else { return }
73 self.session.hiddenTracks = []
74 self.session.focusedTracks = []
75 self.session.fusionHidden = false
76 self.session.fusionFocus = false
77 }
78 addSubview(placeholder)
79 NSLayoutConstraint.activate([
80 placeholder.centerXAnchor.constraint(equalTo: centerXAnchor),
81 placeholder.centerYAnchor.constraint(equalTo: centerYAnchor),
82 placeholder.leadingAnchor.constraint(greaterThanOrEqualTo: leadingAnchor, constant: 16),
83 placeholder.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -16),
84 ])
85 // Captions ride on top of the cells. Added last so later cells (inserted
86 // `.below`) stay under it; positioned in `layout()`.
87 subtitles.ctx = ctx
88 addSubview(subtitles)
89
90 // Dropping media anywhere in the empty viewer imports it (delegating to
91 // the timeline's importer), so the "Drag files…" prompt is real.
92 registerForDraggedTypes([.fileURL])
93 }
94 required init?(coder: NSCoder) { fatalError() }
95
96 /// F / H act on the preview cell under the mouse — focus / hide the track
97 /// (or the Fusion band) you're pointing at, press again to toggle back.
98 /// A local monitor (not keyDown) so it fires wherever keyboard focus sits,
99 /// yet only bites while a cell is actually hovered in THIS window.
100 override func viewDidMoveToWindow() {
101 super.viewDidMoveToWindow()
102 if let keyMonitor { NSEvent.removeMonitor(keyMonitor); self.keyMonitor = nil }
103 guard window != nil else { return }
104 keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
105 guard let self, event.window === self.window else { return event }
106 // Don't steal plain keys from text editing, and leave ⌘/⌥/⌃ combos
107 // alone (⌘F is Zoom to Fit). Shift is ours — ⇧F is Priority.
108 if self.window?.firstResponder is NSText { return event }
109 if !event.modifierFlags.intersection([.command, .option, .control]).isEmpty {
110 return event
111 }
112 let shift = event.modifierFlags.contains(.shift)
113 // ⇧H reveals every track — global, so it works even with nothing
114 // hovered (i.e. when everything is hidden).
115 if shift, event.charactersIgnoringModifiers?.lowercased() == "h" {
116 self.session.showAll(); return nil
117 }
118 guard let cell = self.hoveredCell else { return event }
119 switch event.charactersIgnoringModifiers?.lowercased() {
120 case "f": shift ? cell.togglePriority() : cell.focusButton.onClick?(); return nil
121 case "h" where !shift: cell.hideButton.onClick?(); return nil
122 default: return event
123 }
124 }
125 }
126
127 deinit { if let keyMonitor { NSEvent.removeMonitor(keyMonitor) } }
128
129 @objc private func themeChanged() {
130 layer?.backgroundColor = Theme.viewerBg.cgColor
131 placeholder.refreshColors()
132 }
133
134 override var isFlipped: Bool { true }
135
136 /// Tracks whose previews should show right now: visible (hide/focus) AND
137 /// something VISUAL is under the playhead (audio clips never get a cell).
138 private func activeTracks() -> [TrackRef] {
139 let project = store.project
140 let playhead = playback.playhead
141 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus
142 return project.laneRefs.filter { ref in
143 let visible = focusActive ? session.focusedTracks.contains(ref)
144 : !session.hiddenTracks.contains(ref)
145 guard visible else { return false }
146 return project.clips.contains {
147 $0.track == ref && $0.kind != .audio
148 && playhead >= $0.start && playhead < $0.end
149 }
150 }
151 }
152
153 /// The aspect ratio a lane's cell should have RIGHT NOW (the media's own
154 /// AR — videos are never inward-cropped).
155 private func paneAspect(_ ref: TrackRef) -> CGFloat {
156 if ref == Self.fusionKey { return 16.0 / 9.0 }
157 let project = store.project
158 let t = playback.playhead
159 if let clip = project.clipAt(track: ref, time: t, kind: .storyboard),
160 let b = clip.board, b.height > 0 {
161 return CGFloat(b.width / b.height)
162 }
163 if let clip = project.clipAt(track: ref, time: t, kind: .video),
164 let m = project.media(clip.mediaId), m.width > 0, m.height > 0 {
165 return CGFloat(m.width) / CGFloat(m.height)
166 }
167 return 16.0 / 9.0
168 }
169
170 private func fusionActive() -> Bool {
171 guard comps.visible else { return false }
172 let focusActive = !session.focusedTracks.isEmpty || session.fusionFocus
173 let visible = focusActive ? session.fusionFocus : !session.fusionHidden
174 guard visible else { return false }
175 let fps = store.project.fps
176 let frame = Int((playback.playhead * fps).rounded())
177 return comps.topmost(atFrame: frame) != nil
178 }
179
180 /// One entry point for every notification: reconcile the pane set (with
181 /// a short reflow animation when it changes) and refresh cell contents.
182 @objc private func sync() {
183 let project = store.project
184 let tracks = activeTracks()
185 let fusion = fusionActive()
186 var key = tracks
187 if fusion { key.append(Self.fusionKey) }
188 let aspects = key.map { paneAspect($0) }
189
190 if key != paneKey {
191 let wasEmpty = cells.isEmpty && fusionCell == nil
192 paneKey = key
193 lastAspects = aspects
194 let ids = Set(tracks)
195 for (ref, cell) in cells where !ids.contains(ref) {
196 let dying = cell
197 // Fade out UNDER the settled grid — a cell shrinking/fading in
198 // place otherwise clips the neighbours expanding over its slot.
199 dying.layer?.zPosition = -1
200 NSAnimationContext.runAnimationGroup({ ctx in
201 ctx.duration = 0.16
202 dying.animator().alphaValue = 0
203 }, completionHandler: { dying.removeFromSuperview() })
204 cells.removeValue(forKey: ref)
205 }
206 var newPanes: [NSView] = []
207 for ref in tracks where cells[ref] == nil {
208 let cell = ViewerCell(ref: ref)
209 cell.ctx = ctx
210 cell.alphaValue = 0
211 cells[ref] = cell
212 // Below the settled grid so it grows in UNDER its neighbours.
213 addSubview(cell, positioned: .below, relativeTo: nil)
214 newPanes.append(cell)
215 }
216 if fusion, fusionCell == nil {
217 let cell = FusionViewerCell()
218 cell.ctx = ctx
219 cell.alphaValue = 0
220 fusionCell = cell
221 addSubview(cell, positioned: .below, relativeTo: nil)
222 newPanes.append(cell)
223 } else if !fusion, let fc = fusionCell {
224 fusionCell = nil
225 NSAnimationContext.runAnimationGroup({ ctx in
226 ctx.duration = 0.16
227 fc.animator().alphaValue = 0
228 }, completionHandler: { fc.removeFromSuperview() })
229 }
230 // A cell arriving into an empty viewer just snaps on; cells
231 // joining an existing grid scale up in place (90% → 100%).
232 applyFrames(animated: !wasEmpty, appearing: Set(newPanes.map(\.hash)))
233 } else if aspects != lastAspects || session.priorityPane != lastPriority {
234 // Same panes, but the clip changed shape OR Priority toggled — both
235 // reshuffle the geometry, so reflow with the animation.
236 lastAspects = aspects
237 applyFrames(animated: true, appearing: [])
238 }
239 lastPriority = session.priorityPane
240
241 for ref in tracks { cells[ref]?.apply(hue: project.hue(for: ref)) }
242 for cell in cells.values { cell.update() }
243 fusionCell?.update()
244
245 updatePlaceholder()
246 }
247
248 /// Choose the empty-state prompt (or hide it when panes are present).
249 private func updatePlaceholder() {
250 guard paneKey.isEmpty else { placeholder.kind = nil; return }
251 // A pane is empty only because hide/focus filtered it out iff the same
252 // clip/comp IS under the playhead once visibility is ignored.
253 if mediaAtPlayheadIgnoringVisibility() {
254 placeholder.kind = .unhide
255 } else if hasAnyVisualContent() {
256 placeholder.kind = .noMedia
257 } else {
258 placeholder.kind = .importMedia
259 }
260 }
261
262 /// Is there any non-audio clip or Fusion comp under the playhead, ignoring
263 /// hide/focus? (activeTracks/fusionActive apply the same test WITH the
264 /// visibility filter, so a mismatch means "hidden, not absent".)
265 private func mediaAtPlayheadIgnoringVisibility() -> Bool {
266 let project = store.project
267 let playhead = playback.playhead
268 let hit = project.clips.contains {
269 $0.kind != .audio && playhead >= $0.start && playhead < $0.end
270 }
271 if hit { return true }
272 guard comps.visible else { return false }
273 let frame = Int((playhead * project.fps).rounded())
274 return comps.topmost(atFrame: frame) != nil
275 }
276
277 /// Does the project hold any visual media at all (so "nothing here" means
278 /// "not at THIS playhead" rather than "import something")?
279 private func hasAnyVisualContent() -> Bool {
280 store.project.clips.contains { $0.kind != .audio }
281 || !comps.comps.isEmpty
282 }
283
284 // MARK: Drag-to-import
285
286 private func droppableFiles(from sender: NSDraggingInfo) -> [URL] {
287 guard let urls = sender.draggingPasteboard
288 .readObjects(forClasses: [NSURL.self]) as? [URL] else { return [] }
289 return urls.filter {
290 UI.importableExtensions.contains($0.pathExtension.lowercased())
291 || $0.lastPathComponent == "sync.json"
292 || (try? $0.resourceValues(forKeys: [.isDirectoryKey]).isDirectory) == true
293 }
294 }
295
296 override func draggingEntered(_ sender: NSDraggingInfo) -> NSDragOperation {
297 droppableFiles(from: sender).isEmpty ? [] : .copy
298 }
299 override func draggingUpdated(_ sender: NSDraggingInfo) -> NSDragOperation {
300 droppableFiles(from: sender).isEmpty ? [] : .copy
301 }
302 override func performDragOperation(_ sender: NSDraggingInfo) -> Bool {
303 let files = droppableFiles(from: sender)
304 guard !files.isEmpty,
305 let timeline = (window?.windowController as? SequencerWindowController)?.timeline else { return false }
306 // No spatial target in the viewer: land at the playhead, new lanes below.
307 timeline.importFiles(files, atSecond: max(0, playback.playhead),
308 targetRow: nil)
309 return true
310 }
311
312 private var lastAspects: [CGFloat] = []
313 private var lastPriority: TrackRef?
314
315 override func layout() {
316 super.layout()
317 applyFrames(animated: false, appearing: [])
318 // Keep the caption box centred near the bottom and above every cell.
319 subtitles.frame = subtitles.preferredFrame(in: bounds)
320 if subviews.last !== subtitles {
321 subtitles.removeFromSuperview()
322 addSubview(subtitles)
323 }
324 }
325
326 /// Lay out the panes and animate them into place. Normally a justified-rows
327 /// grid; in Priority mode one pane blows up large and the rest tile in a
328 /// filmstrip along the leftover edge.
329 private func applyFrames(animated: Bool, appearing: Set<Int>) {
330 var panes: [NSView] = []
331 var aspects: [CGFloat] = []
332 var ids: [TrackRef] = []
333 for (i, ref) in paneKey.enumerated() {
334 let pane: NSView? = ref == Self.fusionKey ? fusionCell : cells[ref]
335 if let pane {
336 panes.append(pane)
337 aspects.append(i < lastAspects.count ? lastAspects[i] : 16.0 / 9.0)
338 ids.append(ref)
339 }
340 }
341 guard !panes.isEmpty, bounds.width > 40, bounds.height > 40 else { return }
342
343 // Priority only kicks in when the chosen pane is actually on screen
344 // AND has company to shrink; otherwise fall back to the even grid.
345 let frames: [NSRect]
346 if let pri = session.priorityPane, let p = ids.firstIndex(of: pri), panes.count > 1 {
347 frames = priorityFrames(aspects: aspects, priority: p, in: bounds)
348 } else {
349 frames = justifiedFrames(aspects: aspects, in: bounds)
350 }
351
352 // Tell optimisation how sharp the proxies need to be: the largest video
353 // cell on screen, in device pixels. A big preview asks for sharper
354 // background proxies; a small one avoids over-rendering.
355 let scale = window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2
356 let maxVideoWidth = zip(panes, frames)
357 .filter { $0.0 is ViewerCell }
358 .map { $0.1.width }
359 .max() ?? 0
360 if maxVideoWidth > 0 {
361 ctx.chunks.setPreviewTargetWidth(Int((maxVideoWidth * scale).rounded()))
362 }
363
364 NSAnimationContext.runAnimationGroup({ ctx in
365 ctx.duration = animated ? 0.16 : 0
366 ctx.allowsImplicitAnimation = animated
367 // One shared curve for the frame AND the content layers so they
368 // move in lockstep (see beginAnimatedContentLayout).
369 ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
370 for (i, pane) in panes.enumerated() {
371 let frame = frames[i]
372 if animated {
373 if appearing.contains(pane.hash) {
374 // New cell: grow into place, 90% → 100%, fading in —
375 // UNDER the settled grid (kept there by inserting it at
376 // the bottom of the subview stack in sync(), and belt-
377 // and-suspenders zPosition) so it never clips a neighbour
378 // while it scales.
379 pane.layer?.zPosition = -1
380 let start = NSRect(x: frame.midX - frame.width * 0.45,
381 y: frame.midY - frame.height * 0.45,
382 width: frame.width * 0.9,
383 height: frame.height * 0.9)
384 pane.frame = start
385 // Pin the content layers to that 90% box with NO
386 // animation, so the animated layout below has a matching
387 // frame to grow FROM.
388 (pane as? ViewerCellBase)?
389 .layoutContentLayers(in: CGRect(origin: .zero, size: start.size))
390 pane.layoutSubtreeIfNeeded()
391 }
392 pane.animator().frame = frame
393 pane.animator().alphaValue = 1
394 // Drive the content layers to the FINAL bounds in THIS same
395 // context so the picture tracks the box on one shared curve —
396 // for reflowing cells AND appearing cells scaling up. We pass
397 // the final size explicitly: after animator().frame the view's
398 // own `bounds` still reports the START box, so reading it would
399 // set the sublayers to their current size (no animation) and
400 // the picture would drift instead of scale.
401 (pane as? ViewerCellBase)?
402 .beginAnimatedContentLayout(to: CGRect(origin: .zero, size: frame.size))
403 } else {
404 pane.frame = frame
405 pane.alphaValue = 1
406 }
407 }
408 }, completionHandler: {
409 for pane in panes {
410 pane.layer?.zPosition = 0
411 (pane as? ViewerCellBase)?.isAnimatingContent = false
412 }
413 })
414 }
415
416 /// Justified-rows layout within `rect`: panes are packed into rows where
417 /// every pane in a row shares the row's height and keeps its OWN aspect
418 /// ratio, sitting edge to edge. The row count that maximizes total pane
419 /// area wins. Returns one frame per input aspect, in the same order.
420 private func justifiedFrames(aspects: [CGFloat], in rect: NSRect) -> [NSRect] {
421 let n = aspects.count
422 guard n > 0 else { return [] }
423
424 func rowsFor(_ rowCount: Int) -> [[Int]] {
425 // Even split by index, front rows take the remainder.
426 var rows: [[Int]] = []
427 let base = n / rowCount, extra = n % rowCount
428 var i = 0
429 for r in 0..<rowCount {
430 let count = base + (r < extra ? 1 : 0)
431 guard count > 0 else { continue }
432 rows.append(Array(i..<(i + count)))
433 i += count
434 }
435 return rows
436 }
437
438 var best: (area: CGFloat, heights: [CGFloat], rows: [[Int]]) = (0, [], [])
439 for rowCount in 1...n {
440 let rows = rowsFor(rowCount)
441 let availH = rect.height / CGFloat(rows.count)
442 var heights: [CGFloat] = []
443 var area: CGFloat = 0
444 for row in rows {
445 let sumA = row.reduce(CGFloat(0)) { $0 + aspects[$1] }
446 let h = min(availH, rect.width / sumA)
447 heights.append(h)
448 area += h * h * sumA
449 }
450 if area > best.area { best = (area, heights, rows) }
451 }
452
453 var frames = [NSRect](repeating: .zero, count: n)
454 let totalH = best.heights.reduce(0, +)
455 var y = rect.minY + (rect.height - totalH) / 2
456 for (r, row) in best.rows.enumerated() {
457 let h = best.heights[r]
458 let rowW = row.reduce(CGFloat(0)) { $0 + aspects[$1] * h }
459 var x = rect.minX + (rect.width - rowW) / 2
460 for i in row {
461 frames[i] = NSRect(x: x, y: y, width: aspects[i] * h, height: h)
462 x += aspects[i] * h
463 }
464 y += h
465 }
466 return frames
467 }
468
469 /// The largest aspect-correct box that fits centred inside `rect`.
470 private func aspectFit(_ aspect: CGFloat, in rect: NSRect) -> NSRect {
471 let w = min(rect.width, rect.height * aspect)
472 let h = w / aspect
473 return NSRect(x: rect.midX - w / 2, y: rect.midY - h / 2, width: w, height: h)
474 }
475
476 /// Priority layout: scale the priority pane as large as it will go inside the
477 /// whole container — by definition it ends up touching either both side edges
478 /// or top+bottom — then run the normal justified layout in whatever space is
479 /// left over. The strip's axis is dictated by that maximization, not chosen:
480 /// a full-width priority leaves a band underneath, a full-height one leaves a
481 /// band to the side. The priority pane and the secondaries' strip are laid
482 /// flush against each other and the combined block is centred in `rect`, so
483 /// there's no gap between the two panels and the whole reads as centred.
484 ///
485 /// The lone knob is a 100pt floor on the strip (clamped on tiny viewers): when
486 /// the priority's aspect nearly matches the container's, the natural leftover
487 /// collapses to a sliver, so we reserve enough for the secondaries to stay
488 /// legible and let the priority give back that room.
489 private func priorityFrames(aspects: [CGFloat], priority p: Int,
490 in rect: NSRect) -> [NSRect] {
491 let n = aspects.count
492 var frames = [NSRect](repeating: .zero, count: n)
493 let others = (0..<n).filter { $0 != p }
494
495 // Largest box of the priority's aspect that fits the whole container.
496 let full = aspectFit(aspects[p], in: rect)
497 // Exactly one of these is ~0 (the axis the priority fills); the other is
498 // the free space. The bigger leftover is where the strip goes.
499 let freeBelow = rect.height - full.height // priority is full-width
500 let freeSide = rect.width - full.width // priority is full-height
501
502 // The clamp on the strip's thickness (and any width/height limit on the
503 // priority pane) can leave the pane smaller than its region, so it floats
504 // with dead space toward the strip. Size the pane first, then lay pane +
505 // strip flush against each other and centre that combined block in `rect`
506 // along the split axis — no gap between the two panels, group centred.
507 let priRegion: NSRect, strip: NSRect
508 if freeBelow >= freeSide {
509 let t = min(max(freeBelow, 100), rect.height * 0.5)
510 let pane = aspectFit(aspects[p], in: NSRect(x: rect.minX, y: rect.minY,
511 width: rect.width, height: rect.height - t))
512 let y0 = rect.minY + (rect.height - (pane.height + t)) / 2
513 priRegion = NSRect(x: rect.minX, y: y0, width: rect.width, height: pane.height)
514 strip = NSRect(x: rect.minX, y: y0 + pane.height, width: rect.width, height: t)
515 } else {
516 let t = min(max(freeSide, 100), rect.width * 0.5)
517 let pane = aspectFit(aspects[p], in: NSRect(x: rect.minX, y: rect.minY,
518 width: rect.width - t, height: rect.height))
519 let x0 = rect.minX + (rect.width - (pane.width + t)) / 2
520 priRegion = NSRect(x: x0, y: rect.minY, width: pane.width, height: rect.height)
521 strip = NSRect(x: x0 + pane.width, y: rect.minY, width: t, height: rect.height)
522 }
523 frames[p] = aspectFit(aspects[p], in: priRegion)
524 let otherFrames = justifiedFrames(aspects: others.map { aspects[$0] }, in: strip)
525 for (k, i) in others.enumerated() { frames[i] = otherFrames[k] }
526 return frames
527 }
528}
529
530/// The centred prompt shown when the viewer has no panes: says why the screen
531/// is empty and, for the hidden-tracks case, acts as a button to bring the
532/// previews back. Hidden entirely (kind == nil) whenever panes are present.
533final class ViewerPlaceholder: NSView {
534 enum Kind: Equatable { case unhide, noMedia, importMedia }
535
536 private let icon = NSImageView()
537 private let label = NSTextField(labelWithString: "")
538 /// Invoked when the (clickable) unhide prompt is clicked.
539 var onUnhide: (() -> Void)?
540
541 var kind: Kind? {
542 didSet {
543 guard kind != oldValue else { return }
544 isHidden = kind == nil
545 guard let kind else { return }
546 let (symbol, text): (String, String)
547 switch kind {
548 case .unhide: (symbol, text) = ("eye", "Click to unhide all tracks")
549 case .noMedia: (symbol, text) = ("film", "No media at playhead")
550 case .importMedia: (symbol, text) = ("tray.and.arrow.down",
551 "Drag files to import media")
552 }
553 icon.image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil)?
554 .withSymbolConfiguration(.init(pointSize: 22, weight: .regular))
555 label.stringValue = text
556 refreshColors()
557 window?.invalidateCursorRects(for: self)
558 }
559 }
560
561 init() {
562 super.init(frame: .zero)
563 translatesAutoresizingMaskIntoConstraints = false
564 icon.translatesAutoresizingMaskIntoConstraints = false
565 label.font = .systemFont(ofSize: 13, weight: .medium)
566 label.alignment = .center
567 let stack = NSStackView(views: [icon, label])
568 stack.orientation = .vertical
569 stack.spacing = 9
570 stack.alignment = .centerX
571 stack.translatesAutoresizingMaskIntoConstraints = false
572 addSubview(stack)
573 NSLayoutConstraint.activate([
574 stack.leadingAnchor.constraint(equalTo: leadingAnchor),
575 stack.trailingAnchor.constraint(equalTo: trailingAnchor),
576 stack.topAnchor.constraint(equalTo: topAnchor),
577 stack.bottomAnchor.constraint(equalTo: bottomAnchor),
578 ])
579 isHidden = true
580 }
581 required init?(coder: NSCoder) { fatalError() }
582
583 /// Only the unhide prompt is interactive; the others are informational and
584 /// drawn a touch fainter.
585 func refreshColors() {
586 let clickable = kind == .unhide
587 let color = clickable ? Theme.subtleLabel : Theme.faintLabel
588 icon.contentTintColor = color
589 label.textColor = color
590 }
591
592 override func resetCursorRects() {
593 if kind == .unhide { addCursorRect(bounds, cursor: .pointingHand) }
594 }
595
596 override func mouseDown(with event: NSEvent) {
597 if kind == .unhide { onUnhide?() }
598 }
599}
600
601/// A corner tab that sits flush against a cell edge with a single rounded
602/// INTERIOR corner, filled with the clip's own colour. `corner` is that
603/// interior corner (cells aren't flipped, so layer geometry is y-up).
604final class TightChip: NSView {
605 init(corner: CACornerMask) {
606 super.init(frame: .zero)
607 wantsLayer = true
608 layer?.cornerRadius = 7
609 layer?.maskedCorners = corner
610 layer?.masksToBounds = true
611 translatesAutoresizingMaskIntoConstraints = false
612 }
613 required init?(coder: NSCoder) { fatalError() }
614 var fill: NSColor = .black { didSet { layer?.backgroundColor = fill.cgColor } }
615}
616
617/// Tight corner toggle (an SF Symbol — focus / hide — or a colour swatch) shown
618/// in a cell's top-right on hover. Snug to its glyph so the tab hugs the buttons.
619final class ViewerHoverButton: NSView {
620 private let symbol: NSImage?
621 var onClick: (() -> Void)?
622 var active = false { didSet { needsDisplay = true } }
623 /// When set, the button shows a colour swatch instead of a symbol.
624 var swatch: NSColor? { didSet { needsDisplay = true } }
625
626 /// Pass an SF Symbol name, or "" for a swatch-only button (the colour tab).
627 init(symbol name: String) {
628 symbol = name.isEmpty ? nil
629 : NSImage(systemSymbolName: name, accessibilityDescription: nil)?
630 .withSymbolConfiguration(.init(pointSize: 9, weight: .semibold))
631 super.init(frame: .zero)
632 translatesAutoresizingMaskIntoConstraints = false
633 widthAnchor.constraint(equalToConstant: 16).isActive = true
634 heightAnchor.constraint(equalToConstant: 15).isActive = true
635 }
636 required init?(coder: NSCoder) { fatalError() }
637
638 override func draw(_ dirty: NSRect) {
639 if let swatch {
640 let r = bounds.insetBy(dx: 2.5, dy: 3)
641 let p = NSBezierPath(roundedRect: r, xRadius: 2.5, yRadius: 2.5)
642 swatch.setFill(); p.fill()
643 NSColor(calibratedWhite: 0.97, alpha: 0.9).setStroke()
644 p.lineWidth = 1; p.stroke()
645 return
646 }
647 if active {
648 NSColor.white.withAlphaComponent(0.92).setFill()
649 NSBezierPath(roundedRect: bounds.insetBy(dx: 1.5, dy: 1.5),
650 xRadius: 3, yRadius: 3).fill()
651 }
652 guard let symbol else { return }
653 let tint = active ? NSColor.black.withAlphaComponent(0.85)
654 : NSColor(calibratedWhite: 0.97, alpha: 1)
655 let img = symbol.tinted(tint)
656 let sz = img.size
657 img.draw(in: NSRect(x: (bounds.width - sz.width) / 2,
658 y: (bounds.height - sz.height) / 2,
659 width: sz.width, height: sz.height),
660 from: .zero, operation: .sourceOver, fraction: 1)
661 }
662 override func mouseDown(with event: NSEvent) { onClick?() }
663}
664
665/// Shared chrome for preview cells: corner tabs in the clip's own colour that
666/// sit flush on the edge with one rounded interior corner. Top-left = filename
667/// (video, on hover) or panel name (storyboard, always shown); bottom-left =
668/// processing status; top-right = focus/hide, on hover. Flat cells otherwise —
669/// border only, no rounding, no padding.
670class ViewerCellBase: NSView {
671 /// Document context, inherited by ViewerCell and FusionViewerCell. Facade
672 /// over the shared singletons for now; injected per-document instance later.
673 var ctx: DocumentContext = .headless
674 var store: Store { ctx.store }
675 var project: ProjectModel { ctx.store.project }
676 var playback: PlaybackController { ctx.playback }
677 var players: PlayerManager { ctx.players }
678 var chunks: ChunkManager { ctx.chunks }
679 var comps: FusionComps { ctx.comps }
680 var boards: BoardStore { ctx.boards }
681 var session: SessionState { ctx.session }
682
683 private let topLeftChip = TightChip(corner: .layerMaxXMinYCorner)
684 private let topLeftField = NSTextField(labelWithString: "")
685 private let statusChip = TightChip(corner: .layerMaxXMaxYCorner)
686 private let statusField = NSTextField(labelWithString: "")
687 private let buttonChip = TightChip(corner: .layerMinXMinYCorner)
688 let focusButton = ViewerHoverButton(symbol: UI.focusSymbol)
689 let hideButton = ViewerHoverButton(symbol: UI.hideSymbol)
690 let colorButton = ViewerHoverButton(symbol: "")
691
692 private(set) var hovering = false
693 private var topLeftText = ""
694 private var topLeftHoverOnly = true
695 private var statusText = ""
696
697 /// The clip's colour — fills every corner tab.
698 var chipColor: NSColor = NSColor.black.withAlphaComponent(0.65) {
699 didSet { for c in [topLeftChip, statusChip, buttonChip] { c.fill = chipColor } }
700 }
701
702 override init(frame: NSRect) {
703 super.init(frame: frame)
704 wantsLayer = true
705 layer?.backgroundColor = NSColor.black.cgColor
706 layer?.borderWidth = 2.5
707 layer?.masksToBounds = true
708
709 func styleField(_ f: NSTextField) {
710 f.font = .systemFont(ofSize: 10, weight: .medium)
711 f.textColor = NSColor(calibratedWhite: 0.97, alpha: 1)
712 f.backgroundColor = .clear
713 f.lineBreakMode = .byTruncatingMiddle
714 f.translatesAutoresizingMaskIntoConstraints = false
715 }
716 styleField(topLeftField)
717 styleField(statusField)
718 for c in [topLeftChip, statusChip, buttonChip] { c.fill = chipColor }
719
720 addSubview(topLeftChip); topLeftChip.addSubview(topLeftField)
721 addSubview(statusChip); statusChip.addSubview(statusField)
722 addSubview(buttonChip)
723 focusButton.toolTip = "Focus (F)"
724 hideButton.toolTip = "Hide (H)"
725 let bstack = NSStackView(views: [focusButton, hideButton, colorButton])
726 bstack.orientation = .horizontal
727 bstack.spacing = 0
728 bstack.translatesAutoresizingMaskIntoConstraints = false
729 buttonChip.addSubview(bstack)
730
731 NSLayoutConstraint.activate([
732 topLeftChip.leadingAnchor.constraint(equalTo: leadingAnchor),
733 topLeftChip.topAnchor.constraint(equalTo: topAnchor),
734 topLeftChip.trailingAnchor.constraint(
735 lessThanOrEqualTo: buttonChip.leadingAnchor, constant: -4),
736 topLeftField.leadingAnchor.constraint(equalTo: topLeftChip.leadingAnchor, constant: 7),
737 topLeftField.trailingAnchor.constraint(equalTo: topLeftChip.trailingAnchor, constant: -9),
738 topLeftField.topAnchor.constraint(equalTo: topLeftChip.topAnchor, constant: 3),
739 topLeftField.bottomAnchor.constraint(equalTo: topLeftChip.bottomAnchor, constant: -3),
740
741 statusChip.leadingAnchor.constraint(equalTo: leadingAnchor),
742 statusChip.bottomAnchor.constraint(equalTo: bottomAnchor),
743 statusChip.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -9),
744 statusField.leadingAnchor.constraint(equalTo: statusChip.leadingAnchor, constant: 7),
745 statusField.trailingAnchor.constraint(equalTo: statusChip.trailingAnchor, constant: -9),
746 statusField.topAnchor.constraint(equalTo: statusChip.topAnchor, constant: 3),
747 statusField.bottomAnchor.constraint(equalTo: statusChip.bottomAnchor, constant: -3),
748
749 buttonChip.trailingAnchor.constraint(equalTo: trailingAnchor),
750 buttonChip.topAnchor.constraint(equalTo: topAnchor),
751 bstack.leadingAnchor.constraint(equalTo: buttonChip.leadingAnchor, constant: 2),
752 bstack.trailingAnchor.constraint(equalTo: buttonChip.trailingAnchor, constant: -2),
753 bstack.topAnchor.constraint(equalTo: buttonChip.topAnchor, constant: 1),
754 bstack.bottomAnchor.constraint(equalTo: buttonChip.bottomAnchor, constant: -1),
755 ])
756 topLeftChip.isHidden = true
757 statusChip.isHidden = true
758 buttonChip.isHidden = true
759 }
760 required init?(coder: NSCoder) { fatalError() }
761
762 /// Top-left tab. `hoverOnly` for filenames (video); storyboard names pass
763 /// false so they stay visible like the "1B" badge did.
764 func setTopLeft(_ text: String, hoverOnly: Bool) {
765 topLeftText = text
766 topLeftHoverOnly = hoverOnly
767 topLeftField.stringValue = text
768 toolTip = text.isEmpty ? nil : text
769 refreshChrome()
770 }
771 func setStatus(_ text: String) {
772 statusText = text
773 statusField.stringValue = text
774 refreshChrome()
775 }
776
777 private func refreshChrome() {
778 topLeftChip.isHidden = topLeftText.isEmpty || (topLeftHoverOnly && !hovering)
779 statusChip.isHidden = statusText.isEmpty
780 // The focus/hide/colour buttons only make sense when the cell is big
781 // enough to host them without swamping the picture.
782 let bigEnough = bounds.width >= 108 && bounds.height >= 66
783 buttonChip.isHidden = !hovering || !bigEnough
784 }
785
786 /// Subclasses size their AVPlayer / image layers to `target` here. The
787 /// caller passes the bounds explicitly (rather than the subclass reading
788 /// `self.bounds`) because during an animated frame change `bounds` still
789 /// reports the START box — reading it would set the sublayers to their
790 /// current size, so they'd never animate and would drift as the parent
791 /// layer grows around them.
792 func layoutContentLayers(in target: CGRect) {}
793
794 /// True while applyFrames is animating this cell's frame AND driving its
795 /// content layers in the same NSAnimationContext — layout() must not snap
796 /// them out from under that animation.
797 var isAnimatingContent = false
798
799 /// Animate the content layers to `target` using the AMBIENT animation
800 /// context. Call INSIDE applyFrames' NSAnimationContext
801 /// (allowsImplicitAnimation) so the sublayers inherit the frame animation's
802 /// exact duration AND timing curve — a separate CATransaction drifts out of
803 /// phase and the two curves visibly disagree. `target` is the FINAL cell
804 /// bounds (origin .zero, final size), not `self.bounds` (see above).
805 func beginAnimatedContentLayout(to target: CGRect) {
806 isAnimatingContent = true
807 layoutContentLayers(in: target)
808 }
809
810 override func layout() {
811 super.layout()
812 refreshChrome() // re-evaluate the hide-when-small threshold on resize
813 guard !isAnimatingContent else { return } // ambient animation owns the sublayers
814 CATransaction.begin()
815 CATransaction.setDisableActions(true)
816 layoutContentLayers(in: bounds)
817 CATransaction.commit()
818 }
819
820 override func updateTrackingAreas() {
821 super.updateTrackingAreas()
822 trackingAreas.forEach(removeTrackingArea)
823 addTrackingArea(NSTrackingArea(
824 rect: bounds,
825 options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
826 owner: self, userInfo: nil))
827 }
828
829 override func mouseEntered(with event: NSEvent) {
830 hovering = true; refreshChrome()
831 // Report to the grid so the F/H hover-shortcuts target this cell.
832 (superview as? ViewerGridView)?.hoveredCell = self
833 }
834 override func mouseExited(with event: NSEvent) {
835 hovering = false; refreshChrome()
836 let grid = superview as? ViewerGridView
837 if grid?.hoveredCell === self { grid?.hoveredCell = nil }
838 }
839
840 /// Toggle Priority (⇧F) for this pane. Subclasses map it to their identity.
841 func togglePriority() {}
842}
843
844final class ViewerCell: ViewerCellBase {
845 let ref: TrackRef
846 // Two layers for the double-buffered track player: `playerLayer` shows sub-
847 // player A, `playerLayerB` shows sub-player B. `update()` reveals whichever
848 // is the front buffer and hides the other — so a gapless cut is just a layer
849 // visibility flip (both already hold the right frame), never a rebind flash.
850 private let playerLayer = AVPlayerLayer()
851 private let playerLayerB = AVPlayerLayer()
852 private let imageLayer = CALayer()
853 private var readyObs: NSKeyValueObservation?
854 private var readyObsB: NSKeyValueObservation?
855
856 // "Loading Media…" overlay — a clear, frame-scaled not-rendered state
857 // shown when there's neither live video NOR a filmstrip stand-in to show
858 // (otherwise the cell would just sit near-black with tiny corner text).
859 private let overlayBg = CALayer()
860 private let spinnerLayer = CAShapeLayer()
861 private let loadingText = CATextLayer()
862 private var overlayVisible = false
863 private static let overlayBgColor =
864 NSColor(srgbRed: 0x6b / 255, green: 0x6b / 255, blue: 0x6b / 255, alpha: 1)
865 private static let overlayFgColor =
866 NSColor(srgbRed: 0xba / 255, green: 0xba / 255, blue: 0xba / 255, alpha: 1)
867
868 init(ref: TrackRef) {
869 self.ref = ref
870 super.init(frame: .zero)
871
872 // The cell is sized to the media's own aspect ratio, so aspect-fit
873 // fills it edge to edge WITHOUT the inward crop fill would risk.
874 imageLayer.contentsGravity = .resizeAspect
875 playerLayer.videoGravity = .resizeAspect
876 playerLayerB.videoGravity = .resizeAspect
877 playerLayerB.isHidden = true
878 layer?.insertSublayer(imageLayer, at: 0)
879 layer?.insertSublayer(playerLayer, above: imageLayer)
880 layer?.insertSublayer(playerLayerB, above: playerLayer)
881
882 // Overlay sits above the video/image layers (but below the chrome
883 // subviews). Hidden until update() decides there's nothing to show.
884 overlayBg.backgroundColor = Self.overlayBgColor.cgColor
885 overlayBg.isHidden = true
886 spinnerLayer.fillColor = NSColor.clear.cgColor
887 spinnerLayer.strokeColor = Self.overlayFgColor.cgColor
888 spinnerLayer.lineCap = .round
889 loadingText.string = "Loading Media…"
890 loadingText.alignmentMode = .center
891 loadingText.truncationMode = .end
892 loadingText.foregroundColor = Self.overlayFgColor.cgColor
893 loadingText.contentsScale = NSScreen.main?.backingScaleFactor ?? 2
894 overlayBg.addSublayer(spinnerLayer)
895 overlayBg.addSublayer(loadingText)
896 layer?.addSublayer(overlayBg)
897 // The player layer is hidden until it can actually show a frame
898 // (item swaps otherwise flash black); update when that flips.
899 readyObs = playerLayer.observe(\.isReadyForDisplay) { [weak self] _, _ in
900 DispatchQueue.main.async { self?.update() }
901 }
902 readyObsB = playerLayerB.observe(\.isReadyForDisplay) { [weak self] _, _ in
903 DispatchQueue.main.async { self?.update() }
904 }
905 focusButton.onClick = { [weak self] in guard let self else { return }
906 session.toggleFocus(self.ref) }
907 hideButton.onClick = { [weak self] in guard let self else { return }
908 session.toggleHidden(self.ref) }
909 colorButton.onClick = { [weak self] in self?.openColorPicker() }
910 }
911
912 override func togglePriority() {
913 session.priorityPane = session.priorityPane == ref ? nil : ref
914 }
915 required init?(coder: NSCoder) { fatalError() }
916
917 func apply(hue: Double) {
918 let full = NSColor(calibratedHue: hue, saturation: 0.55, brightness: 0.85, alpha: 1)
919 layer?.borderColor = full.cgColor
920 chipColor = NSColor(
921 calibratedHue: hue, saturation: 0.55, brightness: 0.5, alpha: 0.92)
922 colorButton.swatch = full
923 }
924
925 /// Recolour this track via the shared picker. Edits preview live and land
926 /// as ONE undo step when the picker closes (no held gesture, so timeline
927 /// edits mid-pick can't trip anything). The storyboard lane's hue is fixed.
928 private var colorSnapshot: ProjectModel?
929 private func openColorPicker() {
930 guard let vi = ref.videoIndex else { return }
931 let hue = store.project.hue(for: ref)
932 let seed = NSColor(calibratedHue: hue, saturation: 0.7, brightness: 0.9, alpha: 1)
933 colorSnapshot = store.project
934 ColorPickerPanel.show(under: colorButton, color: seed, onChange: { [weak self] c in
935 guard let self,
936 let h = c.usingColorSpace(.genericRGB)?.hueComponent else { return }
937 self.store.preview { model in
938 if model.tracks.indices.contains(vi) {
939 model.tracks[vi].hue = h
940 }
941 }
942 }, onClose: { [weak self] in
943 guard let self, let snap = self.colorSnapshot else { return }
944 self.colorSnapshot = nil
945 self.store.commitPreview(from: snap)
946 })
947 }
948
949 override func layoutContentLayers(in target: CGRect) {
950 playerLayer.frame = target
951 playerLayerB.frame = target
952 imageLayer.frame = target
953 layoutOverlay(in: target)
954 }
955
956 /// Size + place the loading spinner and text relative to the cell so they
957 /// scale with the frame. Cells are NOT flipped → y-up (larger y = higher).
958 private func layoutOverlay(in bounds: CGRect) {
959 overlayBg.frame = bounds
960 let dim = min(bounds.width, bounds.height)
961 let diameter = max(16, dim * 0.22)
962 let cx = bounds.width / 2, cy = bounds.height * 0.57
963 spinnerLayer.frame = CGRect(x: cx - diameter / 2, y: cy - diameter / 2,
964 width: diameter, height: diameter)
965 let lw = max(1.5, diameter * 0.09)
966 spinnerLayer.lineWidth = lw
967 let r = diameter / 2 - lw
968 let path = CGMutablePath()
969 // A ~300° arc (leaves a gap so the rotation reads as spinning).
970 path.addArc(center: CGPoint(x: diameter / 2, y: diameter / 2), radius: r,
971 startAngle: .pi / 2, endAngle: .pi / 2 - .pi * 1.7, clockwise: true)
972 spinnerLayer.path = path
973 let fontSize = max(9, dim * 0.1)
974 loadingText.font = NSFont.systemFont(ofSize: fontSize, weight: .medium)
975 loadingText.fontSize = fontSize
976 let textH = fontSize * 1.3
977 loadingText.frame = CGRect(x: 4, y: spinnerLayer.frame.minY - textH - fontSize * 0.35,
978 width: bounds.width - 8, height: textH)
979 }
980
981 /// Pending debounced spinner. The empty state (no live frame AND no
982 /// filmstrip stand-in) is often just a brief transient — an item swap or a
983 /// seek that lands within a few frames. Showing the spinner instantly makes
984 /// those flash; instead we wait out a short grace period and only reveal it
985 /// if the cell is still empty. Any resolved frame cancels it.
986 private var pendingSpinner: DispatchWorkItem?
987 private var overlayText: String?
988
989 // Miss metrics: every spinner that actually shows is one recorded "miss",
990 // logged with a diagnosis of WHY the frame wasn't ready (see
991 // ChunkManager.missDiagnosis) and, on recovery, how long it lasted —
992 // so "I saw the loading screen" is answerable from the log.
993 private static var missCount = 0
994 private static var missSeconds = 0.0
995 private var missStart: Date?
996
997 private func scheduleLoadingOverlay(media: MediaItem?, clip: Clip?) {
998 guard overlayText == nil, pendingSpinner == nil else { return }
999 let work = DispatchWorkItem { [weak self] in
1000 guard let self else { return }
1001 self.pendingSpinner = nil
1002 self.showOverlay("Loading Media…", spinning: true)
1003 self.missStart = Date()
1004 Self.missCount += 1
1005 if let media, let clip {
1006 let src = max(0, clip.sourceTime(at: self.playback.playhead))
1007 SeqLog.log("[miss] spinner ON %@ src=%.1f %@ (miss #%d)",
1008 media.displayName, src,
1009 self.chunks.missDiagnosis(media: media, sourceTime: src),
1010 Self.missCount)
1011 } else {
1012 SeqLog.log("[miss] spinner ON (no clip resolved) (miss #%d)", Self.missCount)
1013 }
1014 }
1015 pendingSpinner = work
1016 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work)
1017 }
1018 private func cancelLoadingOverlay() {
1019 pendingSpinner?.cancel()
1020 pendingSpinner = nil
1021 if let start = missStart {
1022 missStart = nil
1023 let d = Date().timeIntervalSince(start)
1024 Self.missSeconds += d
1025 SeqLog.log("[miss] spinner OFF after %.1fs (session: %d misses, %.0fs total)",
1026 d, Self.missCount, Self.missSeconds)
1027 }
1028 hideOverlay()
1029 }
1030
1031 /// A persistent (non-transient) overlay — media offline / failed to decode.
1032 /// No debounce and no spinner: it's a steady state, not a "wait a moment."
1033 private func showStateOverlay(_ text: String) {
1034 pendingSpinner?.cancel(); pendingSpinner = nil
1035 if let start = missStart { // spinner resolved INTO a steady state
1036 missStart = nil
1037 let d = Date().timeIntervalSince(start)
1038 Self.missSeconds += d
1039 SeqLog.log("[miss] spinner OFF after %.1fs → %@", d, text)
1040 }
1041 showOverlay(text, spinning: false)
1042 }
1043
1044 private func showOverlay(_ text: String, spinning: Bool) {
1045 guard overlayText != text else { return } // already showing this
1046 overlayText = text
1047 overlayVisible = true
1048 layoutOverlay(in: bounds)
1049 loadingText.string = text
1050 spinnerLayer.isHidden = !spinning
1051 overlayBg.isHidden = false
1052 if spinning {
1053 if spinnerLayer.animation(forKey: "spin") == nil {
1054 let a = CABasicAnimation(keyPath: "transform.rotation.z")
1055 a.fromValue = 0
1056 a.toValue = -Double.pi * 2 // clockwise
1057 a.duration = 0.9
1058 a.repeatCount = .infinity
1059 spinnerLayer.add(a, forKey: "spin")
1060 }
1061 } else {
1062 spinnerLayer.removeAnimation(forKey: "spin")
1063 }
1064 }
1065 private func hideOverlay() {
1066 guard overlayText != nil else { return }
1067 overlayText = nil
1068 overlayVisible = false
1069 overlayBg.isHidden = true
1070 spinnerLayer.removeAnimation(forKey: "spin")
1071 }
1072
1073 private var currentClipId: UUID?
1074
1075 // MARK: Drawing directly on storyboard previews
1076
1077 private var strokeBoard: Board?
1078 private var lastStrokePoint: CGPoint?
1079
1080 /// The storyboard panel this cell is currently showing, if any.
1081 private var panelClip: Clip? {
1082 let project = store.project
1083 return project.clipAt(track: ref,
1084 time: playback.playhead,
1085 kind: .storyboard)
1086 }
1087
1088 /// View point → board coords through the aspect-fill crop.
1089 private func boardPoint(_ p: NSPoint, board: Board) -> CGPoint {
1090 let bw = CGFloat(board.width), bh = CGFloat(board.height)
1091 guard bw > 0, bh > 0, bounds.width > 0 else { return .zero }
1092 let s = max(bounds.width / bw, bounds.height / bh)
1093 let offX = (bounds.width - bw * s) / 2
1094 let offY = (bounds.height - bh * s) / 2
1095 // Cell views are NOT flipped: view y is up, board y is down.
1096 return CGPoint(x: (p.x - offX) / s,
1097 y: bh - (p.y - offY) / s)
1098 }
1099
1100 // Shape placement armed by the toolbar's Shapes dropdown.
1101 private var shapeDragId: UUID?
1102 private var shapeDragClipId: UUID?
1103 private var shapeStart: CGPoint?
1104
1105 override func mouseDown(with event: NSEvent) {
1106 // Shape placement (from the toolbar dropdown) on a storyboard preview.
1107 if let kind = session.pendingShape, let clip = panelClip, let board = clip.board {
1108 let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board)
1109 beginShapePlacement(kind: kind, at: bp, clip: clip)
1110 return
1111 }
1112 // Draw tools paint straight onto the storyboard preview.
1113 if session.mainTool.isDraw, let clip = panelClip, let board = clip.board {
1114 strokeBoard = board
1115 let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board)
1116 boards.beginStroke(board: board)
1117 strokeSegment(from: bp, to: bp, board: board, pressure: CGFloat(event.pressure))
1118 lastStrokePoint = bp
1119 update()
1120 return
1121 }
1122 // Otherwise: click selects the clip and reveals it in the timeline.
1123 guard let id = currentClipId else { return }
1124 store.selection = [id]
1125 NotificationCenter.default.post(name: .revealClip, object: nil,
1126 userInfo: ["clipId": id])
1127 }
1128
1129 override func mouseDragged(with event: NSEvent) {
1130 if let id = shapeDragId, let clipId = shapeDragClipId, let start = shapeStart,
1131 let clip = store.project.clip(clipId), let board = clip.board {
1132 let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board)
1133 var w = max(4, abs(bp.x - start.x))
1134 var h = max(4, abs(bp.y - start.y))
1135 if event.modifierFlags.contains(.shift) { w = max(w, h); h = w }
1136 let frame = CGRect(x: bp.x < start.x ? start.x - w : start.x,
1137 y: bp.y < start.y ? start.y - h : start.y,
1138 width: w, height: h)
1139 store.updateGesture { model in
1140 guard let i = model.clips.firstIndex(where: { $0.id == clipId }),
1141 var b = model.clips[i].board else { return }
1142 if let j = b.shapes.firstIndex(where: { $0.id == id }) {
1143 b.shapes[j].frame = frame
1144 }
1145 b.revision += 1
1146 model.clips[i].board = b
1147 }
1148 update()
1149 return
1150 }
1151 guard let board = strokeBoard else { return }
1152 let bp = boardPoint(convert(event.locationInWindow, from: nil), board: board)
1153 if let last = lastStrokePoint {
1154 strokeSegment(from: last, to: bp, board: board, pressure: CGFloat(event.pressure))
1155 }
1156 lastStrokePoint = bp
1157 update()
1158 }
1159
1160 override func mouseUp(with event: NSEvent) {
1161 if shapeDragId != nil {
1162 store.endGesture()
1163 shapeDragId = nil
1164 shapeDragClipId = nil
1165 shapeStart = nil
1166 session.pendingShape = nil // placing a shape hands back the select tool
1167 update()
1168 return
1169 }
1170 if let board = strokeBoard {
1171 boards.endStroke(board: board)
1172 strokeBoard = nil
1173 lastStrokePoint = nil
1174 update()
1175 }
1176 }
1177
1178 private func beginShapePlacement(kind: BoardShape.Kind, at bp: CGPoint, clip: Clip) {
1179 if kind == .image {
1180 session.pendingShape = nil
1181 let panel = NSOpenPanel()
1182 panel.allowedContentTypes = [.image]
1183 guard panel.runModal() == .OK, let url = panel.url,
1184 let board = clip.board else { return }
1185 let w = board.width * 0.35
1186 var h = w * 0.66
1187 if let img = NSImage(contentsOf: url), img.size.width > 0 {
1188 h = w * Double(img.size.height / img.size.width)
1189 }
1190 var shape = BoardShape(kind: .image, frame:
1191 CGRect(x: bp.x - w / 2, y: bp.y - h / 2, width: w, height: h))
1192 shape.imagePath = url.path
1193 let new = shape
1194 store.mutate { model in
1195 guard let i = model.clips.firstIndex(where: { $0.id == clip.id }),
1196 var b = model.clips[i].board else { return }
1197 b.shapes.append(new)
1198 b.revision += 1
1199 model.clips[i].board = b
1200 }
1201 return
1202 }
1203 var shape = BoardShape(kind: kind,
1204 frame: CGRect(x: bp.x, y: bp.y, width: 1, height: 1))
1205 shape.color = BoardStore.rgba(session.drawColor)
1206 if kind == .text {
1207 shape.frame = CGRect(x: bp.x, y: bp.y - 35, width: 420, height: 70)
1208 shape.fontSize = 48
1209 shape.text = "Text"
1210 let new = shape
1211 store.mutate { model in
1212 guard let i = model.clips.firstIndex(where: { $0.id == clip.id }),
1213 var b = model.clips[i].board else { return }
1214 b.shapes.append(new)
1215 b.revision += 1
1216 model.clips[i].board = b
1217 }
1218 session.pendingShape = nil
1219 // Typing happens in the full editor (double-click the text there).
1220 StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx)
1221 return
1222 }
1223 shapeDragId = shape.id
1224 shapeDragClipId = clip.id
1225 shapeStart = bp
1226 let new = shape
1227 store.beginGesture()
1228 store.updateGesture { model in
1229 guard let i = model.clips.firstIndex(where: { $0.id == clip.id }),
1230 var b = model.clips[i].board else { return }
1231 b.shapes.append(new)
1232 b.revision += 1
1233 model.clips[i].board = b
1234 }
1235 update()
1236 }
1237
1238 private func strokeSegment(from a: CGPoint, to b: CGPoint, board: Board,
1239 pressure: CGFloat) {
1240 guard let width = session.mainTool.strokeWidth else { return }
1241 boards.strokeSegment(
1242 board: board, from: a, to: b, width: width, color: session.drawColor,
1243 erase: session.mainTool == .eraser,
1244 alpha: session.mainTool == .pencil ? 0.85 : 1, pressure: pressure)
1245 }
1246
1247 // MARK: Context menu
1248
1249 override func menu(for event: NSEvent) -> NSMenu? {
1250 let menu = NSMenu()
1251 func add(_ title: String, _ action: Selector,
1252 key: String = "", mods: NSEvent.ModifierFlags = []) {
1253 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
1254 mi.keyEquivalentModifierMask = mods
1255 mi.target = self
1256 menu.addItem(mi)
1257 }
1258 add(session.focusedTracks.contains(ref) ? "Unfocus Track" : "Focus Track",
1259 #selector(ctxFocus), key: "f")
1260 add(session.priorityPane == ref ? "Remove Priority" : "Prioritize",
1261 #selector(ctxPriority), key: "f", mods: .shift)
1262 add(session.hiddenTracks.contains(ref) ? "Show Preview" : "Hide Preview",
1263 #selector(ctxHide), key: "h")
1264 add("Show All Tracks", #selector(ctxShowAll))
1265 if currentClipId != nil {
1266 menu.addItem(.separator())
1267 add("Reveal Clip in Timeline", #selector(ctxReveal))
1268 }
1269 if panelClip != nil {
1270 add("Open in Storyboard Window", #selector(ctxOpenBoard))
1271 }
1272 return menu
1273 }
1274 @objc private func ctxHide() { session.toggleHidden(ref) }
1275 @objc private func ctxFocus() { session.toggleFocus(ref) }
1276 @objc private func ctxPriority() { togglePriority() }
1277 @objc private func ctxShowAll() { session.showAll() }
1278 @objc private func ctxReveal() {
1279 guard let id = currentClipId else { return }
1280 store.selection = [id]
1281 NotificationCenter.default.post(name: .revealClip, object: nil,
1282 userInfo: ["clipId": id])
1283 }
1284 @objc private func ctxOpenBoard() {
1285 if let clip = panelClip { StoryboardEditor.shared.open(clipId: clip.id, ctx: ctx) }
1286 }
1287
1288 // MARK: Content
1289
1290 func update() {
1291 // Every layer mutation below (isHidden toggles, contents swaps) would
1292 // otherwise fire CALayer's default fade — which dips through the black
1293 // cell background as you skip. Snap instead: no fade-from-black.
1294 CATransaction.begin()
1295 CATransaction.setDisableActions(true)
1296 defer { CATransaction.commit() }
1297 let project = store.project
1298 let playhead = playback.playhead
1299 // Double-buffered: bind each layer to its sub-player once, then show only
1300 // the front buffer's layer. The back buffer (a prerolled upcoming clip)
1301 // stays hidden until a cut flips the roles.
1302 let vt = players.videoTrack(for: ref)
1303 if playerLayer.player !== vt.a.player { playerLayer.player = vt.a.player }
1304 if playerLayerB.player !== vt.b.player { playerLayerB.player = vt.b.player }
1305 let frontLayer = vt.frontIsA ? playerLayer : playerLayerB
1306 let backLayer = vt.frontIsA ? playerLayerB : playerLayer
1307 let front = vt.front
1308 backLayer.isHidden = true
1309 focusButton.active = session.focusedTracks.contains(ref)
1310 hideButton.active = session.hiddenTracks.contains(ref)
1311
1312 // Storyboard panels preview their composite (and take strokes). Their
1313 // "1B" name lives top-left like a filename, and stays visible.
1314 if let panel = project.clipAt(track: ref, time: playhead, kind: .storyboard),
1315 let board = panel.board {
1316 currentClipId = panel.id
1317 frontLayer.isHidden = true
1318 imageLayer.isHidden = false
1319 imageLayer.contents = boards.composite(for: board)
1320 setTopLeft(project.panelNames()[panel.id] ?? "", hoverOnly: false)
1321 setStatus("")
1322 cancelLoadingOverlay()
1323 return
1324 }
1325
1326 // Audio never shows here — cells exist only for visual tracks.
1327 guard let clip = project.clipAt(track: ref, time: playhead, kind: .video),
1328 let media = project.media(clip.mediaId) else {
1329 currentClipId = nil
1330 frontLayer.isHidden = true
1331 imageLayer.isHidden = true
1332 imageLayer.contents = nil
1333 setTopLeft("", hoverOnly: true)
1334 setStatus("")
1335 cancelLoadingOverlay()
1336 return
1337 }
1338 currentClipId = clip.id
1339 setTopLeft(media.displayName, hoverOnly: true)
1340
1341 let src = max(0, clip.sourceTime(at: playhead))
1342 let covered = chunks.isCovered(media: media, sourceTime: src)
1343 // isReadyForDisplay only means SOME frame is decoded — right after an
1344 // item swap or before a seek lands that frame is time 0 (black), not
1345 // the playhead's frame. Reveal the player only once it's actually
1346 // parked near the expected source time; otherwise the filmstrip (a
1347 // real frame at this moment) stands in — so the transition is
1348 // filmstrip → video, never black.
1349 let cur = front.player.currentTime().seconds
1350 let onTime = cur.isFinite && abs(cur - src) < 0.5
1351 let itemOK = front.player.currentItem != nil && !front.itemFailed
1352 && front.player.currentItem?.status != .failed
1353 && (covered || chunks.originalPlayable(media: media))
1354 && frontLayer.isReadyForDisplay
1355 && onTime
1356 var status = covered ? "" : "processing…"
1357 if !itemOK {
1358 // The player can't show this moment yet: start decoding the real
1359 // frame into the RAM cache now (from a chunk, rescue slice, or
1360 // playable original) so the next update can stand in with it —
1361 // exact where the filmstrip is a 240px thumb. Self-deduping.
1362 FrameCache.shared.warm(mediaKey: media.cacheKey, at: src,
1363 source: chunks.frameSource(media: media, sourceTime: src))
1364 }
1365 if itemOK {
1366 frontLayer.isHidden = false
1367 imageLayer.isHidden = true
1368 cancelLoadingOverlay()
1369 } else if let frame = FrameCache.shared.image(media: media, at: src) {
1370 // The RAM frame cache has the exact (full-quality) frame for this
1371 // moment — a warmed cut boundary, or a stand-in decoded on demand
1372 // below. Better than the 240px filmstrip thumb, and it makes the
1373 // hold across an item swap invisible.
1374 frontLayer.isHidden = true
1375 imageLayer.isHidden = false
1376 imageLayer.contents = frame
1377 cancelLoadingOverlay()
1378 if MediaPipeline.shared.isOffline(media) { status = "offline" }
1379 } else if let strip = MediaPipeline.shared.filmstripImage(for: media, at: src) {
1380 // A filmstrip is a real frame for this moment: stand in with it so
1381 // the transition is filmstrip → video, never black. If the original
1382 // is offline the cached strip still previews, but flag it in the
1383 // corner so it's clear playback/export won't work until it's back.
1384 frontLayer.isHidden = true
1385 imageLayer.isHidden = false
1386 imageLayer.contents = strip
1387 cancelLoadingOverlay()
1388 if MediaPipeline.shared.isOffline(media) { status = "offline" }
1389 } else if MediaPipeline.shared.isOffline(media) {
1390 // The original file is gone (unmounted NAS / moved). Don't imply it's
1391 // loading — say so plainly, and don't spin forever.
1392 frontLayer.isHidden = true
1393 imageLayer.isHidden = true
1394 imageLayer.contents = nil
1395 showStateOverlay("Media Offline")
1396 status = "offline"
1397 } else if chunks.buildFailed(media: media, sourceTime: src) {
1398 // The proxy chunk hard-failed and the original isn't playable — this
1399 // frame genuinely can't be shown; surface it instead of spinning.
1400 frontLayer.isHidden = true
1401 imageLayer.isHidden = true
1402 imageLayer.contents = nil
1403 showStateOverlay("Can't Decode")
1404 status = "failed"
1405 } else {
1406 // No live video AND no stand-in. This is usually just a transient
1407 // (an item swap or a seek that lands within a few frames), so do
1408 // NOT blank the cell or flash the spinner immediately — hold what's
1409 // on screen and only escalate to the framed "Loading Media…"
1410 // overlay if the empty state actually persists (see
1411 // scheduleLoadingOverlay). That kills the paused spinner flashes.
1412 scheduleLoadingOverlay(media: media, clip: clip)
1413 status = ""
1414 }
1415 setStatus(status)
1416 }
1417}
1418
1419/// Preview of the topmost Fusion comp's rendered output at the playhead.
1420final class FusionViewerCell: ViewerCellBase {
1421 private let imageLayer = CALayer()
1422
1423 override init(frame: NSRect) {
1424 super.init(frame: frame)
1425 imageLayer.contentsGravity = .resizeAspect
1426 layer?.insertSublayer(imageLayer, at: 0)
1427 layer?.borderColor = FusionComps.yellow.cgColor
1428 // Bright Fusion yellow is unreadable behind white chip text — the tab
1429 // uses a dark amber instead.
1430 chipColor = NSColor(calibratedHue: 0.13, saturation: 0.9, brightness: 0.5, alpha: 0.92)
1431 focusButton.onClick = { [weak self] in self?.session.fusionFocus.toggle() }
1432 hideButton.onClick = { [weak self] in self?.session.fusionHidden = true }
1433 colorButton.isHidden = true // the Fusion band's colour is fixed
1434 }
1435 required init?(coder: NSCoder) { fatalError() }
1436
1437 override func togglePriority() {
1438 session.priorityPane = session.priorityPane == UI.fusionPaneKey ? nil : UI.fusionPaneKey
1439 }
1440
1441 override func layoutContentLayers(in target: CGRect) {
1442 imageLayer.frame = target
1443 }
1444
1445 private var currentFrame: Int {
1446 let fps = store.project.fps
1447 return Int((playback.playhead * fps).rounded())
1448 }
1449
1450 override func mouseDown(with event: NSEvent) {
1451 if let comp = comps.topmost(atFrame: currentFrame) {
1452 comps.selectedCompPath = comp.path
1453 NotificationCenter.default.post(name: .compsChanged, object: nil)
1454 }
1455 }
1456
1457 override func menu(for event: NSEvent) -> NSMenu? {
1458 guard let comp = comps.topmost(atFrame: currentFrame) else { return nil }
1459 comps.selectedCompPath = comp.path
1460 let menu = NSMenu()
1461 func add(_ title: String, _ action: Selector,
1462 key: String = "", mods: NSEvent.ModifierFlags = []) {
1463 let mi = NSMenuItem(title: title, action: action, keyEquivalent: key)
1464 mi.keyEquivalentModifierMask = mods
1465 mi.target = self
1466 menu.addItem(mi)
1467 }
1468 let preferred = store.project.preferredTakes.contains(comp.name)
1469 add(preferred ? "Unmark Preferred Take" : "Set as Preferred Take", #selector(ctxTake))
1470 add("Open in Fusion", #selector(ctxOpen))
1471 menu.addItem(.separator())
1472 add(session.fusionFocus ? "Unfocus Fusion" : "Focus Fusion", #selector(ctxFocus), key: "f")
1473 add(session.priorityPane == UI.fusionPaneKey ? "Remove Priority" : "Prioritize",
1474 #selector(ctxPriority), key: "f", mods: .shift)
1475 add("Hide Fusion Preview", #selector(ctxHide), key: "h")
1476 return menu
1477 }
1478 @objc private func ctxTake() { comps.togglePreferredTake() }
1479 @objc private func ctxOpen() {
1480 if let comp = comps.topmost(atFrame: currentFrame) {
1481 comps.openInFusion(comp)
1482 }
1483 }
1484 @objc private func ctxFocus() { session.fusionFocus.toggle() }
1485 @objc private func ctxPriority() { togglePriority() }
1486 @objc private func ctxHide() { session.fusionHidden = true }
1487
1488 func update() {
1489 CATransaction.begin()
1490 CATransaction.setDisableActions(true)
1491 defer { CATransaction.commit() }
1492 focusButton.active = session.fusionFocus
1493 hideButton.active = session.fusionHidden
1494 guard let (comp, image) = comps.frameImage(atFrame: currentFrame) else {
1495 imageLayer.contents = nil
1496 setTopLeft("", hoverOnly: true)
1497 setStatus("")
1498 return
1499 }
1500 setTopLeft(comp.name, hoverOnly: true)
1501 if let image {
1502 imageLayer.contents = image
1503 setStatus("")
1504 } else {
1505 setStatus("\(comp.title.isEmpty ? comp.name : comp.title) — no render yet")
1506 }
1507 }
1508}