Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1bb9fd9
fix: add backoff + terminal error state to initial schedule fetch
briankeane Jun 7, 2026
8fe6d91
fix: address PR review — remove double error-reporting, doc internal …
briankeane Jun 7, 2026
c90858a
fix: cancel lingering schedulingTask on terminal play() failure (PR r…
briankeane Jun 7, 2026
75d57ff
fix: treat cancelled download as cancellation in play() (PR review P1)
briankeane Jun 7, 2026
1ca78e4
fix: gate stale state writes via play generation; narrow retry (PR re…
briankeane Jun 8, 2026
e8604a5
test: don't construct CoreAudio-backed SpinPlayer in unit tests (fix …
briankeane Jun 8, 2026
a8cfd56
refactor: capture play generation immutably for spin-load callbacks (…
briankeane Jun 8, 2026
6ae9fa1
fix: thread immutable generation through scheduling chain (PR review P1)
briankeane Jun 8, 2026
7e5801f
Merge pull request #95 from playola-radio/briankeane/playola-schedule…
briankeane Jun 8, 2026
65a9986
fix: run SpinPlayer audio control off the main thread to fix App Hangs
briankeane Jun 8, 2026
d568c5b
fix: drop duplicate os import to satisfy swiftlint
briankeane Jun 8, 2026
7925ff0
fix: address PR review (off-main fade-path engine start, clearer play…
briankeane Jun 8, 2026
b3c8b12
fix: keep fade path synchronous, start engine fire-and-forget off-main
briankeane Jun 8, 2026
9ddad58
Merge pull request #96 from playola-radio/briankeane/sentry-related-i…
briankeane Jun 8, 2026
a739c32
docs: document playNow/schedulePlay async signature change as source-…
briankeane Jun 8, 2026
d2580c7
fix: don't report schedule-fetch cancellations as errors; drop dead p…
briankeane Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,45 @@ All notable changes to PlayolaPlayer are documented here. This project follows
[Semantic Versioning](https://semver.org/). Versions correspond to git tags,
which Swift Package Manager consumers pin to.

## 0.19.0

### Added
Comment thread
briankeane marked this conversation as resolved.

- **Bounded retry/backoff on the initial schedule fetch.** `play(stationId:)`
now retries a failed initial `GET /v1/stations/{id}/schedule` up to 3 times
with exponential backoff (0.5s / 1s / 2s) before giving up, matching the
recovery behavior the player already used for spin loading and the ongoing
schedule poll. Only transient failures are retried — server `5xx` responses
and an explicit allow-list of connectivity `URLError`s (timeouts, host/DNS,
connection-lost, etc.). Permanent failures (`404`, decode errors, empty
schedules, and non-connectivity `URLError`s such as `.badURL`) still fail
fast. This means a transient backend outage no longer instantly fails a
station start with no automatic recovery.

### Changed

- **`PlayolaStationPlayer.State` gained a terminal `.error(StationPlayerError)`
case.** A failed `play(...)` now emits `.loading(0)` when the attempt begins
and `.error(_)` when it fails terminally (instead of throwing without ever
changing `state`, which could leave consumers stuck on a loading spinner with
no signal). `play(...)` still `throws` as before — the new state is emitted
*in addition to* the thrown error. Cancellation does not produce an `.error`
state.

**Source-breaking for consumers** that `switch` exhaustively over `State`:
add a `case .error` (or `default`) arm. A typical handler treats `.error` as
a recoverable, retry-able state (show the message, let the user tap play
again). `StationPlayerError` is now `Sendable`.

- **State transitions are now supersession-safe (last `play()` wins).** Each
`play(...)`/`stop()` takes a new internal generation; work from a prior
attempt (its scheduling loop, or a spin whose audio starts later) can no
longer publish state into a newer attempt. This closes a race where a
lingering session could overwrite a freshly-emitted `.error` (or `.idle`)
with `.playing`. Behavioral note: starting a new `play()` supersedes the
previous session immediately, so a *failed* station switch lands on `.error`
rather than rolling back to the previously-playing station.

## 0.18.0

### Added
Expand Down
23 changes: 20 additions & 3 deletions PlayolaPlayerExample/PlayolaPlayerExample/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,18 @@ struct ContentView: View {
.font(.caption)
.foregroundColor(.white.opacity(0.6))
}
} else if case .error(let error) = player.state {
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.yellow)
Text(error.errorDescription ?? "Couldn't start the station")
.font(.subheadline)
.multilineTextAlignment(.center)
.foregroundColor(.white.opacity(0.8))
Text("Tap play to try again")
.font(.caption)
.foregroundColor(.white.opacity(0.6))
}
} else {
Text("Ready to Play")
.font(.title3)
Expand Down Expand Up @@ -237,12 +249,13 @@ struct ContentView: View {
case .playing:
// Stop playing
await player.stop()
case .idle:
// Start playing
case .idle, .error:
// Start (or retry after a failed start)
do {
try await player.play(stationId: selectedStationId)
} catch {
// Handle errors gracefully (including cancellation during loading)
// Handle errors gracefully (including cancellation during loading).
// The terminal .error state is surfaced via player.state above.
print("Failed to start playback: \(error)")
}
}
Expand Down Expand Up @@ -493,6 +506,8 @@ func buttonColor(for state: PlayolaStationPlayer.State) -> Color {
return Color.red
case .idle:
return Color.green
case .error:
return Color.green
}
}

Expand All @@ -504,6 +519,8 @@ func buttonIcon(for state: PlayolaStationPlayer.State) -> String {
return "stop.fill"
case .idle:
return "play.fill"
case .error:
return "arrow.clockwise"
}
}

Expand Down
Loading
Loading