Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 76 additions & 63 deletions apps/ios/FloatNative/Views/VideoPlayerView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ struct VideoPlayerView: View {
// Quality selector state
@State private var isChangingQuality = false

/// Tracks physical device orientation for UI that must stay visible while
/// the keyboard is open. GeometryReader width/height flips to "landscape"
/// when the keyboard shrinks the available height, which was removing the
/// comment composer and immediately dismissing focus.
@State private var isDeviceLandscape = false

// Computed properties for UI
private var hasLiked: Bool {
userInteraction == .like
Expand All @@ -74,82 +80,74 @@ struct VideoPlayerView: View {
}

var body: some View {
GeometryReader { geometry in
let isLandscape = geometry.size.width > geometry.size.height

ZStack {
// Background
Color.adaptiveBackground
.ignoresSafeArea()

// Persistent Layout (Always use portrait layout structure)
// When rotating to landscape, we trigger native fullscreen instead of swapping views
portraitLayout

// Error state
if let error = errorMessage {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 50))
.foregroundStyle(.red)

Text(error)
.foregroundStyle(Color.adaptiveText)
.multilineTextAlignment(.center)

Button("Retry") {
Task {
await loadVideo()
}
}
.buttonStyle(LiquidGlassButtonStyle(tint: .floatplaneBlue))
}
.padding()
}
ZStack {
// Background
Color.adaptiveBackground
.ignoresSafeArea()

// Persistent Layout (Always use portrait layout structure)
// When rotating to landscape, we trigger native fullscreen instead of swapping views
portraitLayout

// Error state
if let error = errorMessage {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 50))
.foregroundStyle(.red)

Text(error)
.foregroundStyle(Color.adaptiveText)
.multilineTextAlignment(.center)

// Stream offline overlay (for livestreams that have ended)
if isStreamOffline && isLivestream {
StreamOfflineOverlay {
dismiss()
Button("Retry") {
Task {
await loadVideo()
}
}
.buttonStyle(LiquidGlassButtonStyle(tint: .floatplaneBlue))
}
.padding()
}

// Bottom comment input overlay (only in portrait, not for livestreams, and when comments loaded successfully)
if !isLandscape && !isLivestream && !commentsLoadError {
VStack {
Spacer()
bottomCommentInput
}
// Stream offline overlay (for livestreams that have ended)
if isStreamOffline && isLivestream {
StreamOfflineOverlay {
dismiss()
}
}
#if !os(tvOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationBarBackButtonHidden(false) // Always show back button in "portrait" view
#if !os(tvOS)
.statusBar(hidden: false) // Always show status bar in "portrait" view
#endif
.toolbar(isLandscape ? .hidden : .visible, for: .tabBar)
.globalMenu()
// We listen to device orientation change because GeometryReader doesn't update
// reliably when the native player is in fullscreen mode (covering this view).
#if !os(tvOS)
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
let orientation = UIDevice.current.orientation

if orientation.isLandscape {
AVPlayerManager.shared.enterFullScreen()
} else if orientation == .portrait {
// Only exit fullscreen on standard portrait, ignore upside down
AVPlayerManager.shared.exitFullScreen()

// Bottom comment input overlay (only in portrait, not for livestreams, and when comments loaded successfully)
if !isDeviceLandscape && !isLivestream && !commentsLoadError {
VStack {
Spacer()
bottomCommentInput
}
}
#endif
}
#if !os(tvOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.navigationBarBackButtonHidden(false) // Always show back button in "portrait" view
#if !os(tvOS)
.statusBar(hidden: false) // Always show status bar in "portrait" view
#endif
.toolbar(isDeviceLandscape ? .hidden : .visible, for: .tabBar)
.globalMenu()
// Device orientation drives landscape UI — not GeometryReader size, which
// falsely reads as landscape when the keyboard shrinks available height.
#if !os(tvOS)
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
handleDeviceOrientationChange(UIDevice.current.orientation)
}
#endif
.onAppear {
// Initialize like/dislike counts from post
currentLikes = post.likes
currentDislikes = post.dislikes
#if !os(tvOS)
handleDeviceOrientationChange(UIDevice.current.orientation)
#endif
}
.task {
await loadVideo()
Expand Down Expand Up @@ -348,8 +346,23 @@ struct VideoPlayerView: View {
.padding()
.padding(.bottom, 100) // Space for floating comment input
}
.scrollDismissesKeyboard(.never)
}
}

#if !os(tvOS)
/// Update landscape UI state and fullscreen player from a device orientation.
/// Ignores face-up/face-down/unknown so incidental motion doesn't flicker UI.
private func handleDeviceOrientationChange(_ orientation: UIDeviceOrientation) {
if orientation.isLandscape {
isDeviceLandscape = true
AVPlayerManager.shared.enterFullScreen()
} else if orientation == .portrait {
isDeviceLandscape = false
AVPlayerManager.shared.exitFullScreen()
}
}
#endif

// MARK: - Load Video

Expand Down