diff --git a/podcasts.xcodeproj/project.pbxproj b/podcasts.xcodeproj/project.pbxproj index 21592c090..ccf03eea2 100644 --- a/podcasts.xcodeproj/project.pbxproj +++ b/podcasts.xcodeproj/project.pbxproj @@ -548,6 +548,8 @@ 8BB2E58B2A8AA02E00E93088 /* SharedConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD054A791E3EDEB300D9195B /* SharedConstants.swift */; }; 8BB4AA662BD1A8040091480A /* sleep-timer-restarted-sound.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 8BB4AA652BD1A8040091480A /* sleep-timer-restarted-sound.mp3 */; }; 8BB4AA672BD1A80E0091480A /* sleep-timer-restarted-sound.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 8BB4AA652BD1A8040091480A /* sleep-timer-restarted-sound.mp3 */; }; + 8BB4AA632BD17EC10091480A /* FadeOutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB4AA622BD17EC10091480A /* FadeOutManager.swift */; }; + 8BB4AA642BD17EC10091480A /* FadeOutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB4AA622BD17EC10091480A /* FadeOutManager.swift */; }; 8BB55E3A28FEEE99001D1766 /* StoryShareableProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB55E3928FEEE99001D1766 /* StoryShareableProvider.swift */; }; 8BBE19EE2BEA973E009E944B /* ShowInfoCoordinating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBE19ED2BEA973E009E944B /* ShowInfoCoordinating.swift */; }; 8BBE19F02BEA9865009E944B /* PodcastIndexChapterDataRetriever.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBE19EF2BEA9865009E944B /* PodcastIndexChapterDataRetriever.swift */; }; @@ -2357,6 +2359,7 @@ 8BAD2EB32AEE9620006264B3 /* PaidStoryWallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaidStoryWallView.swift; sourceTree = ""; }; 8BAD6E602975AFAA00DB7259 /* GoogleSocialLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleSocialLogin.swift; sourceTree = ""; }; 8BB4AA652BD1A8040091480A /* sleep-timer-restarted-sound.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "sleep-timer-restarted-sound.mp3"; sourceTree = ""; }; + 8BB4AA622BD17EC10091480A /* FadeOutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FadeOutManager.swift; sourceTree = ""; }; 8BB55E3928FEEE99001D1766 /* StoryShareableProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryShareableProvider.swift; sourceTree = ""; }; 8BBE19ED2BEA973E009E944B /* ShowInfoCoordinating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowInfoCoordinating.swift; sourceTree = ""; }; 8BBE19EF2BEA9865009E944B /* PodcastIndexChapterDataRetriever.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndexChapterDataRetriever.swift; sourceTree = ""; }; @@ -4950,6 +4953,7 @@ BDF15A3D1B54E053000EC323 /* PlaybackManager.swift */, BDF15A3F1B54E064000EC323 /* PlaybackQueue.swift */, BDF15A451B54E21B000EC323 /* PlaybackEffects.swift */, + 8BB4AA622BD17EC10091480A /* FadeOutManager.swift */, 8B1B782A2BC7104000DC3373 /* SleepTimerManager.swift */, BD44A4442302DAC6004F55B2 /* CommonUpNextItem.swift */, BD2219F0253EAEAF000025BE /* PlaybackCatchUpHelper.swift */, @@ -8739,6 +8743,7 @@ C7BDECB52A15327000BECF02 /* PatronUnlockButton.swift in Sources */, BDA206601BB3D4D600D38389 /* DownloadsViewController.swift in Sources */, 407502B6240DCBF200437528 /* NetworkUtils+Helpers.swift in Sources */, + 8BB4AA632BD17EC10091480A /* FadeOutManager.swift in Sources */, C741D9D428ADBA23006CFBE7 /* AppLifecyleAnalytics.swift in Sources */, BDD84CE527CC9353001C078B /* ChoosePodcastFolderModel.swift in Sources */, 8BA198842AE0205900B66568 /* CompletionRateStory.swift in Sources */, @@ -9507,6 +9512,7 @@ BDE48A242410D0D400ECA6CA /* UserEpisodeManager.swift in Sources */, 461C46CE274D9E400003D3A9 /* LocalizationHelpers.swift in Sources */, C7252863299B353A00A582C3 /* BookmarkManager.swift in Sources */, + 8BB4AA642BD17EC10091480A /* FadeOutManager.swift in Sources */, 46D88AB727DF97CA00F3BC43 /* PodcastsListView.swift in Sources */, BDD3019A1EFB9EE8004A9972 /* TopLevelItemRowController.swift in Sources */, 463FCE7D27BEA90B00E5AF89 /* WatchInterfaceType.swift in Sources */, diff --git a/podcasts/DefaultPlayer.swift b/podcasts/DefaultPlayer.swift index c4d8f2024..498c52147 100644 --- a/podcasts/DefaultPlayer.swift +++ b/podcasts/DefaultPlayer.swift @@ -111,6 +111,8 @@ class DefaultPlayer: PlaybackProtocol, Hashable { performSetPlaybackRate() jumpToStartingPosition() + player?.volume = 1 + completion?() } @@ -681,4 +683,10 @@ class DefaultPlayer: PlaybackProtocol, Hashable { episodeArtwork.loadEmbeddedImage(asset: asset, podcastUuid: podcastUuid, episodeUuid: episodeUuid) #endif } + + // MARK: - Volume + + func setVolume(_ volume: Float) { + player?.volume = volume + } } diff --git a/podcasts/EffectsPlayer.swift b/podcasts/EffectsPlayer.swift index 408bc7ea0..eaf80129a 100644 --- a/podcasts/EffectsPlayer.swift +++ b/podcasts/EffectsPlayer.swift @@ -25,6 +25,8 @@ class EffectsPlayer: PlaybackProtocol, Hashable { private var timePitch: AVAudioUnitTimePitch? private var playbackSpeed = 0 as Double // AVAudioUnitTimePitch seems to not like us querying the rate sometimes, so store that as a separate variable + private var audioMixerNode: AVAudioMixerNode? + // for volume boost private var highPassFilter: AVAudioUnitEffect? private var dynamicsProcessor: AVAudioUnitEffect? @@ -87,6 +89,9 @@ class EffectsPlayer: PlaybackProtocol, Hashable { strongSelf.effects = PlaybackManager.shared.effects() strongSelf.playBufferManager = PlayBufferManager() + strongSelf.audioMixerNode = strongSelf.createAudioMixerNode() + strongSelf.engine?.attach(strongSelf.audioMixerNode!) + // volume boost effects strongSelf.highPassFilter = strongSelf.createHighPassUnit() strongSelf.engine?.attach(strongSelf.highPassFilter!) @@ -136,7 +141,8 @@ class EffectsPlayer: PlaybackProtocol, Hashable { format = strongSelf.audioFile!.processingFormat } - strongSelf.engine?.connect(strongSelf.player!, to: strongSelf.timePitch!, format: format) + strongSelf.engine?.connect(strongSelf.player!, to: strongSelf.audioMixerNode!, format: format) + strongSelf.engine?.connect(strongSelf.audioMixerNode!, to: strongSelf.timePitch!, format: format) strongSelf.engine?.connect(strongSelf.timePitch!, to: strongSelf.highPassFilter!, format: format) strongSelf.engine?.connect(strongSelf.highPassFilter!, to: strongSelf.dynamicsProcessor!, format: format) strongSelf.engine?.connect(strongSelf.dynamicsProcessor!, to: strongSelf.peakLimiter!, format: format) @@ -431,6 +437,10 @@ class EffectsPlayer: PlaybackProtocol, Hashable { } } + private func createAudioMixerNode() -> AVAudioMixerNode { + return AVAudioMixerNode() + } + private func createTimePitchUnit() -> AVAudioUnitTimePitch { var componentDescription = AudioComponentDescription() componentDescription.componentType = kAudioUnitType_FormatConverter @@ -476,4 +486,10 @@ class EffectsPlayer: PlaybackProtocol, Hashable { func hash(into hasher: inout Hasher) { hasher.combine(ObjectIdentifier(self)) } + + // MARK: - Volume + + func setVolume(_ volume: Float) { + audioMixerNode?.outputVolume = volume + } } diff --git a/podcasts/FadeOutManager.swift b/podcasts/FadeOutManager.swift new file mode 100644 index 000000000..0e83b58c1 --- /dev/null +++ b/podcasts/FadeOutManager.swift @@ -0,0 +1,47 @@ +import Foundation + +class FadeOutManager { + weak var player: PlaybackProtocol? + + private var timer: Timer? + + private let volumeChangesPerSecond = 30.0 + private var currentChange = 0.0 + private var fadeDuration = 5.0 + private let fadeVelocity = 2.0 + private let fromVolume = 1.0 + private let toVolume = 0.0 + private var totalNumberOfVolumeChanges = 0.0 + private lazy var timerDelay = 1.0 / volumeChangesPerSecond + + + /// Performs a fade out on the given `PlaybackProtocol` by using a logarithmic algorithm + /// This way the final results is smooth 🧈 to the human's hearing 👂 + /// - Parameter duration: the duration of the fade out effect + func fadeOut(duration: TimeInterval) { + fadeDuration = duration + currentChange = 0 + totalNumberOfVolumeChanges = fadeDuration * volumeChangesPerSecond + timer = Timer.scheduledTimer(withTimeInterval: TimeInterval(timerDelay), repeats: true) { [weak self] _ in + guard let self, + currentChange < totalNumberOfVolumeChanges else { + self?.timer?.invalidate() + return + } + + let normalizedTime = (currentChange / totalNumberOfVolumeChanges).betweenZeroAndOne + let volumeMultiplier = pow(M_E, -fadeVelocity * normalizedTime) * (1 - normalizedTime) + let newVolume = toVolume - (toVolume - fromVolume) * volumeMultiplier + + player?.playing() == true ? player?.setVolume(Float(newVolume)) : timer?.invalidate() + + currentChange += 1 + } + } +} + +private extension Double { + var betweenZeroAndOne: Double { + max(0, min(1, self)) + } +} diff --git a/podcasts/GoogleCastPlayer.swift b/podcasts/GoogleCastPlayer.swift index 8f16836f3..2325d9261 100644 --- a/podcasts/GoogleCastPlayer.swift +++ b/podcasts/GoogleCastPlayer.swift @@ -122,4 +122,10 @@ class GoogleCastPlayer: PlaybackProtocol { func internalPlayerForVideoPlayback() -> AVPlayer? { nil } + + // MARK: - Volume + + func setVolume(_ volume: Float) { + // not supported + } } diff --git a/podcasts/PlaybackManager.swift b/podcasts/PlaybackManager.swift index 7781c2e66..34a9c2314 100644 --- a/podcasts/PlaybackManager.swift +++ b/podcasts/PlaybackManager.swift @@ -1368,6 +1368,10 @@ class PlaybackManager: ServerPlaybackDelegate { // here (as above) we're assuming that in general the timer fires around once a second. Might have to investigate this though as it might not always be the case if sleepTimeRemaining >= 0 { + if sleepTimeRemaining == sleepTimerManager.sleepTimerFadeDuration { + sleepTimerManager.performFadeOut(player: player) + } + sleepTimeRemaining = sleepTimeRemaining - updateTimerInterval if sleepTimeRemaining < 0 { diff --git a/podcasts/PlaybackProtocol.swift b/podcasts/PlaybackProtocol.swift index 29e60b24b..fe7bd856e 100644 --- a/podcasts/PlaybackProtocol.swift +++ b/podcasts/PlaybackProtocol.swift @@ -31,6 +31,8 @@ import PocketCastsDataModel func interruptionDidStart() func internalPlayerForVideoPlayback() -> AVPlayer? + + func setVolume(_ volume: Float) } enum PlaybackError: Error { diff --git a/podcasts/SleepTimerManager.swift b/podcasts/SleepTimerManager.swift index 8744de7ab..5bbc94a27 100644 --- a/podcasts/SleepTimerManager.swift +++ b/podcasts/SleepTimerManager.swift @@ -23,6 +23,10 @@ class SleepTimerManager { } }() + let sleepTimerFadeDuration = 5.seconds + + private lazy var fadeOutManager = FadeOutManager() + init(backgroundShakeObserver: BackgroundShakeObserver = BackgroundShakeObserver()) { self.backgroundShakeObserver = backgroundShakeObserver backgroundShakeObserver.whenShook = { [weak self] in @@ -76,6 +80,11 @@ class SleepTimerManager { } } + func performFadeOut(player: PlaybackProtocol) { + fadeOutManager.player = player + fadeOutManager.fadeOut(duration: sleepTimerFadeDuration) + } + private func observePlaybackEndAndReactivateTime() { NotificationCenter.default.addObserver(self, selector: #selector(playbackTrackChanged), name: Constants.Notifications.playbackTrackChanged, object: nil) }