From 05a03723f69f04b008999b8d7a6cd48c94d6080c Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 23 Sep 2026 07:25:02 +0200 Subject: [PATCH] Rework corner layout with portrait-style info panel below the video The corner layout now always renders the info page as a portrait-style panel below the video (collapsible description, drag to hide/expand), while the side column holds tabs for the remaining pages. Key pieces: - Below-video panel reuses PortraitDetailsPanel (no comments pill, square corners, chapters/related stay in the side column tabs) - Drag down hides the panel with the video recentering in lockstep; swipe up on the video reveals it following the finger - Fullscreen button toggles the panels in corner layout (controls hide first so their resize never plays visibly); status bar and home indicator hide only while the panels are hidden - Panel visibility and column width are seeded at init from a warmed PlayerControlsLayout cache, so the first frame resolves corner mode without flashing the default layout or animating the panels in - Dismiss pan is blocked inside the below-video panel frame (cornerBelowPanelFrame in NavigationCoordinator) - Resize grabber slimmed to match the panel drag handle look --- Yattee/Core/AppEnvironment.swift | 6 + Yattee/Localizable.xcstrings | 426 +++--------------- .../PlayerControls/PlayerControlsLayout.swift | 6 + .../Navigation/NavigationCoordinator.swift | 4 + Yattee/Views/Player/CornerPlayerLayout.swift | 419 ++++++++++++++--- .../Player/ExpandedPlayerSheet+Layouts.swift | 18 +- Yattee/Views/Player/ExpandedPlayerSheet.swift | 21 +- .../Views/Player/ExpandedPlayerWindow.swift | 15 + .../Views/Player/Panels/InfoPanelPage.swift | 11 +- .../Player/Panels/PanelResizeGrabber.swift | 21 +- .../Panels/PlayerPanelContentView.swift | 9 +- .../Views/Player/PlayerControlsActions.swift | 25 + Yattee/Views/Player/PlayerControlsView.swift | 21 +- .../Views/Player/PortraitDetailsPanel.swift | 19 +- 14 files changed, 586 insertions(+), 435 deletions(-) diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 51799aab..fa764789 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -740,6 +740,12 @@ final class AppEnvironment { func refreshActiveControlsSettings() async { let layout = await playerControlsLayoutService.activeLayout() activeControlsCenterSettings = layout.centerSettings + // Warm the layout caches so the expanded player resolves the correct + // layout (corner mode, panel settings) on its very first frame. + PlayerControlsLayout.cached = layout + GlobalLayoutSettings.cached = layout.globalSettings + MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings + PanelSettings.cached = layout.effectivePanelSettings } /// Updates the HTTP client's User-Agent configuration from current settings. diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 4ea3a39d..911d9376 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -6530,16 +6530,6 @@ } } }, - "migration.accounts.prompt.review" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Review" - } - } - } - }, "migration.accounts.prompt.title" : { "localizations" : { "en" : { @@ -6550,26 +6540,6 @@ } } }, - "migration.accounts.prompt.title" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Import from Previous App" - } - } - } - }, - "migration.accounts.remove.confirm" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove" - } - } - } - }, "migration.accounts.remove.confirm" : { "localizations" : { "en" : { @@ -6590,16 +6560,6 @@ } } }, - "migration.accounts.remove.message %@" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This will remove \"%@\" from the legacy import list. It will not be imported." - } - } - } - }, "migration.accounts.remove.title" : { "localizations" : { "en" : { @@ -6610,26 +6570,6 @@ } } }, - "migration.accounts.remove.title" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove Legacy Account?" - } - } - } - }, - "migration.accounts.section" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Accounts" - } - } - } - }, "migration.accounts.section" : { "localizations" : { "en" : { @@ -6650,16 +6590,6 @@ } } }, - "migration.accounts.title" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Legacy Accounts" - } - } - } - }, "migration.import" : { "localizations" : { "en" : { @@ -6670,26 +6600,6 @@ } } }, - "migration.import" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Import" - } - } - } - }, - "migration.importing" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Importing..." - } - } - } - }, "migration.importing" : { "localizations" : { "en" : { @@ -6710,16 +6620,6 @@ } } }, - "migration.sources.footer" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add these sources from the previous Yattee app. No sign-in is required." - } - } - } - }, "migration.sources.imported.title" : { "localizations" : { "en" : { @@ -6730,26 +6630,6 @@ } } }, - "migration.sources.imported.title" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Source Added" - } - } - } - }, - "migration.sources.remove.title" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Remove Legacy Source?" - } - } - } - }, "migration.sources.remove.title" : { "localizations" : { "en" : { @@ -6770,16 +6650,6 @@ } } }, - "migration.sources.section" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Sources" - } - } - } - }, "miniPlayer.addButton.header" : { "localizations" : { "en" : { @@ -6790,26 +6660,6 @@ } } }, - "miniPlayer.addButton.header" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Add Button" - } - } - } - }, - "miniPlayer.buttons.empty" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "No buttons configured" - } - } - } - }, "miniPlayer.buttons.empty" : { "localizations" : { "en" : { @@ -6830,26 +6680,6 @@ } } }, - "miniPlayer.buttons.footer" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Customize which buttons appear in the mini player." - } - } - } - }, - "miniPlayer.buttons.header" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Buttons" - } - } - } - }, "miniPlayer.buttons.header" : { "localizations" : { "en" : { @@ -6862,19 +6692,6 @@ }, "my-server.example.com" : { - }, - "my-server.example.com" : { - - }, - "navigation.sourceNotFound" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Source Not Found" - } - } - } }, "navigation.sourceNotFound" : { "localizations" : { @@ -6896,27 +6713,6 @@ } } }, - "navigation.sourceNotFound.description" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "This media source may have been removed." - } - } - } - }, - "notification.action.watch" : { - "comment" : "Action button on notification to watch video", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Watch" - } - } - } - }, "notification.action.watch" : { "comment" : "Action button on notification to watch video", "localizations" : { @@ -6939,17 +6735,6 @@ } } }, - "notification.multipleChannels %@ %@" : { - "comment" : "Multiple channels notification", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "New videos from %1$@ and %2$@" - } - } - } - }, "notification.multipleVideosOneChannel %lld %@" : { "comment" : "Multiple videos from one channel", "localizations" : { @@ -6961,28 +6746,6 @@ } } }, - "notification.multipleVideosOneChannel %lld %@" : { - "comment" : "Multiple videos from one channel", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$lld new videos from %2$@" - } - } - } - }, - "notification.newVideos.title" : { - "comment" : "Notification title for new videos", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "New Videos" - } - } - } - }, "notification.newVideos.title" : { "comment" : "Notification title for new videos", "localizations" : { @@ -7005,28 +6768,6 @@ } } }, - "notification.singleVideo %@" : { - "comment" : "Notification body for single video", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "New video: %@" - } - } - } - }, - "notification.twoChannels %@ %@" : { - "comment" : "Two channels notification", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "New videos from %1$@ and %2$@" - } - } - } - }, "notification.twoChannels %@ %@" : { "comment" : "Two channels notification", "localizations" : { @@ -7049,17 +6790,6 @@ } } }, - "notification.videoItem %@ %@" : { - "comment" : "Video item: Author: Title", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "%1$@: %2$@" - } - } - } - }, "onboarding.cloud.syncing.preparing" : { "comment" : "Preparing sync text on iCloud onboarding", "localizations" : { @@ -7071,28 +6801,6 @@ } } }, - "onboarding.cloud.syncing.preparing" : { - "comment" : "Preparing sync text on iCloud onboarding", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Preparing sync..." - } - } - } - }, - "onboarding.cloud.syncing.title" : { - "comment" : "iCloud syncing title on onboarding", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Syncing Your Data" - } - } - } - }, "onboarding.cloud.syncing.title" : { "comment" : "iCloud syncing title on onboarding", "localizations" : { @@ -7566,6 +7274,17 @@ } } }, + "panel.settings.comments.showAvatars" : { + "comment" : "Comments page element toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Author Avatars" + } + } + } + }, "panel.settings.defaultVisibility" : { "comment" : "Details panel settings picker label", "localizations" : { @@ -7599,17 +7318,6 @@ } } }, - "panel.settings.comments.showAvatars" : { - "comment" : "Comments page element toggle", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Author Avatars" - } - } - } - }, "panel.settings.elements.header" : { "comment" : "Details panel page editor section header", "localizations" : { @@ -7698,28 +7406,6 @@ } } }, - "panel.settings.pageBarLabels" : { - "comment" : "Details panel page bar labels toggle", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Page Bar Labels" - } - } - } - }, - "panel.settings.pageBarLabels.footer" : { - "comment" : "Details panel page bar labels footer", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Show a name next to each page icon. With labels off, only the selected page shows its name." - } - } - } - }, "panel.settings.page.enabled" : { "comment" : "Details panel page editor toggle", "localizations" : { @@ -7764,6 +7450,50 @@ } } }, + "panel.settings.pageBarLabels" : { + "comment" : "Details panel page bar labels toggle", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Page Bar Labels" + } + } + } + }, + "panel.settings.pageBarLabels.footer" : { + "comment" : "Details panel page bar labels footer", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show a name next to each page icon. With labels off, only the selected page shows its name." + } + } + } + }, + "panel.settings.pages.footer" : { + "comment" : "Details panel settings section footer", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pages appear in this order. The page bar shows when at least two pages are enabled; pages without content hide automatically." + } + } + } + }, + "panel.settings.pages.header" : { + "comment" : "Details panel settings section header", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pages" + } + } + } + }, "panel.settings.queue.showHistory" : { "comment" : "Queue page element toggle", "localizations" : { @@ -7797,28 +7527,6 @@ } } }, - "panel.settings.pages.header" : { - "comment" : "Details panel settings section header", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pages" - } - } - } - }, - "panel.settings.pages.footer" : { - "comment" : "Details panel settings section footer", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Pages appear in this order. The page bar shows when at least two pages are enabled; pages without content hide automatically." - } - } - } - }, "peertube.explore.added" : { "localizations" : { "en" : { @@ -8651,6 +8359,7 @@ } }, "player.tab.comments" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -8661,6 +8370,7 @@ } }, "player.tab.info" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -17345,17 +17055,6 @@ } } }, - "sources.field.invidiousInstance" : { - "comment" : "Field label for Invidious instance in Yattee Server info", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Invidious" - } - } - } - }, "sources.field.frontendURL" : { "comment" : "Field label for optional Piped web frontend URL", "localizations" : { @@ -17367,6 +17066,17 @@ } } }, + "sources.field.invidiousInstance" : { + "comment" : "Field label for Invidious instance in Yattee Server info", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Invidious" + } + } + } + }, "sources.field.name" : { "comment" : "Field label for source name", "localizations" : { diff --git a/Yattee/Models/PlayerControls/PlayerControlsLayout.swift b/Yattee/Models/PlayerControls/PlayerControlsLayout.swift index cf975e21..10499174 100644 --- a/Yattee/Models/PlayerControls/PlayerControlsLayout.swift +++ b/Yattee/Models/PlayerControls/PlayerControlsLayout.swift @@ -106,6 +106,12 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable { // MARK: - Defaults + /// Last loaded active layout — lets the expanded player seed its layout + /// state synchronously before the async load, so the first frames resolve + /// the correct layout (e.g. corner mode) instead of flashing the default. + /// Warmed at app start and kept fresh by the expanded player's loads. + nonisolated(unsafe) static var cached: PlayerControlsLayout = .default + /// Default layout matching the current hardcoded player controls. static let `default`: PlayerControlsLayout = { // Top section: spacer, brightness (widescreen), volume, airplay, debug, close diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index 9245b429..6a5c05f4 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -158,6 +158,10 @@ final class NavigationCoordinator { /// Portrait panel frame in screen coordinates (for gesture conflict resolution). var portraitPanelFrame: CGRect = .zero + /// Corner layout below-video panel frame in screen coordinates — touches + /// inside it must not start the sheet dismiss pan (drag handle conflict). + var cornerBelowPanelFrame: CGRect = .zero + /// Progress bar frame in screen coordinates (for gesture conflict resolution). var progressBarFrame: CGRect = .zero diff --git a/Yattee/Views/Player/CornerPlayerLayout.swift b/Yattee/Views/Player/CornerPlayerLayout.swift index 1479f7a9..3baee828 100644 --- a/Yattee/Views/Player/CornerPlayerLayout.swift +++ b/Yattee/Views/Player/CornerPlayerLayout.swift @@ -2,11 +2,11 @@ // CornerPlayerLayout.swift // Yattee // -// Corner player layout for iPad/macOS: video in the top-left, a resizable -// side column on the right, and an optional region below the video — -// YouTube-style. Pages are assigned to regions in the preset's panel -// settings; the layout resolver in ExpandedPlayerSheet falls back to the -// stacked layout when the window is too narrow. +// Corner player layout for iPad/macOS: video in the top-left, the portrait- +// style info panel below it, and a resizable side column on the right with +// tabs for the remaining pages (related/chapters/queue/comments) — +// YouTube-style. The layout resolver in ExpandedPlayerSheet falls back to +// the stacked layout when the window is too narrow. // import SwiftUI @@ -27,6 +27,34 @@ struct CornerPlayerLayout: View { ) -> PlayerContent let onChannelTap: (() -> Void)? + var onFullscreen: (() -> Void)? = nil + + /// Seed the panel visibility BEFORE the first frame — applying the + /// preset's default visibility via onAppear/onChange animates the panels + /// popping in over an initially full-screen video on player expand + /// (same trap the portrait panel hit, fixed by init-time seeding). + init( + playerControlsLayout: PlayerControlsLayout, + initialPanelVisible: Bool = true, + initialColumnWidth: CGFloat? = nil, + @ViewBuilder playerContent: @escaping ( + _ onTogglePanel: @escaping () -> Void, + _ isPanelVisible: Bool, + _ width: CGFloat, + _ height: CGFloat + ) -> PlayerContent, + onChannelTap: (() -> Void)?, + onFullscreen: (() -> Void)? = nil + ) { + self.playerControlsLayout = playerControlsLayout + self.playerContent = playerContent + self.onChannelTap = onChannelTap + self.onFullscreen = onFullscreen + _isPanelVisible = State(initialValue: initialPanelVisible) + if let initialColumnWidth, initialColumnWidth > 0 { + _columnWidth = State(initialValue: initialColumnWidth) + } + } // MARK: - Layout Constants @@ -44,10 +72,21 @@ struct CornerPlayerLayout: View { @State private var isPanelVisible = true @State private var columnWidth: CGFloat = SettingsManager.defaultCornerPanelColumnWidth @State private var primaryPage: PanelPageType = .related - @State private var belowVideoPage: PanelPageType = .info @State private var isGrabberActive = false + @State private var isBelowPanelVisible = true + @State private var belowPanelDragOffset: CGFloat = 0 + @State private var isBelowPanelExpanded = false + @State private var belowPanelExpandOffset: CGFloat = 0 + /// Swipe-up-on-video reveal in progress: the panel renders in the shown + /// layout, positioned by `belowPanelDragOffset` counting down from full + /// hide travel so it follows the finger. + @State private var isRevealDragActive = false private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } + /// Suppresses the leaf-level .animation modifiers while a panel drag + /// tracks the finger (the host sheet strips withAnimation transactions, + /// so these modifiers are what actually animate releases — like portrait). + private var isPanelDragActive: Bool { appEnvironment?.navigationCoordinator.isPanelDragging ?? false } private var playerState: PlayerState? { appEnvironment?.playerService.state } private var video: Video? { playerState?.currentVideo } private var accentColor: Color { settingsManager?.resolvedAccentColor ?? .accentColor } @@ -56,15 +95,17 @@ struct CornerPlayerLayout: View { playerControlsLayout.effectivePanelSettings } - /// Region emptiness is decided at the config level: runtime-empty pages + /// Column emptiness is decided at the config level: runtime-empty pages /// (no chapters, no related yet) are hidden inside the panel content, not - /// by collapsing regions — layout jumps during loading would be jarring. - private var hasPrimaryRegion: Bool { - !panelSettings.enabledPages(in: .primary).isEmpty + /// by collapsing the column — layout jumps during loading would be jarring. + /// Info always renders below the video; the side column holds the rest. + private var hasSideColumn: Bool { + panelSettings.enabledPages().contains { $0.pageType != .info } } - private var hasBelowVideoRegion: Bool { - !panelSettings.enabledPages(in: .belowVideo).isEmpty + /// The info page config drives the below-video panel's element settings. + private var infoPageSettings: InfoPageSettings { + panelSettings.pages.first { $0.pageType == .info }?.infoSettings ?? InfoPageSettings() } private var panelBackgroundColor: Color { @@ -90,47 +131,182 @@ struct CornerPlayerLayout: View { let width = max(1, geometry.size.width) let height = max(1, geometry.size.height) - let showsPrimary = isPanelVisible && hasPrimaryRegion - let showsBelowVideo = isPanelVisible && hasBelowVideoRegion + let showsSideColumn = isPanelVisible && hasSideColumn + let showsBelowVideo = isPanelVisible && isBelowPanelVisible let effectiveColumnWidth = clampColumnWidth(columnWidth, availableWidth: width) - // Grabber sits on the column's leading edge, inside the column background. - let totalColumnWidth = effectiveColumnWidth + 20 + // Grabber sits on the column's leading edge, inside the column + // background — narrow strip matching the panel drag handle look. + let grabberWidth: CGFloat = 12 + let totalColumnWidth = effectiveColumnWidth + grabberWidth - let videoWidth = showsPrimary ? max(1, width - totalColumnWidth) : width - let videoHeight: CGFloat = { - guard showsBelowVideo else { return height } + let videoWidth = showsSideColumn ? max(1, width - totalColumnWidth) : width + let shownVideoHeight: CGFloat = { let rawAspectRatio = playerState?.displayAspectRatio ?? (16.0 / 9.0) let aspectRatio = rawAspectRatio > 0 ? rawAspectRatio : (16.0 / 9.0) return max(1, min(videoWidth / aspectRatio, height * Self.maxVideoHeightFraction)) }() + // Reveal drag renders the shown layout so the panel can follow + // the finger while `isBelowPanelVisible` is still false. + let showsPanelLayout = showsBelowVideo || (isPanelVisible && isRevealDragActive) + + // Panel travel needed to fully clear the screen (incl. bottom + // safe area + the player window's extra PiP inset), and the video + // recenter offset that follows the drag — like the portrait + // layout's videoYOffset. At full travel the offset video matches + // the centered full-height layout exactly, so the state flips + // after the hide/reveal animations cause no visible jump. + let bottomInset = geometry.safeAreaInsets.bottom + let fullColumnHeight = height + bottomInset + // With the below panel hidden the video (and its bottom controls) + // extends to the physical bottom edge, not the safe-area line. + let videoHeight = showsPanelLayout ? shownVideoHeight : fullColumnHeight + let normalPanelHeight = max(1, fullColumnHeight - shownVideoHeight) + let panelHideTravel = normalPanelHeight + + // One shared duration for the whole panel toggle — both panels + // and both video dimensions animate in lockstep so the video + // corner grows diagonally and everything finishes together. + // Scaled to the larger travel so the speed stays reasonable. + let panelExitSpeed: CGFloat = 1400 + let panelToggleDuration = Double(min(max(max(totalColumnWidth, normalPanelHeight) / panelExitSpeed, 0.25), 0.45)) + let videoDragProgress = showsPanelLayout + ? min(1.0, max(0, belowPanelDragOffset) / panelHideTravel) + : 0 + let videoDragYOffset = (fullColumnHeight - shownVideoHeight) / 2 * videoDragProgress + + // Portrait-style expand: dragging up grows the panel over the + // video (expandOffset is negative while dragging up; when + // expanded, positive while dragging down to collapse). + let panelHeight: CGFloat = { + if isBelowPanelExpanded { + return max(normalPanelHeight, fullColumnHeight - belowPanelExpandOffset) + } + return min(fullColumnHeight, normalPanelHeight - belowPanelExpandOffset) + }() + let panelSpacerHeight: CGFloat = { + if isBelowPanelExpanded { + return min(videoHeight, belowPanelExpandOffset) + } + return max(0, videoHeight + belowPanelExpandOffset) + }() ZStack { Color.black.ignoresSafeArea(.all) HStack(spacing: 0) { - VStack(spacing: 0) { - playerContent( - togglePanel, - isPanelVisible, - videoWidth, - videoHeight - ) - .frame(width: videoWidth, height: videoHeight) - .clipped() - - if showsBelowVideo, let video { - regionPanel( - region: .belowVideo, - selectedPage: $belowVideoPage, - video: video + ZStack(alignment: .top) { + // Sized from the live (animated) geometry, not the + // target values — during panel toggles the video then + // resizes smoothly through every animation frame + // instead of snapping to the final size. + GeometryReader { videoProxy in + playerContent( + togglePanel, + isPanelVisible, + max(1, videoProxy.size.width), + max(1, videoProxy.size.height) ) - .frame(width: videoWidth) - .frame(maxHeight: .infinity) + } + // Height animates with the below-panel timing, width + // with the side-column timing — the inner .animation + // scopes only the height frame. Keyed to gesture/ + // visibility state, NOT to derived geometry — appear + // settling must not animate. + .frame(height: videoHeight) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isBelowPanelVisible) + .frame(width: videoWidth) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible) + .clipped() + .offset(y: videoDragYOffset) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: videoDragYOffset) + // Aspect ratio changes (new video, stream switch) + // resize the video/panel split — animate them + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: playerState?.displayAspectRatio) + // Swipe up on the video to bring the panel back — + // the panel follows the finger (like the portrait + // reveal) via belowPanelDragOffset counting down + // from full hide travel. + .simultaneousGesture( + (isPanelVisible && !isBelowPanelVisible) + ? DragGesture() + .onChanged { value in + guard appEnvironment?.navigationCoordinator.isAdjustingPlayerSliders != true else { return } + guard value.translation.height < 0 || isRevealDragActive else { return } + if appEnvironment?.navigationCoordinator.isPanelDragging != true { + appEnvironment?.navigationCoordinator.isPanelDragging = true + } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isRevealDragActive = true + belowPanelDragOffset = min(panelHideTravel, max(0, panelHideTravel + value.translation.height)) + } + } + .onEnded { value in + guard isRevealDragActive else { return } + appEnvironment?.navigationCoordinator.isPanelDragging = false + if value.translation.height < -80 || value.predictedEndTranslation.height < -160 { + // Revealing: flip to visible at the + // current position, then animate home + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isBelowPanelVisible = true + isRevealDragActive = false + } + withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { + belowPanelDragOffset = 0 + } + } else { + // Snap back off-screen, then drop the + // reveal layout without animation + withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .logicallyComplete) { + belowPanelDragOffset = panelHideTravel + } completion: { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isRevealDragActive = false + belowPanelDragOffset = 0 + } + } + } + } + : nil + ) + .frame(maxHeight: .infinity, alignment: .top) + + if showsPanelLayout, let video { + VStack(spacing: 0) { + Spacer(minLength: 0) + .frame(height: panelSpacerHeight) + + belowVideoInfoPanel(video: video, hideTravel: panelHideTravel) + .frame(width: videoWidth) + .frame(height: panelHeight) + .offset(y: belowPanelDragOffset) + } + .frame(maxHeight: .infinity, alignment: .top) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: belowPanelDragOffset) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: isBelowPanelExpanded) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 0.8), value: belowPanelExpandOffset) + .animation(isPanelDragActive ? nil : .spring(response: 0.3, dampingFraction: 1.0), value: playerState?.displayAspectRatio) + // Pure slide, no fade — with a fade the panel + // seems to vanish before the side column and the + // video resize, breaking the one-transition feel + .transition(.move(edge: .bottom).animation(.easeInOut(duration: panelToggleDuration))) } } + .frame(width: videoWidth) + .frame(maxHeight: .infinity, alignment: .top) + // Extend into the bottom safe area (incl. the player + // window's extra PiP inset) so the panel and its pills + // reach the true bottom edge + .ignoresSafeArea(edges: .bottom) - if showsPrimary, let video { + if showsSideColumn, let video { HStack(spacing: 0) { PanelResizeGrabber( panelWidth: $columnWidth, @@ -138,19 +314,24 @@ struct CornerPlayerLayout: View { maxWidth: clampColumnWidth(.infinity, availableWidth: width), panelSide: .right, isPinned: true, - isDragActive: $isGrabberActive + isDragActive: $isGrabberActive, + hitWidth: grabberWidth ) - regionPanel( - region: .primary, - selectedPage: $primaryPage, - video: video - ) - .frame(width: effectiveColumnWidth) + sideColumnPanel(video: video) + .frame(width: effectiveColumnWidth) } - .background(panelBackgroundColor.ignoresSafeArea(edges: .vertical)) + // Bottom only — the background must not run under + // the system status bar at the top + .background(panelBackgroundColor.ignoresSafeArea(edges: .bottom)) + .transition(.move(edge: .trailing).animation(.easeInOut(duration: panelToggleDuration))) } } + // Container-level animation so panel insert/remove and the + // video-area resize play as one transition (the host sheet + // strips withAnimation transactions, so key it here) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isPanelVisible) + .animation(isPanelDragActive ? nil : .easeInOut(duration: panelToggleDuration), value: isBelowPanelVisible) } .onChange(of: width) { _, newWidth in // Re-clamp when the window shrinks (still above the corner @@ -159,9 +340,12 @@ struct CornerPlayerLayout: View { } } .onAppear { - isPanelVisible = settingsManager?.landscapeDetailsPanelVisible ?? true - if let savedWidth = settingsManager?.cornerPanelColumnWidth, savedWidth > 0 { - columnWidth = savedWidth + // Only sync from the setting once the session's default + // visibility was applied — before that the setting is stale + // (the init seed already resolved the correct initial value). + if appEnvironment?.navigationCoordinator.hasAppliedPanelDefaultVisibility == true, + let visible = settingsManager?.landscapeDetailsPanelVisible { + isPanelVisible = visible } } .onChange(of: columnWidth) { _, newWidth in @@ -177,18 +361,136 @@ struct CornerPlayerLayout: View { } } - // MARK: - Region Panel + // MARK: - Panels + /// Portrait-style panel below the video: the actual PortraitDetailsPanel + /// (drag handle, player pill, collapsible description) with the comments + /// pill hidden and no inline chapters/related — those live in the side + /// column tabs. @ViewBuilder - private func regionPanel( - region: PanelRegion, - selectedPage: Binding, - video: Video - ) -> some View { + private func belowVideoInfoPanel(video: Video, hideTravel: CGFloat) -> some View { + Group { + #if os(iOS) + PortraitDetailsPanel( + onChannelTap: onChannelTap, + playerControlsLayout: playerControlsLayout, + onFullscreen: onFullscreen, + pillsOverlayOpacity: 1, + hidesCommentsPill: true, + inlineInfoSections: false, + panelCornerRadius: 0, + onDragChanged: { offset in + if appEnvironment?.navigationCoordinator.isPanelDragging != true { + appEnvironment?.navigationCoordinator.isPanelDragging = true + } + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + if isBelowPanelExpanded { + // When expanded, only track drag down to collapse + belowPanelExpandOffset = max(0, offset) + } else if offset < 0 { + // Dragging UP - expand panel over the video + belowPanelExpandOffset = offset + belowPanelDragOffset = 0 + } else { + // Dragging DOWN - dismiss tracking + belowPanelDragOffset = offset + belowPanelExpandOffset = 0 + } + } + }, + onDragEnded: { offset, predicted in + appEnvironment?.navigationCoordinator.isPanelDragging = false + if isBelowPanelExpanded { + if offset > 80 || predicted > 160 { + withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { + isBelowPanelExpanded = false + belowPanelExpandOffset = 0 + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + belowPanelExpandOffset = 0 + } + } + } else if offset < -80 || predicted < -160 { + // Expand panel to cover the video + withAnimation(.spring(response: 0.3, dampingFraction: 1.0)) { + isBelowPanelExpanded = true + belowPanelExpandOffset = 0 + belowPanelDragOffset = 0 + } + } else if offset > 80 || predicted > 160 { + // Slide the panel fully off (video recenters via the + // drag-progress offset), then flip the hidden state + // without animation — the offset layout at full + // travel matches the hidden layout exactly. + withAnimation(.spring(response: 0.3, dampingFraction: 1.0), completionCriteria: .logicallyComplete) { + belowPanelDragOffset = hideTravel + } completion: { + var transaction = Transaction() + transaction.disablesAnimations = true + withTransaction(transaction) { + isBelowPanelVisible = false + isBelowPanelExpanded = false + belowPanelDragOffset = 0 + } + } + } else { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + belowPanelDragOffset = 0 + belowPanelExpandOffset = 0 + } + } + }, + onDragCancelled: { + appEnvironment?.navigationCoordinator.isPanelDragging = false + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + belowPanelDragOffset = 0 + belowPanelExpandOffset = 0 + } + } + ) + #else + // macOS: static info panel, no drag-to-hide + InfoPanelPage( + context: PanelPageContext( + appEnvironment: appEnvironment, + video: video, + accentColor: accentColor, + onChannelTap: onChannelTap + ), + settings: infoPageSettings, + scrollConfig: PanelScrollConfig( + topInset: 4, + bottomInset: 16 + ) + ) + .background(panelBackgroundColor.ignoresSafeArea(edges: .vertical)) + #endif + } + // Publish the panel frame so the window-level dismiss pan gesture + // doesn't begin from touches inside this panel (drag handle conflict) + .background { + GeometryReader { proxy in + Color.clear + .onChange(of: proxy.frame(in: .global), initial: true) { _, frame in + appEnvironment?.navigationCoordinator.cornerBelowPanelFrame = frame + } + .onDisappear { + appEnvironment?.navigationCoordinator.cornerBelowPanelFrame = .zero + appEnvironment?.navigationCoordinator.isPanelDragging = false + } + } + } + } + + /// Side column with tabs for every enabled page except info. + @ViewBuilder + private func sideColumnPanel(video: Video) -> some View { PlayerPanelContentView( panelSettings: panelSettings, - region: region, - selectedPage: selectedPage, + selectedPage: $primaryPage, context: PanelPageContext( appEnvironment: appEnvironment, video: video, @@ -199,9 +501,10 @@ struct CornerPlayerLayout: View { topInset: 4, bottomInset: 16 ), - pageBarTopPadding: 8 + pageBarTopPadding: 8, + excludedPages: [.info] ) - .background(panelBackgroundColor.ignoresSafeArea(edges: .vertical)) + .background(panelBackgroundColor.ignoresSafeArea(edges: .bottom)) } // MARK: - Actions diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 38c53687..04faffbe 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -1317,8 +1317,18 @@ extension ExpandedPlayerSheet { /// identical to the stacked layout — only the container size differs. @ViewBuilder func cornerContent(video: Video) -> some View { + // Seed the panel visibility before the first frame: once the + // session's default visibility is applied the setting is the truth; + // until then resolve the preset default (same policy the sheet's + // applyPanelDefaultVisibility writes moments later). + let initialPanelVisible = appEnvironment?.navigationCoordinator.hasAppliedPanelDefaultVisibility == true + ? (appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true) + : Self.initialPortraitPanelVisibility(settingsManager: appEnvironment?.settingsManager) + CornerPlayerLayout( playerControlsLayout: playerControlsLayout, + initialPanelVisible: initialPanelVisible, + initialColumnWidth: appEnvironment?.settingsManager.cornerPanelColumnWidth, playerContent: { onTogglePanel, isPanelVisible, width, height in // isPanelPinned=true keeps the controls visible alongside the // panel regions (corner regions behave like a pinned panel). @@ -1336,7 +1346,12 @@ extension ExpandedPlayerSheet { }, onChannelTap: video.author.hasRealChannelInfo ? { navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true) - } : nil + } : nil, + onFullscreen: { + #if os(iOS) + self.toggleFullscreen() + #endif + } ) } @@ -1558,6 +1573,7 @@ extension ExpandedPlayerSheet { isPanelVisible: isPanelVisible, panelSide: panelSide, isPanelPinned: isPanelPinned, + isCornerLayout: appEnvironment?.navigationCoordinator.isCornerLayoutActive == true, layoutLeadingSafeArea: 0.01, // Non-zero indicates safe areas handled externally layoutTrailingSafeArea: 0.01, onToggleOrientationLock: { [weak appEnvironment] in diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index eefe27bb..8243b460 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -74,7 +74,10 @@ struct ExpandedPlayerSheet: View { /// Track if we're in widescreen layout for status bar hiding @State private var isInWideScreenLayout = false /// Active player controls layout from preset (loaded once, updated on notification) - @State var playerControlsLayout: PlayerControlsLayout = .default + // Seeded from the warm cache so the first frames resolve the correct + // layout (corner mode) instead of flashing the default stacked layout; + // loadPlayerControlsLayout replaces it with the authoritative value. + @State var playerControlsLayout: PlayerControlsLayout = PlayerControlsLayout.cached // MARK: - Autoplay Countdown State @@ -470,8 +473,19 @@ struct ExpandedPlayerSheet: View { ) #if os(iOS) .toolbar(.hidden, for: .navigationBar) - .playerStatusBarHidden(isInWideScreenLayout || !isPortraitPanelVisible) - .persistentSystemOverlays((isInWideScreenLayout || !isPortraitPanelVisible) ? .hidden : .automatic) + // Corner layout with panels visible keeps the system status bar and + // home indicator; hiding the panels (fullscreen state) hides them so + // the video and controls reach the screen edges. + .playerStatusBarHidden( + navigationCoordinator?.isCornerLayoutActive == true + ? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true) + : (isInWideScreenLayout || !isPortraitPanelVisible) + ) + .persistentSystemOverlays( + (navigationCoordinator?.isCornerLayoutActive == true + ? !(appEnvironment?.settingsManager.landscapeDetailsPanelVisible ?? true) + : (isInWideScreenLayout || !isPortraitPanelVisible)) ? .hidden : .automatic + ) #endif } @@ -944,6 +958,7 @@ private struct PlayerEventHandlersModifier: ViewModifier { if let service = appEnvironment?.playerControlsLayoutService { let layout = await service.activeLayout() playerControlsLayout = layout + PlayerControlsLayout.cached = layout GlobalLayoutSettings.cached = layout.globalSettings MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings PanelSettings.cached = layout.effectivePanelSettings diff --git a/Yattee/Views/Player/ExpandedPlayerWindow.swift b/Yattee/Views/Player/ExpandedPlayerWindow.swift index 1ecdab40..14d98fe6 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindow.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindow.swift @@ -144,6 +144,8 @@ private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDe var isPanelDragging: (() -> Bool)? /// Returns the portrait panel frame in screen coordinates (for gesture conflict resolution) var getPortraitPanelFrame: (() -> CGRect)? + /// Returns the corner layout below-video panel frame in screen coordinates (blocks dismiss pan) + var getCornerBelowPanelFrame: (() -> CGRect)? /// Returns whether the portrait panel is currently visible (not hidden off-screen) var isPortraitPanelVisible: (() -> Bool)? /// Returns the progress bar frame in screen coordinates (for gesture conflict resolution) @@ -366,6 +368,16 @@ private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDe return false } + // Corner layout: don't begin dismiss gesture from inside the + // below-video panel — its drag handle hides the panel instead + let belowPanelFrame = getCornerBelowPanelFrame?() ?? .zero + if !belowPanelFrame.isEmpty { + let touchLocation = panGesture.location(in: nil) + if belowPanelFrame.contains(touchLocation) { + return false + } + } + // When portrait panel is visible, only allow dismiss from player area (above panel) // This prevents race conditions where isPanelDragging isn't set yet when gesture begins let panelFrame = getPortraitPanelFrame?() ?? .zero @@ -845,6 +857,9 @@ final class ExpandedPlayerWindowManager { handler.getPortraitPanelFrame = { [weak self] in self?.appEnvironment?.navigationCoordinator.portraitPanelFrame ?? .zero } + handler.getCornerBelowPanelFrame = { [weak self] in + self?.appEnvironment?.navigationCoordinator.cornerBelowPanelFrame ?? .zero + } handler.isPortraitPanelVisible = { [weak self] in self?.appEnvironment?.navigationCoordinator.isPortraitPanelVisible ?? true } diff --git a/Yattee/Views/Player/Panels/InfoPanelPage.swift b/Yattee/Views/Player/Panels/InfoPanelPage.swift index aad5753b..1a92c421 100644 --- a/Yattee/Views/Player/Panels/InfoPanelPage.swift +++ b/Yattee/Views/Player/Panels/InfoPanelPage.swift @@ -19,6 +19,15 @@ struct InfoPanelPage: View { /// and related videos as inline sections below it. var inlineSections: Bool = false + /// Collapse only while something actually renders below the description — + /// with no chapters and no related videos the description stays expanded. + /// While details are still loading, keep it collapsed to avoid an + /// expand-then-clamp flash when sections arrive. + private var usesCollapsedDescription: Bool { + inlineSections + && (!chapters.isEmpty || !relatedVideos.isEmpty || videoDetailsState == .loading) + } + @State private var showFormattedDate = false @State private var showOriginalTitle = false @State private var isDescriptionExpanded = false @@ -124,7 +133,7 @@ struct InfoPanelPage: View { return VStack(alignment: .leading, spacing: 8) { if !description.isEmpty { - if inlineSections { + if usesCollapsedDescription { collapsibleDescription(description) } else { Text(DescriptionText.attributed(description, linkColor: context.accentColor)) diff --git a/Yattee/Views/Player/Panels/PanelResizeGrabber.swift b/Yattee/Views/Player/Panels/PanelResizeGrabber.swift index 897c3793..92bfcc0d 100644 --- a/Yattee/Views/Player/Panels/PanelResizeGrabber.swift +++ b/Yattee/Views/Player/Panels/PanelResizeGrabber.swift @@ -18,6 +18,9 @@ struct PanelResizeGrabber: View { let isPinned: Bool @Binding var isDragActive: Bool + /// Width of the grabber hit area (hosts can narrow it, e.g. corner layout) + var hitWidth: CGFloat = PanelResizeGrabber.grabberHitWidth + @State private var isDragging = false @State private var dragStartWidth: CGFloat? = nil @State private var dragStartLocation: CGFloat? = nil @@ -26,29 +29,29 @@ struct PanelResizeGrabber: View { #endif /// Width of the grabber hit area - private static let grabberHitWidth: CGFloat = 20 + static let grabberHitWidth: CGFloat = 20 /// Grabber fill color - needs to be visible against both light (pinned) and dark (floating) backgrounds private var grabberColor: Color { - // When pinned (light background), use a darker gray for visibility - // When floating (dark background), use lighter color - let baseColor = isPinned ? Color.gray : Color.secondary + // Pinned matches the panel drag handle (secondary 0.4); + // floating (dark background) stays a bit lighter let isActive = isDragging - return baseColor.opacity(isActive ? 1.0 : (isPinned ? 0.5 : 0.6)) + return Color.secondary.opacity(isActive ? 1.0 : (isPinned ? 0.4 : 0.6)) } var body: some View { // Full-height container for expanded hit area Rectangle() .fill(Color.clear) - .frame(width: Self.grabberHitWidth) + .frame(width: hitWidth) .frame(maxHeight: .infinity) .contentShape(Rectangle()) .overlay { - // Visual pill indicator (centered) - RoundedRectangle(cornerRadius: 3) + // Visual pill indicator (centered) — sized like the panel + // drag handle capsule (36x4), rotated vertical + Capsule() .fill(grabberColor) - .frame(width: 6, height: 40) + .frame(width: 4, height: 36) } .gesture(dragGesture) #if os(macOS) diff --git a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift index 35402d53..7272e373 100644 --- a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift +++ b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift @@ -32,6 +32,10 @@ struct PlayerPanelContentView: View { /// those pages are then dropped from the page list. var inlineInfoSections: Bool = false + /// Pages the host renders elsewhere (e.g. corner layout keeps info below + /// the video), removed from this view's page list. + var excludedPages: Set = [] + private var appEnvironment: AppEnvironment? { context.appEnvironment } // MARK: - Page Visibility @@ -73,12 +77,15 @@ struct PlayerPanelContentView: View { } private var visiblePages: [PanelPageConfiguration] { - let pages = Self.visiblePages( + var pages = Self.visiblePages( panelSettings: panelSettings, region: region, video: context.video, appEnvironment: appEnvironment ) + if !excludedPages.isEmpty { + pages = pages.filter { !excludedPages.contains($0.pageType) } + } guard inlineInfoSections else { return pages } return pages.filter { $0.pageType != .chapters && $0.pageType != .related } } diff --git a/Yattee/Views/Player/PlayerControlsActions.swift b/Yattee/Views/Player/PlayerControlsActions.swift index 781793b5..a699ac51 100644 --- a/Yattee/Views/Player/PlayerControlsActions.swift +++ b/Yattee/Views/Player/PlayerControlsActions.swift @@ -39,6 +39,10 @@ struct PlayerControlsActions { #if os(iOS) /// Whether the side panel is pinned (iPad landscape) let isPanelPinned: Bool + + /// Whether the corner layout is active: the fullscreen button then + /// toggles the panels (hide = fullscreen video, unhide on next press) + var isCornerLayout: Bool = false #endif /// Which side the panel is on @@ -119,6 +123,10 @@ struct PlayerControlsActions { /// Toggle details visibility (portrait fullscreen) var onToggleDetailsVisibility: (() -> Void)? + /// Hide the controls overlay (corner layout hides them before the + /// panel toggle so the controls-resize never plays visibly) + var onHideControls: (() -> Void)? + #if os(iOS) /// Toggle orientation lock var onToggleOrientationLock: (() -> Void)? @@ -263,6 +271,10 @@ struct PlayerControlsActions { /// Uses rotation icons when tapping will cause device rotation, /// otherwise uses standard fullscreen arrows. var fullscreenIcon: String { + if isCornerLayout { + // Corner layout: reflects the panels state, not orientation + return isPanelVisible ? "arrow.up.left.and.arrow.down.right" : "arrow.down.right.and.arrow.up.left" + } if willRotateOnFullscreenToggle { if isFullscreen { // Currently landscape fullscreen, will rotate to portrait @@ -280,6 +292,14 @@ struct PlayerControlsActions { /// Shared fullscreen-tap decision used by the fullscreen button and tap gestures. /// Keep in sync with `willRotateOnFullscreenToggle` (icon mirror of this logic). func performFullscreenTap() { + if isCornerLayout { + // Corner layout: hide the controls and toggle the panels — + // the controls fade out while the panel transition plays + onHideControls?() + onTogglePanel?() + return + } + let isActualWidescreenLayout = isWideScreenLayout && onTogglePanel != nil if isIPad { @@ -334,6 +354,11 @@ struct PlayerControlsActions { /// Whether the fullscreen button should be shown var shouldShowFullscreenButton: Bool { + // Corner layout: always shown, toggles the panels both ways + if isCornerLayout { + return onTogglePanel != nil + } + // iPad widescreen (true landscape): hidden (can't force rotation) let isActualWidescreen = isWideScreenLayout && onTogglePanel != nil let showFullscreenButton = !(isIPad && isActualWidescreen) diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index 9337d380..5564c354 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -43,6 +43,8 @@ struct PlayerControlsView: View { var isPanelVisible: Bool = true var panelSide: FloatingPanelSide = .right var isPanelPinned: Bool = false + /// Corner layout: fullscreen button toggles the panels + var isCornerLayout: Bool = false /// Safe area padding from layout (when panel is pinned) var layoutLeadingSafeArea: CGFloat = 0 var layoutTrailingSafeArea: CGFloat = 0 @@ -230,6 +232,14 @@ struct PlayerControlsView: View { cancelHideTimer() } } + .onChange(of: isPanelVisible) { oldValue, newValue in + // Corner layout: any panel toggle resizes the controls area — + // keep the controls hidden while that transition plays + if isCornerLayout, oldValue != newValue { + showControls = false + cancelHideTimer() + } + } .onChange(of: playerState.retryState.exhausted) { _, exhausted in // Hide controls when retries are exhausted (retry exhausted overlay takes over) if exhausted { @@ -389,6 +399,7 @@ struct PlayerControlsView: View { isOrientationLocked: isOrientationLocked, isPanelVisible: isPanelVisible, isPanelPinned: isPanelPinned, + isCornerLayout: isCornerLayout, panelSide: panelSide, isIPad: isIPad, showVolumeControls: showVolumeControls, @@ -415,6 +426,10 @@ struct PlayerControlsView: View { onTogglePiP: onTogglePiP, onToggleFullscreen: onToggleFullscreen, onToggleDetailsVisibility: onToggleDetailsVisibility, + onHideControls: { [self] in + showControls = false + cancelHideTimer() + }, onToggleOrientationLock: onToggleOrientationLock, onTogglePanel: onTogglePanel, onTogglePanscan: onTogglePanscan, @@ -484,7 +499,11 @@ struct PlayerControlsView: View { // bottomExtraPadding handles positioning, so no safe area needed // In landscape (widescreen), cap the safe area since home indicator needs less clearance let floatingPanelActive = isPanelVisible && !isPanelPinned - let needsBottomSafeArea = (isWideScreenLayout || isFullscreen) && !floatingPanelActive + // Corner layout with panels visible: the controls frame ends at the + // video area mid-screen, so the window's bottom safe area (incl. the + // PiP inset) must not push the bottom bar up from the video edge + let cornerPanelsActive = isCornerLayout && isPanelVisible + let needsBottomSafeArea = (isWideScreenLayout || isFullscreen) && !floatingPanelActive && !cornerPanelsActive let bottomSafeArea = isWideScreenLayout ? min(safeArea.bottom, 12) : safeArea.bottom let bottomPadding: CGFloat = 2 + (needsBottomSafeArea ? bottomSafeArea : 0) diff --git a/Yattee/Views/Player/PortraitDetailsPanel.swift b/Yattee/Views/Player/PortraitDetailsPanel.swift index 6d774557..ce0114eb 100644 --- a/Yattee/Views/Player/PortraitDetailsPanel.swift +++ b/Yattee/Views/Player/PortraitDetailsPanel.swift @@ -18,6 +18,19 @@ struct PortraitDetailsPanel: View { let onFullscreen: (() -> Void)? let pillsOverlayOpacity: CGFloat + /// Corner layout host: comments live in the side column, so the pill is + /// suppressed entirely. + var hidesCommentsPill: Bool = false + + /// Portrait inlines chapters/related into the info page; the corner + /// layout's below-video host turns this off because those live in the + /// side column tabs (the description stays collapsible either way). + var inlineInfoSections: Bool = true + + /// Panel clip rounding — the corner layout's below-video host uses 0 + /// for a plain rectangle. + var panelCornerRadius: CGFloat = 16 + // Drag gesture callbacks var onDragChanged: ((CGFloat) -> Void)? var onDragEnded: ((CGFloat, CGFloat) -> Void)? @@ -64,7 +77,7 @@ struct PortraitDetailsPanel: View { playerPillSettings.effectiveCommentsPillMode } private var shouldShowCommentsPill: Bool { - playerPillSettings.shouldShowCommentsPill + !hidesCommentsPill && playerPillSettings.shouldShowCommentsPill } private var isCommentsPillAlwaysCollapsed: Bool { playerPillSettings.isCommentsPillAlwaysCollapsed @@ -165,7 +178,7 @@ struct PortraitDetailsPanel: View { ), pageBarTopPadding: 14, showPageBar: false, - inlineInfoSections: true + inlineInfoSections: inlineInfoSections ) .padding(.top, 10) .background(Color(.systemBackground)) @@ -220,7 +233,7 @@ struct PortraitDetailsPanel: View { } } .background(Color(.systemBackground)) - .clipShape(RoundedRectangle(cornerRadius: 16)) + .clipShape(RoundedRectangle(cornerRadius: panelCornerRadius)) // Pills overlay at bottom .overlay { if !isCommentsExpanded {