From d382973f073d1a70c61f06cad867a6262ffabd58 Mon Sep 17 00:00:00 2001 From: Gianni Carlo Date: Tue, 7 Apr 2026 05:12:54 -0500 Subject: [PATCH] Add support for next/previous chapter as intervals --- BookPlayer/AppDelegate.swift | 12 +++- .../Generated/AutoMockable.generated.swift | 22 +++++++ BookPlayer/Player/PlayerManager.swift | 58 +++++++++++++++++-- BookPlayer/Player/PlayerManagerProtocol.swift | 2 + .../Player/ViewModels/PlayerViewModel.swift | 20 +------ .../Player/Views/PlayControlsRowView.swift | 28 ++++++++- BookPlayer/Services/VoiceOverService.swift | 6 ++ .../SkipIntervalsSectionView.swift | 24 +++++++- BookPlayerWatch/ExtensionDelegate.swift | 12 +++- .../PlaybackFullControlsView.swift | 12 +++- .../SkipDurationListView.swift | 26 ++++++++- .../LocalPlayback/Player/PlayerManager.swift | 58 +++++++++++++++++-- .../NowPlaying/Views/SkipIntervalView.swift | 33 ++++++++--- Shared/Constants.swift | 5 ++ 14 files changed, 274 insertions(+), 44 deletions(-) diff --git a/BookPlayer/AppDelegate.swift b/BookPlayer/AppDelegate.swift index 32754418a..2fd37d958 100644 --- a/BookPlayer/AppDelegate.swift +++ b/BookPlayer/AppDelegate.swift @@ -214,7 +214,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { func setupMPSkipRemoteCommands() { let center = MPRemoteCommandCenter.shared() // Forward - center.skipForwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.forwardInterval)] + if PlayerManager.isForwardChapterSkip { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.forwardInterval)] + } center.skipForwardCommand.addTarget { (_) -> MPRemoteCommandHandlerStatus in guard let playerManager = AppServices.shared.coreServices?.playerManager else { return .commandFailed } @@ -244,7 +248,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, BPLogger { } // Rewind - center.skipBackwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.rewindInterval)] + if PlayerManager.isRewindChapterSkip { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.rewindInterval)] + } center.skipBackwardCommand.addTarget { (_) -> MPRemoteCommandHandlerStatus in guard let playerManager = AppServices.shared.coreServices?.playerManager else { return .commandFailed } diff --git a/BookPlayer/Generated/AutoMockable.generated.swift b/BookPlayer/Generated/AutoMockable.generated.swift index cbc997b25..99033ac21 100644 --- a/BookPlayer/Generated/AutoMockable.generated.swift +++ b/BookPlayer/Generated/AutoMockable.generated.swift @@ -1224,6 +1224,28 @@ class PlayerManagerProtocolMock: PlayerManagerProtocol { forwardCallsCount += 1 forwardClosure?() } + //MARK: - skipToNextChapter + + var skipToNextChapterCallsCount = 0 + var skipToNextChapterCalled: Bool { + return skipToNextChapterCallsCount > 0 + } + var skipToNextChapterClosure: (() -> Void)? + func skipToNextChapter() { + skipToNextChapterCallsCount += 1 + skipToNextChapterClosure?() + } + //MARK: - skipToPreviousChapter + + var skipToPreviousChapterCallsCount = 0 + var skipToPreviousChapterCalled: Bool { + return skipToPreviousChapterCallsCount > 0 + } + var skipToPreviousChapterClosure: (() -> Void)? + func skipToPreviousChapter() { + skipToPreviousChapterCallsCount += 1 + skipToPreviousChapterClosure?() + } //MARK: - skip var skipCallsCount = 0 diff --git a/BookPlayer/Player/PlayerManager.swift b/BookPlayer/Player/PlayerManager.swift index 20fb2a1b8..021f6c25f 100755 --- a/BookPlayer/Player/PlayerManager.swift +++ b/BookPlayer/Player/PlayerManager.swift @@ -602,7 +602,13 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Constants.UserDefaults.rewindInterval) - MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [newValue] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if newValue == Constants.SkipInterval.chapterSkipValue { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.isEnabled = true + center.skipBackwardCommand.preferredIntervals = [newValue] as [NSNumber] + } } } @@ -618,10 +624,24 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Constants.UserDefaults.forwardInterval) - MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [newValue] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if newValue == Constants.SkipInterval.chapterSkipValue { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.isEnabled = true + center.skipForwardCommand.preferredIntervals = [newValue] as [NSNumber] + } } } + static var isRewindChapterSkip: Bool { + rewindInterval == Constants.SkipInterval.chapterSkipValue + } + + static var isForwardChapterSkip: Bool { + forwardInterval == Constants.SkipInterval.chapterSkipValue + } + func setNowPlayingBookTitle(chapter: PlayableChapter) { guard let currentItem = self.currentItem else { return } @@ -735,11 +755,41 @@ extension PlayerManager { } func forward() { - skip(PlayerManager.forwardInterval) + if PlayerManager.isForwardChapterSkip { + skipToNextChapter() + } else { + skip(PlayerManager.forwardInterval) + } } func rewind() { - skip(-PlayerManager.rewindInterval) + if PlayerManager.isRewindChapterSkip { + skipToPreviousChapter() + } else { + skip(-PlayerManager.rewindInterval) + } + } + + func skipToNextChapter() { + if let currentChapter = currentItem?.currentChapter, + let nextChapter = currentItem?.nextChapter(after: currentChapter) + { + jumpToChapter(nextChapter) + } else { + playNextItem(autoPlayed: false, shouldAutoplay: true) + } + NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) + } + + func skipToPreviousChapter() { + if let currentChapter = currentItem?.currentChapter, + let previousChapter = currentItem?.previousChapter(before: currentChapter) + { + jumpToChapter(previousChapter) + } else { + playPreviousItem() + } + NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) } func skip(_ interval: TimeInterval) { diff --git a/BookPlayer/Player/PlayerManagerProtocol.swift b/BookPlayer/Player/PlayerManagerProtocol.swift index d72b92681..e75b25ad8 100644 --- a/BookPlayer/Player/PlayerManagerProtocol.swift +++ b/BookPlayer/Player/PlayerManagerProtocol.swift @@ -33,6 +33,8 @@ public protocol PlayerManagerProtocol: AnyObject { func stop() func rewind() func forward() + func skipToNextChapter() + func skipToPreviousChapter() func skip(_ interval: TimeInterval) /// Bypass checks on chapter limits func directSkip(_ interval: TimeInterval) diff --git a/BookPlayer/Player/ViewModels/PlayerViewModel.swift b/BookPlayer/Player/ViewModels/PlayerViewModel.swift index 6b0d25889..57c98da09 100644 --- a/BookPlayer/Player/ViewModels/PlayerViewModel.swift +++ b/BookPlayer/Player/ViewModels/PlayerViewModel.swift @@ -400,25 +400,11 @@ final class PlayerViewModel: ObservableObject { } func handleNextTap() { - if let currentChapter = self.playerManager.currentItem?.currentChapter, - let nextChapter = self.playerManager.currentItem?.nextChapter(after: currentChapter) - { - self.playerManager.jumpToChapter(nextChapter) - } else { - self.playerManager.playNextItem(autoPlayed: false, shouldAutoplay: true) - } - NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) + self.playerManager.skipToNextChapter() } - + func handlePreviousTap() { - if let currentChapter = self.playerManager.currentItem?.currentChapter, - let previousChapter = self.playerManager.currentItem?.previousChapter(before: currentChapter) - { - self.playerManager.jumpToChapter(previousChapter) - } else { - self.playerManager.playPreviousItem() - } - NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) + self.playerManager.skipToPreviousChapter() } func hasLoadedBook() -> Bool { diff --git a/BookPlayer/Player/Views/PlayControlsRowView.swift b/BookPlayer/Player/Views/PlayControlsRowView.swift index 28ae89d49..32061658b 100644 --- a/BookPlayer/Player/Views/PlayControlsRowView.swift +++ b/BookPlayer/Player/Views/PlayControlsRowView.swift @@ -16,10 +16,34 @@ struct PlayControlsRowView: View { @EnvironmentObject private var theme: ThemeViewModel @EnvironmentObject private var playerManager: PlayerManager + private var rewindImage: Image { + rewindInterval == Constants.SkipInterval.chapterSkipValue + ? Image(systemName: "backward.end.fill") + : Image(.playerIconRewind) + } + + private var rewindLabelText: String { + rewindInterval == Constants.SkipInterval.chapterSkipValue + ? "" + : "-\(String(Int(rewindInterval.rounded())))" + } + + private var forwardImage: Image { + forwardInterval == Constants.SkipInterval.chapterSkipValue + ? Image(systemName: "forward.end.fill") + : Image(.playerIconForward) + } + + private var forwardLabelText: String { + forwardInterval == Constants.SkipInterval.chapterSkipValue + ? "" + : "+\(String(Int(forwardInterval.rounded())))" + } + var body: some View { HStack(spacing: 0) { Spacer() - PlayerJumpView(backgroundImage: Image(.playerIconRewind), text: "-\(String(Int(rewindInterval.rounded())))", tintColor: Color(theme.linkColor)) { + PlayerJumpView(backgroundImage: rewindImage, text: rewindLabelText, tintColor: Color(theme.linkColor)) { UIImpactFeedbackGenerator(style: .medium).impactOccurred() playerManager.rewind() } @@ -33,7 +57,7 @@ struct PlayControlsRowView: View { .accessibilityLabel(isPlaying ? "pause_title".localized : "play_title".localized) Spacer() Spacer() - PlayerJumpView(backgroundImage: Image(.playerIconForward), text: "+\(String(Int(forwardInterval.rounded())))", tintColor: Color(theme.linkColor)) { + PlayerJumpView(backgroundImage: forwardImage, text: forwardLabelText, tintColor: Color(theme.linkColor)) { UIImpactFeedbackGenerator(style: .medium).impactOccurred() playerManager.forward() } diff --git a/BookPlayer/Services/VoiceOverService.swift b/BookPlayer/Services/VoiceOverService.swift index 8382dec8b..436cfd911 100644 --- a/BookPlayer/Services/VoiceOverService.swift +++ b/BookPlayer/Services/VoiceOverService.swift @@ -53,6 +53,9 @@ class VoiceOverService { // MARK: - ArtworkControl public static func rewindText() -> String { + if PlayerManager.isRewindChapterSkip { + return "chapters_previous_title".localized + } return String( describing: String.localizedStringWithFormat( "voiceover_rewind_time".localized, @@ -62,6 +65,9 @@ class VoiceOverService { } public static func fastForwardText() -> String { + if PlayerManager.isForwardChapterSkip { + return "chapters_next_title".localized + } return String( describing: String.localizedStringWithFormat( "voiceover_forward_time".localized, diff --git a/BookPlayer/Settings/Sections/PlayerControls/SkipIntervalsSectionView.swift b/BookPlayer/Settings/Sections/PlayerControls/SkipIntervalsSectionView.swift index de05a693a..c849d1e57 100644 --- a/BookPlayer/Settings/Sections/PlayerControls/SkipIntervalsSectionView.swift +++ b/BookPlayer/Settings/Sections/PlayerControls/SkipIntervalsSectionView.swift @@ -40,13 +40,23 @@ struct SkipIntervalsSectionView: View { .tag(interval) .foregroundStyle(theme.linkColor) } + Text("chapters_previous_title") + .bpFont(.body) + .tag(Constants.SkipInterval.chapterSkipValue) + .foregroundStyle(theme.linkColor) } label: { Text("settings_skip_rewind_title") .bpFont(.body) } .pickerStyle(.menu) .onChange(of: rewindInterval) { - MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [rewindInterval] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if rewindInterval == Constants.SkipInterval.chapterSkipValue { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.isEnabled = true + center.skipBackwardCommand.preferredIntervals = [rewindInterval] as [NSNumber] + } } Picker(selection: $forwardInterval) { @@ -56,13 +66,23 @@ struct SkipIntervalsSectionView: View { .tag(interval) .foregroundStyle(theme.linkColor) } + Text("chapters_next_title") + .bpFont(.body) + .tag(Constants.SkipInterval.chapterSkipValue) + .foregroundStyle(theme.linkColor) } label: { Text("settings_skip_forward_title") .bpFont(.body) } .pickerStyle(.menu) .onChange(of: forwardInterval) { - MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [forwardInterval] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if forwardInterval == Constants.SkipInterval.chapterSkipValue { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.isEnabled = true + center.skipForwardCommand.preferredIntervals = [forwardInterval] as [NSNumber] + } } } header: { Text("settings_skip_title".localized.capitalized) diff --git a/BookPlayerWatch/ExtensionDelegate.swift b/BookPlayerWatch/ExtensionDelegate.swift index 7ffc48013..b5783de30 100644 --- a/BookPlayerWatch/ExtensionDelegate.swift +++ b/BookPlayerWatch/ExtensionDelegate.swift @@ -214,7 +214,11 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { func setupMPSkipRemoteCommands() { let center = MPRemoteCommandCenter.shared() // Forward - center.skipForwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.forwardInterval)] + if PlayerManager.isForwardChapterSkip { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.forwardInterval)] + } center.skipForwardCommand.addTarget { [weak self] (_) -> MPRemoteCommandHandlerStatus in guard let playerManager = self?.coreServices?.playerManager else { return .commandFailed } @@ -244,7 +248,11 @@ class ExtensionDelegate: NSObject, WKApplicationDelegate, ObservableObject { } // Rewind - center.skipBackwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.rewindInterval)] + if PlayerManager.isRewindChapterSkip { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.preferredIntervals = [NSNumber(value: PlayerManager.rewindInterval)] + } center.skipBackwardCommand.addTarget { [weak self] (_) -> MPRemoteCommandHandlerStatus in guard let playerManager = self?.coreServices?.playerManager else { return .commandFailed } diff --git a/BookPlayerWatch/LocalPlayback/PlaybackControls/PlaybackFullControlsView.swift b/BookPlayerWatch/LocalPlayback/PlaybackControls/PlaybackFullControlsView.swift index 6595a3f64..c6c146571 100644 --- a/BookPlayerWatch/LocalPlayback/PlaybackControls/PlaybackFullControlsView.swift +++ b/BookPlayerWatch/LocalPlayback/PlaybackControls/PlaybackFullControlsView.swift @@ -126,7 +126,11 @@ struct PlaybackFullControlsView: View { HStack { Text("settings_skip_rewind_title") Spacer() - Text(TimeParser.formatDuration(rewindInterval)) + Text( + rewindInterval == Constants.SkipInterval.chapterSkipValue + ? "chapters_previous_title".localized + : TimeParser.formatDuration(rewindInterval) + ) Image(systemName: "chevron.forward") } } @@ -137,7 +141,11 @@ struct PlaybackFullControlsView: View { HStack { Text("settings_skip_forward_title") Spacer() - Text(TimeParser.formatDuration(forwardInterval)) + Text( + forwardInterval == Constants.SkipInterval.chapterSkipValue + ? "chapters_next_title".localized + : TimeParser.formatDuration(forwardInterval) + ) Image(systemName: "chevron.forward") } } diff --git a/BookPlayerWatch/LocalPlayback/PlaybackControls/SkipDurationListView.swift b/BookPlayerWatch/LocalPlayback/PlaybackControls/SkipDurationListView.swift index 901e4be5f..fc989e294 100644 --- a/BookPlayerWatch/LocalPlayback/PlaybackControls/SkipDurationListView.swift +++ b/BookPlayerWatch/LocalPlayback/PlaybackControls/SkipDurationListView.swift @@ -47,7 +47,7 @@ struct SkipDurationListView: View { ForEach(intervals, id: \.self) { interval in Button { switch skipDirection { - case .forward: + case .forward: selectedForwardInterval = interval case .back: selectedRewindInterval = interval @@ -65,6 +65,30 @@ struct SkipDurationListView: View { } } } + + Button { + switch skipDirection { + case .forward: + selectedForwardInterval = Constants.SkipInterval.chapterSkipValue + case .back: + selectedRewindInterval = Constants.SkipInterval.chapterSkipValue + } + dismiss() + } label: { + HStack { + Text( + skipDirection == .forward + ? "chapters_next_title".localized + : "chapters_previous_title".localized + ) + .font(.caption) + Spacer() + if selectedInterval == Constants.SkipInterval.chapterSkipValue { + Image(systemName: "checkmark") + .foregroundColor(.accentColor) + } + } + } } .onAppear { proxy.scrollTo(selectedInterval, anchor: .center) diff --git a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift index b2220eba0..59fad3a94 100644 --- a/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift +++ b/BookPlayerWatch/LocalPlayback/Player/PlayerManager.swift @@ -579,7 +579,13 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Constants.UserDefaults.rewindInterval) - MPRemoteCommandCenter.shared().skipBackwardCommand.preferredIntervals = [newValue] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if newValue == Constants.SkipInterval.chapterSkipValue { + center.skipBackwardCommand.isEnabled = false + } else { + center.skipBackwardCommand.isEnabled = true + center.skipBackwardCommand.preferredIntervals = [newValue] as [NSNumber] + } } } @@ -595,10 +601,24 @@ final class PlayerManager: NSObject, PlayerManagerProtocol, ObservableObject { set { UserDefaults.standard.set(newValue, forKey: Constants.UserDefaults.forwardInterval) - MPRemoteCommandCenter.shared().skipForwardCommand.preferredIntervals = [newValue] as [NSNumber] + let center = MPRemoteCommandCenter.shared() + if newValue == Constants.SkipInterval.chapterSkipValue { + center.skipForwardCommand.isEnabled = false + } else { + center.skipForwardCommand.isEnabled = true + center.skipForwardCommand.preferredIntervals = [newValue] as [NSNumber] + } } } + static var isRewindChapterSkip: Bool { + rewindInterval == Constants.SkipInterval.chapterSkipValue + } + + static var isForwardChapterSkip: Bool { + forwardInterval == Constants.SkipInterval.chapterSkipValue + } + func setNowPlayingBookTitle(chapter: PlayableChapter) { guard let currentItem = self.currentItem else { return } @@ -713,11 +733,41 @@ extension PlayerManager { } func forward() { - skip(PlayerManager.forwardInterval) + if PlayerManager.isForwardChapterSkip { + skipToNextChapter() + } else { + skip(PlayerManager.forwardInterval) + } } func rewind() { - skip(-PlayerManager.rewindInterval) + if PlayerManager.isRewindChapterSkip { + skipToPreviousChapter() + } else { + skip(-PlayerManager.rewindInterval) + } + } + + func skipToNextChapter() { + if let currentChapter = currentItem?.currentChapter, + let nextChapter = currentItem?.nextChapter(after: currentChapter) + { + jumpToChapter(nextChapter) + } else { + playNextItem(autoPlayed: false, shouldAutoplay: true) + } + NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) + } + + func skipToPreviousChapter() { + if let currentChapter = currentItem?.currentChapter, + let previousChapter = currentItem?.previousChapter(before: currentChapter) + { + jumpToChapter(previousChapter) + } else { + playPreviousItem() + } + NotificationCenter.default.post(name: .listeningProgressChanged, object: nil) } func skip(_ interval: TimeInterval) { diff --git a/BookPlayerWatch/NowPlaying/Views/SkipIntervalView.swift b/BookPlayerWatch/NowPlaying/Views/SkipIntervalView.swift index 731e35a66..3985bc549 100644 --- a/BookPlayerWatch/NowPlaying/Views/SkipIntervalView.swift +++ b/BookPlayerWatch/NowPlaying/Views/SkipIntervalView.swift @@ -6,23 +6,40 @@ // Copyright © 2022 BookPlayer LLC. All rights reserved. // +import BookPlayerWatchKit import SwiftUI struct SkipIntervalView: View { let interval: Int? let skipDirection: SkipDirection + private var isChapterSkip: Bool { + guard let interval else { return false } + return interval == Int(Constants.SkipInterval.chapterSkipValue) + } + var body: some View { ZStack { - if let interval = interval { - Text("**\(interval)**") - .minimumScaleFactor(0.1) - .lineLimit(1) - .padding(5) - .offset(y: 1) - } + if isChapterSkip { + Image( + systemName: skipDirection == .forward + ? "forward.end.fill" + : "backward.end.fill" + ) + .resizable() + .aspectRatio(contentMode: .fit) + .padding(12) + } else { + if let interval = interval { + Text("**\(interval)**") + .minimumScaleFactor(0.1) + .lineLimit(1) + .padding(5) + .offset(y: 1) + } - ResizeableImageView(name: skipDirection.systemImage) + ResizeableImageView(name: skipDirection.systemImage) + } } } } diff --git a/Shared/Constants.swift b/Shared/Constants.swift index a9daf46b6..60a92a4de 100644 --- a/Shared/Constants.swift +++ b/Shared/Constants.swift @@ -90,6 +90,11 @@ public enum Constants { public static let macOSTextScale = "userSettingsMacOSTextScale" } + public enum SkipInterval { + /// Sentinel value indicating "skip to next/previous chapter" mode + public static let chapterSkipValue: TimeInterval = -1.0 + } + public enum SmartRewind { public static let threshold: TimeInterval = 60 * 60 // 60 minutes public static let minRewind: TimeInterval = 2