mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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:
@@ -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" : {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user