eleven / ocho Public

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

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.
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.
15struct 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
22struct 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.
164struct 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.
175struct 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
241extension 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.
255struct 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.
264struct 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.
334private 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