Add project idea and design document

Design doc covers: custom raw audio pipeline (OkHttp → IcyParser →
Mp3FrameSync → MediaCodec → AudioTrack), foreground service with
aggressive reconnection, Room DB with future-proofed schema, Compose UI,
and ICY metadata with album art fallback chain.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-10 00:18:05 -04:00
commit c98f0f32fc
2 changed files with 307 additions and 0 deletions

11
IDEA.md Normal file
View File

@@ -0,0 +1,11 @@
# Android 24/7 Radio
A simple Android application, targeting Android 9.0, which can:
1) Manage playlists of Internet radio stations (mp3/shoutcast/icecast).
2) Playback of stations.
3) A "Stay Connected" mode which stays connected to the radio station no matter what, reconnecting when disconnected (not timing out), and running in a foreground service so Android doesn't kill it.
a) with a button to manually cancel/disconnect.
4) A "as live as possible" mode which eliminates as many buffers and delays as possible, raw unfiltered bytes from the stream directly decoded (mp3) into audio playback, realizing that cellular connections are unreliabe (this is OK, if we drop packets or hear brief drops that is fine.).
As stated above, assume that the streams will be mp3 and primarily icecast/shoutcast-compliant, meaning we can look for metadata and show album art and things like this.

View File

@@ -0,0 +1,296 @@
# Android 24/7 Radio — Design Document
Personal-use Android app for 24/7 internet radio streaming with absolute minimum latency, aggressive reconnection, and Icecast/Shoutcast metadata support.
**Target:** Android 9.0 (API 28) minimum
**Language:** Kotlin
**UI:** Jetpack Compose, Material Design 3
**Approach:** Custom raw audio pipeline (no ExoPlayer)
---
## Architecture Overview
Four layers:
```
┌─────────────────────────────────────────┐
│ UI Layer │
│ Compose screens, ViewModel, StateFlow │
├─────────────────────────────────────────┤
│ Service Layer │
│ RadioPlaybackService (foreground) │
│ MediaSession, notification, wake lock │
├─────────────────────────────────────────┤
│ Audio Engine │
│ StreamConnection → IcyParser → │
│ Mp3FrameSync → MediaCodec → AudioTrack │
├─────────────────────────────────────────┤
│ Data Layer │
│ Room DB, PLS/M3U import/export, │
│ DataStore preferences │
└─────────────────────────────────────────┘
```
### Playback Data Flow
1. User taps a station in the UI.
2. ViewModel tells `RadioPlaybackService` to play a URL.
3. Service starts foreground, acquires wifi + wake locks, launches the audio engine.
4. Audio engine opens HTTP connection with `Icy-MetaData: 1` header.
5. Engine reads raw bytes, splits stream data from ICY metadata blocks.
6. MP3 frame synchronizer finds frame boundaries, feeds complete frames to `MediaCodec`.
7. `MediaCodec` decodes MP3 → PCM, output goes directly to `AudioTrack`.
8. ICY metadata (track title) is emitted as events back up to the service → UI.
9. On disconnect: engine signals the service, service decides whether to reconnect based on mode.
### Key Constraints
- The audio engine is a standalone component with no Android framework dependencies (testable in isolation).
- The service owns lifecycle, reconnection policy, and notification.
- The UI observes state via `StateFlow` — never directly touches the engine.
---
## Audio Engine
Five pipeline stages running on a single dedicated thread.
### Stage 1: StreamConnection
Opens an HTTP connection via OkHttp. Sends `Icy-MetaData: 1` request header. Reads the `icy-metaint` response header to learn the metadata interval. Exposes the raw `InputStream` to the next stage.
On connection failure or stream EOF, emits a `Disconnected` event with the cause. Does not retry — retry logic lives in the service layer.
### Stage 2: IcyParser
Wraps the raw input stream. Reads exactly `metaint` bytes of audio data, then reads the metadata block (1-byte length prefix × 16 = metadata size). Separates concerns:
- Audio bytes forward to Stage 3.
- Metadata strings (e.g., `StreamTitle='Artist - Song';`) are parsed and emitted as events.
If `icy-metaint` is absent, this stage is a passthrough.
### Stage 3: Mp3FrameSync
Scans audio bytes for MP3 frame boundaries (sync word `0xFF` + frame header). Parses the 4-byte header to determine frame size from bitrate, sample rate, and padding. Reads exactly that many bytes to form a complete frame. Passes complete frames to Stage 4.
On corrupted data, discards bytes and re-syncs. This is where glitchy audio manifests on bad connections — we lose a frame or two and pick back up.
### Stage 4: MediaCodec (MP3 → PCM)
Android's hardware-accelerated `MediaCodec` configured for `audio/mpeg`. Synchronous operation: dequeue input buffer, fill with MP3 frame, queue, dequeue output buffer with decoded PCM (16-bit signed, typically 44.1kHz stereo), pass to Stage 5.
### Stage 5: AudioTrack (PCM → Speaker)
`AudioTrack` in `MODE_STREAM` with the smallest buffer size the device supports (`AudioTrack.getMinBufferSize()`). Writes decoded PCM chunks directly. Final latency contributor: ~20-40ms from Android's hardware buffer minimum.
### Configurable Buffer
A ring buffer between Stage 3 and Stage 4. In "as live as possible" mode, capacity is 0 frames — frames go straight through. For smoother listening, user sets 0-500ms via a slider (0-~19 frames at 26ms each).
### Threading
The entire pipeline runs on a single dedicated thread: read from socket, parse ICY, sync frames, decode, write to AudioTrack. No inter-thread synchronization overhead. The thread blocks on socket reads when waiting for data.
### Error Model
Errors propagate as sealed classes:
- `ConnectionFailed(cause)` — HTTP/socket error
- `StreamEnded` — server closed the connection
- `DecoderError(cause)` — MediaCodec failure (restart codec)
- `AudioOutputError(cause)` — AudioTrack write failure
The engine surfaces these to the service layer for handling.
---
## Foreground Service & Stay Connected
### RadioPlaybackService
A `Service` subclass running as a foreground service with a persistent notification.
**Lifecycle:**
1. UI sends "play station X" command.
2. Service starts foreground with notification showing station name.
3. Acquires `PARTIAL_WAKE_LOCK` and `WifiLock`.
4. Creates and starts the audio engine on its dedicated thread.
5. Listens for engine events (metadata, errors, disconnects).
6. On stop: releases locks, tears down engine, stops foreground.
### MediaSession
Provides lockscreen/notification playback controls (play/stop), Bluetooth headset button support, and metadata display on lockscreen. The service updates `MediaSession` metadata when ICY updates arrive.
### Stay Connected Mode
When enabled, aggressive reconnection on disconnect:
1. Engine emits `Disconnected` or `ConnectionFailed`.
2. Service checks Stay Connected flag. If off, stop. If on, continue.
3. Show "Reconnecting..." in the notification.
4. Retry immediately (first attempt).
5. If that fails, backoff: 1s, 2s, 4s, 8s... capped at 30s.
6. Monitor `ConnectivityManager` — when network returns, retry immediately (skip backoff timer).
7. Never give up. Retry indefinitely until the user hits disconnect.
### Notification
Single persistent notification:
- Station name (always)
- Track title (when metadata available)
- Album art (when available, fallbacks as defined below)
- Stop button (always)
- "Reconnecting..." state indicator (when disconnected)
Built with `NotificationCompat`, `MEDIA` notification channel.
---
## Data Model
### Room Database Entities
**Station**
- `id: Long` (auto-generated PK)
- `name: String`
- `url: String` (stream URL)
- `playlistId: Long?` (nullable FK → Playlist)
- `sortOrder: Int`
- `starred: Boolean` (default false)
- `defaultArtworkUrl: String?` (from `#EXTIMG` or manually set)
**Playlist**
- `id: Long` (auto-generated PK)
- `name: String`
- `sortOrder: Int`
- `starred: Boolean` (default false)
**MetadataSnapshot** (append-only, logs every track change)
- `id: Long`
- `stationId: Long` (FK → Station)
- `title: String?`
- `artist: String?` (parsed from StreamTitle if "Artist - Title" format)
- `artworkUrl: String?` (resolved album art URL)
- `timestamp: Long` (epoch millis)
**ListeningSession** (one per play-to-stop session)
- `id: Long`
- `stationId: Long` (FK → Station)
- `startedAt: Long` (epoch millis)
- `endedAt: Long?` (null while active)
**ConnectionSpan** (one per TCP connection within a session)
- `id: Long`
- `sessionId: Long` (FK → ListeningSession)
- `startedAt: Long` (epoch millis)
- `endedAt: Long?` (null while active)
### Future-Proofing
The schema supports features not built in V1:
- **Recording:** Tap the pipeline between Stage 3 (raw MP3 frames) and Stage 4 to write frames to a file. `MetadataSnapshot` timestamps enable splitting by track.
- **Clips:** Reference `MetadataSnapshot` timestamp ranges within a recording.
- **Analytics:** `ListeningSession` + `ConnectionSpan` enable total listening time, reconnect frequency, per-station stats.
### PLS/M3U Import/Export
**Import:** User picks a `.pls` or `.m3u` file via `ACTION_OPEN_DOCUMENT`. Format detected by extension/content. Stations created and added to a user-selected playlist.
**M3U extensions supported:**
- `#EXTINF:` — station display name
- `#EXTIMG:` — station default artwork URL
**PLS parsing:** INI-style, reads `File1=`, `Title1=`, `NumberOfEntries=`.
**Export:** Write stations from a playlist to PLS or M3U via `ACTION_CREATE_DOCUMENT`. Includes `#EXTIMG` for stations with `defaultArtworkUrl`.
### Preferences (DataStore)
- Stay Connected: on/off (default off)
- Live Mode buffer: 0-500ms (default 0)
- Last played station ID
---
## UI
Kotlin/Jetpack Compose. Three screens. Single-activity, state-driven navigation via a `Screen` sealed class.
### Station List (Home)
Playlists as expandable groups, stations as rows within them. Starred items sort to the top.
**Station row:** station name, star toggle, "now playing" indicator if active. Tap to play, long-press for edit/delete.
**Drag-to-reorder** for both stations within playlists and playlists themselves.
**Top bar:** Import (file picker), Add Station (name + URL), Add Playlist, Settings.
Ungrouped stations appear in an "Unsorted" section at the top.
**Mini-player bar** at the bottom when audio is active: station name, current track title (marquee scroll), stop button, tap to open Now Playing.
### Now Playing
- Station name
- Track title + artist (or "No track info" fallback)
- Album art (large, with fallback chain)
- Stop button
- Stay Connected toggle (inline)
- Live Mode buffer indicator (e.g., "Live: 0ms")
- Connection status ("Connected", "Reconnecting...")
- **Session timer** — total elapsed time since play, not reset on reconnect
- **Connection timer** — elapsed time since current TCP connection established, resets on reconnect
- **Latency indicator** — estimated ms from buffer frames + AudioTrack pending position
### Settings
- Stay Connected toggle
- Live Mode buffer slider (0-500ms)
- Export playlist (pick playlist, choose format, save)
- Recently Played (stations with timestamps)
- Track History (searchable "Artist - Title" list with station and timestamp)
---
## Metadata & Album Art
### ICY Metadata Flow
1. IcyParser extracts `StreamTitle` from the raw metadata string.
2. Attempt split on ` - ` into artist + title. If no separator, treat whole string as title.
3. Emit `MetadataUpdate(artist, title, raw)` event.
4. Service persists as `MetadataSnapshot`, updates `MediaSession`, UI observes via StateFlow.
### Album Art Priority
1. **MusicBrainz / Cover Art Archive** — free, no API key. Query by artist + title. Skip if metadata lacks ` - ` separator (likely spoken word).
2. **ICY StreamUrl** — use if the station provides a valid image URL in the metadata.
3. **Station `defaultArtworkUrl`** — from `#EXTIMG` in M3U or manually set.
4. **Station favicon/logo** — scraped from station website.
5. **Generic placeholder** — radio icon.
Art is cached on disk, keyed on `"$artist-$title"`, bounded to ~50MB with LRU eviction.
### Graceful Degradation
| Available Data | Display |
|---|---|
| Artist + Title + Art | Full display |
| Artist + Title, no art | Text + fallback art from chain |
| Title only | Title + fallback art |
| No metadata | Station name + "No track info" + station art or placeholder |
No crashes, no error dialogs. Metadata is additive.
---
## Out of Scope (V1)
- AAC/OGG stream formats (MP3 only)
- Stream authentication
- Lyrics lookup
- Scrobbling (Last.fm etc.)
- Similar station recommendations
- Recording / clips (schema supports it, feature deferred)