mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
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.
384 lines
16 KiB
Swift
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
|