Commit c6734dbc authored by Kourser's avatar Kourser
Browse files

feat(playback): sleep at episode end, streaming errors, prompt position saves

- Sleep timer gains a 'Fin de l'épisode' option alongside the fixed
  durations: playback fades out over the episode's last 20 seconds and
  stops there; the episode is marked played but the queue does not
  advance (new onSleepAtEpisodeEnd callback).
- The streaming engine now observes AVPlayerItem failures (status KVO +
  FailedToPlayToEndTime) and reports them through engineDidFail, so a
  dead URL or a network drop shows the playback-error alert instead of
  a silently frozen player.
- The resume position is persisted immediately on pause (new onPause
  hook) and when the app enters the background, instead of only every
  10 seconds while playing.

Co-Authored-By: Claude (RCA)
parent e1284bad
Loading
Loading
Loading
Loading
+8 −0
Original line number Diff line number Diff line
@@ -15,6 +15,9 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
- **Fondu du minuteur de sommeil** : le volume décroît progressivement sur les
  20 dernières secondes au lieu d'une coupure nette, puis est restauré pour la
  lecture suivante.
- **Minuteur « Fin de l'épisode »** : en plus des durées fixes, le minuteur
  peut s'arrêter à la fin de l'épisode en cours (avec le même fondu) —
  l'épisode est marqué lu mais la file n'enchaîne pas.

### Corrigé
- L'égaliseur (et le saut de silence) activé **pendant** la lecture d'un épisode
@@ -23,6 +26,11 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
- Les **erreurs de lecture** (fichier téléchargé illisible, moteur audio en
  échec) affichent désormais une alerte au lieu de passer silencieusement à
  l'épisode suivant de la file.
- Les **échecs de streaming** (URL morte, coupure réseau) affichent une alerte
  au lieu de laisser le lecteur figé sans message.
- La **position de lecture** est sauvegardée immédiatement à la pause et au
  passage de l'app en arrière-plan (auparavant : uniquement toutes les 10 s
  pendant la lecture — jusqu'à 10 s pouvaient être perdues).

### À venir
- Intégrations système : Widget, CarPlay, Apple Watch, Siri / Raccourcis.
+30 −0
Original line number Diff line number Diff line
@@ -13,6 +13,8 @@ public final class AVAudioPlayerEngine: AudioPlayerEngine {
    private let player = AVPlayer()
    private var timeObserver: Any?
    private var endObserver: NSObjectProtocol?
    private var failureObserver: NSObjectProtocol?
    private var statusObservation: NSKeyValueObservation?
    private var desiredRate: Float = 1.0
    private var reportedDuration: TimeInterval = 0

@@ -30,6 +32,10 @@ public final class AVAudioPlayerEngine: AudioPlayerEngine {
        if let endObserver {
            NotificationCenter.default.removeObserver(endObserver)
        }
        if let failureObserver {
            NotificationCenter.default.removeObserver(failureObserver)
        }
        statusObservation?.invalidate()
        let item = AVPlayerItem(url: url)
        reportedDuration = 0
        player.replaceCurrentItem(with: item)
@@ -42,11 +48,35 @@ public final class AVAudioPlayerEngine: AudioPlayerEngine {
                self?.delegate?.engineDidFinishPlaying()
            }
        }
        // Streams fail asynchronously (dead URL, network drop): surface both an
        // item that never becomes ready and a mid-playback stall as errors.
        failureObserver = NotificationCenter.default.addObserver(
            forName: .AVPlayerItemFailedToPlayToEndTime,
            object: item,
            queue: .main
        ) { [weak self] note in
            let error = note.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
            MainActor.assumeIsolated {
                self?.reportFailure(error)
            }
        }
        statusObservation = item.observe(\.status) { [weak self] item, _ in
            guard item.status == .failed else { return }
            let error = item.error
            Task { @MainActor in
                self?.reportFailure(error)
            }
        }
        if seconds > 0 {
            player.seek(to: CMTime(seconds: seconds, preferredTimescale: 600))
        }
    }

    private func reportFailure(_ error: Error?) {
        player.pause()
        delegate?.engineDidFail(error ?? URLError(.unknown))
    }

    public func play() {
        player.rate = desiredRate
    }
