Files
yattee/Yattee/Views/Player/WideScreenPlayerLayout.swift
Arkadiusz Fal 5f31610489 Look up the foreground scene instead of the first connected one
The swipe-action share sheet, the expanded player's safe area and the
Dynamic Island side check took connectedScenes.first, which ignores
which scene is active (on the iPhone Duo it can be the other panel's)
and presented the share sheet from the main window, behind an expanded
player.

UIApplication.foregroundWindowScene/foregroundWindow resolve the
foreground-active scene (else foreground-inactive, else any) and its
key window, the order the widescreen layout already used. The share
sheet now presents from the key window, which is the player's while it
is up. The tvOS display mode manager uses the same lookup.
2026-10-04 23:27:18 +02:00

384 lines
16 KiB
Swift

//
// WideScreenPlayerLayout.swift
// Yattee
//
// Widescreen layout with video player and floating details panel.
// Supports two modes:
// - Overlay: Panel floats over full-width player, appears with controls
// - Pinned: Player resizes to make room for panel
//
// iPhone uses the overlay mode only, always on the right. iPad/macOS default
// to pinned, can unpin with the pin button, and pick the side in the preset's
// panel settings.
//
import SwiftUI
#if os(iOS) || os(macOS)
struct WideScreenPlayerLayout<PlayerContent: View>: View {
@Environment(\.appEnvironment) private var appEnvironment
let playerControlsLayout: PlayerControlsLayout
@ViewBuilder let playerContent: (
_ onTogglePanel: @escaping () -> Void,
_ isPanelVisible: Bool,
_ isPanelPinned: Bool,
_ panelSide: FloatingPanelSide,
_ onHidePanel: @escaping () -> Void,
_ leadingSafeArea: CGFloat,
_ trailingSafeArea: CGFloat,
_ fullWidth: CGFloat,
_ fullHeight: CGFloat
) -> PlayerContent
// Callbacks for panel actions
let onChannelTap: (() -> Void)?
let onFullscreen: (() -> Void)?
@State private var controlsVisible = false
@State private var isPanelVisible = false // Local state synced with settingsManager
@State private var lastVideoId: String? // Track video ID to detect actual video changes
@State private var panelWidth: CGFloat = FloatingDetailsPanel.defaultPanelWidth
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
private var playerState: PlayerState? { appEnvironment?.playerService.state }
// Read video from playerState for reactive updates
private var video: Video? { playerState?.currentVideo }
private var panelSide: FloatingPanelSide {
if PanelSettings.isSectionsOnlyDevice { return .right }
return playerControlsLayout.effectivePanelSettings.effectiveWideLayoutSide
}
/// `landscapeDetailsPanelPinned` already resolves to `false` on iPhone.
private var isPanelPinned: Bool {
settingsManager?.landscapeDetailsPanelPinned ?? false
}
/// Base panel width including grabber and padding (without safe area)
private var basePanelWidth: CGFloat {
panelWidth + 20 + 12 // panelWidth + grabber (20pt) + outer edge padding (12pt)
}
/// Maximum panel width based on available width and pinned state
private func maxPanelWidth(availableWidth: CGFloat) -> CGFloat {
let minWidth = FloatingDetailsPanel.minPanelWidth
let minVideoWidth: CGFloat = 400
if isPanelPinned {
// Pinned: leave at least 300pt for video
return max(minWidth, availableWidth - minVideoWidth)
} else {
// Unpinned: 80% of available width, capped at 1000pt
return max(minWidth, min(availableWidth * 0.8, 1000))
}
}
/// Calculate safe area padding for panel outer edge
/// Always use full safe area - rounded corner side is already small
private func panelSafeAreaPadding(safeAreaLeft: CGFloat, safeAreaRight: CGFloat) -> CGFloat {
if panelSide == .left {
return safeAreaLeft
} else {
return safeAreaRight
}
}
/// Total panel width including safe area padding
/// Must include the same padding applied to the panel view
private func totalPanelWidth(safeAreaLeft: CGFloat, safeAreaRight: CGFloat) -> CGFloat {
basePanelWidth + panelSafeAreaPadding(safeAreaLeft: safeAreaLeft, safeAreaRight: safeAreaRight)
}
/// Whether to show the panel
private var shouldShowPanel: Bool {
isPanelVisible
}
/// Toggle panel visibility from controls
private func togglePanelFromControls() {
withAnimation(.easeInOut(duration: 0.3)) {
isPanelVisible.toggle()
settingsManager?.landscapeDetailsPanelVisible = isPanelVisible
settingsManager?.landscapeDetailsPanelLastVisible = isPanelVisible
}
}
/// Hide the panel completely
private func hidePanel() {
isPanelVisible = false
settingsManager?.landscapeDetailsPanelVisible = false
settingsManager?.landscapeDetailsPanelLastVisible = false
}
#if os(iOS)
/// The app's window, looked up from any foreground scene. Notification
/// Center and Control Center move the scene to `.foregroundInactive`, so
/// requiring `.foregroundActive` would drop the safe area to zero while
/// they are open and make the panel jump to the screen edge.
private var foregroundWindow: UIWindow? {
UIApplication.shared.foregroundWindow
}
#endif
/// Get safe area insets from window scene (geometry reader insets are 0 when .ignoresSafeArea is used)
private var windowSafeAreaInsets: EdgeInsets {
#if os(iOS)
guard let window = foregroundWindow else { return EdgeInsets() }
let insets = window.safeAreaInsets
return EdgeInsets(top: insets.top, leading: insets.left, bottom: insets.bottom, trailing: insets.right)
#else
return EdgeInsets()
#endif
}
/// Get full screen bounds from window scene (ignores safe area constraints from parent)
/// On macOS, this is not used - geometry reader size is used directly
private func windowSceneBounds(fallback: CGSize) -> CGSize {
#if os(iOS)
guard let window = foregroundWindow else {
// Fallback to the space this view was actually offered
return fallback
}
// Window frame gives actual size including orientation
return window.frame.size
#else
return .zero // Not used on macOS - geometry reader size is used directly
#endif
}
var body: some View {
GeometryReader { geometry in
// On iOS: Use window bounds to get full size (avoids safe area constraint from parent)
// On macOS: Use geometry reader size directly (no safe area concerns, window is the source of truth)
#if os(iOS)
let windowBounds = windowSceneBounds(fallback: geometry.size)
let availableWidth = windowBounds.width
let availableHeight = windowBounds.height
let safeAreaLeft = windowSafeAreaInsets.leading
let safeAreaRight = windowSafeAreaInsets.trailing
#else
let availableWidth = geometry.size.width
let availableHeight = geometry.size.height
let safeAreaLeft: CGFloat = 0
let safeAreaRight: CGFloat = 0
#endif
// Safe area strategy:
// - Left side (Dynamic Island): Always respect full safe area - content would be cut off
// - Right side (rounded corners): Can extend to edge - content still visible
// - Panel: Use half safe area on outer edge to get closer to edge while keeping content visible
// Calculate total panel width including safe area padding
let totalPanelW = totalPanelWidth(safeAreaLeft: safeAreaLeft, safeAreaRight: safeAreaRight)
// Detect which side has Dynamic Island based on interface orientation
// Note: Can't use safe area comparison because both sides have similar values (~62px each)
#if os(iOS)
// TODO: Modernization - This distinguishes landscapeLeft from landscapeRight to locate
// the Dynamic Island, which window bounds/size classes cannot express. Read the
// orientation from the hosting scene's `effectiveGeometry.interfaceOrientation`
// (iOS 26+) instead of the deprecated `UIWindowScene.interfaceOrientation` once the
// deployment target allows, and consider `traitCollection.verticalBarEdge` (iOS 27.1)
// for the bar-edge question. Global scene lookup should be replaced by the hosting
// window (e.g. via a UIViewRepresentable reading `view.window?.windowScene`).
let interfaceOrientation = UIApplication.shared.foregroundWindowScene?
.interfaceOrientation ?? .unknown
// landscapeLeft = home/gesture bar on left = Dynamic Island on RIGHT
// landscapeRight = home/gesture bar on right = Dynamic Island on LEFT
let isDynamicIslandOnLeft = interfaceOrientation == .landscapeRight
let isDynamicIslandOnRight = interfaceOrientation == .landscapeLeft
#else
let isDynamicIslandOnLeft = safeAreaLeft > safeAreaRight
let isDynamicIslandOnRight = safeAreaRight > safeAreaLeft
#endif
// Calculate Dynamic Island safe area on the side opposite to panel
// Video must not extend into Dynamic Island
let opposingDynamicIslandSafeArea: CGFloat = {
guard isPanelPinned && isPanelVisible else { return 0 }
if panelSide == .right && isDynamicIslandOnLeft {
return safeAreaLeft
} else if panelSide == .left && isDynamicIslandOnRight {
return safeAreaRight
}
return 0
}()
// Calculate player width based on pinned state
let _ = isPanelPinned && isPanelVisible
? availableWidth - totalPanelW - opposingDynamicIslandSafeArea
: availableWidth
ZStack {
// Black background - extends under status bar and home indicator
Color.black
.ignoresSafeArea(.all)
// Calculate safe areas to pass to player content
// When panel is pinned, the side with the panel needs space for it
// The opposite side needs space for Dynamic Island if present
let leadingSafeArea: CGFloat = {
guard isPanelPinned && isPanelVisible else { return 0 }
if panelSide == .left {
return totalPanelW
} else if isDynamicIslandOnLeft {
return safeAreaLeft
}
return 0
}()
let trailingSafeArea: CGFloat = {
guard isPanelPinned && isPanelVisible else { return 0 }
if panelSide == .right {
return totalPanelW
} else if isDynamicIslandOnRight {
return safeAreaRight
}
return 0
}()
// Player content - fills entire space, handles safe areas internally
// Pass full geometry so playerContent doesn't need its own GeometryReader
playerContent(
togglePanelFromControls,
isPanelVisible,
isPanelPinned,
panelSide,
{ withAnimation(.easeInOut(duration: 0.3)) { hidePanel() } },
leadingSafeArea,
trailingSafeArea,
availableWidth,
availableHeight
)
// Panel container
// Dynamic Island side needs full safe area, rounded corner side needs half
let panelOuterPadding = panelSafeAreaPadding(safeAreaLeft: safeAreaLeft, safeAreaRight: safeAreaRight)
// Panel - always in hierarchy, visibility controlled via opacity
panelContainer(
safeAreaPadding: panelOuterPadding,
availableWidth: availableWidth,
availableHeight: availableHeight
)
.opacity(shouldShowPanel ? 1 : 0)
.allowsHitTesting(shouldShowPanel)
}
.animation(.easeInOut(duration: 0.3), value: isPanelPinned)
.animation(.easeInOut(duration: 0.3), value: isPanelVisible)
.animation(.easeInOut(duration: 0.3), value: panelSide)
.animation(.easeInOut(duration: 0.3), value: controlsVisible)
}
.onChange(of: playerState?.controlsVisible) { _, newValue in
withAnimation(.easeInOut(duration: 0.2)) {
controlsVisible = newValue ?? false
}
}
.onChange(of: video?.id) { _, newId in
// Only reset panel state when video actually changes (not on view recreation)
guard let newId, lastVideoId != newId.videoID else { return }
lastVideoId = newId.videoID
// Reset comments state (stored in PlayerState)
playerState?.comments = []
playerState?.commentsState = .idle
playerState?.commentsContinuation = nil
// Panel state remains unchanged - don't modify visibility or expanded state
}
.onAppear {
controlsVisible = playerState?.controlsVisible ?? false
// Track initial video ID
if lastVideoId == nil {
lastVideoId = video?.id.videoID
}
// Initialize panel state from settings (respect user/system-set visibility)
isPanelVisible = settingsManager?.landscapeDetailsPanelVisible ?? false
// Don't override settingsManager values - they were already set by ExpandedPlayerSheet
// Load saved panel width from settings
if let savedWidth = settingsManager?.floatingDetailsPanelWidth, savedWidth > 0 {
panelWidth = savedWidth
}
}
.onChange(of: panelWidth) { _, newWidth in
// Persist panel width changes to settings
settingsManager?.floatingDetailsPanelWidth = newWidth
}
.onChange(of: settingsManager?.landscapeDetailsPanelVisible) { _, newValue in
// External writes (e.g. the preset's default visibility applied
// after the async layout load) must reach the local state too.
guard let newValue, newValue != isPanelVisible else { return }
withAnimation(.easeInOut(duration: 0.3)) {
isPanelVisible = newValue
}
}
.ignoresSafeArea(.all)
#if os(iOS)
.persistentSystemOverlays(.hidden)
.playerStatusBarHidden(true)
#endif
}
// MARK: - Panel Container
/// Panel container for the floating details panel
@ViewBuilder
private func panelContainer(safeAreaPadding: CGFloat, availableWidth: CGFloat, availableHeight: CGFloat) -> some View {
// Panel dimensions
let panelHeight = availableHeight - 24 // 12pt top + 12pt bottom padding
ZStack(alignment: panelSide == .right ? .topTrailing : .topLeading) {
detailsPanel(availableWidth: availableWidth)
.frame(height: panelHeight)
.padding(.top, 12)
.padding(.bottom, 12)
.padding(panelSide == .left ? .leading : .trailing, safeAreaPadding + 12)
.transition(.scale(scale: 0.9, anchor: panelSide == .right ? .topTrailing : .topLeading).combined(with: .opacity))
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: panelSide == .right ? .topTrailing : .topLeading)
}
// MARK: - Details Panel
@ViewBuilder
private func detailsPanel(availableWidth: CGFloat) -> some View {
FloatingDetailsPanel(
onPinToggle: {
if isPanelPinned {
// Unpinning - convert to floating mode (don't close)
withAnimation(.easeInOut(duration: 0.3)) {
settingsManager?.landscapeDetailsPanelPinned = false
}
} else {
// Pinning - ensure panel is visible first
isPanelVisible = true
settingsManager?.landscapeDetailsPanelVisible = true
settingsManager?.landscapeDetailsPanelLastVisible = true
// Animate panscan to zero and pin concurrently for smooth transition
appEnvironment?.navigationCoordinator.animatePanscanToZero(completion: nil)
withAnimation(.easeInOut(duration: 0.3)) {
settingsManager?.landscapeDetailsPanelPinned = true
}
}
},
isPinned: isPanelPinned,
panelSide: panelSide,
onChannelTap: onChannelTap,
onFullscreen: onFullscreen,
panelWidth: $panelWidth,
availableWidth: availableWidth,
maxPanelWidth: maxPanelWidth(availableWidth: availableWidth),
playerControlsLayout: playerControlsLayout
)
}
}
#endif