Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Re-add: Sleep Timer Improvements: fade out 5s before pausing playback #1736

Merged
merged 8 commits into from
May 13, 2024
6 changes: 6 additions & 0 deletions podcasts.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -2357,6 +2359,7 @@
8BAD2EB32AEE9620006264B3 /* PaidStoryWallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaidStoryWallView.swift; sourceTree = "<group>"; };
8BAD6E602975AFAA00DB7259 /* GoogleSocialLogin.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleSocialLogin.swift; sourceTree = "<group>"; };
8BB4AA652BD1A8040091480A /* sleep-timer-restarted-sound.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "sleep-timer-restarted-sound.mp3"; sourceTree = "<group>"; };
8BB4AA622BD17EC10091480A /* FadeOutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FadeOutManager.swift; sourceTree = "<group>"; };
8BB55E3928FEEE99001D1766 /* StoryShareableProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryShareableProvider.swift; sourceTree = "<group>"; };
8BBE19ED2BEA973E009E944B /* ShowInfoCoordinating.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShowInfoCoordinating.swift; sourceTree = "<group>"; };
8BBE19EF2BEA9865009E944B /* PodcastIndexChapterDataRetriever.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastIndexChapterDataRetriever.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
8 changes: 8 additions & 0 deletions podcasts/DefaultPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ class DefaultPlayer: PlaybackProtocol, Hashable {
performSetPlaybackRate()
jumpToStartingPosition()

player?.volume = 1

completion?()
}

Expand Down Expand Up @@ -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
}
}
18 changes: 17 additions & 1 deletion podcasts/EffectsPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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!)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
47 changes: 47 additions & 0 deletions podcasts/FadeOutManager.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
6 changes: 6 additions & 0 deletions podcasts/GoogleCastPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,10 @@ class GoogleCastPlayer: PlaybackProtocol {
func internalPlayerForVideoPlayback() -> AVPlayer? {
nil
}

// MARK: - Volume

func setVolume(_ volume: Float) {
// not supported
}
}
4 changes: 4 additions & 0 deletions podcasts/PlaybackManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions podcasts/PlaybackProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import PocketCastsDataModel
func interruptionDidStart()

func internalPlayerForVideoPlayback() -> AVPlayer?

func setVolume(_ volume: Float)
}

enum PlaybackError: Error {
Expand Down
9 changes: 9 additions & 0 deletions podcasts/SleepTimerManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
Expand Down
Loading