+37 −3
Original line number Diff line number Diff line
@@ -20,6 +20,9 @@ public final class PlaybackController {
    public private(set) var playbackRate: Float = 1.0
    /// Seconds left on the sleep timer, or `nil` when inactive.
    public private(set) var sleepRemaining: TimeInterval?
    /// Sleep mode that stops at the end of the current episode (no queue
    /// advance), with the same fade-out. Exclusive with ``sleepRemaining``.
    public private(set) var sleepAtEpisodeEnd = false

    /// Fraction in 0...1, safe when the duration is unknown.
    public var progress: Double {
@@ -38,8 +41,13 @@ public final class PlaybackController {
    @ObservationIgnored public weak var observer: PlaybackObserver?
    /// Called when the current item plays to its end (used for queue advance).
    @ObservationIgnored public var onFinish: (@MainActor () -> Void)?
    /// Called instead of ``onFinish`` when ``sleepAtEpisodeEnd`` stops playback:
    /// the episode completed but the queue must not advance.
    @ObservationIgnored public var onSleepAtEpisodeEnd: (@MainActor () -> Void)?
    /// Called when the engine fails to load or play (no queue advance).
    @ObservationIgnored public var onError: (@MainActor (Error) -> Void)?
    /// Called after playback pauses (used to persist the resume position).
    @ObservationIgnored public var onPause: (@MainActor () -> Void)?

    /// Seconds over which the sleep timer fades the volume before pausing.
    static let sleepFadeSeconds: TimeInterval = 20
@@ -90,6 +98,7 @@ public final class PlaybackController {
        engine.pause()
        isPlaying = false
        notify()
        onPause?()
    }

    /// Stops playback and clears the current item (used by the in-app reset).
@@ -126,11 +135,19 @@ public final class PlaybackController {
        }
    }

    /// Pauses at the end of the current episode instead of after a delay.
    /// Replaces any running timer.
    public func startSleepTimerAtEpisodeEnd() {
        cancelSleepTimer()
        sleepAtEpisodeEnd = true
    }

    public func cancelSleepTimer() {
        sleepTask?.cancel()
        sleepTask = nil
        let wasFading = sleepFadeFactor < 1
        sleepRemaining = nil
        sleepAtEpisodeEnd = false
        if wasFading { engine.setVolume(baseVolume) }
    }

@@ -149,11 +166,20 @@ public final class PlaybackController {
        }
    }

    /// 1 outside the fade window, falling linearly to 0 at timer expiry.
    /// 1 outside the fade window, falling linearly to 0 at timer expiry (or at
    /// the end of the episode in ``sleepAtEpisodeEnd`` mode).
    private var sleepFadeFactor: Float {
        guard let remaining = sleepRemaining, remaining < Self.sleepFadeSeconds else { return 1 }
        if let remaining = sleepRemaining {
            guard remaining < Self.sleepFadeSeconds else { return 1 }
            return Float(remaining / Self.sleepFadeSeconds)
        }
        if sleepAtEpisodeEnd, duration > 0 {
            let left = duration - currentTime
            guard left < Self.sleepFadeSeconds else { return 1 }
            return Float(max(0, left) / Self.sleepFadeSeconds)
        }
        return 1
    }

    public func resume() {
        guard currentEpisode != nil else { return }
@@ -210,6 +236,9 @@ extension PlaybackController: AudioPlayerEngineDelegate {
        // High-frequency: update the observable state but don't notify the
        // observer on every tick (the system extrapolates elapsed time).
        if time.isFinite { currentTime = time }
        // End-of-episode sleep: re-derive the fade from the position on every
        // tick (also restores full volume after a seek back out of the window).
        if sleepAtEpisodeEnd { engine.setVolume(baseVolume * sleepFadeFactor) }
        // Per-podcast "skip end": once close enough to the end, treat the item
        // as finished so the queue advances early. Fires at most once per item.
        if skipEndSeconds > 0, !didCutEnd, duration > skipEndSeconds,
@@ -229,8 +258,13 @@ extension PlaybackController: AudioPlayerEngineDelegate {
    public func engineDidFinishPlaying() {
        isPlaying = false
        currentTime = duration
        let sleptAtEnd = sleepAtEpisodeEnd
        if sleptAtEnd {
            sleepAtEpisodeEnd = false
            engine.setVolume(baseVolume) // undo the fade for the next playback
        }
        notify()
        onFinish?()
        if sleptAtEnd { onSleepAtEpisodeEnd?() } else { onFinish?() }
    }

    public func engineDidFail(_ error: Error) {
+15 −0
Original line number Diff line number Diff line
@@ -157,6 +157,21 @@ private func makeEpisode() -> Episode {
        #expect(finishes == 0)
    }

    @Test func pauseInvokesThePersistenceHook() {
        let engine = FakeAudioEngine()
        let controller = PlaybackController(engine: engine)
        var pauses = 0
        controller.onPause = { pauses += 1 }
        controller.play(episode: makeEpisode())

        controller.pause()
        #expect(pauses == 1)

        controller.resume()
        controller.togglePlayPause()
        #expect(pauses == 2)
    }

    @Test func engineFailureReportsErrorWithoutAdvancingQueue() {
        struct FakeError: Error {}
        let engine = FakeAudioEngine()
+56 −0
Original line number Diff line number Diff line
@@ -66,4 +66,60 @@ private final class NoopEngine: AudioPlayerEngine {
        controller.setVolume(1.0)
        #expect(engine.volume < 1.0)
    }

    @Test func episodeEndModeStopsWithoutAdvancingAndSignalsCompletion() {
        let engine = NoopEngine()
        let controller = PlaybackController(engine: engine)
        var finishes = 0
        var sleepStops = 0
        controller.onFinish = { finishes += 1 }
        controller.onSleepAtEpisodeEnd = { sleepStops += 1 }
        controller.play(episode: Episode(title: "Ep",
                                         enclosureURL: URL(string: "https://example.com/a.mp3"),
                                         duration: 100))
        controller.startSleepTimerAtEpisodeEnd()
        #expect(controller.sleepAtEpisodeEnd)

        engine.delegate?.engineDidFinishPlaying()

        #expect(!controller.isPlaying)
        #expect(!controller.sleepAtEpisodeEnd) // one-shot
        #expect(sleepStops == 1)
        #expect(finishes == 0) // the queue must not advance

        // The next episode finishing normally advances again.
        engine.delegate?.engineDidFinishPlaying()
        #expect(finishes == 1)
    }

    @Test func episodeEndModeFadesNearTheEndAndRecoversAfterSeekBack() {
        let engine = NoopEngine()
        let controller = PlaybackController(engine: engine)
        controller.setVolume(0.8)
        controller.play(episode: Episode(title: "Ep",
                                         enclosureURL: URL(string: "https://example.com/a.mp3"),
                                         duration: 100))
        controller.startSleepTimerAtEpisodeEnd()

        engine.delegate?.engineDidUpdateTime(50)
        #expect(engine.volume == 0.8)

        engine.delegate?.engineDidUpdateTime(95) // 5 s left, inside the window
        #expect(engine.volume < 0.8)

        engine.delegate?.engineDidUpdateTime(40) // seek back out of the window
        #expect(engine.volume == 0.8)

        engine.delegate?.engineDidUpdateTime(99)
        engine.delegate?.engineDidFinishPlaying()
        #expect(engine.volume == 0.8) // fade undone for the next playback
    }

    @Test func startingAMinutesTimerClearsEpisodeEndMode() {
        let controller = PlaybackController(engine: NoopEngine())
        controller.startSleepTimerAtEpisodeEnd()
        controller.startSleepTimer(minutes: 15)
        #expect(!controller.sleepAtEpisodeEnd)
        #expect(controller.sleepRemaining == 900)
    }
}
Loading