# 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)