| 1 | import LChatCore |
| 2 | import SwiftUI |
| 3 | import AVKit |
| 4 | |
| 5 | // The native lightbox. A clicked photo zooms OUT FROM ITS BUBBLE to the middle |
| 6 | // of the window over a fade-to-black scrim (the web lightbox's feel), shown at |
| 7 | // actual size and only scaled down when it would exceed the viewport (with a |
| 8 | // margin). Held in memory — deliberately NOT QuickLook, which would silently |
| 9 | // write the decrypted bytes to disk. |
| 10 | // |
| 11 | // Pinch to zoom, drag to pan, double-click to toggle — hand-rolled because |
| 12 | // not-QuickLook means not inheriting them (iOS's feed taps DO go through |
| 13 | // Quick Look, so it has all three already; this keeps the two in step). The |
| 14 | // media view's viewer carries the same gestures. |
| 15 | struct LightboxItem: Identifiable { |
| 16 | let id: String // blob id |
| 17 | let name: String? |
| 18 | let image: NSImage |
| 19 | let sourceFrame: CGRect // the thumbnail's window frame — where the zoom starts |
| 20 | } |
| 21 | |
| 22 | struct LightboxOverlay: View { |
| 23 | let item: LightboxItem |
| 24 | let onClose: () -> Void |
| 25 | |
| 26 | // Drives both the scrim fade and the zoom; flipped on with a spring in |
| 27 | // onAppear, off + delayed teardown on close (so the zoom-back is visible). |
| 28 | @State private var shown = false |
| 29 | // Live gesture values plus what they settle to, so a second pinch picks |
| 30 | // up where the last left off. |
| 31 | @State private var scale: CGFloat = 1 |
| 32 | @State private var steadyScale: CGFloat = 1 |
| 33 | @State private var offset: CGSize = .zero |
| 34 | @State private var steadyOffset: CGSize = .zero |
| 35 | |
| 36 | private let margin: CGFloat = 40 |
| 37 | private let maxScale: CGFloat = 8 |
| 38 | |
| 39 | var body: some View { |
| 40 | GeometryReader { geo in |
| 41 | ZStack { |
| 42 | Color.black.opacity(shown ? 0.82 : 0) |
| 43 | .onTapGesture { close() } |
| 44 | |
| 45 | let target = targetRect(in: geo.size) |
| 46 | let src = item.sourceFrame |
| 47 | Image(nsImage: item.image) |
| 48 | .resizable() |
| 49 | .interpolation(.high) |
| 50 | .frame(width: shown ? target.width : src.width, |
| 51 | height: shown ? target.height : src.height) |
| 52 | .clipShape(RoundedRectangle(cornerRadius: shown ? 6 : 18, style: .continuous)) |
| 53 | .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) |
| 54 | .scaleEffect(scale) |
| 55 | .position(x: shown ? target.midX : src.midX, |
| 56 | y: shown ? target.midY : src.midY) |
| 57 | .offset(offset) |
| 58 | .gesture(magnify.simultaneously(with: pan)) |
| 59 | .onTapGesture(count: 2) { toggleZoom() } |
| 60 | // A single click still dismisses — but only at 1×, or |
| 61 | // every click while reading a zoomed screenshot would |
| 62 | // close the photo out from under you. |
| 63 | .onTapGesture { if scale <= 1 { close() } } |
| 64 | |
| 65 | if shown { |
| 66 | VStack { |
| 67 | Spacer() |
| 68 | HStack(spacing: 12) { |
| 69 | Text(item.name ?? "Photo") |
| 70 | .font(.callout) |
| 71 | .foregroundStyle(.white.opacity(0.85)) |
| 72 | .lineLimit(1) |
| 73 | Button("Done") { close() } |
| 74 | .keyboardShortcut(.cancelAction) |
| 75 | .arrowCursor() |
| 76 | } |
| 77 | .padding(.bottom, 14) |
| 78 | } |
| 79 | .transition(.opacity) |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | .ignoresSafeArea() |
| 84 | .onAppear { |
| 85 | withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | // Actual size, scaled down only when it exceeds the viewport minus a |
| 90 | // margin (never scaled UP past 1:1 — a small photo shows small), centred. |
| 91 | private func targetRect(in viewport: CGSize) -> CGRect { |
| 92 | let size = item.image.size |
| 93 | guard size.width > 0, size.height > 0 else { |
| 94 | return CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0) |
| 95 | } |
| 96 | let scale = min(1, |
| 97 | (viewport.width - margin * 2) / size.width, |
| 98 | (viewport.height - margin * 2) / size.height) |
| 99 | let w = size.width * scale |
| 100 | let h = size.height * scale |
| 101 | return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) |
| 102 | } |
| 103 | |
| 104 | // Trackpad pinch. Clamped at 1 (fitted) so a photo can't shrink away. |
| 105 | private var magnify: some Gesture { |
| 106 | MagnifyGesture() |
| 107 | .onChanged { value in |
| 108 | scale = min(max(steadyScale * value.magnification, 1), maxScale) |
| 109 | } |
| 110 | .onEnded { _ in |
| 111 | steadyScale = scale |
| 112 | if scale <= 1 { resetPan() } |
| 113 | } |
| 114 | } |
| 115 | |
| 116 | // Drag pans, but only while zoomed — at 1× a drag would slide the fitted |
| 117 | // photo off its own scrim. |
| 118 | private var pan: some Gesture { |
| 119 | DragGesture() |
| 120 | .onChanged { value in |
| 121 | guard scale > 1 else { return } |
| 122 | offset = CGSize(width: steadyOffset.width + value.translation.width, |
| 123 | height: steadyOffset.height + value.translation.height) |
| 124 | } |
| 125 | .onEnded { _ in steadyOffset = offset } |
| 126 | } |
| 127 | |
| 128 | private func toggleZoom() { |
| 129 | withAnimation(.easeOut(duration: 0.18)) { |
| 130 | if scale > 1 { |
| 131 | resetZoom() |
| 132 | } else { |
| 133 | scale = 2.5 |
| 134 | steadyScale = 2.5 |
| 135 | } |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | private func resetZoom() { |
| 140 | scale = 1 |
| 141 | steadyScale = 1 |
| 142 | resetPan() |
| 143 | } |
| 144 | |
| 145 | private func resetPan() { |
| 146 | offset = .zero |
| 147 | steadyOffset = .zero |
| 148 | } |
| 149 | |
| 150 | private func close() { |
| 151 | // Unzoom as part of the dismissal, or the fly-back to the bubble |
| 152 | // starts from a transform the thumbnail never had. |
| 153 | withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { |
| 154 | resetZoom() |
| 155 | shown = false |
| 156 | } |
| 157 | // Tear down once the zoom-back has played. |
| 158 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | // A clicked video, decrypted in memory and ready to play. Retains the |
| 163 | // MemoryVideoAsset loader — AVAssetResourceLoader only holds it weakly. |
| 164 | struct VideoLightboxItem: Identifiable { |
| 165 | let id: String // blob id |
| 166 | let name: String? |
| 167 | let player: AVPlayer |
| 168 | let loader: MemoryVideoAsset |
| 169 | let videoSize: CGSize // payload w/h, for the target frame |
| 170 | let sourceFrame: CGRect // the card's window frame — where the zoom starts |
| 171 | } |
| 172 | |
| 173 | // The video lightbox: same zoom-out-of-the-bubble over black as the photo one, |
| 174 | // with an AVKit player that autoplays on arrival and never touches disk. |
| 175 | struct VideoLightboxOverlay: View { |
| 176 | let item: VideoLightboxItem |
| 177 | let onClose: () -> Void |
| 178 | |
| 179 | @State private var shown = false |
| 180 | private let margin: CGFloat = 40 |
| 181 | |
| 182 | var body: some View { |
| 183 | GeometryReader { geo in |
| 184 | ZStack { |
| 185 | Color.black.opacity(shown ? 0.82 : 0) |
| 186 | .onTapGesture { close() } |
| 187 | |
| 188 | let target = targetRect(in: geo.size) |
| 189 | let src = item.sourceFrame |
| 190 | PlayerView(player: item.player) |
| 191 | .frame(width: shown ? target.width : src.width, |
| 192 | height: shown ? target.height : src.height) |
| 193 | .clipShape(RoundedRectangle(cornerRadius: shown ? 6 : 18, style: .continuous)) |
| 194 | .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) |
| 195 | .position(x: shown ? target.midX : src.midX, |
| 196 | y: shown ? target.midY : src.midY) |
| 197 | |
| 198 | if shown { |
| 199 | VStack { |
| 200 | Spacer() |
| 201 | HStack(spacing: 12) { |
| 202 | Text(item.name ?? "Video") |
| 203 | .font(.callout) |
| 204 | .foregroundStyle(.white.opacity(0.85)) |
| 205 | .lineLimit(1) |
| 206 | Button("Done") { close() } |
| 207 | .keyboardShortcut(.cancelAction) |
| 208 | .arrowCursor() |
| 209 | } |
| 210 | .padding(.bottom, 14) |
| 211 | } |
| 212 | .transition(.opacity) |
| 213 | } |
| 214 | } |
| 215 | } |
| 216 | .ignoresSafeArea() |
| 217 | .onAppear { |
| 218 | withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } |
| 219 | item.player.play() |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | // The video at its natural size, scaled down only past viewport − margin. |
| 224 | private func targetRect(in viewport: CGSize) -> CGRect { |
| 225 | let size = item.videoSize.width > 0 ? item.videoSize : CGSize(width: 1280, height: 720) |
| 226 | let scale = min(1, |
| 227 | (viewport.width - margin * 2) / size.width, |
| 228 | (viewport.height - margin * 2) / size.height) |
| 229 | let w = size.width * scale |
| 230 | let h = size.height * scale |
| 231 | return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) |
| 232 | } |
| 233 | |
| 234 | private func close() { |
| 235 | item.player.pause() |
| 236 | withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { shown = false } |
| 237 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } |
| 238 | } |
| 239 | } |
| 240 | |
| 241 | extension View { |
| 242 | // Buttons over the lightbox scrim inherit the I-beam from the text system |
| 243 | // (or the embed webview) beneath — and WebKit re-asserts its cursor on |
| 244 | // every mouse move, so a push/pop loses the fight. Continuous set() wins |
| 245 | // every tick while hovering. (SwiftUI's pointerStyle needs macOS 15; this |
| 246 | // is the 14-compatible spelling.) |
| 247 | func arrowCursor() -> some View { |
| 248 | onContinuousHover { phase in |
| 249 | if case .active = phase { NSCursor.arrow.set() } |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // A clicked YouTube card, lifted into the lightbox. |
| 255 | struct YouTubeLightboxItem: Identifiable { |
| 256 | let videoID: String |
| 257 | let start: Int |
| 258 | let sourceFrame: CGRect |
| 259 | var id: String { videoID } |
| 260 | } |
| 261 | |
| 262 | // The YouTube lightbox: the same zoom-out-of-the-bubble over black, hosting |
| 263 | // the shared embed (YouTubeEmbedView) at 16:9 capped to the viewport. |
| 264 | struct YouTubeLightboxOverlay: View { |
| 265 | let item: YouTubeLightboxItem |
| 266 | let onClose: () -> Void |
| 267 | |
| 268 | @State private var shown = false |
| 269 | // Done kills the PLAYER first (the embed webview is swapped for a black |
| 270 | // stand-in, so audio stops instantly), then the stand-in animates back to |
| 271 | // the bubble — a video still talking through its own exit is wrong. |
| 272 | @State private var stopped = false |
| 273 | private let margin: CGFloat = 48 |
| 274 | |
| 275 | var body: some View { |
| 276 | GeometryReader { geo in |
| 277 | ZStack { |
| 278 | Color.black.opacity(shown ? 0.82 : 0) |
| 279 | .onTapGesture { close() } |
| 280 | |
| 281 | let target = targetRect(in: geo.size) |
| 282 | let src = item.sourceFrame |
| 283 | Group { |
| 284 | if stopped { |
| 285 | Color.black |
| 286 | } else { |
| 287 | YouTubeEmbedView(id: item.videoID, start: item.start) |
| 288 | } |
| 289 | } |
| 290 | .frame(width: shown ? target.width : src.width, |
| 291 | height: shown ? target.height : src.height) |
| 292 | .clipShape(RoundedRectangle(cornerRadius: shown ? 8 : 18, style: .continuous)) |
| 293 | .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) |
| 294 | .position(x: shown ? target.midX : src.midX, |
| 295 | y: shown ? target.midY : src.midY) |
| 296 | |
| 297 | if shown { |
| 298 | VStack { |
| 299 | Spacer() |
| 300 | Button("Done") { close() } |
| 301 | .keyboardShortcut(.cancelAction) |
| 302 | .arrowCursor() |
| 303 | .padding(.bottom, 14) |
| 304 | } |
| 305 | .transition(.opacity) |
| 306 | } |
| 307 | } |
| 308 | } |
| 309 | .ignoresSafeArea() |
| 310 | .onAppear { |
| 311 | withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } |
| 312 | } |
| 313 | } |
| 314 | |
| 315 | // 16:9 at the largest size the viewport allows (with margin) — YouTube has |
| 316 | // no "natural size" worth honouring beyond its aspect. |
| 317 | private func targetRect(in viewport: CGSize) -> CGRect { |
| 318 | let w = min(viewport.width - margin * 2, (viewport.height - margin * 2) * 16 / 9) |
| 319 | let h = w * 9 / 16 |
| 320 | return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) |
| 321 | } |
| 322 | |
| 323 | private func close() { |
| 324 | stopped = true // tear the webview down NOW — sound stops before the zoom-back |
| 325 | withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { shown = false } |
| 326 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } |
| 327 | } |
| 328 | } |
| 329 | |
| 330 | // AVKit's real AVPlayerView, wrapped directly. NOT SwiftUI's VideoPlayer: its |
| 331 | // _AVKit_SwiftUI overlay aborts in the Swift runtime (getSuperclassMetadata) |
| 332 | // instantiating its view class on macOS 15 — crashed the app the moment the |
| 333 | // lightbox rendered. The AppKit view is also the more Mac-assed control set. |
| 334 | private struct PlayerView: NSViewRepresentable { |
| 335 | let player: AVPlayer |
| 336 | |
| 337 | func makeNSView(context: Context) -> AVPlayerView { |
| 338 | let view = AVPlayerView() |
| 339 | view.player = player |
| 340 | view.controlsStyle = .floating |
| 341 | view.showsFullScreenToggleButton = false |
| 342 | return view |
| 343 | } |
| 344 | |
| 345 | func updateNSView(_ view: AVPlayerView, context: Context) { |
| 346 | if view.player !== player { view.player = player } |
| 347 | } |
| 348 | } |
| 349 | |