From 1230905b5caf5783022dd67fe97ff7326e087b61 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Mon, 12 Oct 2026 00:35:57 -0500 Subject: [PATCH] feat(player): lay the player out side by side in landscape In landscape the player squeezed its portrait layout into a short screen: the artwork shrank to under a third of its size, a book without artwork had its title and author cut, and swipe-to-close needed most of the screen. On a short screen (vertical size class compact, every iPhone in landscape) the artwork now sits on the left, as large as fits above the button row, with the chapter row, progress and playback controls on the right. The button row stays across the bottom, its buttons kept together in the middle. An AnyLayout switches between the two, so rotating moves the views instead of rebuilding them. Swipe-to-close needs half the distance side by side. --- BookPlayer/Player/Views/ArtworkView.swift | 6 +- BookPlayer/Player/Views/MediaActionRow.swift | 11 +- .../Player/Views/NavigationRowView.swift | 8 +- BookPlayer/Player/Views/PlayerJumpView.swift | 4 +- BookPlayer/Player/Views/PlayerView.swift | 160 ++++++++++-------- .../SettingsPlayerControlsView.swift | 2 +- 6 files changed, 112 insertions(+), 79 deletions(-) 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)