Files
Android-247-Radio/docs/plans/2026-03-11-playback-and-ui-fixes-design.md
cottongin 0705e6a2bc docs: add playback and UI fixes design
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
2026-03-11 16:03:59 -04:00

7.9 KiB
Raw Blame History

Playback and UI Fixes Design

Scope

Fixes 16 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

  • Playing is only entered after AudioEngineEvent.Started — never before the engine connects.
  • stayConnected is a configuration flag (read from preferences), not part of the state.
  • All transitions go through a single transition() function that validates and updates RadioController.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_streams entries)
  • QualityOverrideDialog: shows available stream qualities, drag-to-reorder (matches Settings pattern), "Use default" to clear
  • StationListViewModel.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

  • Playlist gains a pinned: Boolean column (default: true for built-in tabs SomaFM and My Stations/Favorites, false for 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.sortOrder for affected playlists via PlaylistDao.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 ReconnectingIdle + stopSelf + session ended
  • Concurrent play: Station A → Station B → old job cancelled, no stopSelf race
  • 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 override
  • PlaylistDaoTest: sort order updates, pin/unpin

Compose UI tests

  • QualityOverrideDialogTest: renders stream list, selection, save callback
  • RenamePlaylistDialogTest: pre-filled name, validates non-empty
  • MiniPlayerTest: bottom padding includes navigation bar inset

Priority Order

  1. Cleartext HTTP — trivial, unblocks HTTP streams immediately
  2. State machine refactor + stuck state fix — most disruptive UX bug
  3. URL fallback — natural part of the state machine refactor
  4. Miniplayer insets — quick UI fix
  5. Per-station quality UI — feature addition
  6. Playlist management — feature addition (import naming → rename → pin/unpin → drag reorder)