diff --git a/BookPlayer/Player/Views/ArtworkView.swift b/BookPlayer/Player/Views/ArtworkView.swift index e2ccd284c..95ecd5cf1 100644 --- a/BookPlayer/Player/Views/ArtworkView.swift +++ b/BookPlayer/Player/Views/ArtworkView.swift @@ -16,7 +16,9 @@ struct ArtworkView: View { var title: String var author: String var imagePath: String? - + /// Where the square sits when it's narrower than the space it's given + var alignment: Alignment = .center + var body: some View { ZStack(alignment: .top) { ZStack { @@ -103,7 +105,7 @@ struct ArtworkView: View { .cornerRadius(12) .clipped() } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment) } } diff --git a/BookPlayer/Player/Views/MediaActionRow.swift b/BookPlayer/Player/Views/MediaActionRow.swift index 55e0abc5b..e9c0f63c5 100644 --- a/BookPlayer/Player/Views/MediaActionRow.swift +++ b/BookPlayer/Player/Views/MediaActionRow.swift @@ -18,6 +18,9 @@ struct MediaActionRow: View { @Binding var showsTimerPopover: Bool let currentAlertOrigin: MediaAction? + /// A fixed gap that keeps the buttons together in the middle; without one they spread + /// evenly across the row + var buttonSpacing: CGFloat? = nil let onActionTapped: (MediaAction) -> Void @ViewBuilder let timerPopover: () -> TimerPopover @@ -55,7 +58,7 @@ struct MediaActionRow: View { } var body: some View { - HStack { + HStack(spacing: buttonSpacing) { Spacer() ForEach(MediaAction.allCases) { ma in BubbleButton( @@ -77,6 +80,12 @@ struct MediaActionRow: View { content: timerPopover ) + if buttonSpacing == nil { + Spacer() + } + } + + if buttonSpacing != nil { Spacer() } } diff --git a/BookPlayer/Player/Views/NavigationRowView.swift b/BookPlayer/Player/Views/NavigationRowView.swift index 45b334cd2..0355d33c1 100644 --- a/BookPlayer/Player/Views/NavigationRowView.swift +++ b/BookPlayer/Player/Views/NavigationRowView.swift @@ -17,7 +17,11 @@ struct NavigationRowView: View { var onNextTap: (() -> Void)? var onTitleToggle: (() -> Void)? var onPreviousTap: (() -> Void)? - + /// Keeps the row as tall for a one-line title as for two. Off beside the artwork, where the + /// row lines up with the artwork's top and the extra height would sit as empty space above + /// a one-line title + var keepsTitleHeight = true + var body: some View { HStack(spacing: 4) { Button { @@ -41,7 +45,7 @@ struct NavigationRowView: View { .bpFont(.playerTitle) .foregroundColor(theme.primaryColor) .multilineTextAlignment(.center) - .frame(height: 56) + .frame(height: keepsTitleHeight ? 56 : nil) } .layoutPriority(1) diff --git a/BookPlayer/Player/Views/PlayerJumpView.swift b/BookPlayer/Player/Views/PlayerJumpView.swift index baf53c1c5..c240f2759 100644 --- a/BookPlayer/Player/Views/PlayerJumpView.swift +++ b/BookPlayer/Player/Views/PlayerJumpView.swift @@ -36,7 +36,9 @@ struct PlayerJumpView: View { } .buttonStyle(.plain) .background(Color.clear) - .frame(width: 54, height: 48) + /// As tall as the icon: a shorter frame lets it spill past, and layouts around it + /// (centering, spacing) go by the frame + .frame(width: 54, height: 56) } } diff --git a/BookPlayer/Player/Views/PlayerView.swift b/BookPlayer/Player/Views/PlayerView.swift index c6259d979..737bb897c 100644 --- a/BookPlayer/Player/Views/PlayerView.swift +++ b/BookPlayer/Player/Views/PlayerView.swift @@ -14,19 +14,29 @@ struct PlayerView: View { @Environment(\.dismiss) private var dismiss @Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.displayScale) private var displayScale - + @Environment(\.verticalSizeClass) private var verticalSizeClass + @StateObject private var viewModel: PlayerViewModel @StateObject private var theme = ThemeViewModel() @State private var noteText: String = "" @State private var dragOffset: CGSize = .zero @State private var dragThresholdReached = false - private var dismissThreshold: CGFloat { 44.0 * displayScale } - + /// Side by side, the screen is about half as tall, so half the distance closes the player + private var dismissThreshold: CGFloat { (isSideBySide ? 22.0 : 44.0) * displayScale } + /// The artwork beside the controls on a short screen, like iPhone in landscape + private var isSideBySide: Bool { verticalSizeClass == .compact } + /// Side by side, the artwork sits against the controls; it fills less than its half when + /// the height is what limits it + private var artworkAlignment: Alignment { isSideBySide ? .trailing : .center } + init(initModel: @escaping () -> PlayerViewModel) { self._viewModel = .init(wrappedValue: initModel()) } - - var body: some View { + + var body: some View { + /// Switching the layout keeps its views, so rotating moves them instead of rebuilding them + let layout = isSideBySide ? AnyLayout(HStackLayout(spacing: 40)) : AnyLayout(VStackLayout()) + VStack { DismissableRegionView() .simultaneousGesture( @@ -38,81 +48,85 @@ struct PlayerView: View { handleDragEnded(gesture) } ) - - VStack { - Group { - if viewModel.isVideoItem { - VideoArtworkView() - } else { - ArtworkView( - title: viewModel.title, - author: viewModel.author, - imagePath: viewModel.relativePath - ) - } - } - .simultaneousGesture( - DragGesture(minimumDistance: 15) - .onChanged { gesture in - handleDragChanged(gesture) - } - .onEnded { gesture in - handleDragEnded(gesture) - } - ) - Spacer() - } - .frame(maxWidth: .infinity) + layout { + VStack { + ArtworkView( + title: viewModel.title, + author: viewModel.author, + imagePath: viewModel.relativePath, + alignment: artworkAlignment + ) + .simultaneousGesture( + DragGesture(minimumDistance: 15) + .onChanged { gesture in + handleDragChanged(gesture) + } + .onEnded { gesture in + handleDragEnded(gesture) + } + ) - VStack(spacing: 4) { - NavigationRowView( - playerTitle: viewModel.progressData.chapterTitle, - hasNextChapter: viewModel.hasNextChapter, - hasPreviousChapter: viewModel.hasPreviousChapter, - onNextTap: viewModel.handleNextTap, - onTitleToggle: viewModel.processToggleProgressState, - onPreviousTap: viewModel.handlePreviousTap - ) + /// Side by side, the artwork spans the controls' full height + Spacer(minLength: isSideBySide ? 0 : nil) + } + .frame(maxWidth: .infinity, alignment: artworkAlignment) + /// VoiceOver reads by position, which side by side would mix the artwork's elements + /// into the controls + .accessibilityElement(children: .contain) - ListeningProgressView( - progress: $viewModel.progressData.sliderValue, - remainingTime: viewModel.progressData.formattedMaxTime ?? "00:00", - remainingTimeAccessLabel: viewModel.remainingTimeAccessLabel, - currentTime: viewModel.progressData.formattedCurrentTime, - currentTimeAccessLabel: viewModel.currentTimeAccessLabel, - progressLabel: viewModel.progressData.progress ?? "", - onSliderDragChanged: { value in - viewModel.handleSliderDragChanged(value: value) - }, - onSliderChange: { progress in - viewModel.handleSliderUpEvent(with: Float(progress)) - }, - onProgressToggle: { - viewModel.processToggleProgressState() - }, - onRemainingToggle: { - viewModel.processToggleMaxTime() - } - ) + VStack(spacing: 4) { + NavigationRowView( + playerTitle: viewModel.progressData.chapterTitle, + hasNextChapter: viewModel.hasNextChapter, + hasPreviousChapter: viewModel.hasPreviousChapter, + onNextTap: viewModel.handleNextTap, + onTitleToggle: viewModel.processToggleProgressState, + onPreviousTap: viewModel.handlePreviousTap, + keepsTitleHeight: !isSideBySide + ) - Spacer() + ListeningProgressView( + progress: $viewModel.progressData.sliderValue, + remainingTime: viewModel.progressData.formattedMaxTime ?? "00:00", + remainingTimeAccessLabel: viewModel.remainingTimeAccessLabel, + currentTime: viewModel.progressData.formattedCurrentTime, + currentTimeAccessLabel: viewModel.currentTimeAccessLabel, + progressLabel: viewModel.progressData.progress ?? "", + onSliderDragChanged: { value in + viewModel.handleSliderDragChanged(value: value) + }, + onSliderChange: { progress in + viewModel.handleSliderUpEvent(with: Float(progress)) + }, + onProgressToggle: { + viewModel.processToggleProgressState() + }, + onRemainingToggle: { + viewModel.processToggleMaxTime() + } + ) + + Spacer() - PlayControlsRowView(isPlaying: viewModel.isPlaying) + PlayControlsRowView(isPlaying: viewModel.isPlaying) - Spacer() + Spacer() + } + .contentShape(Rectangle()) + .simultaneousGesture( + DragGesture(minimumDistance: 15) + .onChanged(handleDragChanged) + .onEnded(handleDragEnded) + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .contain) } - .contentShape(Rectangle()) - .simultaneousGesture( - DragGesture(minimumDistance: 15) - .onChanged(handleDragChanged) - .onEnded(handleDragEnded) - ) - .frame(maxWidth: .infinity, maxHeight: .infinity) } .padding(.horizontal, 8) .safeAreaPadding([.top, .horizontal]) - .safeAreaInset(edge: .bottom) { + /// Side by side, the artwork runs down to the row, so it gets some room + .safeAreaInset(edge: .bottom, spacing: isSideBySide ? 16 : nil) { MediaActionRow( speedText: viewModel.formattedSpeed(), sleepText: viewModel.sleepText, @@ -120,6 +134,9 @@ struct PlayerView: View { currentAlert: $viewModel.currentAlert, showsTimerPopover: $viewModel.showsCustomSleepTimer, currentAlertOrigin: viewModel.currentAlertOrigin, + /// Side by side, the buttons stay together in the middle instead of spreading across + /// the screen's full width + buttonSpacing: isSideBySide ? 24 : nil, onActionTapped: { viewModel.handleButtonTap(media: $0) } ) { DurationPickerPopover( @@ -145,7 +162,6 @@ struct PlayerView: View { viewModel.bindBookObservers() viewModel.handleAutolockStatus(forceDisable: false) viewModel.recalculateProgress() - viewModel.refreshVideoState() } .onDisappear { viewModel.handleAutolockStatus(forceDisable: true) @@ -211,7 +227,7 @@ struct PlayerView: View { .environmentObject(theme) } } - + private func handleDragChanged(_ gesture: DragGesture.Value) { if gesture.translation.height > 0 { dragOffset = gesture.translation diff --git a/BookPlayer/Settings/Sections/PlayerControls/SettingsPlayerControlsView.swift b/BookPlayer/Settings/Sections/PlayerControls/SettingsPlayerControlsView.swift index ecb6ebff2..539f7586e 100644 --- a/BookPlayer/Settings/Sections/PlayerControls/SettingsPlayerControlsView.swift +++ b/BookPlayer/Settings/Sections/PlayerControls/SettingsPlayerControlsView.swift @@ -22,7 +22,7 @@ struct SettingsPlayerControlsView: View { ListOptionsSectionView() ProgressLabelsSectionView() StartupPlayerSectionView() - VideoPlaybackSectionView() + // VideoPlaybackSectionView() } .environmentObject(theme) .scrollContentBackground(.hidden)