Skip to content

Incorporate changes from New Streaming Player#83

Merged
briankeane merged 15 commits into
developfrom
main
Mar 21, 2026
Merged

Incorporate changes from New Streaming Player#83
briankeane merged 15 commits into
developfrom
main

Conversation

@briankeane

Copy link
Copy Markdown
Collaborator

This pull request introduces significant improvements to the PlayolaPlayer example app, primarily by adding a toggle between streaming and download playback modes, refactoring the main view logic to support both modes, and enhancing the flexibility and testability of the listening session reporting infrastructure. Additional code cleanup and UI improvements are also included.

Major feature: Dual playback mode support

  • Added a PlayerMode enum and UI toggle to switch between streaming (AVPlayer) and download (AVAudioEngine) playback modes in ContentView.swift, with logic updates throughout the view to support both player types. [1] [2] [3] [4]
  • Updated playback control logic (playOrPause, playWithOffset, etc.) and UI feedback (button color, icon, album art animation) to reflect the selected playback mode and player state. [1] [2] [3] [4] [5]

Station selection and playback integration

  • Modified StationPickerView to support both playback modes, allowing users to start playback in the selected mode directly from the station picker. [1] [2]

Listening session infrastructure improvements

  • Refactored ListeningSessionReporter to decouple from a concrete PlayolaStationPlayer by introducing a stationIdGetter closure and an alternate initializer that accepts a publisher and getter, improving testability and flexibility. [1] [2] [3] [4]

Code cleanup and UI polish

  • Removed unused helper functions and streamlined button logic in ContentView.swift for clarity and maintainability.
  • Minor documentation and code comment improvements for clarity. [1] [2] [3] [4]

Documentation update

  • Added instructions for handling PR review comments and reactions in CLAUDE.md.

briankeane and others added 15 commits August 19, 2025 09:48
Introduces StreamingStationPlayer as an alternative to PlayolaStationPlayer
that streams audio via AVPlayer instead of downloading entire files first.
This significantly reduces startup latency for long audio files.

New files:
- FadeScheduleBuilder: pure fade schedule generation (shared utility)
- AVPlayerProviding: protocol wrapping AVPlayer for testability
- StreamingSpinPlayer: AVPlayer-based single spin playback
- StreamingStationPlayer: orchestrator with schedule polling
- ScheduleService: extracted shared schedule-fetching logic

Modified:
- ListeningSessionReporter: added publisher-based init for both players

37 new tests across 3 test suites, all passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Add player mode picker (Streaming vs Download) to ContentView
- Stop current player before switching stations
- Hard-code default station ID for testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace 3-member tuple return with PlayerTestContext struct
- Use `where` clause instead of `if` inside `for` loop

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- P1: AVPlayerWrapper.waitForReady now keeps observing on .unknown status
  instead of leaking the continuation; throws on @unknown default
- P1: StreamingSpinPlayer.playNow guards state after await seek to prevent
  clear() race from leaving player in .playing with dangling timers
- P1: StreamingStationPlayer adds deinit to remove NotificationCenter
  observers, preventing retain cycle / memory leak
- P2: stopCurrentPlayer() is now async; callers await it before starting
  new playback to prevent stop/play races

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- P1: StreamingStationPlayer transitions to .idle when last spin finishes
  and spinPlayers is empty
- P1: Change @ObservedObject to @StateObject for streamingPlayer in
  ContentView so SwiftUI owns its lifecycle
- P2: FadeScheduleBuilder skips overlapping steps to ensure monotonic
  time ordering when fades are closer than fadeDurationMS
- Add test for overlapping fade edge case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
After awaiting player.load(), check that stationId is still set before
proceeding to playback. If stop() ran during the suspension, clean up
the player instead of overwriting the .idle state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Mar 21, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces the new StreamingStationPlayer (AVPlayer-based) alongside the existing download-based PlayolaStationPlayer, adds a dual-mode UI toggle in the example app, and refactors ListeningSessionReporter to accept a publisher/getter pair instead of a concrete player reference — improving both testability and reuse. The new streaming infrastructure is clean, well-tested, and properly isolated with @MainActor.

