State machine refactor of RadioPlaybackService, cleartext HTTP, URL fallback, miniplayer insets, per-station quality prefs, and playlist management (import naming, rename, pin/unpin, reorder). Made-with: Cursor
7.9 KiB
Playback and UI Fixes Design
Scope
Fixes 1–6 from the root cause analysis. Non-MP3 stream support (Fix 7) is deferred.
| # | Bug | Fix |
|---|---|---|
| 1 | Cleartext HTTP blocked | network_security_config.xml + manifest attribute |
| 2 | No URL fallback | startEngine iterates resolved URL list |
| 3 | Stuck Playing state on failure |
Full state machine refactor of RadioPlaybackService |
| 4 | Miniplayer overlaps nav bar | Navigation bar inset padding in MiniPlayer |
| 5 | No per-station quality/SSL preference | StationPreference table + long-press quality dialog |
| 6 | Playlist management gaps | Import naming dialog, tab rename, tab pin/unpin, drag-to-reorder |
Approach
Full state machine refactor (Approach B). The service layer will grow to support recording and clipping in the future; a proper state machine prevents the class of bugs where state gets stuck or transitions are missed.
1. State Machine
States
Idle
Connecting(station, urls: List<String>, currentUrlIndex: Int)
Playing(station, metadata?, streamInfo?, sessionStartedAt, connectionStartedAt)
Paused(station, metadata?, sessionStartedAt, streamInfo?)
Reconnecting(station, metadata?, sessionStartedAt, attempt)
Playing is designed to gain an optional recording: RecordingState? field later without changing the state machine.
Transitions
Idle ──[play]──► Connecting(station, urls[], urlIndex=0)
Connecting ──[engine Started]──► Playing
Connecting ──[URL failed, more URLs]──► Connecting(urlIndex + 1)
Connecting ──[all URLs failed, stayConnected]──► Reconnecting(attempt=1)
Connecting ──[all URLs failed, !stayConnected]──► Idle + stopSelf
Playing ──[user pause]──► Paused
Playing ──[connection lost, stayConnected]──► Reconnecting(attempt=1)
Playing ──[connection lost, !stayConnected]──► Idle + stopSelf
Paused ──[user resume]──► Connecting(station, urls[], urlIndex=0)
Reconnecting ──[delay / network available]──► Connecting(station, urls[], urlIndex=0)
Reconnecting ──[user stop]──► Idle + stopSelf
Any ──[user stop]──► Idle + stopSelf
Key invariants
Playingis only entered afterAudioEngineEvent.Started— never before the engine connects.stayConnectedis a configuration flag (read from preferences), not part of the state.- All transitions go through a single
transition()function that validates and updatesRadioController.state.
2. RadioPlaybackService Refactor
transition() function
All state changes route through one function. No more scattered controller.updateState() calls.
startEngine(station, urls)
Loops over the URL list:
for ((index, url) in urls.withIndex()) {
transition(Connecting(station, urls, index))
try {
engine = AudioEngine(url, bufferMs)
engine.start()
awaitEngineResult(engine, station)
return
} catch (e: ConnectionFailedException) {
continue
}
}
throw AllUrlsExhaustedException()
handlePlay() finally block
Explicitly handles all states — no silent fall-through:
finally {
endConnectionSpan()
when {
state is Paused -> updateNotification(paused)
playJob == coroutineContext[Job] -> {
transition(Idle)
endListeningSession()
cleanupResources()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
// else: new play job took over
}
}
stopSelf race fix
playJob == coroutineContext[Job] guards all cleanup, not just the Idle branch. If a new play was launched, the old job exits without calling stopSelf.
handlePause / handleStop
Remain thin. RadioController.pause() sets the Paused state optimistically from the UI side. The service's finally block respects it.
3. Cleartext HTTP
New file app/src/main/res/xml/network_security_config.xml:
<network-security-config>
<base-config cleartextTrafficPermitted="true" />
</network-security-config>
In AndroidManifest.xml <application> tag:
android:networkSecurityConfig="@xml/network_security_config"
4. Miniplayer Navigation Bar Insets
In MiniPlayer.kt, add bottom padding for the navigation bar:
val navBarPadding = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
// Apply as additional bottom padding inside the Surface
Works on both FireOS and stock Android (gesture and 3-button nav).
5. Per-Station Quality Preference
Data layer
New StationPreference entity (separate table, extensible for future per-station settings like recording prefs):
stationId: Long(FK to Station, unique)qualityOverride: String?- Future columns:
bufferOverrideMs,autoRecord, etc.
New StationPreferenceDao: getByStationId(), upsert().
Room migration adds the table.
StreamResolver
Checks StationPreference.qualityOverride for the station before falling back to the global preferences.qualityPreference.
UI
- Long-press station → context menu gains "Quality" (only for stations with
station_streamsentries) QualityOverrideDialog: shows available stream qualities, drag-to-reorder (matches Settings pattern), "Use default" to clearStationListViewModel.setQualityOverride(stationId, override)
6. Playlist Management
Import naming
Split importFile() into parse → NamePlaylistDialog (pre-filled with filename) → insert. Simple text field + OK/Cancel.
Tab rename
Long-press non-built-in tab → "Rename" in context menu → RenamePlaylistDialog (pre-filled with current name) → PlaylistDao.rename().
Tab pin/unpin
Playlistgains apinned: Booleancolumn (default:truefor built-in tabs SomaFM and My Stations/Favorites,falsefor custom playlists)- Long-press any tab → "Pin" / "Unpin" toggle in context menu
- Tab row renders two groups: pinned tabs (left), unpinned tabs (right)
Tab drag-to-reorder
- Both groups are independently drag-reorderable:
- Pinned group (left): reorderable among pinned tabs only
- Unpinned group (right): reorderable among unpinned tabs only
- Dragging does not move tabs between groups — use Pin/Unpin to change groups
- On drop, update
Playlist.sortOrderfor affected playlists viaPlaylistDao.updateSortOrder()
7. Testing
Service state machine (unit tests, MockK)
Idle → Connecting → Playing(happy path)Connecting → Connecting(URL fallback: first URL fails, second succeeds)Connecting → Reconnecting → Connecting → Playing(all URLs fail, reconnect, succeed)Playing → Reconnecting → Playing(connection lost, reconnect)Playing → Paused → Connecting → Playing(pause/resume)- Stop during
Reconnecting→Idle+stopSelf+ session ended - Concurrent play: Station A → Station B → old job cancelled, no
stopSelfrace - Network callback:
onAvailable→ immediate retry (skip backoff)
URL fallback (unit tests)
- Mock engine fails on URL 0, succeeds on URL 1. Verify transitions and correct URL used.
DAO tests (Room in-memory DB)
StationPreferenceDaoTest: upsert, query quality overridePlaylistDaoTest: sort order updates, pin/unpin
Compose UI tests
QualityOverrideDialogTest: renders stream list, selection, save callbackRenamePlaylistDialogTest: pre-filled name, validates non-emptyMiniPlayerTest: bottom padding includes navigation bar inset
Priority Order
- Cleartext HTTP — trivial, unblocks HTTP streams immediately
- State machine refactor + stuck state fix — most disruptive UX bug
- URL fallback — natural part of the state machine refactor
- Miniplayer insets — quick UI fix
- Per-station quality UI — feature addition
- Playlist management — feature addition (import naming → rename → pin/unpin → drag reorder)