import LChatCore import SwiftUI import AVKit // The native lightbox. A clicked photo zooms OUT FROM ITS BUBBLE to the middle // of the window over a fade-to-black scrim (the web lightbox's feel), shown at // actual size and only scaled down when it would exceed the viewport (with a // margin). Held in memory — deliberately NOT QuickLook, which would silently // write the decrypted bytes to disk. struct LightboxItem: Identifiable { let id: String // blob id let name: String? let image: NSImage let sourceFrame: CGRect // the thumbnail's window frame — where the zoom starts } struct LightboxOverlay: View { let item: LightboxItem let onClose: () -> Void // Drives both the scrim fade and the zoom; flipped on with a spring in // onAppear, off + delayed teardown on close (so the zoom-back is visible). @State private var shown = false private let margin: CGFloat = 40 var body: some View { GeometryReader { geo in ZStack { Color.black.opacity(shown ? 0.82 : 0) .onTapGesture { close() } let target = targetRect(in: geo.size) let src = item.sourceFrame Image(nsImage: item.image) .resizable() .interpolation(.high) .frame(width: shown ? target.width : src.width, height: shown ? target.height : src.height) .clipShape(RoundedRectangle(cornerRadius: shown ? 6 : 18, style: .continuous)) .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) .position(x: shown ? target.midX : src.midX, y: shown ? target.midY : src.midY) .onTapGesture { close() } if shown { VStack { Spacer() HStack(spacing: 12) { Text(item.name ?? "Photo") .font(.callout) .foregroundStyle(.white.opacity(0.85)) .lineLimit(1) Button("Done") { close() } .keyboardShortcut(.cancelAction) .arrowCursor() } .padding(.bottom, 14) } .transition(.opacity) } } } .ignoresSafeArea() .onAppear { withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } } } // Actual size, scaled down only when it exceeds the viewport minus a // margin (never scaled UP past 1:1 — a small photo shows small), centred. private func targetRect(in viewport: CGSize) -> CGRect { let size = item.image.size guard size.width > 0, size.height > 0 else { return CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0) } let scale = min(1, (viewport.width - margin * 2) / size.width, (viewport.height - margin * 2) / size.height) let w = size.width * scale let h = size.height * scale return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) } private func close() { withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { shown = false } // Tear down once the zoom-back has played. DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } } } // A clicked video, decrypted in memory and ready to play. Retains the // MemoryVideoAsset loader — AVAssetResourceLoader only holds it weakly. struct VideoLightboxItem: Identifiable { let id: String // blob id let name: String? let player: AVPlayer let loader: MemoryVideoAsset let videoSize: CGSize // payload w/h, for the target frame let sourceFrame: CGRect // the card's window frame — where the zoom starts } // The video lightbox: same zoom-out-of-the-bubble over black as the photo one, // with an AVKit player that autoplays on arrival and never touches disk. struct VideoLightboxOverlay: View { let item: VideoLightboxItem let onClose: () -> Void @State private var shown = false private let margin: CGFloat = 40 var body: some View { GeometryReader { geo in ZStack { Color.black.opacity(shown ? 0.82 : 0) .onTapGesture { close() } let target = targetRect(in: geo.size) let src = item.sourceFrame PlayerView(player: item.player) .frame(width: shown ? target.width : src.width, height: shown ? target.height : src.height) .clipShape(RoundedRectangle(cornerRadius: shown ? 6 : 18, style: .continuous)) .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) .position(x: shown ? target.midX : src.midX, y: shown ? target.midY : src.midY) if shown { VStack { Spacer() HStack(spacing: 12) { Text(item.name ?? "Video") .font(.callout) .foregroundStyle(.white.opacity(0.85)) .lineLimit(1) Button("Done") { close() } .keyboardShortcut(.cancelAction) .arrowCursor() } .padding(.bottom, 14) } .transition(.opacity) } } } .ignoresSafeArea() .onAppear { withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } item.player.play() } } // The video at its natural size, scaled down only past viewport − margin. private func targetRect(in viewport: CGSize) -> CGRect { let size = item.videoSize.width > 0 ? item.videoSize : CGSize(width: 1280, height: 720) let scale = min(1, (viewport.width - margin * 2) / size.width, (viewport.height - margin * 2) / size.height) let w = size.width * scale let h = size.height * scale return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) } private func close() { item.player.pause() withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { shown = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } } } extension View { // Buttons over the lightbox scrim inherit the I-beam from the text system // (or the embed webview) beneath — and WebKit re-asserts its cursor on // every mouse move, so a push/pop loses the fight. Continuous set() wins // every tick while hovering. (SwiftUI's pointerStyle needs macOS 15; this // is the 14-compatible spelling.) func arrowCursor() -> some View { onContinuousHover { phase in if case .active = phase { NSCursor.arrow.set() } } } } // A clicked YouTube card, lifted into the lightbox. struct YouTubeLightboxItem: Identifiable { let videoID: String let start: Int let sourceFrame: CGRect var id: String { videoID } } // The YouTube lightbox: the same zoom-out-of-the-bubble over black, hosting // the shared embed (YouTubeEmbedView) at 16:9 capped to the viewport. struct YouTubeLightboxOverlay: View { let item: YouTubeLightboxItem let onClose: () -> Void @State private var shown = false // Done kills the PLAYER first (the embed webview is swapped for a black // stand-in, so audio stops instantly), then the stand-in animates back to // the bubble — a video still talking through its own exit is wrong. @State private var stopped = false private let margin: CGFloat = 48 var body: some View { GeometryReader { geo in ZStack { Color.black.opacity(shown ? 0.82 : 0) .onTapGesture { close() } let target = targetRect(in: geo.size) let src = item.sourceFrame Group { if stopped { Color.black } else { YouTubeEmbedView(id: item.videoID, start: item.start) } } .frame(width: shown ? target.width : src.width, height: shown ? target.height : src.height) .clipShape(RoundedRectangle(cornerRadius: shown ? 8 : 18, style: .continuous)) .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) .position(x: shown ? target.midX : src.midX, y: shown ? target.midY : src.midY) if shown { VStack { Spacer() Button("Done") { close() } .keyboardShortcut(.cancelAction) .arrowCursor() .padding(.bottom, 14) } .transition(.opacity) } } } .ignoresSafeArea() .onAppear { withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } } } // 16:9 at the largest size the viewport allows (with margin) — YouTube has // no "natural size" worth honouring beyond its aspect. private func targetRect(in viewport: CGSize) -> CGRect { let w = min(viewport.width - margin * 2, (viewport.height - margin * 2) * 16 / 9) let h = w * 9 / 16 return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) } private func close() { stopped = true // tear the webview down NOW — sound stops before the zoom-back withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { shown = false } DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } } } // AVKit's real AVPlayerView, wrapped directly. NOT SwiftUI's VideoPlayer: its // _AVKit_SwiftUI overlay aborts in the Swift runtime (getSuperclassMetadata) // instantiating its view class on macOS 15 — crashed the app the moment the // lightbox rendered. The AppKit view is also the more Mac-assed control set. private struct PlayerView: NSViewRepresentable { let player: AVPlayer func makeNSView(context: Context) -> AVPlayerView { let view = AVPlayerView() view.player = player view.controlsStyle = .floating view.showsFullScreenToggleButton = false return view } func updateNSView(_ view: AVPlayerView, context: Context) { if view.player !== player { view.player = player } } }