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

Sleep Timer Improvements: fade out 5s before pausing playback #1629

Merged
merged 10 commits into from
Apr 19, 2024
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
-----
- Show the Up Next bar on the tab bar. [#1613]
- Shake the device to restart Sleep Timer [#1627]
- Playback fades out when finishing a sleep timer [#1629]

7.62
-----
Expand Down
6 changes: 6 additions & 0 deletions podcasts.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,8 @@
8BAD6E5E2975ADB800DB7259 /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = 8BAD6E5D2975ADB800DB7259 /* GoogleSignIn */; };
8BAD6E612975AFAA00DB7259 /* GoogleSocialLogin.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BAD6E602975AFAA00DB7259 /* GoogleSocialLogin.swift */; };
8BB2E58B2A8AA02E00E93088 /* SharedConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD054A791E3EDEB300D9195B /* SharedConstants.swift */; };
8BB4AA632BD17EC10091480A /* FadeOutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB4AA622BD17EC10091480A /* FadeOutManager.swift */; };
8BB4AA642BD17EC10091480A /* FadeOutManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB4AA622BD17EC10091480A /* FadeOutManager.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 */; };
8BB55E3A28FEEE99001D1766 /* StoryShareableProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BB55E3928FEEE99001D1766 /* StoryShareableProvider.swift */; };
Expand Down Expand Up @@ -2353,6 +2355,7 @@
8BAB8B2E299ABC8200B8404C /* SearchResultsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultsViewController.swift; sourceTree = "<group>"; };
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>"; };
8BB4AA622BD17EC10091480A /* FadeOutManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FadeOutManager.swift; sourceTree = "<group>"; };
8BB4AA652BD1A8040091480A /* sleep-timer-restarted-sound.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = "sleep-timer-restarted-sound.mp3"; sourceTree = "<group>"; };
8BB55E3928FEEE99001D1766 /* StoryShareableProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryShareableProvider.swift; sourceTree = "<group>"; };
8BC060072AB1FA0400A4FEC6 /* PlayEpisodeIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayEpisodeIntent.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -4934,6 +4937,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 @@ -8719,6 +8723,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 @@ -9487,6 +9492,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 */,
463FCE7D27BEA90B00E5AF89 /* WatchInterfaceType.swift in Sources */,
46CD891327E222B20046A9FB /* FilterEpisodeListView.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 @@ -84,6 +86,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 @@ -137,7 +142,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 @@ -432,6 +438,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 @@ -477,4 +487,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 {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this name at all, but couldn't think of something better.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tricky one maybe:VolumeControl or FaderMixer

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 @@ -7,6 +7,10 @@ class SleepTimerManager {

private let backgroundShakeObserver: BackgroundShakeObserver

let sleepTimerFadeDuration = 5.seconds

private lazy var fadeOutManager = FadeOutManager()

private lazy var tonePlayer: AVAudioPlayer? = {
guard let url = Bundle.main.url(forResource: "sleep-timer-restarted-sound", withExtension: "mp3") else {
FileLog.shared.addMessage("[Sleep Timer] Unable to create tone player because the sound file is missing from the bundle.")
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