eleven / ocho Public

ios: open chat photos in Quick Look — native chrome, Share / Save / Markup (#901)

Replace the bespoke 229-line ImageViewer with Apple's Quick Look. Tapping
a photo now zooms out of its bubble (and back on dismiss) exactly as
before, but rides QLPreviewController — so it carries Apple's own chrome
and the system Share / Save to Photos / Markup buttons, with no
hand-rolled controls to maintain.

- New ImageQuickLook.swift: a QuickLookItem + an invisible presenter that
  drives QLPreviewController imperatively, using the tapped thumbnail's
  window frame as the zoom transition anchor. Also warms Quick Look's
  out-of-process renderer once per launch so the first tap doesn't flash
  cold-start chrome.
- openQuickLook decrypts the original blob to a temp file (Quick Look
  reads by URL), reusing the same stagePreviewFile/cleanupPreview the file
  preview already uses — a photo tap and a file tap are mutually
  exclusive, so they share previewCleanupDir safely. The plaintext temp
  file is burned on dismiss.
- suppressSettleOnReturn keeps the feed from re-pinning to the bottom when
  the modal dismiss re-fires the feed's onAppear (which would strand the
  just-viewed photo off-screen).

Net -20 LOC. iOS-scoped: Quick Look is UIKit; the Mac keeps its own
AppKit lightbox.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Dermot Daly 534bfebb68099ab9cf974f9a71dc917610bb944e parent dede74b
2 files changed, +247 −267
  • ios/LChat/Views/ImageQuickLook.swift +193 −0
  • ios/LChat/Views/NativeChatScreen.swift +54 −267
