diff --git a/Yattee/ContentView.swift b/Yattee/ContentView.swift index 0a335807..9c7e4c3b 100644 --- a/Yattee/ContentView.swift +++ b/Yattee/ContentView.swift @@ -64,7 +64,11 @@ struct ContentView: View { if isInlinePlayerOverlayActive(appEnvironment: appEnvironment) { ZStack { Color.black - ExpandedPlayerSheet() + ExpandedPlayerSheet( + initialPortraitPanelVisible: ExpandedPlayerSheet.initialPortraitPanelVisibility( + settingsManager: appEnvironment.settingsManager + ) + ) } .ignoresSafeArea() .transition(.opacity) diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 895a0da6..22a52d44 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -385,6 +385,7 @@ extension ExpandedPlayerSheet { panelRevealOffset = 0 isPortraitPanelVisible = true navigationCoordinator?.isPortraitPanelVisible = true + appEnvironment?.settingsManager.detailsPanelLastVisible = true // Match video position from reveal formula let revealProgress = min(1.0, -currentRevealOffset / pinnedPanelHeight) let maxVideoOffset = centerY - topY @@ -508,6 +509,7 @@ extension ExpandedPlayerSheet { withTransaction(transaction) { isPortraitPanelVisible = false navigationCoordinator?.isPortraitPanelVisible = false + appEnvironment?.settingsManager.detailsPanelLastVisible = false isCommentsExpanded = false navigationCoordinator?.isCommentsExpanded = false navigationCoordinator?.commentsFrame = .zero @@ -648,6 +650,7 @@ extension ExpandedPlayerSheet { isPortraitPanelVisible = newValue } navigationCoordinator?.isPortraitPanelVisible = newValue + appEnvironment?.settingsManager.detailsPanelLastVisible = newValue } /// Hide floating panel with animation @@ -656,6 +659,7 @@ extension ExpandedPlayerSheet { isPortraitPanelVisible = false } navigationCoordinator?.isPortraitPanelVisible = false + appEnvironment?.settingsManager.detailsPanelLastVisible = false // Reset main window transform when hiding panel to fix safe area clipping (iOS only) #if os(iOS) @@ -853,6 +857,7 @@ extension ExpandedPlayerSheet { isPortraitPanelVisible.toggle() } navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible + appEnvironment?.settingsManager.detailsPanelLastVisible = isPortraitPanelVisible }, onShowSettings: { [self] in showingQualitySheet = true @@ -1125,6 +1130,7 @@ extension ExpandedPlayerSheet { isPortraitPanelVisible.toggle() } navigationCoordinator?.isPortraitPanelVisible = isPortraitPanelVisible + appEnvironment?.settingsManager.detailsPanelLastVisible = isPortraitPanelVisible // Re-enable controls after animation completes (0.35s) // Controls will still be hidden (forceInitialHidden) until user taps DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 8623ae3f..eefe27bb 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -35,6 +35,26 @@ struct ExpandedPlayerSheet: View { /// Temporarily hide controls during fullscreen transition to avoid animation glitch @State var hideControlsDuringTransition: Bool = false + /// Resolves the preset's default panel visibility for the portrait panel + /// from the cached settings, so creators of this view can seed the initial + /// state BEFORE the first frame — applying it in onAppear animates a + /// visible top-to-center video jump during player expansion. + @MainActor + static func initialPortraitPanelVisibility(settingsManager: SettingsManager?) -> Bool { + switch PanelSettings.cached.effectiveDefaultVisibility { + case .hidden: + return false + case .visible: + return true + case .remember: + return settingsManager?.detailsPanelLastVisible ?? false + } + } + + init(initialPortraitPanelVisible: Bool = true) { + _isPortraitPanelVisible = State(initialValue: initialPortraitPanelVisible) + } + // MARK: - Panel Drag State /// Current drag offset when dismissing panel (positive = dragging down) @@ -194,6 +214,8 @@ struct ExpandedPlayerSheet: View { isAutoplayCancelled: $isAutoplayCancelled, pendingScrollToTopOnHeightChange: $pendingScrollToTopOnHeightChange, playerControlsLayout: $playerControlsLayout, + isPortraitPanelVisible: $isPortraitPanelVisible, + isInWideScreenLayout: isInWideScreenLayout, stopAutoplayCountdown: stopAutoplayCountdown, startAutoplayCountdown: startAutoplayCountdown, startPreloadingComments: startPreloadingComments, @@ -399,10 +421,23 @@ struct ExpandedPlayerSheet: View { // state from a previous session). Uses the cached settings; // loadPlayerControlsLayout re-applies the authoritative value. if navigationCoordinator?.hasAppliedPanelDefaultVisibility != true { - applyPanelDefaultVisibility( + let visible = applyPanelDefaultVisibility( PanelSettings.cached, settingsManager: appEnvironment?.settingsManager ) + #if os(iOS) || os(macOS) + LoggingService.shared.logPlayer("[ExpandedPlayerSheet] onAppear provisional apply: visible=\(visible), state=\(isPortraitPanelVisible), wideScreen=\(wideScreen)") + // No animation: this is the opening state, not a user + // toggle — animating it here slides the video mid-expand. + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isPortraitPanelVisible = visible + } + if !wideScreen { + navigationCoordinator?.isPortraitPanelVisible = visible + } + #endif } } } @@ -794,6 +829,8 @@ private struct PlayerEventHandlersModifier: ViewModifier { @Binding var isAutoplayCancelled: Bool @Binding var pendingScrollToTopOnHeightChange: Bool @Binding var playerControlsLayout: PlayerControlsLayout + @Binding var isPortraitPanelVisible: Bool + let isInWideScreenLayout: Bool let stopAutoplayCountdown: () -> Void let startAutoplayCountdown: () -> Void @@ -914,10 +951,21 @@ private struct PlayerEventHandlersModifier: ViewModifier { // Apply the preset's default panel visibility once per expanded // player session, now that the authoritative settings are loaded. if navigationCoordinator?.hasAppliedPanelDefaultVisibility != true { - applyPanelDefaultVisibility( + let visible = applyPanelDefaultVisibility( layout.effectivePanelSettings, settingsManager: appEnvironment?.settingsManager ) + #if os(iOS) || os(macOS) + LoggingService.shared.logPlayer("[ExpandedPlayerSheet] authoritative apply: visible=\(visible), state=\(isPortraitPanelVisible), wideScreen=\(isInWideScreenLayout)") + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isPortraitPanelVisible = visible + } + if !isInWideScreenLayout { + navigationCoordinator?.isPortraitPanelVisible = visible + } + #endif navigationCoordinator?.hasAppliedPanelDefaultVisibility = true } } @@ -926,18 +974,25 @@ private struct PlayerEventHandlersModifier: ViewModifier { /// Applies the preset's default details panel visibility for a fresh /// panel mount. "Hidden" mirrors the historical reset (unpinned too); -/// the other modes leave the pinned state as persisted. +/// the other modes leave the pinned state as persisted. Returns the +/// resolved visibility so callers can mirror it into the portrait +/// panel state, which lives in view @State rather than settings. @MainActor -private func applyPanelDefaultVisibility(_ settings: PanelSettings, settingsManager: SettingsManager?) { - guard let settingsManager else { return } +@discardableResult +private func applyPanelDefaultVisibility(_ settings: PanelSettings, settingsManager: SettingsManager?) -> Bool { + guard let settingsManager else { return true } switch settings.effectiveDefaultVisibility { case .hidden: settingsManager.landscapeDetailsPanelVisible = false settingsManager.landscapeDetailsPanelPinned = false + return false case .visible: settingsManager.landscapeDetailsPanelVisible = true + return true case .remember: - settingsManager.landscapeDetailsPanelVisible = settingsManager.detailsPanelLastVisible + let lastVisible = settingsManager.detailsPanelLastVisible + settingsManager.landscapeDetailsPanelVisible = lastVisible + return lastVisible } } diff --git a/Yattee/Views/Player/ExpandedPlayerWindow.swift b/Yattee/Views/Player/ExpandedPlayerWindow.swift index 26163bce..1ecdab40 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindow.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindow.swift @@ -769,7 +769,13 @@ final class ExpandedPlayerWindowManager { let statusBarController = StatusBarVisibilityController() // Create the expanded player view with status bar controller in environment - let expandedView = ExpandedPlayerSheet() + // Seed the portrait panel state before the first frame so the preset's + // default visibility doesn't animate a video position jump on expand. + let initialPanelVisible = ExpandedPlayerSheet.initialPortraitPanelVisibility( + settingsManager: appEnvironment.settingsManager + ) + LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show: initialPortraitPanelVisible=\(initialPanelVisible), defaultVisibility=\(PanelSettings.cached.effectiveDefaultVisibility)") + let expandedView = ExpandedPlayerSheet(initialPortraitPanelVisible: initialPanelVisible) .appEnvironment(appEnvironment) .environment(\.statusBarVisibilityController, statusBarController) diff --git a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift index f0b2b758..fc615b89 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift @@ -850,6 +850,7 @@ extension ExpandedPlayerWindowManager: NSWindowDelegate { /// its player window renders, and removes the perceptible "dead gap" between the /// click and the window appearing. private struct ExpandedPlayerWindowRoot: View { + @Environment(\.appEnvironment) private var appEnvironment @State private var showFullPlayer = false var body: some View { @@ -859,7 +860,11 @@ private struct ExpandedPlayerWindowRoot: View { Color.black if showFullPlayer { - ExpandedPlayerSheet() + ExpandedPlayerSheet( + initialPortraitPanelVisible: ExpandedPlayerSheet.initialPortraitPanelVisibility( + settingsManager: appEnvironment?.settingsManager + ) + ) } else { ProgressView() .controlSize(.large)