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>
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) |
| } |
| } |