eleven / messenger Public

ios: video viewer zooms out of its card, matching the photo lightbox (#621)

The tapped video's surface now zooms OUT OF ITS CARD to full-bleed over
the fading scrim (cover presented with no transition, geometry
interpolated from the card's window frame — the photo viewer's grammar,
which itself follows the Mac lightbox), and zooms back into place on
close, tap-X and swipe-down alike. The card frame the video card was
already passing (real on iOS since the LiveFrame tap-time anchor) is
threaded through PlayingVideo.

Rotation-to-landscape, the fullscreen toggle, chrome fade, and the
swipe-to-dismiss thresholds are unchanged; chrome moved to its own
safe-area reader since the stage now ignores it.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8334e4a66b87702c1dfd66b37e3a052fa4970c62 parent 0a77fde
1 file changed, +125 −71
  • ios/LChat/Views/NativeChatScreen.swift +125 −71
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index 8b6551d..446b7e8 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -112,6 +112,7 @@ struct NativeChatScreen: View {
let id: String
let player: AVPlayer
let loader: MemoryVideoAsset
+ let sourceFrame: CGRect // the video card's window frame — where the zoom starts
}
// Belt-and-braces: any thread with 3+ members shows sender names,
@@ -392,8 +393,10 @@ struct NativeChatScreen: View {
.presentationBackground(.clear)
}
.fullScreenCover(item: $playingVideo) { item in
- VideoViewer(player: item.player) { coverWithoutSlide { playingVideo = nil } }
- .presentationBackground(.clear)
+ VideoViewer(player: item.player, sourceFrame: item.sourceFrame) {
+ coverWithoutSlide { playingVideo = nil }
+ }
+ .presentationBackground(.clear)
}
// Tapping a file chip previews it in Quick Look (with the system
// share button) once its bytes are decrypted to a temp file.
@@ -765,7 +768,7 @@ struct NativeChatScreen: View {
upgradeViewerImage(att)
},
onFileTap: { openFile($0) },
- onVideoTap: { att, _ in playVideo(att) },
+ onVideoTap: { att, frame in playVideo(att, from: frame) },
downloading: blobDownload,
actions: MessageActions(
canEdit: model.canEdit(m),
@@ -879,7 +882,7 @@ struct NativeChatScreen: View {
upgradeViewerImage(att)
},
onFileTap: { openFile($0) },
- onVideoTap: { att, _ in playVideo(att) },
+ onVideoTap: { att, frame in playVideo(att, from: frame) },
downloading: blobDownload,
actions: MessageActions(
canEdit: model.canEdit(m),
@@ -997,7 +1000,7 @@ struct NativeChatScreen: View {
// across both: a given attachment is either a video or a file, never both.
@State private var blobDownload: (id: String, progress: Double)?
- private func playVideo(_ att: Attachment) {
+ private func playVideo(_ att: Attachment, from frame: CGRect) {
guard blobDownload == nil else { return }
blobDownload = (att.id, 0)
Task {
@@ -1006,7 +1009,12 @@ struct NativeChatScreen: View {
guard let data else { return }
let loader = MemoryVideoAsset(data: data, mimeType: att.type)
let player = AVPlayer(playerItem: AVPlayerItem(asset: loader.makeAsset()))
- playingVideo = PlayingVideo(id: att.id, player: player, loader: loader)
+ // The frame is the card's at TAP time — good enough even though the
+ // download ran in between (the feed can't scroll under the cover).
+ coverWithoutSlide {
+ playingVideo = PlayingVideo(id: att.id, player: player, loader: loader,
+ sourceFrame: frame)
+ }
player.play()
}
}
@@ -1822,16 +1830,24 @@ private struct ImageViewer: View {
// painted its whole full-screen frame opaque black, so the swipe visibly
// dragged the entire lightbox, dim and all.) Bytes still come from memory only.
//
+// Like the photo viewer, the surface ZOOMS OUT OF ITS CARD to full-bleed (the
+// cover presents with no transition — this view is the whole choreography)
+// and zooms back into place on close, tap-X and swipe-down alike.
+//
// Rotating the phone rotates the player into landscape fullscreen even though
// the app is portrait-locked (VideoOrientation gates the mask while this view
// is up), and a YouTube-style expand button covers phones whose rotation lock
// blocks auto-rotation (RotationLockSensor infers the lock).
private struct VideoViewer: View {
let player: AVPlayer
+ 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.
+ @State private var shown = false
+ @State private var closing = false
@State private var dismissOffset: CGFloat = 0
- @State private var exiting = false
// Our overlay buttons shadow the system chrome's visibility: any touch on
// the player shows them + restarts the hide timer. We can't SEE the real
// chrome's state (no public API), so we never tap-to-hide — erring visible
@@ -1842,72 +1858,24 @@ private struct VideoViewer: View {
@StateObject private var lockSensor = RotationLockSensor()
private var dismissProgress: CGFloat { min(1, abs(dismissOffset) / 300) }
- private var chromeShown: Bool { controlsVisible && !exiting && dismissOffset == 0 }
+ private var chromeShown: Bool { controlsVisible && shown && !closing && dismissOffset == 0 }
var body: some View {
- GeometryReader { geo in
- let landscape = geo.size.width > geo.size.height
- ZStack(alignment: .topTrailing) {
- Color.black.opacity(exiting ? 0 : Double(1 - dismissProgress * 0.9))
- .ignoresSafeArea()
- PlayerSurface(player: player, onTouch: pokeChrome)
- .ignoresSafeArea() // full-bleed; the chrome insets itself
- .scaleEffect(1 - dismissProgress * 0.15)
- .offset(y: dismissOffset)
- .simultaneousGesture(
- DragGesture(minimumDistance: 25)
- .onChanged { v in
- guard abs(v.translation.height) > abs(v.translation.width) * 1.4
- else { return }
- dismissOffset = v.translation.height
- }
- .onEnded { v in
- if abs(dismissOffset) > 130 || abs(v.predictedEndTranslation.height) > 450 {
- close()
- } else {
- withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
- dismissOffset = 0
- }
- }
- }
- )
- Button(action: close) {
- Image(systemName: "xmark.circle.fill")
- .font(.system(size: 28))
- .foregroundStyle(.white.opacity(0.8))
- .padding()
- }
- .opacity(chromeShown ? 1 : 0)
- // The YouTube-style fullscreen toggle, tucked bottom-right above
- // the transport bar. Portrait shows expand only while rotation
- // looks blocked (locked or no signal yet — .unknown defaults to
- // showing: a spare button beats no path to fullscreen);
- // landscape always shows collapse, because the lock could have
- // been flipped on mid-video, stranding auto-rotation back.
- if landscape || lockSensor.verdict != .unlocked {
- Button {
- lockSensor.noteManualRotation()
- VideoOrientation.rotate(to: landscape ? .portrait : .landscapeRight)
- } label: {
- Image(systemName: landscape
- ? "arrow.down.right.and.arrow.up.left"
- : "arrow.up.left.and.arrow.down.right")
- .font(.system(size: 17, weight: .semibold))
- .foregroundStyle(.white)
- .frame(width: 44, height: 44)
- .background(.black.opacity(0.55), in: Circle())
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity,
- alignment: .bottomTrailing)
- .padding(.trailing, 16)
- .padding(.bottom, 84) // clear the system transport bar
- .opacity(chromeShown ? 1 : 0)
- .accessibilityLabel(landscape ? "Exit fullscreen" : "Fullscreen")
- }
+ ZStack {
+ GeometryReader { geo in
+ stage(in: geo.size)
+ }
+ // Full-bleed stage; local coordinates == window coordinates, the
+ // space sourceFrame is in.
+ .ignoresSafeArea()
+ // The chrome keeps its own reader INSIDE the safe area.
+ GeometryReader { geo in
+ chrome(landscape: geo.size.width > geo.size.height)
}
.animation(.easeInOut(duration: 0.25), value: chromeShown)
}
.onAppear {
+ withAnimation(.spring(response: 0.36, dampingFraction: 0.86)) { shown = true }
VideoOrientation.setAllowsLandscape(true)
pokeChrome()
}
@@ -1916,6 +1884,86 @@ private struct VideoViewer: View {
.onDisappear { VideoOrientation.setAllowsLandscape(false) }
}
+ private func stage(in viewport: CGSize) -> some View {
+ let full = CGRect(origin: .zero, size: 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 (the chat shows through the clear cover);
+ // only the video surface rides the swipe.
+ Color.black.opacity(shown ? Double(1 - dismissProgress * 0.9) : 0)
+ // Full-bleed once open (the letterbox is transparent and the
+ // system chrome insets itself); the zoom interpolates the surface
+ // from the card's frame, so the video grows out of its bubble.
+ PlayerSurface(player: player, onTouch: pokeChrome)
+ .frame(width: shown ? full.width : src.width,
+ height: shown ? full.height : src.height)
+ .clipShape(RoundedRectangle(cornerRadius: shown ? 0 : 18, style: .continuous))
+ .scaleEffect(1 - dismissProgress * 0.15)
+ .position(x: shown ? full.midX : src.midX,
+ y: (shown ? full.midY : src.midY) + dismissOffset)
+ .simultaneousGesture(
+ DragGesture(minimumDistance: 25)
+ .onChanged { v in
+ guard abs(v.translation.height) > abs(v.translation.width) * 1.4
+ else { return }
+ dismissOffset = v.translation.height
+ }
+ .onEnded { v in
+ if abs(dismissOffset) > 130 || abs(v.predictedEndTranslation.height) > 450 {
+ close()
+ } else {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) {
+ dismissOffset = 0
+ }
+ }
+ }
+ )
+ }
+ }
+
+ @ViewBuilder
+ private func chrome(landscape: Bool) -> some View {
+ Button(action: close) {
+ Image(systemName: "xmark.circle.fill")
+ .font(.system(size: 28))
+ .foregroundStyle(.white.opacity(0.8))
+ .padding()
+ }
+ .opacity(chromeShown ? 1 : 0)
+ .accessibilityLabel("Close")
+ .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
+ // The YouTube-style fullscreen toggle, tucked bottom-right above
+ // the transport bar. Portrait shows expand only while rotation
+ // looks blocked (locked or no signal yet — .unknown defaults to
+ // showing: a spare button beats no path to fullscreen);
+ // landscape always shows collapse, because the lock could have
+ // been flipped on mid-video, stranding auto-rotation back.
+ if landscape || lockSensor.verdict != .unlocked {
+ Button {
+ lockSensor.noteManualRotation()
+ VideoOrientation.rotate(to: landscape ? .portrait : .landscapeRight)
+ } label: {
+ Image(systemName: landscape
+ ? "arrow.down.right.and.arrow.up.left"
+ : "arrow.up.left.and.arrow.down.right")
+ .font(.system(size: 17, weight: .semibold))
+ .foregroundStyle(.white)
+ .frame(width: 44, height: 44)
+ .background(.black.opacity(0.55), in: Circle())
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity,
+ alignment: .bottomTrailing)
+ .padding(.trailing, 16)
+ .padding(.bottom, 84) // clear the system transport bar
+ .opacity(chromeShown ? 1 : 0)
+ .accessibilityLabel(landscape ? "Exit fullscreen" : "Fullscreen")
+ }
+ }
+
// Show the overlay buttons and (while playing) fade them after the same
// few seconds the system chrome lingers; paused keeps them up, as it does.
private func pokeChrome() {
@@ -1928,17 +1976,23 @@ private struct VideoViewer: View {
}
}
+ // Everything springs home together — the surface back into its card, the
+ // scrim out — then the animation-less cover clears once the zoom-back has
+ // played. Sound stops up front: a video still talking through its own
+ // exit is wrong (the YouTube lightbox set that rule).
private func close() {
+ guard !closing else { return }
+ closing = true
player.pause()
// Re-lock portrait BEFORE the cover starts leaving, or the chat behind
// gets caught (or stuck) sideways.
VideoOrientation.setAllowsLandscape(false)
if VideoOrientation.interfaceIsLandscape { VideoOrientation.rotate(to: .portrait) }
- withAnimation(.easeIn(duration: 0.18)) {
- exiting = true
- if dismissOffset != 0 { dismissOffset = dismissOffset > 0 ? 900 : -900 }
+ withAnimation(.spring(response: 0.32, dampingFraction: 0.88)) {
+ shown = false
+ dismissOffset = 0
}
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: onClose)
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.32, execute: onClose)
}
}