Skip to content

0.17.0#92

Merged
briankeane merged 14 commits into
mainfrom
develop
May 24, 2026
Merged

0.17.0#92
briankeane merged 14 commits into
mainfrom
develop

Conversation

@briankeane

Copy link
Copy Markdown
Collaborator

Changes

Version Bump

  • Previous: 0.16.0 (on main)
  • New: 0.17.0
  • Bump type: minor

Note: 0.16.1 was tagged on develop after #89 but was never merged to main. All of that work is included in this release.

After Merge

Tag main with 0.17.0:

git checkout main && git pull
git tag 0.17.0
git push origin 0.17.0

briankeane and others added 12 commits March 20, 2026 22:09
Incorporate changes from New Streaming Player
play(stationId:) now clears existing spin players before starting fresh,
preventing stale AVPlayers from being reused after audio interruptions.
Removed misleading interruption state saves from headphone disconnect
handler and dead isSuspended property. Added scheduleFetcher injection
for testability and 8 new tests covering all interruption codepaths.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Revert all source code to the stable 0.14.0 state, removing the
streaming player port (0.15.0) which introduced issues. Add
STREAMING_APPROACH.md documenting the AVPlayer-based streaming
architecture for future reference.
Revert to 0.14.0 and document streaming approach
* Fix app hang by awaiting audio session before engine start

The Sentry hang (2000ms+) was caused by AVAudioEngine.start() being
called before the audio session was configured. configureAudioSession()
fired a Task (fire-and-forget) then engine.start() ran immediately,
causing the engine to stall on the main thread.

Add ensureAudioSessionConfigured() async method to PlayolaMainMixer
and await it in handleSuccessfulDownload before playback begins. Also
remove the Thread.sleep retry loop from start() which was masking the
unconfigured session issue while blocking the main thread.

* Handle audio session config failure and add precondition asserts

Replace try? with proper error handling in handleSuccessfulDownload so
a failed audio session config bails out instead of proceeding to
engine.start() with an unconfigured session.

Add assertions in playNow and prepareAudioEngine to catch direct
callers that skip ensureAudioSessionConfigured().

* Add .context to .gitignore
* Start audio engine off the main thread on interruption resume

AVAudioEngine.start() makes a synchronous mach_msg to the audio server
to bring up the hardware. When resuming after a phone-call interruption
the hardware is cold and this call can exceed the 2s main-thread
watchdog, producing Sentry "App Hanging" reports with AUIOClient_StartIO
at the top of the stack.

Make PlayolaMainMixer.start() async and dispatch the blocking engine
start to a background queue via withCheckedThrowingContinuation, so the
main thread is await-suspended rather than blocked. restartEngine()
follows suit. resumeAfterInterruption now awaits the restart.

AVAudioEngine is documented thread-safe for start/stop; the topology is
only mutated at init/setup time on the main actor, so there is no race.
@preconcurrency import AVFoundation silences the expected Sendable
warning on the AVAudioEngine capture.

* Keep restartEngine and start on MainActor

Per review: only engine.start() needs to leave the main thread. Restore
@mainactor on start() and restartEngine() so engine.stop() and
engine.prepare() stay on the main actor — the continuation inside
start() still dispatches the blocking engine.start() call to a
background queue.
Normalization is now handled server-side, so the client no longer needs to
calculate per-file gain or apply it via an EQ stage. Simplifies the audio
graph from playerNode -> normalizationEQ -> trackMixer -> mainMixer to
playerNode -> trackMixer -> mainMixer.

- Delete AudioNormalizationCalculator and its tests
- Delete the now-orphaned AudioBufferTestUtilities helper
- Drop the zero-band AVAudioUnitEQ gain stage from SpinPlayer
- Update hardResetTrackMixer to reconnect playerNode directly to the new mixer
- Remove normalization mentions from README features/architecture and CLAUDE.md
Stopgap for an iOS 26 networking bug: URLSession's TLS 1.3 ClientHello
includes the X25519MLKEM768 post-quantum hybrid key share (~1.6 KB), and
some middleboxes (antivirus SSL inspection, parental-control routers,
captive portals) drop the larger ClientHello, surfacing as
NSURLErrorSecureConnectionFailed (-1200). Capping to TLS 1.2 keeps the
ClientHello small enough to pass through.

