| 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 | struct LightboxItem: Identifiable { |
| 11 | let id: String // blob id |
| 12 | let name: String? |
| 13 | let image: NSImage |
| 14 | let sourceFrame: CGRect // the thumbnail's window frame — where the zoom starts |
| 15 | } |
| 16 | |
| 17 | struct LightboxOverlay: View { |
| 18 | let item: LightboxItem |
| 19 | let onClose: () -> Void |
| 20 | |
| 21 | // Drives both the scrim fade and the zoom; flipped on with a spring in |
| 22 | // onAppear, off + delayed teardown on close (so the zoom-back is visible). |
| 23 | @State private var shown = false |
| 24 | |
| 25 | private let margin: CGFloat = 40 |
| 26 | |
| 27 | var body: some View { |
| 28 | GeometryReader { geo in |
| 29 | ZStack { |
| 30 | Color.black.opacity(shown ? 0.82 : 0) |
| 31 | .onTapGesture { close() } |
| 32 | |
| 33 | let target = targetRect(in: geo.size) |
| 34 | let src = item.sourceFrame |
| 35 | Image(nsImage: item.image) |
| 36 | .resizable() |
| 37 | .interpolation(.high) |
| 38 | .frame(width: shown ? target.width : src.width, |
| 39 | height: shown ? target.height : src.height) |
| 40 | .clipShape(RoundedRectangle(cornerRadius: shown ? 6 : 18, style: .continuous)) |
| 41 | .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) |
| 42 | .position(x: shown ? target.midX : src.midX, |
| 43 | y: shown ? target.midY : src.midY) |
| 44 | .onTapGesture { close() } |
| 45 | |
| 46 | if shown { |
| 47 | VStack { |
| 48 | Spacer() |
| 49 | HStack(spacing: 12) { |
| 50 | Text(item.name ?? "Photo") |
| 51 | .font(.callout) |
| 52 | .foregroundStyle(.white.opacity(0.85)) |
| 53 | .lineLimit(1) |
| 54 | Button("Done") { close() } |
| 55 | .keyboardShortcut(.cancelAction) |
| 56 | .arrowCursor() |
| 57 | } |
| 58 | .padding(.bottom, 14) |
| 59 | } |
| 60 | .transition(.opacity) |
| 61 | } |
| 62 | } |
| 63 | } |
| 64 | .ignoresSafeArea() |
| 65 | .onAppear { |
| 66 | withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | // Actual size, scaled down only when it exceeds the viewport minus a |
| 71 | // margin (never scaled UP past 1:1 — a small photo shows small), centred. |
| 72 | private func targetRect(in viewport: CGSize) -> CGRect { |
| 73 | let size = item.image.size |
| 74 | guard size.width > 0, size.height > 0 else { |
| 75 | return CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0) |
| 76 | } |
| 77 | let scale = min(1, |
| 78 | (viewport.width - margin * 2) / size.width, |
| 79 | (viewport.height - margin * 2) / size.height) |
| 80 | let w = size.width * scale |
| 81 | let h = size.height * scale |
| 82 | return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) |
| 83 | } |
| 84 | |
| 85 | private func close() { |
| 86 | withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { shown = false } |
| 87 | // Tear down once the zoom-back has played. |
| 88 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | // A clicked video, decrypted in memory and ready to play. Retains the |
| 93 | // MemoryVideoAsset loader — AVAssetResourceLoader only holds it weakly. |
| 94 | struct VideoLightboxItem: Identifiable { |
| 95 | let id: String // blob id |
| 96 | let name: String? |
| 97 | let player: AVPlayer |
| 98 | let loader: MemoryVideoAsset |
| 99 | let videoSize: CGSize // payload w/h, for the target frame |
| 100 | let sourceFrame: CGRect // the card's window frame — where the zoom starts |
| 101 | } |
| 102 | |
| 103 | // The video lightbox: same zoom-out-of-the-bubble over black as the photo one, |
| 104 | // with an AVKit player that autoplays on arrival and never touches disk. |
| 105 | struct VideoLightboxOverlay: View { |
| 106 | let item: VideoLightboxItem |
| 107 | let onClose: () -> Void |
| 108 | |
| 109 | @State private var shown = false |
| 110 | private let margin: CGFloat = 40 |
| 111 | |
| 112 | var body: some View { |
| 113 | GeometryReader { geo in |
| 114 | ZStack { |
| 115 | Color.black.opacity(shown ? 0.82 : 0) |
| 116 | .onTapGesture { close() } |
| 117 | |
| 118 | let target = targetRect(in: geo.size) |
| 119 | let src = item.sourceFrame |
| 120 | PlayerView(player: item.player) |
| 121 | .frame(width: shown ? target.width : src.width, |
| 122 | height: shown ? target.height : src.height) |
| 123 | .clipShape(RoundedRectangle(cornerRadius: shown ? 6 : 18, style: .continuous)) |
| 124 | .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) |
| 125 | .position(x: shown ? target.midX : src.midX, |
| 126 | y: shown ? target.midY : src.midY) |
| 127 | |
| 128 | if shown { |
| 129 | VStack { |
| 130 | Spacer() |
| 131 | HStack(spacing: 12) { |
| 132 | Text(item.name ?? "Video") |
| 133 | .font(.callout) |
| 134 | .foregroundStyle(.white.opacity(0.85)) |
| 135 | .lineLimit(1) |
| 136 | Button("Done") { close() } |
| 137 | .keyboardShortcut(.cancelAction) |
| 138 | .arrowCursor() |
| 139 | } |
| 140 | .padding(.bottom, 14) |
| 141 | } |
| 142 | .transition(.opacity) |
| 143 | } |
| 144 | } |
| 145 | } |
| 146 | .ignoresSafeArea() |
| 147 | .onAppear { |
| 148 | withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } |
| 149 | item.player.play() |
| 150 | } |
| 151 | } |
| 152 | |
| 153 | // The video at its natural size, scaled down only past viewport − margin. |
| 154 | private func targetRect(in viewport: CGSize) -> CGRect { |
| 155 | let size = item.videoSize.width > 0 ? item.videoSize : CGSize(width: 1280, height: 720) |
| 156 | let scale = min(1, |
| 157 | (viewport.width - margin * 2) / size.width, |
| 158 | (viewport.height - margin * 2) / size.height) |
| 159 | let w = size.width * scale |
| 160 | let h = size.height * scale |
| 161 | return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) |
| 162 | } |
| 163 | |
| 164 | private func close() { |
| 165 | item.player.pause() |
| 166 | withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { shown = false } |
| 167 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } |
| 168 | } |
| 169 | } |
| 170 | |
| 171 | extension View { |
| 172 | // Buttons over the lightbox scrim inherit the I-beam from the text system |
| 173 | // (or the embed webview) beneath — and WebKit re-asserts its cursor on |
| 174 | // every mouse move, so a push/pop loses the fight. Continuous set() wins |
| 175 | // every tick while hovering. (SwiftUI's pointerStyle needs macOS 15; this |
| 176 | // is the 14-compatible spelling.) |
| 177 | func arrowCursor() -> some View { |
| 178 | onContinuousHover { phase in |
| 179 | if case .active = phase { NSCursor.arrow.set() } |
| 180 | } |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // A clicked YouTube card, lifted into the lightbox. |
| 185 | struct YouTubeLightboxItem: Identifiable { |
| 186 | let videoID: String |
| 187 | let start: Int |
| 188 | let sourceFrame: CGRect |
| 189 | var id: String { videoID } |
| 190 | } |
| 191 | |
| 192 | // The YouTube lightbox: the same zoom-out-of-the-bubble over black, hosting |
| 193 | // the shared embed (YouTubeEmbedView) at 16:9 capped to the viewport. |
| 194 | struct YouTubeLightboxOverlay: View { |
| 195 | let item: YouTubeLightboxItem |
| 196 | let onClose: () -> Void |
| 197 | |
| 198 | @State private var shown = false |
| 199 | // Done kills the PLAYER first (the embed webview is swapped for a black |
| 200 | // stand-in, so audio stops instantly), then the stand-in animates back to |
| 201 | // the bubble — a video still talking through its own exit is wrong. |
| 202 | @State private var stopped = false |
| 203 | private let margin: CGFloat = 48 |
| 204 | |
| 205 | var body: some View { |
| 206 | GeometryReader { geo in |
| 207 | ZStack { |
| 208 | Color.black.opacity(shown ? 0.82 : 0) |
| 209 | .onTapGesture { close() } |
| 210 | |
| 211 | let target = targetRect(in: geo.size) |
| 212 | let src = item.sourceFrame |
| 213 | Group { |
| 214 | if stopped { |
| 215 | Color.black |
| 216 | } else { |
| 217 | YouTubeEmbedView(id: item.videoID, start: item.start) |
| 218 | } |
| 219 | } |
| 220 | .frame(width: shown ? target.width : src.width, |
| 221 | height: shown ? target.height : src.height) |
| 222 | .clipShape(RoundedRectangle(cornerRadius: shown ? 8 : 18, style: .continuous)) |
| 223 | .shadow(color: .black.opacity(shown ? 0.5 : 0), radius: 30, y: 8) |
| 224 | .position(x: shown ? target.midX : src.midX, |
| 225 | y: shown ? target.midY : src.midY) |
| 226 | |
| 227 | if shown { |
| 228 | VStack { |
| 229 | Spacer() |
| 230 | Button("Done") { close() } |
| 231 | .keyboardShortcut(.cancelAction) |
| 232 | .arrowCursor() |
| 233 | .padding(.bottom, 14) |
| 234 | } |
| 235 | .transition(.opacity) |
| 236 | } |
| 237 | } |
| 238 | } |
| 239 | .ignoresSafeArea() |
| 240 | .onAppear { |
| 241 | withAnimation(.spring(response: 0.34, dampingFraction: 0.86)) { shown = true } |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | // 16:9 at the largest size the viewport allows (with margin) — YouTube has |
| 246 | // no "natural size" worth honouring beyond its aspect. |
| 247 | private func targetRect(in viewport: CGSize) -> CGRect { |
| 248 | let w = min(viewport.width - margin * 2, (viewport.height - margin * 2) * 16 / 9) |
| 249 | let h = w * 9 / 16 |
| 250 | return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2, width: w, height: h) |
| 251 | } |
| 252 | |
| 253 | private func close() { |
| 254 | stopped = true // tear the webview down NOW — sound stops before the zoom-back |
| 255 | withAnimation(.spring(response: 0.3, dampingFraction: 0.9)) { shown = false } |
| 256 | DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { onClose() } |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | // AVKit's real AVPlayerView, wrapped directly. NOT SwiftUI's VideoPlayer: its |
| 261 | // _AVKit_SwiftUI overlay aborts in the Swift runtime (getSuperclassMetadata) |
| 262 | // instantiating its view class on macOS 15 — crashed the app the moment the |
| 263 | // lightbox rendered. The AppKit view is also the more Mac-assed control set. |
| 264 | private struct PlayerView: NSViewRepresentable { |
| 265 | let player: AVPlayer |
| 266 | |
| 267 | func makeNSView(context: Context) -> AVPlayerView { |
| 268 | let view = AVPlayerView() |
| 269 | view.player = player |
| 270 | view.controlsStyle = .floating |
| 271 | view.showsFullScreenToggleButton = false |
| 272 | return view |
| 273 | } |
| 274 | |
| 275 | func updateNSView(_ view: AVPlayerView, context: Context) { |
| 276 | if view.player !== player { view.player = player } |
| 277 | } |
| 278 | } |
| 279 | |