mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
Yattee v2 rewrite
This commit is contained in:
370
Yattee/Views/Player/WideScreenPlayerLayout.swift
Normal file
370
Yattee/Views/Player/WideScreenPlayerLayout.swift
Normal file
@@ -0,0 +1,370 @@
|
||||
//
|
||||
// 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
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS) || os(macOS)
|
||||
|
||||
struct WideScreenPlayerLayout<PlayerContent: View>: View {
|
||||
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)?
|
||||
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@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 {
|
||||
settingsManager?.floatingDetailsPanelSide ?? .left
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/// Hide the panel completely
|
||||
private func hidePanel() {
|
||||
isPanelVisible = false
|
||||
settingsManager?.landscapeDetailsPanelVisible = false
|
||||
}
|
||||
|
||||
/// 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 windowScene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive }),
|
||||
let window = windowScene.windows.first
|
||||
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 var windowSceneBounds: CGSize {
|
||||
#if os(iOS)
|
||||
guard let windowScene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive }),
|
||||
let window = windowScene.windows.first
|
||||
else {
|
||||
// Fallback to screen bounds
|
||||
let screen = UIScreen.main.bounds
|
||||
return CGSize(width: max(screen.width, screen.height), height: min(screen.width, screen.height))
|
||||
}
|
||||
// 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
|
||||
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)
|
||||
let interfaceOrientation = UIApplication.shared.connectedScenes
|
||||
.compactMap { $0 as? UIWindowScene }
|
||||
.first?
|
||||
.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
|
||||
}
|
||||
.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
|
||||
|
||||
// Animate panscan to zero and pin concurrently for smooth transition
|
||||
appEnvironment?.navigationCoordinator.animatePanscanToZero(completion: nil)
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
settingsManager?.landscapeDetailsPanelPinned = true
|
||||
}
|
||||
}
|
||||
},
|
||||
onAlignmentToggle: {
|
||||
withAnimation(.easeInOut(duration: 0.3)) {
|
||||
settingsManager?.floatingDetailsPanelSide = panelSide.opposite
|
||||
}
|
||||
},
|
||||
isPinned: isPanelPinned,
|
||||
panelSide: panelSide,
|
||||
onChannelTap: onChannelTap,
|
||||
onFullscreen: onFullscreen,
|
||||
panelWidth: $panelWidth,
|
||||
availableWidth: availableWidth,
|
||||
maxPanelWidth: maxPanelWidth(availableWidth: availableWidth),
|
||||
playerControlsLayout: playerControlsLayout
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the first enabled Yattee Server instance URL, if any.
|
||||
private var yatteeServerURL: URL? {
|
||||
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user