Key changes:

  • StreamingStationPlayer + StreamingSpinPlayer + FadeScheduleBuilder — full AVPlayer streaming stack with fade automation and schedule polling
  • AVPlayerProviding protocol + AVPlayerWrapper — testable AVPlayer abstraction
  • ListeningSessionReporter refactored: new init(stationIdPublisher:stationIdGetter:) decouples it from PlayolaStationPlayer, and the startPeriodicNotifications timer now correctly uses the stationIdGetter closure rather than the stored player reference
  • ContentView updated with a PlayerMode enum and segmented Picker, with mode-aware playOrPause / playWithOffset / stopCurrentPlayer logic
  • StationPickerView wired up to support both player types

Issues found:

  • AVPlayerProviding.waitForReady — KVO callback uses [weak self] but the CheckedContinuation has no fallback resume path if self is nil; the observation should be invalidated synchronously before resuming to harden against edge cases (see inline comment)
  • configure() on StreamingStationPlayer must be called before play() for the listening session reporter to receive the stationId change; this ordering is undocumented
  • StationPickerView calls dismiss() synchronously outside its Task, so playback errors are silently swallowed with no user feedback
  • stopCurrentPlayer() in ContentView always stops both players regardless of the active playerMode

Confidence Score: 4/5

  • Safe to merge with one hardening fix recommended for the AVPlayerWrapper continuation bridge.
  • The streaming infrastructure is well-designed, properly @MainActor-isolated, and has solid unit test coverage. The ListeningSessionReporter refactor is clean and non-breaking. The one substantive concern is in AVPlayerWrapper.waitForReady: the [weak self] KVO pattern can in theory leave a CheckedContinuation permanently un-resumed, which would cause a silent hang rather than a recoverable error. The example-app issues (dismiss() ordering, stopCurrentPlayer() stopping both players) are minor UX/style concerns that do not affect library correctness.
  • Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift — the waitForReady continuation safety pattern should be reviewed before shipping to production.

Important Files Changed

Filename Overview
Sources/PlayolaPlayer/Player/Streaming/AVPlayerProviding.swift New protocol and production wrapper for AVPlayer. The waitForReady KVO-to-continuation bridge uses [weak self] in a way that can leave a CheckedContinuation un-resumed if self is deallocated before the observation fires.
Sources/PlayolaPlayer/Player/Streaming/StreamingStationPlayer.swift New streaming player using AVPlayer; well-structured with proper actor isolation, schedule polling, and audio interruption handling. configure() must be called before play() for listening session reporting to work, but this ordering is not documented or enforced.
Sources/PlayolaPlayer/Player/Streaming/StreamingSpinPlayer.swift New per-spin player managing AVPlayer lifecycle, fade automation, and cleanup timers. State machine is clearly documented and well-implemented with safe async guards.
Sources/PlayolaPlayer/Player/Streaming/FadeScheduleBuilder.swift Pure value-type utility for building discrete fade interpolation steps. Clean implementation with comprehensive test coverage; monotonic ordering guaranteed for overlapping fades.
Sources/PlayolaPlayer/Player/ListeningSessionReporter.swift Refactored to decouple from PlayolaStationPlayer by introducing a stationIdGetter closure and a publisher-based initializer. Improves testability and flexibility with no observable regressions in the existing initializer path.
PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift Adds dual-mode (streaming/download) toggle with mode-aware playback controls. Two minor issues: dismiss() fires before async playback completes, silently swallowing errors; stopCurrentPlayer() always stops both players regardless of active mode.
Tests/PlayolaPlayerTests/StreamingSpinPlayerTests.swift Good unit test coverage for StreamingSpinPlayer using a MockAVPlayer; covers load success/failure, state transitions, delegate callbacks, and deduplication of state changes.
Tests/PlayolaPlayerTests/StreamingStationPlayerTests.swift Clean tests for StreamingStationPlayer covering initial state, stop, configure, delegate events, and isPlaying derived property. Uses a tracker pattern to capture mock players.
Tests/PlayolaPlayerTests/FadeScheduleBuilderTests.swift Thorough tests for all three public methods of FadeScheduleBuilder, including edge cases for overlapping fades, empty input, custom parameters, and mid-schedule index lookups.
Sources/PlayolaPlayer/Player/ScheduleService.swift Schedule fetching service with structured error handling; no changes of concern.

