From 7f74b50e94ab91e50f0384f855f8480436bde326 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sun, 27 Sep 2026 16:36:42 +0200 Subject: [PATCH] Make the player pill an on/off setting on iPad, drop the dead wide-layout pill overlay On iPad both orientations show the video next to a panel, the corner layout hosts the portrait panel in landscape, and Split View windows make "portrait" mean "narrow", so the portrait/landscape visibility split never matched what the user saw. PillVisibility now treats any mode except Never as visible on iPad, and the pill editor shows a Show pill toggle there (writes Both/Never, reads synced orientation modes as on). iPhone keeps the four-way picker. ExpandedPlayerSheet's bottomPillsOverlay was unreferenced; the floating panel renders its own pill row. --- Yattee/Localizable.xcstrings | 10 + .../PlayerControls/PlayerPillSettings.swift | 16 ++ Yattee/Views/Player/ExpandedPlayerSheet.swift | 197 ------------------ .../PlayerControls/PlayerPillEditorView.swift | 40 ++-- 4 files changed, 54 insertions(+), 209 deletions(-) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 8c0abdb4..ed7d6ee0 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -7788,6 +7788,16 @@ } } }, + "pill.visibility.footer.toggle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show the player pill in the details panel." + } + } + } + }, "pill.visibility.header" : { "localizations" : { "en" : { diff --git a/Yattee/Models/PlayerControls/PlayerPillSettings.swift b/Yattee/Models/PlayerControls/PlayerPillSettings.swift index dca1efe2..2ae39e12 100644 --- a/Yattee/Models/PlayerControls/PlayerPillSettings.swift +++ b/Yattee/Models/PlayerControls/PlayerPillSettings.swift @@ -31,10 +31,26 @@ enum PillVisibility: String, Codable, Hashable, Sendable, CaseIterable { } } + /// iPad treats the pill as on/off: both orientations show the video next + /// to a panel, the corner layout hosts the portrait panel in landscape, + /// and Split View windows make "portrait" mean "narrow" — so an + /// orientation split never matches what the user sees. + static var isOrientationAgnostic: Bool { + #if os(iOS) + return UIDevice.current.userInterfaceIdiom == .pad + #else + return false + #endif + } + + /// Whether the pill is enabled at all (any mode except `.never`). + var isEnabled: Bool { self != .never } + /// Returns whether the pill should be visible for the given layout context. /// - Parameter isWideLayout: True if in wide/landscape layout, false for portrait. /// - Returns: Whether the pill should be shown. func isVisible(isWideLayout: Bool) -> Bool { + if Self.isOrientationAgnostic { return isEnabled } switch self { case .portraitOnly: return !isWideLayout diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index dfecfe1b..2e7edc93 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -27,7 +27,6 @@ struct ExpandedPlayerSheet: View { @State var commentsDismissOffset: CGFloat = 0 /// Direct scroll position control - use scrollTo(y:) to scroll to top @State var scrollPosition: ScrollPosition = .init(y: 0) - @State private var showScrollButton = false @State private var currentScrollOffset: CGFloat = 0 @State var currentPlayerHeight: CGFloat = 0 @State private var isBottomOverscroll: Bool = false @@ -141,16 +140,6 @@ struct ExpandedPlayerSheet: View { var isQueueEnabled: Bool { appEnvironment?.settingsManager.queueEnabled ?? true } var hasQueueItems: Bool { !queue.isEmpty && isQueueEnabled } - // MARK: - Player Pill Helpers - - var playerPillSettings: PlayerPillSettings { - playerControlsLayout.effectivePlayerPillSettings - } - var shouldShowPlayerPill: Bool { - playerPillSettings.visibility.isVisible(isWideLayout: true) && - !playerPillSettings.buttons.isEmpty - } - // MARK: - Autoplay Helpers var isAutoPlayEnabled: Bool { @@ -489,192 +478,6 @@ struct ExpandedPlayerSheet: View { ) #endif } - - // MARK: - Bottom Pills Overlay (iOS) - - #if os(iOS) - /// Extracted overlay for player pill, comments pill and scroll button to help compiler type-checking - @ViewBuilder - private var bottomPillsOverlay: some View { - GeometryReader { geometry in - let hasCommentsPill = commentsState == .loaded && !comments.isEmpty - let isPlaying = playerState?.playbackState == .playing - // Disable play/pause when loading OR when thumbnail is still visible (buffer/frame not ready) - let isPlayPauseDisabled = playerState?.playbackState == .loading || - playerState?.playbackState == .buffering || - !(playerState?.isFirstFrameReady ?? false) || - !(playerState?.isBufferReady ?? false) - let hasNext = playerState?.hasNext ?? false - // On narrow devices, use smaller side buttons so the player pill gets more horizontal space - let isCompactPillRow = geometry.size.width <= 390 - let collapsedCommentWidth: CGFloat = isCompactPillRow ? 40 : 52 - let scrollButtonWidth: CGFloat = isCompactPillRow ? 40 : 52 - let pillRowSpacing: CGFloat = isCompactPillRow ? 8 : 12 - - VStack { - Spacer() - - VStack(spacing: 12) { - // Comments pill - on its own row (only when player pill exists) - if hasCommentsPill, let firstComment = comments.first, shouldShowPlayerPill { - CommentsPillView( - comment: firstComment, - isCollapsed: false, - showAvatar: playerControlsLayout.effectivePanelSettings.commentsShowAvatars, - onTap: expandComments - ) - .transition(.scale.combined(with: .opacity)) - } - - // Bottom row - centered group - bottomPillsRow( - hasCommentsPill: hasCommentsPill, - collapsedCommentWidth: collapsedCommentWidth, - pillRowSpacing: pillRowSpacing, - isCompact: isCompactPillRow, - isPlaying: isPlaying, - hasNext: hasNext, - isPlayPauseDisabled: isPlayPauseDisabled, - scrollButtonWidth: scrollButtonWidth, - availableWidth: geometry.size.width - ) - } - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - .padding(.bottom, 24) - .background { - // Block touches on description links behind pills area - // Prevents accidental link taps when trying to tap pills - Color.clear - .contentShape(Rectangle()) - } - } - .animation(.spring(response: 0.35, dampingFraction: 0.8), value: hasCommentsPill) - .animation(.spring(response: 0.3, dampingFraction: 0.7), value: shouldShowPlayerPill) - .animation(.easeInOut(duration: 0.2), value: showScrollButton) - } - .ignoresSafeArea(edges: .bottom) - } - - /// Bottom row of pills (comments, player pill, scroll button) - extracted to help compiler - @ViewBuilder - private func bottomPillsRow( - hasCommentsPill: Bool, - collapsedCommentWidth: CGFloat, - pillRowSpacing: CGFloat, - isCompact: Bool, - isPlaying: Bool, - hasNext: Bool, - isPlayPauseDisabled: Bool, - scrollButtonWidth: CGFloat, - availableWidth: CGFloat - ) -> some View { - // Calculate max pill width: available width minus placeholders and spacing - // Layout: [16 padding] [left] [spacing] [PILL] [spacing] [right] [16 padding] - let edgeElementsWidth: CGFloat = 32 + collapsedCommentWidth + scrollButtonWidth + (pillRowSpacing * 2) - let maxPillWidth = max(availableWidth - edgeElementsWidth, 150) - - HStack(spacing: pillRowSpacing) { - // Left side: comments pill or placeholder - if hasCommentsPill, let firstComment = comments.first { - if !shouldShowPlayerPill { - // No player pill: expanded comments in bottom row - CommentsPillView( - comment: firstComment, - isCollapsed: false, - fillWidth: true, - showAvatar: playerControlsLayout.effectivePanelSettings.commentsShowAvatars, - onTap: expandComments - ) - .frame(maxWidth: 400) - .frame(maxWidth: .infinity, alignment: .center) - .transition(.scale.combined(with: .opacity)) - } else { - // Placeholder (expanded comments on its own row) - Color.clear - .frame(width: collapsedCommentWidth, height: collapsedCommentWidth) - } - } else if shouldShowPlayerPill { - Color.clear - .frame(width: collapsedCommentWidth, height: collapsedCommentWidth) - } - - // Player pill - if shouldShowPlayerPill { - PlayerPillView( - settings: playerPillSettings, - maxWidth: maxPillWidth, - isPlaying: isPlaying, - hasNext: hasNext, - queueCount: queue.count, - queueModeIcon: playerState?.queueMode.icon ?? "list.bullet", - isPlayPauseDisabled: isPlayPauseDisabled, - isOrientationLocked: inAppOrientationLock, - video: playerState?.currentVideo, - playbackRate: playerState?.rate ?? .x1, - showingPlaylistSheet: $showingPlaylistSheet, - onQueueTap: { showingQueueSheet = true }, - onPrevious: { - Task { await playerService?.playPrevious() } - }, - onPlayPause: { - playerService?.togglePlayPause() - }, - onNext: { - playNextInQueue() - }, - onSeek: { signedSeconds in - if signedSeconds >= 0 { - playerService?.seekForward(by: signedSeconds) - } else { - playerService?.seekBackward(by: -signedSeconds) - } - }, - onClose: { - appEnvironment?.queueManager.clearQueue() - playerService?.stop() - navigationCoordinator?.isPlayerExpanded = false - }, - onAirPlay: { /* AirPlay - handled by system */ }, - onPiP: { - if let mpvBackend = playerService?.currentBackend as? MPVBackend { - mpvBackend.togglePiP() - } - }, - onOrientationLock: { - appEnvironment?.settingsManager.inAppOrientationLock.toggle() - }, - onRateChanged: { rate in - playerState?.rate = rate - playerService?.currentBackend?.rate = Float(rate.rawValue) - } - ) - } - - // Scroll button or placeholder - if showScrollButton { - Button { - scrollToTop() - } label: { - Image(systemName: "arrow.up") - .font(.system(size: isCompact ? 16 : 18, weight: .semibold)) - .foregroundStyle(.primary) - .frame(width: isCompact ? 28 : 32, height: isCompact ? 28 : 32) - .padding(isCompact ? 6 : 10) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .glassBackground(.regular, in: .circle, fallback: .thinMaterial) - .shadow(color: .black.opacity(0.1), radius: 8, y: 2) - .contentShape(Circle()) - .transition(.scale.combined(with: .opacity)) - } else if shouldShowPlayerPill { - Color.clear - .frame(width: scrollButtonWidth, height: scrollButtonWidth) - } - } - } - #endif } // MARK: - Player Sheet Modifiers diff --git a/Yattee/Views/Settings/PlayerControls/PlayerPillEditorView.swift b/Yattee/Views/Settings/PlayerControls/PlayerPillEditorView.swift index 583fad8c..77d9ff27 100644 --- a/Yattee/Views/Settings/PlayerControls/PlayerPillEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/PlayerPillEditorView.swift @@ -41,23 +41,39 @@ struct PlayerPillEditorView: View { private var visibilitySection: some View { Section { - Picker( - String(localized: "pill.visibility.title"), - selection: $visibility - ) { - ForEach(PillVisibility.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) + if PillVisibility.isOrientationAgnostic { + // iPad: on/off only. Any orientation mode from a synced preset + // reads as "on"; turning it on stores `.both` so the preset + // shows the pill everywhere on iPhone too. + Toggle( + String(localized: "pill.visibility.title"), + isOn: Binding( + get: { visibility.isEnabled }, + set: { visibility = $0 ? .both : .never } + ) + ) + .disabled(!viewModel.canEditActivePreset) + } else { + Picker( + String(localized: "pill.visibility.title"), + selection: $visibility + ) { + ForEach(PillVisibility.allCases, id: \.self) { mode in + Text(mode.displayName).tag(mode) + } } - } - .disabled(!viewModel.canEditActivePreset) - .onChange(of: visibility) { _, newValue in - guard newValue != viewModel.pillVisibility else { return } - viewModel.syncPillVisibility(newValue) + .disabled(!viewModel.canEditActivePreset) } } header: { Text(String(localized: "pill.visibility.header")) } footer: { - Text(String(localized: "pill.visibility.footer")) + Text(String(localized: PillVisibility.isOrientationAgnostic + ? "pill.visibility.footer.toggle" + : "pill.visibility.footer")) + } + .onChange(of: visibility) { _, newValue in + guard newValue != viewModel.pillVisibility else { return } + viewModel.syncPillVisibility(newValue) } }