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.
This commit is contained in:
Arkadiusz Fal
2026-09-27 16:36:42 +02:00
parent 61786ec6f7
commit 7f74b50e94
4 changed files with 54 additions and 209 deletions

View File

@@ -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" : {

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}
}