eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

1import LChatCore
2import SwiftUI
3import 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.
10struct 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
17struct 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.
94struct 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.
105struct 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
171extension 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.
185struct 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.
194struct 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.
264private 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