| 1 | import AppKit |
| 2 | import 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. |
| 9 | final 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. |
| 533 | final 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). |
| 604 | final 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. |
| 619 | final 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. |
| 670 | class 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 | |
| 844 | final 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. |
| 1420 | final 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 | } |