Sequence Diagram

sequenceDiagram
    participant UI as ContentView
    participant SSP as StreamingStationPlayer
    participant SpinP as StreamingSpinPlayer
    participant AVW as AVPlayerWrapper
    participant LSR as ListeningSessionReporter
    participant API as Playola API

    UI->>SSP: play(stationId:)
    SSP->>API: getSchedule(stationId:)
    API-->>SSP: Schedule (spins)
    SSP->>SpinP: load(spin)
    SpinP->>AVW: loadURL(url)
    AVW->>AVW: waitForReady (KVO on AVPlayerItem.status)
    AVW-->>SpinP: .success / .failure
    SpinP-->>SSP: Result
    SSP->>SpinP: playNow(from: elapsedSeconds)
    SpinP->>AVW: seek + play()
    SpinP-->>SSP: delegate.startedPlaying(spin)
    SSP->>SSP: state = .playing(spin)
    SSP-->>UI: @Published state update

    Note over SSP: $stationId publishes new value
    SSP->>LSR: stationIdPublisher emits stationId
    LSR->>API: POST /v1/listeningSessions
    API-->>LSR: 200 OK
    LSR->>LSR: startPeriodicNotifications (every 10s)

    loop Every 20s (schedule polling)
        SSP->>API: getSchedule(stationId:)
        API-->>SSP: Updated schedule
        SSP->>SpinP: loadAndPlaySpin (future spins)
    end

    UI->>SSP: stop()
    SSP->>SpinP: stop() → clear()
    SSP->>SSP: stationId = nil
    SSP-->>LSR: stationIdPublisher emits nil
    LSR->>API: POST /v1/listeningSessions/end
    LSR->>LSR: stopPeriodicNotifications
Loading

Last reviewed commit: "Merge pull request #..."

