eleven / ocho Public

ios: video lightbox — layered dismiss, rotate-to-fullscreen, YouTube-style expand button (#566)

Three device-testing fixes to the native video viewer:

- Layered dismiss: the viewer now hosts an AVPlayerViewController with a
  CLEAR view background (PlayerSurface) instead of SwiftUI's VideoPlayer,
  whose opaque full-screen letterbox made the dismiss swipe drag the whole
  lightbox. Now only the video surface rides the finger (scaling down as it
  goes) while the black backdrop stays put and fades with the drag — the
  Photos grammar the photo viewer already follows.

- Rotate-to-fullscreen: the iPhone app stays portrait-only, but while the
  lightbox is up VideoOrientation widens the orientation mask (consulted by
  the app delegate's supportedInterfaceOrientationsFor), so physically
  rotating the phone rotates the player into landscape fullscreen and back.
  Closing the viewer re-locks portrait before the cover leaves.

- Manual fullscreen button: a YouTube-style expand control (44pt dark circle,
  arrow.up.left.and.arrow.down.right) bottom-right above the transport bar,
  driving requestGeometryUpdate — which rotates even under the Control Center
  rotation lock. Shown only when rotation looks blocked: RotationLockSensor
  infers the (API-less) lock by watching UIDevice orientation — a physically
  landscape phone whose interface stays portrait means locked; no signal
  (device flat) defaults to showing the button. Landscape always offers the
  collapse counterpart. The overlay buttons fade after a few seconds of
  playback and return on touch, shadowing the system chrome.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 26a974efaa56a7dbcda296195d9ff084bc2ba1f2 parent cf5d290
3 files changed, +232 −29
  • ios/LChat/PushManager.swift +11 −0
  • ios/LChat/Views/NativeChatScreen.swift +124 −29
  • ios/LChat/Views/VideoOrientation.swift +97 −0
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift
index b666d16..0e66d91 100644
--- a/ios/LChat/PushManager.swift
+++ b/ios/LChat/PushManager.swift
@@ -48,6 +48,17 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs
return true
}
+ // Not push at all, but this class is the app's one UIApplicationDelegate:
+ // the iPhone app is portrait-only EXCEPT while the video lightbox is up
+ // (VideoOrientation), when a playing video may rotate into landscape
+ // fullscreen. Implementing this callback overrides the Info.plist masks,
+ // so it must reproduce the plist's iPad "all orientations" too.
+ func application(_ application: UIApplication,
+ supportedInterfaceOrientationsFor window: UIWindow?) -> UIInterfaceOrientationMask {
+ if UIDevice.current.userInterfaceIdiom == .pad { return .all }
+ return VideoOrientation.allowsLandscape ? .allButUpsideDown : .portrait
+ }
+
/// Ask for permission (first time) and (re)register. Call whenever the app
/// has at least one instance — repeat calls are cheap and idempotent.
func enablePush() {
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
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
diff --git a/ios/LChat/Views/VideoOrientation.swift b/ios/LChat/Views/VideoOrientation.swift
new file mode 100644
index 0000000..23cc354
--- /dev/null
+++ b/ios/LChat/Views/VideoOrientation.swift
@@ -0,0 +1,97 @@
+import SwiftUI
+import UIKit
+
+// 🤖 The app is portrait-only on iPhone (project.yml), but a playing video
+// wants landscape fullscreen. This gate widens the allowed orientation mask
+// while the video lightbox is up: PushManager's
+// application(_:supportedInterfaceOrientationsFor:) consults it, so rotating
+// the phone rotates the player into landscape (and back) without unlocking
+// the rest of the app.
+enum VideoOrientation {
+ // Read by the app-delegate orientation callback. Main thread only — both
+ // the callback and the lightbox's appear/disappear run there.
+ private(set) static var allowsLandscape = false
+
+ static func setAllowsLandscape(_ on: Bool) {
+ allowsLandscape = on
+ // Make the system re-ask for the mask NOW — without this the change
+ // only takes effect on the next natural rotation, so closing the
+ // lightbox in landscape would leave the chat sideways.
+ scene?.keyWindow?.rootViewController?.setNeedsUpdateOfSupportedInterfaceOrientations()
+ }
+
+ /// Manual rotation (the YouTube-style expand/collapse button). A geometry
+ /// request rotates even when the user's Control Center rotation lock is
+ /// on — the lock only suppresses device-driven AUTO-rotation, which is
+ /// exactly why the button exists.
+ static func rotate(to orientations: UIInterfaceOrientationMask) {
+ scene?.requestGeometryUpdate(.iOS(interfaceOrientations: orientations))
+ }
+
+ static var interfaceIsLandscape: Bool {
+ scene?.interfaceOrientation.isLandscape ?? false
+ }
+
+ private static var scene: UIWindowScene? {
+ let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
+ return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
+ }
+}
+
+// 🤖 iOS has no public "is rotation lock on?" API, so infer it. While the
+// lightbox is up the app's mask allows landscape, so auto-rotation is expected
+// to WORK — if UIDevice reports the phone physically held landscape while the
+// interface stays portrait, something (in practice the Control Center rotation
+// lock) is blocking it, and the manual button is the only path to fullscreen.
+// The interface actually reaching landscape on its own proves rotation works.
+// A device lying flat (faceUp/faceDown/unknown) gives NO signal either way, so
+// the verdict starts .unknown and the button defaults to showing — a spare
+// button beats no path to fullscreen.
+final class RotationLockSensor: ObservableObject {
+ enum Verdict { case unknown, locked, unlocked }
+ @Published private(set) var verdict: Verdict = .unknown
+
+ // True briefly after the expand/collapse button fires — an interface that
+ // lands in landscape because WE asked proves nothing about the lock.
+ private var manualRotation = false
+ private var observer: NSObjectProtocol?
+
+ init() {
+ UIDevice.current.beginGeneratingDeviceOrientationNotifications()
+ observer = NotificationCenter.default.addObserver(
+ forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main
+ ) { [weak self] _ in
+ // Give auto-rotation a beat to actually turn the interface before
+ // comparing the two — reading immediately always sees the stale
+ // portrait interface and would cry "locked" on every phone.
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { self?.assess() }
+ }
+ }
+
+ deinit {
+ if let observer { NotificationCenter.default.removeObserver(observer) }
+ // Balance the begin — on the main queue; deinit can run anywhere.
+ DispatchQueue.main.async { UIDevice.current.endGeneratingDeviceOrientationNotifications() }
+ }
+
+ /// Call when the expand/collapse button drives a rotation, so the sensor
+ /// doesn't read the resulting orientation change as evidence.
+ func noteManualRotation() {
+ manualRotation = true
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { [weak self] in
+ self?.manualRotation = false
+ }
+ }
+
+ private func assess() {
+ guard !manualRotation else { return }
+ let device = UIDevice.current.orientation
+ let interfaceLandscape = VideoOrientation.interfaceIsLandscape
+ if device.isLandscape && !interfaceLandscape {
+ verdict = .locked
+ } else if device.isLandscape && interfaceLandscape {
+ verdict = .unlocked
+ }
+ // Portrait or flat: no signal — keep the current verdict.
+ }
+}