Android companion app for the llizard CarThing UI system. Bridges Android media playback to the Spotify CarThing device via Bluetooth Low Energy (BLE).
Janus acts as a BLE GATT server that exposes media playback state, album art, and podcast browsing to Mercury, the BLE client daemon running on the CarThing. It monitors the Android device's active media sessions (Spotify, YouTube Music, etc.) and makes that state available over BLE, while also providing a built-in podcast player with lazy-loading episode browsing.
Architecture: Android Phone (Janus/GATT Server) ← BLE → CarThing (Mercury/GATT Client) → Redis → llizard
Janus is part of a three-component system for bringing media control to the Spotify CarThing:
| Component | Platform | Role |
|---|---|---|
| Janus | Android | BLE GATT server exposing media state from phone |
| Mercury | CarThing (Go) | BLE client daemon that bridges phone ↔ Redis |
| llizard | CarThing (C/raylib) | Native GUI that displays media from Redis |
- BLE GATT Server: Advertises as "Janus" and serves media state over BLE
- Universal Media Control: Monitors any Android media app via NotificationListenerService
- Media Channel Selection: Switch which media app to control (Spotify, YouTube Music, Podcasts, etc.)
- Podcast Player: Built-in Media3/ExoPlayer podcast player with subscription management
- Podcast Browsing: Lazy-loading podcast browser with A-Z list, recent episodes, and paginated per-podcast episode lists
- Album Art Transfer: Optimized binary chunked transfer protocol (WebP, 250x250px)
- Compact BLE Format: ~55% reduction in payload size for podcast data
- Synced Lyrics: Fetches time-synced lyrics from LRCLIB API with caching
- Playback Commands: Bidirectional control (play, pause, seek, volume, skip, toggle)
- Time Sync: Syncs phone time to CarThing on connection
- Foreground Service: Maintains BLE connection in background
- Spotify Integration: OAuth 2.0 authentication and library browser (see Spotify Integration section)
Janus includes a full Spotify Web API integration for browsing your library, controlling playback, and managing liked tracks directly from the app.
The Spotify integration provides:
- OAuth 2.0 PKCE Authentication: Secure login without storing secrets
- Library Browser: Browse your recently played, liked songs, saved albums, and playlists
- Playback Controls: Toggle shuffle/repeat modes, view and skip through queue
- Like/Unlike: Add or remove tracks from your library
- Direct Playback: Start playing tracks, albums, or playlists from the browser
- Go to developer.spotify.com/dashboard
- Click Create app
- Fill in:
- App name: Your choice (e.g., "Janus CarThing")
- App description: Your choice
- Redirect URI:
janus://spotify-callback(required)
- Click Create
- Copy your Client ID (32-character string)
While in development mode, only allowlisted users can authenticate:
- In your app dashboard, go to Settings → User Management
- Add Spotify email addresses for each user (up to 25 in dev mode)
- Users must accept the invitation
- Open Janus app on your Android device
- Swipe to the Spotify page (4th page in the pager)
- Tap the Spotify Client ID card
- Paste your Client ID
- Tap Save
- Tap Log in with Spotify
Janus uses OAuth 2.0 PKCE (Proof Key for Code Exchange) for secure authentication without requiring a client secret:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Janus │ │ Spotify │ │ Spotify │
│ App │ │ Auth │ │ API │
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
│ 1. Generate PKCE │ │
│ code_verifier │ │
│ code_challenge │ │
│ │ │
│ 2. Auth Request │ │
│ + code_challenge│ │
├──────────────────>│ │
│ │ │
│ 3. User Login │ │
│ (Browser) │ │
│<─ ─ ─ ─ ─ ─ ─ ─ ─>│ │
│ │ │
│ 4. Auth Code │ │
│ (redirect) │ │
│<──────────────────┤ │
│ │ │
│ 5. Token Exchange │ │
│ + code_verifier │ │
├──────────────────>│ │
│ │ │
│ 6. Access Token │ │
│ + Refresh Token │ │
│<──────────────────┤ │
│ │ │
│ 7. API Requests │ │
│ + Access Token │ │
├───────────────────┼──────────────────>│
│ │ │
Tokens are stored securely in Android DataStore and automatically refreshed when expired.
The Spotify page features a tabbed library browser:
| Tab | Description | Content |
|---|---|---|
| Overview | Summary view | Profile, library stats, activity, playback controls, queue |
| Recent | Recently played | Last 50 played tracks with like buttons |
| Liked | Saved tracks | Your liked songs with remove option |
| Albums | Saved albums | Your saved albums |
| Playlists | Your playlists | All playlists (owned and followed) |
Overview Tab
- Shows user profile (avatar, name, subscription tier, country)
- Library statistics (liked songs count, albums, playlists, followed artists)
- Current activity (now playing, recently played)
- Playback controls (shuffle, repeat mode toggles)
- Queue viewer with skip-to-track functionality
Recent Tab
- Loads automatically when selected (50 most recent tracks)
- Each track shows: album art, title, artist, duration
- Heart icon shows saved status (filled green = saved, outline = not saved)
- Tap heart to like/unlike
- Tap track to start playback
- Pull-to-refresh available
Liked Tab
- Paginated list of saved tracks (20 per page)
- "Load More" button for pagination
- Tap heart to remove from library
- Tap track to start playback
- Optimistic UI updates (track removed immediately, API call in background)
Albums Tab
- Paginated list of saved albums (20 per page)
- Shows: album art, name, artist, track count
- Tap to start playing the album
- "Load More" for pagination
Playlists Tab
- Paginated list of playlists (20 per page)
- Shows: playlist image, name, owner, track count, public/private icon
- Tap to start playing the playlist
- "Load More" for pagination
| State | Icon | Description |
|---|---|---|
| OFF | Gray shuffle icon | Normal playback order |
| ON | Green shuffle icon | Standard shuffle |
| SMART | Purple shuffle icon | Spotify's AI-enhanced shuffle (read-only) |
Tap the shuffle button to toggle: OFF → ON → OFF
| State | Icon | Description |
|---|---|---|
| OFF | Gray repeat icon | No repeat |
| CONTEXT | Green repeat icon | Repeat playlist/album |
| TRACK | Green repeat-one icon | Repeat current track |
Tap the repeat button to cycle: OFF → CONTEXT → TRACK → OFF
- View Queue: Shows up to 10 upcoming tracks
- Skip to Track: Tap any queue item to skip to it
- Refresh: Pull or tap refresh to update queue state
- Find the track in Recent or Queue
- Tap the outline heart icon
- Heart fills green, track is saved to library
- Liked songs count increases
Option 1 (from Recent/Queue):
- Tap the filled green heart icon
- Heart returns to outline, track removed from library
Option 2 (from Liked tab):
- Navigate to Liked tab
- Tap the heart icon on any track
- Track is removed from list immediately
Playing a Track
- Tap any track row in Recent, Liked, or Queue
- Playback starts immediately on your active Spotify device
- If no device is active, you'll see "No active device found. Open Spotify on a device first."
Playing an Album
- Navigate to Albums tab
- Tap any album row
- Album starts playing from the first track
Playing a Playlist
- Navigate to Playlists tab
- Tap any playlist row
- Playlist starts playing from the first track
The Spotify integration requests these OAuth scopes:
| Scope | Purpose |
|---|---|
user-read-private |
Read user profile data |
user-read-email |
Read user email |
user-read-recently-played |
Access recently played tracks |
user-read-playback-state |
Read playback state (shuffle, repeat, queue) |
user-modify-playback-state |
Control playback (play, pause, shuffle, repeat) |
user-library-read |
Read saved tracks, albums |
user-library-modify |
Add/remove saved tracks |
user-follow-read |
Read followed artists |
playlist-read-private |
Read private playlists |
playlist-read-collaborative |
Read collaborative playlists |
- Development Mode: Max 25 authenticated users until Spotify approves extended quota
- Active Device Required: Playback commands require an active Spotify app session somewhere
- Rate Limits: Spotify API has rate limits; the app handles these with automatic retry
- Premium Features: Some playback features may require Spotify Premium
"Client ID appears invalid"
- Ensure the Client ID is exactly 32 characters
- Copy directly from Spotify Dashboard without extra spaces
"Authentication error" after login
- Verify redirect URI is exactly
janus://spotify-callbackin Spotify Dashboard - Check that your Spotify account is added to the app's allowlist
"No active device found"
- Open Spotify on any device (phone, computer, smart speaker)
- Start playing something to make it the active device
- Then retry the playback command from Janus
Token refresh fails
- Tap Log out and log back in
- This gets a fresh token pair
Library stats show "-"
- Tap the refresh icon on the Library card
- Check your internet connection
The Spotify integration uses a clean architecture:
┌─────────────────────────────────────────────────────────┐
│ UI Layer (Compose) │
│ SpotifyAuthPage, SpotifyTabRow, Section Composables │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ ViewModel Layer │
│ SpotifyAuthViewModel (state, events, business logic) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ SDK Layer (spotSDK) │
│ SpotifyApiService (Retrofit), SpotifyAuthManager │
│ Data models, Token management │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Spotify Web API │
│ accounts.spotify.com (auth), api.spotify.com (data) │
└─────────────────────────────────────────────────────────┘
Key Components:
| Component | Location | Purpose |
|---|---|---|
SpotifyAuthPage |
ui/spotify/ |
Main UI composable with tabs |
SpotifyAuthViewModel |
ui/spotify/ |
State management and API calls |
SpotifyApiService |
spotSDK |
Retrofit interface for Spotify API |
SpotifyAuthManager |
spotSDK |
OAuth 2.0 PKCE flow management |
SpotifyApiClient |
spotSDK |
Configured Retrofit client with auth |
Data Flow:
- User taps tab →
SpotifyAuthEvent.SelectTabdispatched - ViewModel calls
selectTab()→ triggersloadXxxTracks()if needed - API call via
SpotifyApiClient.apiService.getXxx() - Response mapped to UI model (e.g.,
RecentTrackItem) - State updated → UI recomposes automatically
- Android 8.0+ (API level 26)
- BLE Hardware (Bluetooth Low Energy)
- Notification Listener Permission (for universal media monitoring)
- Storage Permission (for podcast caching)
- Android Studio Hedgehog (2023.1.1) or later
- JDK 17
- Android SDK 34
- Kotlin 1.9.22
- Clone the repository:
git clone https://github.com/pautown/janus-android.git
cd janus-android- Open in Android Studio or build via command line:
./gradlew assembleDebug- Install to device:
./gradlew installDebugOr build release APK:
./gradlew assembleRelease
# APK output: app/build/outputs/apk/release/app-release-unsigned.apk┌─────────────────────────────────────────────────────────┐
│ UI Layer (Compose) │
│ MainActivity, MainViewModel, PodcastPage, PlayerPage │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Domain Layer │
│ MediaState, PlaybackCommand, CompactBleModels │
│ PodcastInfoResponse, PodcastListResponse │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ Data Layer │
│ MediaRepository, PodcastRepository │
│ MediaSessionListener, PodcastPlayerService │
│ Room Database (podcasts, episodes) │
└─────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────┐
│ BLE Layer │
│ GattServerService, GattServerManager │
│ AlbumArtTransmitter, NotificationThrottler │
└─────────────────────────────────────────────────────────┘
- GattServerService: Foreground service hosting the BLE GATT server. Manages the lifecycle, observes media state changes, and coordinates characteristic updates. Uses Hilt for dependency injection.
- GattServerManager: Core BLE management - opens GATT server, sets up characteristics, handles advertising, manages device connections, and sends notifications. Singleton scoped.
- AlbumArtTransmitter: Handles chunked binary album art transmission with flow control. Tracks in-flight transfers per device and uses notification callbacks for pacing.
- NotificationThrottler: Rate-limits BLE notifications with configurable minimum interval (10ms default) to prevent buffer overflow.
- BleConstants: Protocol constants including UUIDs, chunk sizes (496 bytes), header size (16 bytes), and image dimensions (250x250).
- MediaRepository/MediaRepositoryImpl: Provides current media state, album art chunks, and processes playback commands. Bridges MediaControllerManager and PodcastRepository.
- MediaControllerManager: Manages the active Android MediaController for external apps (Spotify, YouTube Music, etc.). Tracks playback state, processes metadata changes, and prepares album art chunks.
- MediaSessionListener: NotificationListenerService that monitors all Android media sessions. Implements auto-switching when a new app starts playing and channel selection.
- PlaybackSourceTracker: Tracks which media source (internal podcast vs external app) is active. Enables proper resume functionality and play/pause routing.
- PodcastRepository: Manages podcast subscriptions, episodes, and feed parsing using Room database. Supports iTunes API search and RSS feed subscriptions.
- AlbumArtCache: LRU cache for prepared album art chunks, keyed by hash.
- AlbumArtFetcher: Fetches album art from MediaMetadata or URLs, resizes to 250x250, encodes as WebP.
- LyricsManager: Manages lyrics fetching, caching (LRU, 50 entries), and BLE transmission. Converts lyrics to chunked format.
- SettingsManager: DataStore-backed settings (e.g., lyrics enabled toggle).
- PodcastPlayerService: Media3 MediaSessionService hosting ExoPlayer for podcast playback. Handles audio focus and background playback.
- PodcastPlayerManager: High-level podcast playback API. Manages playlist, playback controls, and syncs state to MediaControllerManager for BLE exposure.
- EpisodeDownloadManager: Handles podcast episode downloads for offline playback.
- MediaState: Current playback state serialized to JSON (track, artist, album, duration, position, volume, albumArtHash, mediaChannel).
- PlaybackCommand: Commands from CarThing with validation. Supports playback controls, podcast browsing, and media channel selection.
- CompactBleModels: Optimized data models with short field names for minimal BLE bandwidth. Includes hash generation functions.
- PodcastInfoResponse: Legacy full podcast response and new lazy-loading response types (PodcastListResponse, RecentEpisodesResponse, PodcastEpisodesResponse).
- LyricsState/CompactLyricsResponse: Lyrics models with timestamps for synced lyrics display.
- AlbumArtChunk: Binary chunk model with serialization to 16-byte header + data format.
- AppModule: Provides BluetoothManager, BluetoothAdapter, AudioManager, coroutine dispatchers, and application scope.
- BleModule: Provides GattServerManager and related BLE components.
- MediaModule: Provides MediaRepository, MediaControllerManager, and related media components.
- PodcastModule: Provides Room database, DAOs, PodcastRepository, and RSS parser.
- MainActivity: Entry point with permission handling for Bluetooth and notifications.
- MainScreen: Compose-based main UI with connection status, now playing, and navigation.
- MainViewModel: UI state management, service control, and podcast observation.
- PodcastPage/PodcastViewModel: Podcast subscription management and browsing.
- PodcastPlayerPage/PodcastPlayerViewModel: Podcast playback controls and progress.
0000a0d0-0000-1000-8000-00805f9b34fb (Janus Service)
| Characteristic | UUID | Properties | Description |
|---|---|---|---|
| Media State | 0000a0d1-0000-1000-8000-00805f9b34fb |
Read, Notify | Current media playback state (JSON) |
| Playback Control | 0000a0d2-0000-1000-8000-00805f9b34fb |
Write, Write No Response | Commands from CarThing (JSON) |
| Album Art Request | 0000a0d3-0000-1000-8000-00805f9b34fb |
Write, Write No Response | Request album art by hash (JSON) |
| Album Art Data | 0000a0d4-0000-1000-8000-00805f9b34fb |
Read, Notify | Album art chunks (binary) |
| Podcast Info | 0000a0d5-0000-1000-8000-00805f9b34fb |
Read, Notify | Podcast data (JSON, chunked) |
| Lyrics Request | 0000a0d6-0000-1000-8000-00805f9b34fb |
Write, Write No Response | Request lyrics for track (JSON) |
| Lyrics Data | 0000a0d7-0000-1000-8000-00805f9b34fb |
Read, Notify | Synced lyrics (JSON, chunked) |
| Settings | 0000a0d8-0000-1000-8000-00805f9b34fb |
Read, Notify | Configuration settings (JSON) |
| Time Sync | 0000a0d9-0000-1000-8000-00805f9b34fb |
Read, Notify | Unix timestamp for time sync |
JSON structure sent to CarThing on media changes:
{
"isPlaying": true,
"playbackState": "playing",
"trackTitle": "Song Title",
"artist": "Artist Name",
"album": "Album Name",
"duration": 240000,
"position": 45000,
"volume": 75,
"albumArtHash": "1234567890",
"mediaChannel": "Spotify"
}The mediaChannel field indicates which app is being controlled (e.g., "Spotify", "YouTube Music", "Podcasts").
Commands sent from CarThing:
// Basic playback controls
{"action": "play"}
{"action": "pause"}
{"action": "toggle"}
{"action": "next"}
{"action": "previous"}
{"action": "stop"}
{"action": "seek", "value": 60000}
{"action": "volume", "value": 80}
// Podcast playback (by episode hash - recommended)
{"action": "play_episode", "episodeHash": "a1b2c3d4"}
// Legacy podcast playback (by index - deprecated)
{"action": "play_podcast_episode", "podcastId": "abc123", "episodeIndex": 5}
// Podcast data requests (lazy loading)
{"action": "request_podcast_list"}
{"action": "request_recent_episodes", "limit": 30}
{"action": "request_podcast_episodes", "podcastId": "abc123", "offset": 0, "limit": 15}
// Media channel selection (switch which app to control)
{"action": "request_media_channels"}
{"action": "select_media_channel", "channel": "Spotify"}Request lyrics for current track:
{"action": "get", "artist": "Artist Name", "track": "Track Title"}
{"action": "get", "hash": "abc12345"}
{"action": "clear", "hash": "abc12345"}On client connection, Janus sends a Unix timestamp (seconds since epoch) as a UTF-8 string for CarThing time synchronization.
Binary chunk format (16-byte header + up to 496 bytes data):
Offset Size Type Field
------ ---- ---- -----
0 4 uint32 hash (CRC32 of artist+album, little-endian)
4 2 uint16 chunkIndex (0-based, little-endian)
6 2 uint16 totalChunks (little-endian)
8 2 uint16 dataLength (bytes in this chunk, little-endian)
10 4 uint32 dataCRC32 (CRC32 of chunk data, little-endian)
14 2 uint16 reserved (0)
16+ N bytes raw WebP image data (max 496 bytes)
Protocol details:
- Album art resized to 250x250px, WebP format, quality 75
- Maximum notification size: 512 bytes (16 header + 496 data)
- Chunks sent with 10ms minimum interval between notifications
- CarThing requests art by CRC32 hash to avoid redundant transfers
- Album art hash = CRC32(artist + album) as decimal string
- Request format:
{"hash": "1234567890"}
Three response types for lazy-loading podcast browsing:
Header: [0x01][chunk_index][total_chunks] + JSON payload
{
"p": [
{"h": "abc12345", "n": "Podcast Name", "c": 150}
],
"np": {"h": "abc12345", "t": "Episode Title", "i": 5}
}Header: [0x02][chunk_index][total_chunks] + JSON payload
{
"e": [
{"h": "a1b2c3d4", "p": "def67890", "c": "Podcast Name", "t": "Episode Title", "d": 3600, "u": 1704499200, "i": 0}
],
"t": 30
}Fields: h=episode hash, p=podcast hash, c=channel/podcast name, t=title, d=duration (seconds), u=pubDate (unix timestamp seconds), i=index (for backward compat)
Header: [0x03][chunk_index][total_chunks] + JSON payload
{
"h": "abc12345",
"n": "Podcast Name",
"t": 150,
"o": 0,
"m": true,
"e": [
{"h": "a1b2c3d4", "t": "Episode Title", "d": 3600, "u": 1704499200}
]
}Fields: h=podcast/episode hash, n=name, t=total count or title, o=offset, m=has more, e=episodes, d=duration (seconds), u=pubDate (unix timestamp seconds)
Header: [0x04][chunk_index][total_chunks] + binary payload
Binary format for media channel list:
2 bytes: uint16 count (big-endian)
For each channel:
1 byte: length of name
N bytes: UTF-8 name
Example channels: "Spotify", "YouTube Music", "Podcasts"
Lyrics are sent in chunked JSON format. Each chunk has a 3-byte header followed by JSON:
Header: [lyrics_chunk_index][ble_packet_index][total_ble_packets]
{
"h": "abc12345",
"s": true,
"n": 50,
"c": 0,
"m": 3,
"l": [
{"t": 15000, "l": "First line of lyrics"},
{"t": 18500, "l": "Second line of lyrics"}
]
}Fields:
h: Hash (CRC32 of artist|track)s: Synced (true if has timestamps)n: Total line countc: Chunk index (0-based)m: Max chunks (total)l: Array of lyrics linest: Timestamp in milliseconds (0 if unsynced)l: Lyrics text
Clear notification sends empty lines array with n=0.
Settings are broadcast as JSON when they change:
{"lyricsEnabled": true}Clients can read current settings or subscribe to changes via notifications
To minimize BLE bandwidth usage, podcast data uses a compact JSON format with abbreviated field names and optimized data types.
Original Format (~180 bytes per episode):
{
"podcastId": "com.example.podcast.feed.123",
"podcastTitle": "The Example Podcast Show",
"title": "Episode 42: Understanding the Universe",
"duration": 3600000,
"publishDate": "Jan 15, 2024",
"pubDate": 1705305600000,
"episodeIndex": 0
}Compact Format (~80 bytes per episode, 55% smaller):
{
"h": "a1b2c3d4",
"c": "The Example Podcast Show",
"t": "Episode 42: Understanding the Universe",
"d": 3600,
"i": 0
}| Original | Compact | Notes |
|---|---|---|
podcastId + pubDate |
h |
CRC32 hash (8 chars) |
podcastTitle |
c |
Channel name |
title |
t |
Episode title |
duration |
d |
Seconds (not ms) |
episodeIndex |
i |
Index for playback |
podcastHash |
h |
Podcast ID hash |
name |
n |
Podcast name |
count |
c |
Episode count |
total |
t |
Total count |
offset |
o |
Pagination offset |
more |
m |
Has more pages |
episodes |
e |
Episode array |
podcasts |
p |
Podcast array |
nowPlaying |
np |
Currently playing |
Episode hash encodes feedUrl|pubDate|duration as CRC32 (uses seconds, not milliseconds):
fun generateEpisodeHash(feedUrl: String, pubDate: Long, duration: Long): String {
val pubDateSec = pubDate / 1000
val durationSec = duration / 1000
val input = "$feedUrl|$pubDateSec|$durationSec"
val crc = CRC32()
crc.update(input.toByteArray())
return String.format("%08x", crc.value) // "a1b2c3d4"
}Podcast hash uses the podcast ID directly if short, otherwise CRC32:
fun generatePodcastHash(podcastId: String): String {
if (podcastId.length <= 8) return podcastId
val crc = CRC32()
crc.update(podcastId.toByteArray())
return String.format("%08x", crc.value)
}Album art hash encodes artist|album as CRC32:
fun generateAlbumArtHash(artist: String, album: String): String {
val input = "$artist|$album"
val crc = CRC32()
crc.update(input.toByteArray())
return crc.value.toString() // Decimal string: "1234567890"
}Lyrics hash encodes artist|track (lowercase, trimmed) as CRC32.
Janus monitors all active Android media sessions and allows the CarThing to switch which app it controls.
- MediaSessionListener monitors Android's MediaSessionManager for active sessions
- When a new app starts playing, Janus auto-switches to control it
- CarThing can request the list of available channels via
request_media_channels - CarThing can select a specific channel via
select_media_channel
| Channel | Source | Description |
|---|---|---|
| Spotify | External | Spotify app media session |
| YouTube Music | External | YouTube Music app media session |
| Podcasts | Internal | Janus built-in podcast player |
| (other apps) | External | Any app with active MediaSession |
When an external app starts playing:
- MediaSessionListener detects the new playing session
- If different from current controlled app, auto-switches to it
- Media state updates to reflect new source
mediaChannelfield in MediaState updates
// Request available channels
{"action": "request_media_channels"}
// Response (Type 4 binary on Podcast Info characteristic)
// Channels: ["Spotify", "YouTube Music", "Podcasts"]
// Select specific channel
{"action": "select_media_channel", "channel": "Spotify"}When selecting a channel:
- Currently playing app is paused (if different from selected)
- Selected app becomes the active controller
- Playback commands route to selected app
-
A-Z Podcast List (Type 1 Response)
- Shows all subscribed podcasts sorted alphabetically
- Displays: podcast name, episode count
- No episodes loaded initially → minimal bandwidth
-
Recent Episodes (Type 2 Response)
- Cross-podcast chronological feed
- Displays: podcast name, episode title, duration
- Limited to N most recent episodes (default 30)
-
Per-Podcast Episodes (Type 3 Response)
- Episodes for specific podcast, paginated
- Displays: episode title, duration
- Loads 15 episodes per page, on-demand
CarThing (Mercury) Janus (Android)
│ │
├─ request_podcast_list ─────────>│
│<─ Type 1: Podcast List ─────────┤
│ (names only, no episodes) │
│ │
├─ request_podcast_episodes ─────>│
│ podcastId="abc123" │
│ offset=0, limit=15 │
│<─ Type 3: Episodes 0-14 ────────┤
│ │
├─ request_podcast_episodes ─────>│
│ podcastId="abc123" │
│ offset=15, limit=15 │
│<─ Type 3: Episodes 15-29 ───────┤
Traditional approach (send all episodes upfront):
- 10 podcasts × 100 episodes × 180 bytes = 180 KB
Lazy loading approach (send list + on-demand episodes):
- 10 podcasts × 80 bytes = 800 bytes
- 1 podcast × 15 episodes × 80 bytes = 1.2 KB
- Total: ~2 KB (99% reduction for initial load)
- Grant Bluetooth permissions
- Grant Notification Listener permission (Settings → Apps → Janus → Notification access)
- Grant Post Notifications permission (Android 13+)
- Tap Start BLE Service in the app
- Ensure Mercury daemon is running on CarThing
- CarThing will auto-discover and connect to "Janus" BLE advertisement
- Connection status shows in app UI
- Navigate to Podcasts tab
- Search for podcasts or add RSS feed URL
- Tap Subscribe to add to library
- Subscribed podcasts appear in My Podcasts section
- Podcasts are automatically exposed to CarThing via BLE
- Play media on any Android app (Spotify, YouTube Music, etc.)
- Media state automatically syncs to CarThing
- Control playback from CarThing UI
- Album art transfers on-demand when requested
- Kotlin 1.9.22
- AndroidX Core KTX 1.12.0
- AndroidX Lifecycle 2.7.0
- Jetpack Compose (BOM 2024.12.01)
- Hilt 2.50
- Retrofit 2.9.0
- OkHttp 4.12.0
- Kotlinx Serialization 1.6.2
- Media3 (ExoPlayer) 1.2.1
- AndroidX Media 1.7.0
- Room 2.6.1
- AppAuth 0.11.1 (OAuth 2.0 PKCE)
- spotSDK (local module) - Spotify Web API client
- RSS Parser 6.0.7
- Coil (image loading) 2.5.0
<!-- BLE -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<!-- Media monitoring -->
<uses-permission android:name="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE" />
<!-- Networking (podcast fetching, lyrics) -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Foreground service -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />Janus is designed to work with Mercury, the BLE client daemon on the CarThing. Mercury:
- Scans for "Janus" BLE advertisement
- Connects as GATT client
- Subscribes to Media State, Album Art, and Lyrics characteristics
- Sends playback commands via Playback Control characteristic
- Requests podcast data via Podcast Info characteristic
- Stores all state in Redis for consumption by llizard UI plugins
Use nRF Connect app on another Android device to inspect GATT characteristics:
- Install nRF Connect for Mobile
- Scan for "Janus" device
- Connect and explore service
0000a0d0-... - Read/subscribe to characteristics
- Write commands to Playback Control characteristic
The app uses structured logging with semantic tags for easy filtering:
# BLE operations
adb logcat -s GattServerManager:V GattServerService:V AlbumArtTransmitter:V
# Album art transfers (detailed)
adb logcat -s ALBUMART:V
# Podcast operations
adb logcat -s PODCAST:I PodcastAudio:D
# Lyrics fetching and transmission
adb logcat -s LYRICS:D LyricsManager:D
# Media channels
adb logcat -s MEDIA_CHANNELS:I
# Media controller and session
adb logcat -s MediaControllerManager:D MediaSessionListener:D
# Playback source tracking
adb logcat -s PlaybackSourceTracker:D
# Combined useful filter
adb logcat -s GattServerManager:D GattServerService:D ALBUMART:I PODCAST:I LYRICS:I MEDIA_CHANNELS:I MediaControllerManager:DLog prefixes used in verbose output:
═══Start/end of major operations───Section separators📥Incoming requests📤Outgoing responses✅Success⚠️Warnings❌Errors📦Cache operations📡Network/BLE transmission
app/src/main/kotlin/com/mediadash/android/
├── MediaDashApplication.kt # Hilt application entry point
├── ble/
│ ├── BleConstants.kt # UUIDs, sizes, protocol constants
│ ├── GattServerService.kt # Foreground service (Hilt-injected)
│ ├── GattServerManager.kt # GATT server lifecycle & operations
│ ├── AlbumArtTransmitter.kt # Binary chunk transmission
│ └── NotificationThrottler.kt # Rate limiting
├── data/
│ ├── local/
│ │ ├── PodcastDatabase.kt # Room database
│ │ ├── PodcastDao.kt # Podcast DAO
│ │ ├── EpisodeDao.kt # Episode DAO (in PodcastDao.kt)
│ │ ├── PodcastEntity.kt # Room entities
│ │ └── SettingsManager.kt # DataStore preferences
│ ├── media/
│ │ ├── MediaControllerManager.kt # External app control
│ │ ├── MediaSessionListener.kt # Session monitoring
│ │ ├── PlaybackSourceTracker.kt # Active source tracking
│ │ ├── AlbumArtCache.kt # LRU cache
│ │ ├── AlbumArtFetcher.kt # Image fetching & processing
│ │ └── LyricsManager.kt # Lyrics fetch & cache
│ ├── remote/
│ │ ├── ITunesApiService.kt # iTunes podcast search
│ │ ├── RssFeedParser.kt # RSS feed parsing
│ │ ├── LyricsApiService.kt # LRCLIB API client
│ │ └── OPMLParser.kt # OPML import support
│ └── repository/
│ ├── MediaRepository.kt # Interface
│ ├── MediaRepositoryImpl.kt # Implementation
│ └── PodcastRepository.kt # Podcast data access
├── di/
│ ├── AppModule.kt # Core dependencies
│ ├── BleModule.kt # BLE dependencies
│ ├── MediaModule.kt # Media dependencies
│ └── PodcastModule.kt # Podcast dependencies
├── domain/
│ ├── model/
│ │ ├── MediaState.kt # Playback state model
│ │ ├── PlaybackCommand.kt # Command model
│ │ ├── AlbumArtChunk.kt # Binary chunk model
│ │ ├── Podcast.kt # Podcast & episode models
│ │ ├── PodcastInfoResponse.kt # BLE response types
│ │ ├── CompactBleModels.kt # Optimized BLE models
│ │ ├── LyricsState.kt # Lyrics models
│ │ └── ConnectionStatus.kt # BLE connection states
│ └── usecase/
│ └── ProcessPlaybackCommandUseCase.kt
├── media/
│ ├── PodcastPlayerService.kt # Media3 service
│ ├── PodcastPlayerManager.kt # Playback management
│ └── EpisodeDownloadManager.kt # Offline downloads
└── ui/
├── MainActivity.kt # Entry point
├── MainViewModel.kt # Main screen state
├── theme/Theme.kt # Material 3 theme
├── composables/ # Reusable Compose components
│ ├── MainScreen.kt
│ ├── NowPlayingCard.kt
│ ├── ConnectionStatusCard.kt
│ └── ...
├── podcast/
│ ├── PodcastPage.kt
│ └── PodcastViewModel.kt
├── player/
│ ├── PodcastPlayerPage.kt
│ └── PodcastPlayerViewModel.kt
└── spotify/
├── SpotifyAuthPage.kt # Spotify integration UI
└── SpotifyAuthViewModel.kt # Spotify state & API calls
# spotSDK module (supporting_projects/spotSDK/)
spotsdk/src/main/java/com/spotsdk/
├── SpotSDK.kt # Main SDK entry point
├── api/
│ ├── SpotifyApiService.kt # Retrofit API interface
│ └── SpotifyApiClient.kt # Configured Retrofit client
├── auth/
│ ├── SpotifyAuthManager.kt # OAuth 2.0 PKCE flow
│ └── TokenResult.kt # Token response model
└── models/
├── Album.kt # Album, SimplifiedAlbum
├── Artist.kt # Artist, SimplifiedArtist
├── Playlist.kt # Playlist, SimplifiedPlaylist
├── Track.kt # Track, SavedTrack, SavedAlbum
├── Responses.kt # API response wrappers
├── PlayHistory.kt # Recently played models
└── SpotifyUser.kt # User profile model
-
Add action constant to
PlaybackCommand.kt:const val ACTION_MY_COMMAND = "my_command"
-
Add to
VALID_ACTIONSset in the same file -
Handle in
GattServerService.observeCommands():PlaybackCommand.ACTION_MY_COMMAND -> { Log.i("MY_TAG", "Processing my command") handleMyCommand(command) }
-
For data requests, implement handler method and use
gattServerManager.notify*()to respond -
For playback commands, delegate to
ProcessPlaybackCommandUseCasewhich routes toMediaRepository
-
Add UUID constant to
BleConstants.kt:val MY_CHARACTERISTIC_UUID: UUID = UUID.fromString("0000a0da-0000-1000-8000-00805f9b34fb")
-
Add characteristic property in
GattServerManager.kt:private var myCharacteristic: BluetoothGattCharacteristic? = null
-
Create characteristic in
setupService():myCharacteristic = BluetoothGattCharacteristic( BleConstants.MY_CHARACTERISTIC_UUID, BluetoothGattCharacteristic.PROPERTY_READ or BluetoothGattCharacteristic.PROPERTY_NOTIFY, BluetoothGattCharacteristic.PERMISSION_READ ).apply { addDescriptor(createCCCD()) } service.addCharacteristic(myCharacteristic)
-
Add notify method for sending data:
suspend fun notifyMyData(data: MyData) { val characteristic = myCharacteristic ?: return val server = gattServer ?: return // ... serialize and send }
IMPORTANT: BLE UUIDs and data formats must match Mercury exactly. Any changes require coordinated updates on both sides.
Protocol changes checklist:
- Update
BleConstants.kt(Janus) - Update
ble/constants.go(Mercury) - Update binary format handling in both projects
- Update this README documentation
- Test with nRF Connect before integration testing
- llizard: Native CarThing GUI (raylib/raygui)
- Mercury: CarThing BLE client daemon (bridges Janus ↔ Redis)
See repository for license details.
Note: Janus requires a Spotify CarThing device running llizard with Mercury. It will not function as a standalone media player without BLE connectivity to the CarThing.