Comment on lines +66 to +96
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
self.statusObservation = item.observe(\.status, options: [.new]) {
[weak self] observedItem, _ in
guard let self else { return }

Task { @MainActor in
switch observedItem.status {
case .readyToPlay:
self.statusObservation?.invalidate()
self.statusObservation = nil
continuation.resume()
case .failed:
self.statusObservation?.invalidate()
self.statusObservation = nil
let error =
observedItem.error
?? StationPlayerError.playbackError("AVPlayerItem failed to load")
continuation.resume(throwing: error)
case .unknown:
// Not yet determined — keep observing.
return
@unknown default:
self.statusObservation?.invalidate()
self.statusObservation = nil
continuation.resume(
throwing: StationPlayerError.playbackError(
"AVPlayerItem entered unexpected status"))
}
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Continuation may never resume if self is nil

The KVO callback captures self weakly and returns early via guard let self else { return } if the AVPlayerWrapper has been deallocated. In that case, the CheckedContinuation is never resumed — leaving the awaiting loadURL caller suspended indefinitely (or until its enclosing Task is cancelled).

While in practice AVPlayerWrapper is kept alive by the local player variable inside StreamingSpinPlayer.load(_:) for the duration of the await, the pattern is fragile. If clear() is ever called before the observation fires, the local player variable is the only remaining reference, and any intervening refactoring that changes ownership could silently introduce a hang.

A safer approach is to use strong (unowned) capture of the continuation, or store it in an Optional that is nilled after first use:

private func waitForReady(item: AVPlayerItem) async throws {
    try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
        self.statusObservation = item.observe(\.status, options: [.new]) {
            [weak self] observedItem, _ in
            // Invalidate synchronously to prevent potential double-fire
            self?.statusObservation?.invalidate()
            self?.statusObservation = nil

            switch observedItem.status {
            case .readyToPlay:
                continuation.resume()
            case .failed:
                let error = observedItem.error
                    ?? StationPlayerError.playbackError("AVPlayerItem failed to load")
                continuation.resume(throwing: error)
            case .unknown:
                return
            @unknown default:
                continuation.resume(
                    throwing: StationPlayerError.playbackError(
                        "AVPlayerItem entered unexpected status"))
            }
        }
    }
}

This also removes the Task { @MainActor in } wrapper from the callback, eliminating the (admittedly small) window where a second KVO event could enqueue a second Task that also attempts to resume an already-consumed continuation.

Comment on lines 463 to 481
@@ -401,7 +481,6 @@ struct StationPickerView: View {
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 dismiss() fires before playback completes — errors are silently dropped

dismiss() is called synchronously outside the Task block, so the sheet is dismissed immediately when the user taps a station — before streamingPlayer.play() or PlayolaStationPlayer.shared.play() even begins executing. If either play() call throws (e.g. due to a network error), the error is silently consumed by the print(...) and the user receives no feedback.

If surfacing playback errors to the user is desirable, consider moving dismiss() inside the Task (before the do/catch) so the sheet stays visible until playback actually starts:

Task {
    do {
        let stationId = station.playolaID ?? station.id
        selectedStationId = stationId
        switch playerMode {
        case .streaming:
            streamingPlayer.stop()
            try await streamingPlayer.play(stationId: stationId)
        case .download:
            await PlayolaStationPlayer.shared.stop()
            try await PlayolaStationPlayer.shared.play(stationId: stationId)
        }
        dismiss()
    } catch {
        // TODO: surface error to user
        print("Failed to start playback: \(error)")
    }
}

Comment on lines +304 to +307
func stopCurrentPlayer() async {
await downloadPlayer.stop()
streamingPlayer.stop()
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 stopCurrentPlayer() always stops both players, not just the active one

Regardless of the current playerMode, both downloadPlayer.stop() and streamingPlayer.stop() are always called. This is harmless but creates unnecessary calls on the inactive player each time mode is switched or the stop button is tapped. Stopping only the player that corresponds to the current mode would be cleaner:

func stopCurrentPlayer() async {
    switch playerMode {
    case .streaming:
        streamingPlayer.stop()
    case .download:
        await downloadPlayer.stop()
    }
}

If the intent is specifically to stop both players when switching modes (e.g., via onChange(of: playerMode)), that call site could explicitly stop both, making the invariant explicit.

Comment on lines +101 to +113
public func configure(
authProvider: PlayolaAuthenticationProvider,
baseURL: URL = URL(string: "https://admin-api.playola.fm")!
) {
self.authProvider = authProvider
self.baseUrl = baseURL
self.listeningSessionReporter = ListeningSessionReporter(
stationIdPublisher: $stationId.eraseToAnyPublisher(),
stationIdGetter: { [weak self] in self?.stationId },
authProvider: authProvider,
baseURL: baseURL
)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 configure() must be called before play() for listening sessions to work — no enforcement

ListeningSessionReporter is created in configure() and subscribes to $stationId changes via publisher.sink. If play(stationId:) is called before configure(), stationId is already set when the reporter is created, and the sink receives no initial value — so the first listening session is never reported.

A doc comment or runtime precondition would help callers avoid this silent failure. For example:

/// Configure with authentication provider.
///
/// - Important: Must be called before `play(stationId:)` to ensure
///   listening sessions are reported correctly.
public func configure(
    authProvider: PlayolaAuthenticationProvider,
    baseURL: URL = URL(string: "https://admin-api.playola.fm")!
) {

Alternatively, the reporter could use $stationId.prepend(stationId) so it captures the current value at subscription time and handles both orderings.

@briankeane
briankeane merged commit 48b75b2 into develop Mar 21, 2026
5 checks passed
@briankeane
briankeane deleted the main branch March 21, 2026 03:09
@briankeane
briankeane restored the main branch March 21, 2026 03:09
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