Routes the library's URLSessions through a new internal tls12Session
(or makeTLS12Configuration() when a delegate is needed) so audio
downloads from S3, schedule fetches, and listening-session reports
all succeed on affected networks.

Hardcoded rather than configurable on purpose — this is temporary and
should be easy to revert when Apple ships a fix or a supported opt-out
for the post-quantum hybrid key share.
Greptile P2: makeTLS12Configuration() only pinned the maximum TLS
version, leaving the minimum at its default (.TLSv10). ATS already
enforces a TLS 1.2 floor for HTTPS on iOS, but pinning the minimum
explicitly removes any ambiguity about the supported range and makes
the intent ("TLS 1.2, exclusively") obvious from the code.
* Cap all URLSessions to TLS 1.2

Stopgap for an iOS 26 networking bug: URLSession's TLS 1.3 ClientHello
includes the X25519MLKEM768 post-quantum hybrid key share (~1.6 KB), and
some middleboxes (antivirus SSL inspection, parental-control routers,
captive portals) drop the larger ClientHello, surfacing as
NSURLErrorSecureConnectionFailed (-1200). Capping to TLS 1.2 keeps the
ClientHello small enough to pass through.

Routes the library's URLSessions through a new internal tls12Session
(or makeTLS12Configuration() when a delegate is needed) so audio
downloads from S3, schedule fetches, and listening-session reports
all succeed on affected networks.

Hardcoded rather than configurable on purpose — this is temporary and
should be easy to revert when Apple ships a fix or a supported opt-out
for the post-quantum hybrid key share.

* Pin TLS minimum to 1.2 as well

Greptile P2: makeTLS12Configuration() only pinned the maximum TLS
version, leaving the minimum at its default (.TLSv10). ATS already
enforces a TLS 1.2 floor for HTTPS on iOS, but pinning the minimum
explicitly removes any ambiguity about the supported range and makes
the intent ("TLS 1.2, exclusively") obvious from the code.
@greptile-apps

greptile-apps Bot commented May 23, 2026

Copy link
Copy Markdown

Greptile Summary

This PR bundles five fixes and a feature removal into the 0.17.0 release: a TLS 1.2 cap for iOS 26 middlebox failures, removal of client-side audio normalization, an off-main-thread audio engine start to prevent AUIOClient_StartIO hangs, and corrections to the audio-session/interruption-resume flow.

  • TLS 1.2 cap (TLS12Session.swift): A new makeTLS12Configuration() helper and shared tls12Session singleton are wired into every network path (audio downloads, schedule fetches, listening-session reports) to avoid oversized TLS 1.3 ClientHellos being dropped by middleboxes on iOS 26. The workaround is well-documented with explicit reversion criteria.
  • Off-main-thread engine start (PlayolaMainMixer.swift): start() and restartEngine() are promoted to async throws; engine.start() is dispatched via withCheckedThrowingContinuation + DispatchQueue.global to prevent blocking the main thread. All call sites in SpinPlayer and PlayolaStationPlayer updated accordingly.
  • Audio normalization removal (AudioNormalizationCalculator.swift, SpinPlayer.swift): The normalizationEQ graph node and normalizationCalculator are removed cleanly, along with all associated tests and utilities.

Confidence Score: 5/5

All changes are targeted bug fixes and a clean feature removal with no regressions identified.

Every changed path was traced end-to-end: TLS configuration is applied consistently across all three network paths, the async engine-start dispatch is correctly implemented with withCheckedThrowingContinuation, all call sites were updated to await the now-async start()/restartEngine(), and the audio normalization removal is complete with no dangling references.

No files require special attention.

Important Files Changed

