Add AVPlayer-based streaming player#82
Conversation
Remove Basic Auth
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>
Greptile SummaryThis PR adds Notable changes:
Issues found:
Confidence Score: 3/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant App
participant SSP as StreamingStationPlayer
participant SchedSvc as ScheduleService
participant SpinPlayer as StreamingSpinPlayer
participant AVW as AVPlayerWrapper
App->>SSP: play(stationId, atDate)
SSP->>SchedSvc: getSchedule(stationId, baseUrl)
SchedSvc-->>SSP: Schedule
SSP->>SpinPlayer: load(spin)
SpinPlayer->>AVW: loadURL(url)
AVW-->>SpinPlayer: readyToPlay via KVO
SpinPlayer-->>SSP: .success
SSP->>SpinPlayer: playNow(from offset) or schedulePlay(at airtime)
SpinPlayer->>AVW: seek + play
SpinPlayer-->>SSP: startedPlaying(spin)
SSP->>SSP: state = .playing(spin)
loop Every 20s polling
SSP->>SchedSvc: getSchedule poll
SchedSvc-->>SSP: Updated schedule
SSP->>SpinPlayer: load future spin
SpinPlayer->>SpinPlayer: schedulePlay(at airtime)
end
Note over SpinPlayer: clearTimer fires at endtime+1s
SpinPlayer->>SpinPlayer: state = .finished then clear
SpinPlayer-->>SSP: didChangeState(.finished)
SSP->>SSP: remove from spinPlayers
SSP->>SSP: if empty, state = .idle
App->>SSP: stop()
SSP->>SpinPlayer: stop then clear
SSP->>SSP: state = .idle
Last reviewed commit: "Fix review round 2: ..." |
- 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>
|
@greptile one more pass, please! |
- 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>
|
@greptile one more pass! |
|
|
||
| do { | ||
| try await Task.sleep(for: .seconds(StreamingStationPlayer.schedulePollingInterval)) | ||
| } catch { | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private func performScheduleUpdate(stationId: String) async throws { | ||
| let updatedSchedule = try await ScheduleService.getSchedule( | ||
| stationId: stationId, baseUrl: baseUrl) | ||
|
|
||
| let spinsToLoad = updatedSchedule.current(offsetTimeInterval: scheduleOffset).filter { | ||
| $0.airtime < .now + StreamingStationPlayer.scheduleWindow | ||
| } | ||
|
|
||
| for spin in spinsToLoad { | ||
| try Task.checkCancellation() | ||
| if spinPlayers[spin.id] == nil { |
There was a problem hiding this comment.
play() state overwritten after interleaved stop() call
loadAndPlaySpin is async and suspends at await player.load(spin). Because StreamingStationPlayer is @MainActor, stop() can run at that suspension point — it clears spinPlayers, nils stationId, and sets self.state = .idle. When loadAndPlaySpin then resumes, it unconditionally proceeds to player.playNow(from:) and sets self.state = .playing(spin), overwriting the .idle state.
This leaves the station player stuck in .playing(spin) with no active spin players, no active scheduling loop, and a stale now-playing spin in the UI.
The fix is to add a cancellation guard immediately after the await player.load(spin) call completes — check self.stationId != nil (or self.state != .idle) before proceeding to start playback, and if stop was called, call player.clear() and remove the player from spinPlayers before returning.
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>
Summary
StreamingStationPlayeras an alternative toPlayolaStationPlayerthat streams audio via AVPlayer instead of downloading entire files first, significantly reducing startup latencyScheduleServicefor shared schedule-fetching logic (DRY between both players)FadeScheduleBuilderfor timer-based fade automation mirroring the web player's approachListeningSessionReporterto accept a publisher-based init, supporting both player typesTest plan
🤖 Generated with Claude Code