Commit 9e3724cb authored by Kourser's avatar Kourser
Browse files

fix(ios): take the audio session when playback starts, not at launch

Xcode's Thread Performance Checker flagged AVAudioSession.setActive on
the main thread. It was called at launch, which had a second effect the
checker does not report: opening the app silenced whatever another app
was playing, before anything had been asked of it.

The category is still declared at launch; the session is now activated
just before playback starts or resumes — a new episode, an intent, the
end of an interruption — on a serial queue of its own, so neither call
holds up the main thread and the category always comes first.

Also clears the five compiler warnings of the package: the tunables of
SilenceSkippingEngine, read from its detached analysis task, are
immutable constants and say so with nonisolated; and DateFormatter is
Sendable, so its nonisolated(unsafe) goes.

Verified on an iOS 27 simulator: at launch, only the category is set,
off the main thread; playing an episode from the app, and from a widget
with the app terminated, activates the session off the main thread
before playback starts. Not seen on a device: that other apps' audio now
survives opening the app, and the resume after a call.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent d94d769c
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -35,6 +35,11 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
  (enceinte AirPlay, Apple TV, casque) sans quitter le lecteur.

### Corrigé
- **iOS — ouvrir l'app ne coupe plus le son des autres apps** : la session audio
  était prise dès le lancement, ce qui interrompait la musique ou la vidéo en
  cours ailleurs avant même d'avoir lancé un épisode, et pouvait figer
  l'interface un instant. Elle n'est plus prise qu'au moment où la lecture
  démarre.
- **iOS — appels et écouteurs** : un épisode téléchargé, lu avec l'égaliseur ou
  la suppression des silences, continuait sur le haut-parleur quand on retirait
  ses écouteurs, et restait figé après un appel. La lecture se met désormais en
+1 −1
Original line number Diff line number Diff line
@@ -5,7 +5,7 @@ import Foundation
/// RSS 2.0 mandates RFC 822 dates (e.g. `Wed, 02 Oct 2002 13:00:00 GMT`),
/// but real-world feeds also use Atom/ISO 8601 and a few common variants.
enum FeedDateParsing {
    nonisolated(unsafe) private static let rfc822: [DateFormatter] = {
    private static let rfc822: [DateFormatter] = {
        let formats = [
            "EEE, dd MMM yyyy HH:mm:ss Z",   // canonical RFC 822
            "EEE, dd MMM yyyy HH:mm:ss zzz",  // textual zone (GMT, PST…)
+4 −4
Original line number Diff line number Diff line
@@ -15,10 +15,10 @@ public final class SilenceSkippingEngine: AudioPlayerEngine {
    public static let equalizerBandFrequencies: [Float] = [60, 250, 1000, 4000, 12000]

    // Tunables (silence threshold is linear RMS; chunk ≈ 93 ms at 44.1 kHz).
    private static let chunkFrames: AVAudioFrameCount = 4096
    private static let windowChunks = 32
    private static let keepSilentLead = 3
    private static let silenceThreshold: Float = 0.015
    nonisolated private static let chunkFrames: AVAudioFrameCount = 4096
    nonisolated private static let windowChunks = 32
    nonisolated private static let keepSilentLead = 3
    nonisolated private static let silenceThreshold: Float = 0.015

    private let engine = AVAudioEngine()
    private let player = AVAudioPlayerNode()
+1 −0
Original line number Diff line number Diff line
@@ -579,6 +579,7 @@ final class AppModel {
        let localURL = downloads.localURL(for: episode)
        // Private feed played from the network: authenticate the stream.
        let streamingAuth = localURL == nil ? credentials(forEpisode: episode) : nil
        await AudioSessionController.activate()
        playback?.play(
            episode: episode,
            url: localURL,
+11 −3
Original line number Diff line number Diff line
@@ -96,7 +96,7 @@ final class AppServices: PlaybackActions {
    func resume() async throws {
        await start()
        if playback.currentEpisode != nil {
            if !playback.isPlaying { playback.resume() }
            await resumeLoaded()
            return
        }
        guard let episode = await model.recentlyPlayed().first else {
@@ -105,6 +105,14 @@ final class AppServices: PlaybackActions {
        await model.play(episode)
    }

    /// Resumes the episode already in the player. Another app may have taken
    /// the audio session since it was paused.
    private func resumeLoaded() async {
        guard !playback.isPlaying else { return }
        await AudioSessionController.activate()
        playback.resume()
    }

    func pause() async {
        await start()
        if playback.isPlaying { playback.pause() }
@@ -115,7 +123,7 @@ final class AppServices: PlaybackActions {
    func play(episodeID: UUID) async throws {
        await start()
        if playback.currentEpisode?.id == episodeID {
            if !playback.isPlaying { playback.resume() }
            await resumeLoaded()
            return
        }
        var episode = model.queue.first { $0.id == episodeID }
@@ -146,7 +154,7 @@ final class AppServices: PlaybackActions {
        }
        if playback.currentEpisode?.id == episode.id {
            playback.seek(to: handoff.position)
            if !playback.isPlaying { playback.resume() }
            await resumeLoaded()
        } else {
            await model.play(episode, at: handoff.position)
        }
Loading