| index 8a851d8..2ebe23a 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -13,8 +13,10 @@ import UniformTypeIdentifiers |
| // Phone-appropriate divergences from the Mac container: actions ride the |
| // long-press context menu MessageBubble already provides on iOS (no hover, no |
| // NSMenu); photos open a full-screen viewer; encrypted videos play in a |
| -// full-screen VideoPlayer (fine on iOS — the macOS crash was in the Mac |
| -// overlay); attachments come from PhotosPicker + the document picker. Not yet |
| +// full-screen native player (VideoViewer — an AVPlayerViewController; the |
| +// macOS crash was in the Mac overlay, and SwiftUI's VideoPlayer couldn't do |
| +// the layered dismiss); attachments come from PhotosPicker + the document |
| +// picker. Not yet |
| // here: Show Times, file-chip export, scroll-back wave. |
| struct NativeChatScreen: View { |
| let instance: SavedInstance |
| @@ -1542,30 +1544,45 @@ private struct ImageViewer: View { |
| } |
| } |
| |
| -// Full-screen encrypted-video playback — SwiftUI's VideoPlayer is fine on iOS |
| -// (the macOS overlay is what crashed); bytes still come from memory only. |
| -// Swipe down to dismiss (the player rides the finger, the backdrop fades), and |
| -// a rotate button turns the video sideways for orientation-locked phones. |
| +// 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 |
| +// goes) while the black backdrop stays put and fades with the drag — the |
| +// Photos grammar the photo viewer already follows. (SwiftUI's VideoPlayer |
| +// 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. |
| +// |
| +// 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 onClose: () -> Void |
| |
| @State private var dismissOffset: CGFloat = 0 |
| @State private var exiting = false |
| - @State private var rotated = 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 |
| + // keeps the fullscreen path reachable at the cost of occasionally outliving |
| + // the system fade by a moment. |
| + @State private var controlsVisible = true |
| + @State private var hideGen = 0 |
| + @StateObject private var lockSensor = RotationLockSensor() |
| |
| private var dismissProgress: CGFloat { min(1, abs(dismissOffset) / 300) } |
| + private var chromeShown: Bool { controlsVisible && !exiting && 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() |
| - VideoPlayer(player: player) |
| - .frame(width: rotated ? geo.size.height : geo.size.width, |
| - height: rotated ? geo.size.width : geo.size.height) |
| - .rotationEffect(.degrees(rotated ? 90 : 0)) |
| - .position(x: geo.size.width / 2, y: geo.size.height / 2) |
| + PlayerSurface(player: player, onTouch: pokeChrome) |
| + .ignoresSafeArea() // full-bleed; the chrome insets itself |
| + .scaleEffect(1 - dismissProgress * 0.15) |
| .offset(y: dismissOffset) |
| .simultaneousGesture( |
| DragGesture(minimumDistance: 25) |
| @@ -1584,33 +1601,69 @@ private struct VideoViewer: View { |
| } |
| } |
| ) |
| - HStack { |
| - // For the orientation-locked: turn the video, not the phone. |
| + 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 { |
| - withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { |
| - rotated.toggle() |
| - } |
| + lockSensor.noteManualRotation() |
| + VideoOrientation.rotate(to: landscape ? .portrait : .landscapeRight) |
| } label: { |
| - Image(systemName: "rotate.right") |
| - .font(.system(size: 22)) |
| - .foregroundStyle(.white.opacity(0.8)) |
| - .padding() |
| - } |
| - Spacer() |
| - Button(action: close) { |
| - Image(systemName: "xmark.circle.fill") |
| - .font(.system(size: 28)) |
| - .foregroundStyle(.white.opacity(0.8)) |
| - .padding() |
| + 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") |
| } |
| - .opacity(exiting ? 0 : 1) |
| } |
| + .animation(.easeInOut(duration: 0.25), value: chromeShown) |
| + } |
| + .onAppear { |
| + VideoOrientation.setAllowsLandscape(true) |
| + pokeChrome() |
| + } |
| + // Belt-and-braces: close() already re-locks, but a cover torn down any |
| + // other way must still return the app to portrait-only. |
| + .onDisappear { VideoOrientation.setAllowsLandscape(false) } |
| + } |
| + |
| + // 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() { |
| + controlsVisible = true |
| + hideGen += 1 |
| + let gen = hideGen |
| + DispatchQueue.main.asyncAfter(deadline: .now() + 4) { |
| + guard gen == hideGen, player.timeControlStatus == .playing else { return } |
| + controlsVisible = false |
| } |
| } |
| |
| private func close() { |
| 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 } |
| @@ -1618,6 +1671,48 @@ private struct VideoViewer: View { |
| DispatchQueue.main.asyncAfter(deadline: .now() + 0.18, execute: onClose) |
| } |
| } |
| + |
| +// The AVPlayerViewController host. Two jobs SwiftUI's VideoPlayer can't do: |
| +// a CLEAR view background, so the letterbox doesn't paint the whole screen |
| +// black (the opaque full-screen surface is what made the dismiss swipe look |
| +// like it dragged the entire lightbox); and a non-stealing touch recognizer |
| +// that mirrors "the user touched the player" up to SwiftUI, so our overlay |
| +// buttons can fade roughly in step with the system chrome. |
| +private struct PlayerSurface: UIViewControllerRepresentable { |
| + let player: AVPlayer |
| + let onTouch: () -> Void |
| + |
| + func makeCoordinator() -> Coordinator { Coordinator(onTouch: onTouch) } |
| + |
| + func makeUIViewController(context: Context) -> AVPlayerViewController { |
| + let vc = AVPlayerViewController() |
| + vc.player = player |
| + vc.showsPlaybackControls = true |
| + vc.updatesNowPlayingInfoCenter = false |
| + vc.view.backgroundColor = .clear |
| + let tap = UITapGestureRecognizer(target: context.coordinator, |
| + action: #selector(Coordinator.tapped)) |
| + tap.cancelsTouchesInView = false |
| + tap.delegate = context.coordinator |
| + vc.view.addGestureRecognizer(tap) |
| + return vc |
| + } |
| + |
| + func updateUIViewController(_ vc: AVPlayerViewController, context: Context) { |
| + vc.view.backgroundColor = .clear // AVKit relayouts can repaint it black |
| + } |
| + |
| + final class Coordinator: NSObject, UIGestureRecognizerDelegate { |
| + let onTouch: () -> Void |
| + init(onTouch: @escaping () -> Void) { self.onTouch = onTouch } |
| + @objc func tapped() { onTouch() } |
| + // Recognise alongside the player's own recognisers — never instead of. |
| + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, |
| + shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { |
| + true |
| + } |
| + } |
| +} |
| import SwiftUI |
| import UIKit |
| |