diff --git a/ios/LChat/Views/ImageQuickLook.swift b/ios/LChat/Views/ImageQuickLook.swift
new file mode 100644
index 0000000..19eaa87
--- /dev/null
+++ b/ios/LChat/Views/ImageQuickLook.swift
@@ -0,0 +1,193 @@
+import QuickLook
+import SwiftUI
+import UIKit
+
+// 🤖 A photo opened from a chat bubble: the full-res bytes staged to a temp file
+// for Quick Look, plus the thumbnail + its on-screen frame so the preview can
+// ZOOM out of the bubble it came from.
+struct QuickLookItem: Identifiable {
+ let id: String
+ let url: URL // staged temp file (full-res decrypted bytes)
+ let thumbnail: UIImage // zoom-transition anchor image
+ let sourceFrame: CGRect // the tapped thumbnail's window frame
+}
+
+// 🤖 Presents QLPreviewController IMPERATIVELY (not via .sheet) so Quick Look
+// owns the transition: given a transition view, it zooms the photo out of its
+// bubble on open and back into it on dismiss — the Photos/Files feel — and
+// carries Apple's own chrome plus the system Share/Save action button, so there
+// are no hand-rolled controls to style. The representable itself is an invisible
+// host; the preview is presented from the window's top-most controller and torn
+// down (with the caller's onClose) when Quick Look dismisses.
+struct ImageQuickLookPresenter: UIViewControllerRepresentable {
+ @Binding var item: QuickLookItem?
+ let onClose: () -> Void
+
+ func makeCoordinator() -> Coordinator { Coordinator(onClose: onClose) }
+ func makeUIViewController(context: Context) -> UIViewController { UIViewController() }
+
+ func updateUIViewController(_ host: UIViewController, context: Context) {
+ context.coordinator.sync(item: item, host: host)
+ }
+
+ final class Coordinator: NSObject, QLPreviewControllerDataSource, QLPreviewControllerDelegate {
+ private let onClose: () -> Void
+ private var current: QuickLookItem?
+ private weak var preview: QLPreviewController?
+ private var transitionView: UIImageView?
+ private var presenting = false
+
+ init(onClose: @escaping () -> Void) { self.onClose = onClose }
+
+ // Present when an item arrives; dismiss if it's cleared while still up.
+ // Presentation is deferred a runloop turn so it never runs mid-update.
+ func sync(item: QuickLookItem?, host: UIViewController) {
+ // Boot Quick Look's out-of-process renderer once, invisibly, so the
+ // first real tap doesn't flash its cold-start chrome.
+ QuickLookWarmup.warmIfNeeded(scene: host.view.window?.windowScene)
+ if let item, !presenting {
+ current = item
+ presenting = true
+ DispatchQueue.main.async { [weak self, weak host] in
+ guard let self, let host else { return }
+ self.present(from: host)
+ }
+ } else if item == nil, presenting {
+ preview?.dismiss(animated: true)
+ }
+ }
+
+ private func present(from host: UIViewController) {
+ guard let window = host.view.window else { presenting = false; return }
+ var top = window.rootViewController
+ while let next = top?.presentedViewController { top = next }
+ guard let top else { presenting = false; return }
+ let ql = QLPreviewController()
+ ql.dataSource = self
+ ql.delegate = self
+ preview = ql
+ top.present(ql, animated: true)
+ }
+
+ // MARK: - Data source
+ func numberOfPreviewItems(in controller: QLPreviewController) -> Int {
+ current == nil ? 0 : 1
+ }
+
+ func previewController(_ controller: QLPreviewController,
+ previewItemAt index: Int) -> QLPreviewItem {
+ PreviewFile(url: current?.url)
+ }
+
+ // MARK: - Delegate (the zoom anchor)
+ // A one-off UIImageView pinned at the thumbnail's window frame, showing
+ // the same downsampled image. Quick Look zooms its preview out of this
+ // view on open and back into it on dismiss. Reused across the open/close
+ // calls and removed once the preview is gone.
+ func previewController(_ controller: QLPreviewController,
+ transitionViewFor item: QLPreviewItem) -> UIView? {
+ if let transitionView { return transitionView }
+ guard let current,
+ let window = controller.view.window
+ ?? preview?.presentingViewController?.view.window else { return nil }
+ let iv = UIImageView(image: current.thumbnail)
+ iv.frame = current.sourceFrame
+ iv.contentMode = .scaleAspectFill
+ iv.clipsToBounds = true
+ iv.layer.cornerRadius = 18
+ iv.layer.cornerCurve = .continuous
+ window.addSubview(iv)
+ transitionView = iv
+ return iv
+ }
+
+ // The anchor lives on the window, ABOVE the chat header — so when Quick
+ // Look reveals it at the tail of the zoom-back it would briefly draw
+ // over the header/avatar (the flash). Fade it out across the dismiss so
+ // there's nothing to flash: the real, header-clipped thumbnail is
+ // already underneath. Quick Look cross-fades its shrinking snapshot into
+ // this view too, so a fading target also fades the zoom itself.
+ func previewControllerWillDismiss(_ controller: QLPreviewController) {
+ guard let transitionView else { return }
+ UIView.animate(withDuration: 0.3) { transitionView.alpha = 0 }
+ }
+
+ func previewControllerDidDismiss(_ controller: QLPreviewController) {
+ transitionView?.removeFromSuperview()
+ transitionView = nil
+ preview = nil
+ presenting = false
+ current = nil
+ onClose()
+ }
+ }
+
+ // Quick Look reads the file by URL. A nil URL can't reach here: the data
+ // source reports 0 items until an item exists.
+ private final class PreviewFile: NSObject, QLPreviewItem {
+ let previewItemURL: URL?
+ init(url: URL?) { previewItemURL = url }
+ }
+}
+
+// 🤖 Boots Quick Look's out-of-process render service ONCE per launch so the
+// first real preview doesn't flash its cold-start chrome. Presents a preview of
+// a throwaway file on a 1pt, fully-transparent window behind everything, then
+// tears it (and the file) straight back down — the user never sees it, but the
+// service is warm by the time they tap a photo.
+@MainActor
+enum QuickLookWarmup {
+ private static var done = false
+ private static var window: UIWindow?
+ private static var source: Source?
+
+ static func warmIfNeeded(scene: UIWindowScene?) {
+ guard !done, let scene else { return }
+ done = true
+ guard let url = try? stageDummy() else { return }
+ let src = Source(url: url)
+ source = src
+
+ let w = UIWindow(windowScene: scene)
+ w.frame = CGRect(x: 0, y: 0, width: 1, height: 1)
+ w.windowLevel = UIWindow.Level(rawValue: -1000) // behind the real UI
+ w.alpha = 0.01 // effectively invisible
+ w.isHidden = false
+ let root = UIViewController()
+ w.rootViewController = root
+ window = w
+
+ let ql = QLPreviewController()
+ ql.dataSource = src
+ root.present(ql, animated: false) {
+ root.dismiss(animated: false) {
+ window?.isHidden = true
+ window = nil
+ source = nil
+ try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
+ }
+ }
+ }
+
+ private static func stageDummy() throws -> URL {
+ let dir = FileManager.default.temporaryDirectory
+ .appendingPathComponent("ql-warm-\(UUID().uuidString)", isDirectory: true)
+ try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
+ let url = dir.appendingPathComponent("warm.txt")
+ try Data(" ".utf8).write(to: url)
+ return url
+ }
+
+ final class Source: NSObject, QLPreviewControllerDataSource {
+ private let item: Item
+ init(url: URL) { item = Item(url: url) }
+ func numberOfPreviewItems(in controller: QLPreviewController) -> Int { 1 }
+ func previewController(_ controller: QLPreviewController,
+ previewItemAt index: Int) -> QLPreviewItem { item }
+ }
+
+ private final class Item: NSObject, QLPreviewItem {
+ let previewItemURL: URL?
+ init(url: URL) { previewItemURL = url }
+ }
+}
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 8919142..fd556bc 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -82,7 +82,7 @@ struct NativeChatScreen: View {
@State private var showFilePicker = false
@State private var staged: [StagedUpload] = []
@State private var sendQueued = false
- @State private var viewerImage: IdentifiedImage?
+ @State private var quickLookItem: QuickLookItem?
@State private var playingVideo: PlayingVideo?
// A downloaded file staged on disk for Quick Look, plus the temp directory
// to burn once the preview closes (decrypted plaintext never lingers).
@@ -102,12 +102,6 @@ struct NativeChatScreen: View {
var attachment: Attachment?
}
- struct IdentifiedImage: Identifiable {
- let id: String
- let image: PlatformImage
- let sourceFrame: CGRect // the bubble thumbnail's window frame — where the zoom starts
- }
-
struct PlayingVideo: Identifiable {
let id: String
let player: AVPlayer
@@ -507,12 +501,16 @@ struct NativeChatScreen: View {
withAnimation(.spring(response: 0.42, dampingFraction: 0.45)) { avatarPop = true }
}
}
- .fullScreenCover(item: $viewerImage) { item in
- ImageViewer(image: item.image, sourceFrame: item.sourceFrame) {
- coverWithoutSlide { viewerImage = nil }
- }
- .presentationBackground(.clear)
- }
+ // Tapping a photo opens it in Quick Look — which zooms out of the
+ // tapped bubble (and back on dismiss), and carries Apple's own
+ // chrome plus the system Share / Save to Photos / Markup buttons.
+ // An invisible presenter drives QLPreviewController imperatively from
+ // the thumbnail's frame; the decrypted temp file is burned on
+ // dismiss (cleanupPreview), so plaintext never lingers.
+ .background(ImageQuickLookPresenter(item: $quickLookItem) {
+ cleanupPreview()
+ quickLookItem = nil
+ })
.fullScreenCover(item: $playingVideo) { item in
VideoViewer(player: item.player, sourceFrame: item.sourceFrame) {
coverWithoutSlide { playingVideo = nil }
@@ -550,17 +548,33 @@ struct NativeChatScreen: View {
withTransaction(t, change)
}
- // The feed hands the lightbox its downsampled thumbnail so the zoom opens
- // instantly; the full-fidelity decode (off-main, cached under its own size
- // class) swaps in underneath once it lands — unless the viewer has already
- // moved on to another photo or closed.
- private func upgradeViewerImage(_ att: Attachment) {
- guard let source = model.blobSource(att) else { return }
+ // A tapped photo: decrypt the ORIGINAL blob to a temp file (Quick Look reads
+ // by URL and renders the full-fidelity image itself), then open the preview
+ // zooming out of the tapped thumbnail. `thumbnail` is the zoom-transition
+ // anchor; `sourceFrame` is where it zooms from / back to. Reuses the same
+ // temp-file staging + cleanup the file preview uses (a photo and a file tap
+ // are mutually exclusive, so they share previewCleanupDir safely).
+ private func openQuickLook(_ att: Attachment, thumbnail: PlatformImage, sourceFrame: CGRect) {
+ guard blobDownload == nil else { return }
+ blobDownload = (att.id, 0)
+ let started = Date()
Task {
- guard let full = await BlobImageLoader.shared.image(source),
- let current = viewerImage, current.id == att.id else { return }
- viewerImage = IdentifiedImage(id: att.id, image: full,
- sourceFrame: current.sourceFrame)
+ let data = await model.blobData(att) { p in
+ if Date().timeIntervalSince(started) > 0.15 { blobDownload = (att.id, p) }
+ }
+ blobDownload = nil
+ guard let data,
+ let url = stagePreviewFile(
+ data, name: AttachmentDownload.safeFilename(name: att.name, mime: att.type))
+ else {
+ setRouteNotice("Couldn’t open that photo.")
+ return
+ }
+ // Dismissing Quick Look re-fires the feed's onAppear; don't let it
+ // re-pin to the bottom and strand this photo off-screen.
+ suppressSettleOnReturn = true
+ quickLookItem = QuickLookItem(id: att.id, url: url,
+ thumbnail: thumbnail, sourceFrame: sourceFrame)
}
}
@@ -575,6 +589,11 @@ struct NativeChatScreen: View {
// unread-cutoff opens). The sentinel's onAppear uses it to tell "the
// opening pin needs me" from an ordinary later scroll to the bottom.
@State private var pinningToBottom = false
+ // Quick Look is a full-screen modal, so dismissing it re-fires the feed's
+ // onAppear — which would re-run settle() and yank a scrolled-up reader back
+ // to the bottom, stranding the photo they just viewed off-screen. Set when
+ // we open the viewer, it makes that one return-appear skip the pin.
+ @State private var suppressSettleOnReturn = false
private func settle(_ proxy: ScrollViewProxy) {
guard !model.messages.isEmpty else {
@@ -824,8 +843,14 @@ struct NativeChatScreen: View {
}
.onAppear {
// A parked model is ready immediately — onChange(ready) never
- // fires on a revisit, so settle from here too.
- if model.ready { settle(proxy) }
+ // fires on a revisit, so settle from here too. But skip it when
+ // returning from Quick Look: re-pinning would strand the
+ // just-viewed photo off-screen (see suppressSettleOnReturn).
+ if suppressSettleOnReturn {
+ suppressSettleOnReturn = false
+ } else if model.ready {
+ settle(proxy)
+ }
// Arm the arrival animation only after the opening render has
// settled (parked models are ready immediately).
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { landed = true }
@@ -900,11 +925,7 @@ struct NativeChatScreen: View {
onOpenThread: { root in openThread(root) },
blobSource: { model.blobSource($0) },
onImageTap: { att, image, frame in
- coverWithoutSlide {
- viewerImage = IdentifiedImage(
- id: att.id, image: image, sourceFrame: frame)
- }
- upgradeViewerImage(att)
+ openQuickLook(att, thumbnail: image, sourceFrame: frame)
},
onFileTap: { openFile($0) },
onVideoTap: { att, frame in playVideo(att, from: frame) },
@@ -1018,11 +1039,7 @@ struct NativeChatScreen: View {
theme: .named(themeName),
blobSource: { model.blobSource($0) },
onImageTap: { att, image, frame in
- coverWithoutSlide {
- viewerImage = IdentifiedImage(
- id: att.id, image: image, sourceFrame: frame)
- }
- upgradeViewerImage(att)
+ openQuickLook(att, thumbnail: image, sourceFrame: frame)
},
onFileTap: { openFile($0) },
onVideoTap: { att, frame in playVideo(att, from: frame) },
@@ -1798,236 +1815,6 @@ struct NativeChatScreen: View {
}
}
-// The full-screen photo viewer, Messages-style. The tapped photo ZOOMS OUT OF
-// ITS BUBBLE to an edge-to-edge fit over a fading black scrim (the cover
-// itself presents with no transition — this view is the whole choreography),
-// and closing zooms it back into place. While open it's freely manipulable:
-// pinch zooms about the fingers and KEEPS its zoom, drag pans while zoomed
-// (or pulls to dismiss at 1:1, the photo following the finger), twist rotates
-// 1:1 and springs back level, double-tap toggles zoom at the tapped spot.
-private struct ImageViewer: View {
- let image: PlatformImage
- let sourceFrame: CGRect // the bubble thumbnail, in window coordinates
- let onClose: () -> Void
-
- // Drives the scrim and the bubble↔fullscreen geometry (the Mac lightbox's
- // pattern): sprung on in onAppear, off in close() with delayed teardown.
- @State private var shown = false
- @State private var closing = false
-
- // The gesture pose, applied on top of the fitted geometry.
- @State private var scale: CGFloat = 1
- @State private var pinchBase: CGFloat = 1
- @State private var offset: CGSize = .zero
- @State private var panBase: CGSize = .zero
- @State private var rotation: Angle = .zero
- @State private var dismissOffset: CGFloat = 0
-
- private let maxZoom: CGFloat = 5
- private var dismissProgress: CGFloat { min(1, abs(dismissOffset) / 300) }
-
- var body: some View {
- ZStack(alignment: .topTrailing) {
- GeometryReader { geo in
- stage(in: geo.size)
- }
- // Edge-to-edge: the stage spans the whole screen, so its local
- // coordinates are window coordinates — the space sourceFrame is in.
- .ignoresSafeArea()
- // The close button stays inside the safe area, above the stage.
- if shown, !closing, dismissOffset == 0 {
- Button(action: close) {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 28))
- .foregroundStyle(.white.opacity(0.8))
- .padding()
- }
- .accessibilityLabel("Close")
- .transition(.opacity)
- }
- }
- // Sprung from onAppear (not init) so the first frame still renders the
- // photo exactly over its bubble — a seamless start to the zoom.
- .onAppear {
- withAnimation(.spring(response: 0.36, dampingFraction: 0.86)) { shown = true }
- }
- }
-
- private func stage(in viewport: CGSize) -> some View {
- let target = fittedRect(in: viewport)
- // A frame that never got captured (shouldn't happen) degrades to a
- // fade-up from center rather than a zoom from nowhere.
- let src = sourceFrame.isEmpty
- ? CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0)
- : sourceFrame
- return ZStack {
- // The backdrop FADES (fully — the chat shows through the clear
- // cover); only the photo itself rides the swipe.
- Color.black
- .opacity(shown ? Double(1 - dismissProgress * 0.9) : 0)
- .onTapGesture(perform: close)
- Image(platform: image)
- .resizable()
- .interpolation(.high)
- .frame(width: shown ? target.width : src.width,
- height: shown ? target.height : src.height)
- .clipShape(RoundedRectangle(cornerRadius: shown ? 0 : 18, style: .continuous))
- .scaleEffect(scale * (1 - dismissProgress * 0.15))
- .rotationEffect(rotation)
- .position(x: (shown ? target.midX : src.midX) + offset.width,
- y: (shown ? target.midY : src.midY) + offset.height + dismissOffset)
- .onTapGesture(count: 2) { loc in
- doubleTap(at: loc, target: target, viewport: viewport)
- }
- .onTapGesture { if scale == 1 { close() } }
- .gesture(drag(target: target, viewport: viewport))
- .simultaneousGesture(pinch(target: target, viewport: viewport))
- .simultaneousGesture(rotate)
- }
- }
-
- // Aspect-fit to the FULL screen — the photo meets the edges (scaled up if
- // needed, like Messages), letterboxed by the scrim on the other axis.
- private func fittedRect(in viewport: CGSize) -> CGRect {
- let size = image.size
- guard size.width > 0, size.height > 0, viewport.width > 0, viewport.height > 0 else {
- return CGRect(x: viewport.width / 2, y: viewport.height / 2, width: 0, height: 0)
- }
- let s = min(viewport.width / size.width, viewport.height / size.height)
- let w = size.width * s
- let h = size.height * s
- return CGRect(x: (viewport.width - w) / 2, y: (viewport.height - h) / 2,
- width: w, height: h)
- }
-
- // Everything springs home together — the pose to identity, the geometry
- // back into the bubble, the scrim out — then the animation-less cover
- // clears once the zoom-back has played.
- private func close() {
- guard !closing else { return }
- closing = true
- withAnimation(.spring(response: 0.32, dampingFraction: 0.88)) {
- shown = false
- scale = 1
- offset = .zero
- rotation = .zero
- dismissOffset = 0
- }
- pinchBase = 1
- panBase = .zero
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.32, execute: onClose)
- }
-
- // Zoom in about the tapped spot; zoom out back to the fit.
- private func doubleTap(at loc: CGPoint, target: CGRect, viewport: CGSize) {
- if scale > 1 {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
- scale = 1
- offset = .zero
- }
- } else {
- // q: the tapped point in the photo's own (unscaled, centered)
- // coordinates — hold it in place while the photo grows around it.
- let q = CGPoint(x: (loc.x - target.midX - offset.width) / scale,
- y: (loc.y - target.midY - offset.height) / scale)
- let zoomed: CGFloat = 2.5
- let held = CGSize(width: offset.width + (scale - zoomed) * q.x,
- height: offset.height + (scale - zoomed) * q.y)
- withAnimation(.spring(response: 0.3, dampingFraction: 0.85)) {
- scale = zoomed
- offset = clamped(held, scale: zoomed, fitted: target.size, in: viewport)
- }
- }
- pinchBase = scale
- panBase = offset
- }
-
- // One drag, two meanings: pan while zoomed in, pull-to-dismiss at 1:1.
- private func drag(target: CGRect, viewport: CGSize) -> some Gesture {
- DragGesture()
- .onChanged { v in
- if scale > 1 {
- offset = CGSize(width: panBase.width + v.translation.width,
- height: panBase.height + v.translation.height)
- } else {
- dismissOffset = v.translation.height
- }
- }
- .onEnded { v in
- if scale > 1 {
- // Rubber-band any letterbox dragged into view back out.
- withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
- offset = clamped(offset, scale: scale,
- fitted: target.size, in: viewport)
- }
- panBase = offset
- } else if abs(dismissOffset) > 120 || abs(v.predictedEndTranslation.height) > 400 {
- close()
- } else {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- dismissOffset = 0
- }
- }
- }
- }
-
- // Messages-style pinch: the photo point under the fingers stays under
- // them, and the zoom KEEPS on release — clamped back into [1, maxZoom]
- // (with rubber give past both ends while the fingers are down).
- private func pinch(target: CGRect, viewport: CGSize) -> some Gesture {
- MagnifyGesture()
- .onChanged { v in
- let s = softClamped(pinchBase * v.magnification)
- let q = CGPoint(x: (v.startLocation.x - target.midX - panBase.width) / pinchBase,
- y: (v.startLocation.y - target.midY - panBase.height) / pinchBase)
- scale = s
- offset = CGSize(width: panBase.width + (pinchBase - s) * q.x,
- height: panBase.height + (pinchBase - s) * q.y)
- }
- .onEnded { _ in
- let settled = min(maxZoom, max(1, scale))
- withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
- scale = settled
- offset = settled == 1
- ? .zero
- : clamped(offset, scale: settled, fitted: target.size, in: viewport)
- }
- pinchBase = settled
- panBase = offset
- }
- }
-
- // Twist turns the photo 1:1 under the fingers (Messages-style) and springs
- // back level on release — the zoom stays, the tilt never does.
- private var rotate: some Gesture {
- RotateGesture()
- .onChanged { rotation = $0.rotation }
- .onEnded { _ in
- withAnimation(.spring(response: 0.45, dampingFraction: 0.72)) {
- rotation = .zero
- }
- }
- }
-
- // Rubber give past the zoom limits: the photo keeps responding at a
- // root-curve fraction of the overshoot, and the release springs it back.
- private func softClamped(_ raw: CGFloat) -> CGFloat {
- if raw > maxZoom { return maxZoom * pow(raw / maxZoom, 0.4) }
- if raw < 1 { return max(0.55, pow(raw, 0.6)) }
- return raw
- }
-
- // Pan bounds: no dead letterbox on an axis where the zoomed photo exceeds
- // the screen; centered on an axis where it doesn't.
- private func clamped(_ o: CGSize, scale: CGFloat, fitted: CGSize,
- in viewport: CGSize) -> CGSize {
- let maxX = max(0, (fitted.width * scale - viewport.width) / 2)
- let maxY = max(0, (fitted.height * scale - viewport.height) / 2)
- return CGSize(width: min(maxX, max(-maxX, o.width)),
- height: min(maxY, max(-maxY, o.height)))
- }
-}
-
// Full-screen encrypted-video playback — an AVPlayerViewController wrapped so
// its letterbox is TRANSPARENT (PlayerSurface): the backdrop is our own layer,
// so the dismiss swipe translates only the video surface (scaling down as it
@@ -2049,8 +1836,8 @@ private struct VideoViewer: View {
let sourceFrame: CGRect // the video card, in window coordinates
let onClose: () -> Void
- // Drives the scrim and the card↔fullscreen geometry, as in ImageViewer:
- // sprung on in onAppear, off in close() with delayed teardown.
+ // Drives the scrim and the card↔fullscreen geometry: sprung on in onAppear,
+ // off in close() with delayed teardown.
@State private var shown = false
@State private var closing = false
@State private var dismissOffset: CGFloat = 0