Filename Overview
Sources/PlayolaPlayer/Player/TLS12Session.swift New file introducing a TLS 1.2 cap via makeTLS12Configuration() and a shared tls12Session singleton — well-documented with a clear reversion comment for when Apple ships a fix.
Sources/PlayolaPlayer/Player/PlayolaMainMixer.swift start() and restartEngine() promoted to async throws; engine start dispatched to DispatchQueue.global(qos: .userInitiated) via withCheckedThrowingContinuation to unblock the main thread from AUIOClient_StartIO. @preconcurrency import AVFoundation added to suppress Sendable warnings for the cross-thread engine dispatch.
Sources/PlayolaPlayer/Player/SpinPlayer.swift Audio normalization graph nodes (normalizationEQ, normalizationCalculator) removed; engine-start paths guarded with !engine.isRunning; loadFile now calls playolaMainMixer.start() for the off-main-thread startup path before signalling ready.
Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift Schedule fetch switched from URLSession.shared to tls12Session; interruption-resume path updated to await the now-async restartEngine(); baseUrl default corrected to drop the erroneous /v1 suffix.
Sources/PlayolaPlayer/Player/FileDownloaderAsync.swift Both download paths (direct and retry) switched from URLSessionConfiguration.default to makeTLS12Configuration() for consistent TLS 1.2 capping across all audio downloads.
Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift Default URLSession changed from URLSession.shared to tls12Session in both the production and #if DEBUG initializers, completing the TLS coverage for listening-session reporting.
Sources/PlayolaPlayer/Player/AudioNormalizationCalculator.swift Entire file deleted as part of the client-side audio normalization removal; corresponding tests and utilities also removed cleanly.

Sequence Diagram

sequenceDiagram
    participant App
    participant StationPlayer as PlayolaStationPlayer (@MainActor)
    participant SpinPlayer as SpinPlayer (@MainActor)
    participant Mixer as PlayolaMainMixer (@MainActor)
    participant BG as DispatchQueue.global
    participant Engine as AVAudioEngine

    App->>StationPlayer: play(stationId:)
    StationPlayer->>StationPlayer: fetchSchedule() via tls12Session
    StationPlayer->>SpinPlayer: load(spin:)
    SpinPlayer->>SpinPlayer: loadFile(localUrl:)
    SpinPlayer->>Mixer: ensureAudioSessionConfigured()
    Mixer-->>SpinPlayer: session configured
    SpinPlayer->>Mixer: start() [async]
    Mixer->>BG: DispatchQueue.global.async
    BG->>Engine: engine.start()
    Engine-->>BG: started
    BG-->>Mixer: continuation.resume()
    Mixer-->>SpinPlayer: engine running
    SpinPlayer-->>StationPlayer: .success(localUrl)
    StationPlayer->>SpinPlayer: playNow(from:)
    SpinPlayer->>SpinPlayer: check isRunning (skip redundant start)
    SpinPlayer->>Engine: playerNode.play()
Loading

Reviews (3): Last reviewed commit: "Merge main into develop to resync after ..." | Re-trigger Greptile

Comment thread Sources/PlayolaPlayer/Player/PlayolaStationPlayer.swift Outdated
- PlayolaStationPlayer.baseUrl default no longer includes /v1, so the
  shared instance constructs schedule URLs as /v1/stations/{id}/schedule
  instead of /v1/v1/stations/{id}/schedule when configure() is never
  called. Aligns with configure()'s baseURL parameter default and the
  /v1 prefix that createScheduleURL still appends.
- SpinPlayer.handleSuccessfulDownload now awaits playolaMainMixer.start()
  after ensureAudioSessionConfigured(), so engine.start() runs off-main
  on the cold first-play path (not just on interruption resume).
  playNow and prepareAudioEngine now skip the sync engine.start() when
  the engine is already running.
@briankeane

Copy link
Copy Markdown
Collaborator Author

@greptile review this

The 0.16.0 release (PR #88) was squash-merged into main, which broke
git's view of shared history. Conflicts arose in PlayolaMainMixer.swift
and SpinPlayer.swift because develop has since evolved those files
(PR #89 made PlayolaMainMixer.start async, and the recent P2 fix added
off-main engine start to handleSuccessfulDownload).

Resolved by taking develop's version in both files — develop has
everything main has plus the newer fixes. Also restored the P1 baseUrl
fix in PlayolaStationPlayer.swift that the auto-merge silently reverted.
@briankeane

Copy link
Copy Markdown
Collaborator Author

@greptile please review this

@briankeane
briankeane merged commit eae75ea into main May 24, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant