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