-
-
Notifications
You must be signed in to change notification settings - Fork 55
Architecture
Technical documentation of Rhythm's app structure, design patterns, and architectural decisions.
Rhythm employs a unique dual-mode architecture to support both local and streaming playback experiences while sharing core infrastructure.
Focuses on device-based media using the Android MediaStore API. It handles local file indexing, metadata extraction from files, and local playback state.
Provides a completely separate pipeline for streaming servers. It includes its own data repositories and presentation layer, allowing the app to function as a streaming client without interfering with the local library.
Both modes leverage the shared and infrastructure layers:
- Shared Data: Common domain models (Song, Album, Artist) ensure consistency.
-
Playback Service: A unified
MediaPlaybackServicehandles the actual audio output via ExoPlayer, regardless of whether the source is local or streaming. - Infrastructure: Common utilities for networking, permissions, and background workers are used by both modes.
Playback state is observed via Player.Listener attached to the ExoPlayer Player interface.
// Observe ExoPlayer state via MediaController
mediaController.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(state: Int) { ... }
override fun onIsPlayingChanged(isPlaying: Boolean) { ... }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { ... }
})
// In Compose, use collectAsState() on StateFlow from ViewModel
@Composable
fun PlayerScreen() {
val playbackState by viewModel.playbackState.collectAsState()
val isPlaying by viewModel.isPlaying.collectAsState()
}// LRCLib API for synchronized lyrics
interface LRCLibApiService {
@GET("api/search")
suspend fun searchLyrics(
@Query("q") query: String
): Response<List<LyricsData>>
}
// Deezer API for artwork
interface DeezerApiService {
@GET("search/track")
suspend fun searchTrack(
@Query("q") query: String
): Response<DeezerSearchResponse>
}
// YouTube Music API for artwork
interface YouTubeMusicApiService {
@GET("api/music/song")
suspend fun searchSong(
@Query("q") query: String
): Response<YouTubeMusicResponse>
}class MusicViewModelTest {
@get:Rule
val rule = createAndroidComposeRule<MainActivity>()
@Test
fun `player displays current track title`() {
composeTestRule
.onNodeWithTag("track_title")
.assertIsDisplayed()
}
}- No analytics or tracking code
- All data stored locally
- No server communication except optional features
- Encrypted backups (optional)
// Runtime permission requests are handled via Accompanist Permissions API
// and AndroidX Activity Result API at the composable level
@Composable
fun RequestAudioPermission() {
val permissionState = rememberPermissionState(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
android.Manifest.permission.READ_MEDIA_AUDIO
else
android.Manifest.permission.READ_EXTERNAL_STORAGE
)
// LaunchedEffect to trigger request
}@Composable
fun SongList(songs: List<Song>) {
LazyColumn {
items(songs, key = { it.id }) { song ->
SongItem(song)
}
}
}// Coil for efficient image loading
AsyncImage(
model = ImageRequest.Builder(context)
.data(song.albumArtUri)
.crossfade(true)
.build(),
contentDescription = "Album Art"
)// Media scanning happens via ContentResolver + MediaStore queries
// in the MusicRepository layer, not in a dedicated worker
suspend fun scanMedia(context: Context): List<PlayableItem> {
val projection = arrayOf(
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DATA
)
val cursor = context.contentResolver.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection, null, null, null
)
// Parse cursor into PlayableItem list
}Currently using manual DI (manual constructor injection + service locator pattern in feature modules).
// MusicViewModel receives a MediaController from MediaPlaybackService
// and interacts with it directly for playback control
class MusicViewModel(application: Application) : AndroidViewModel(application) {
private var mediaController: MediaController? = null
private val _isPlaying = MutableStateFlow(false)
val isPlaying: StateFlow<Boolean> = _isPlaying.asStateFlow()
fun onConnected(controller: MediaController) {
mediaController = controller
controller.addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
_isPlaying.value = isPlaying
}
})
}
}// build.gradle.kts
android {
namespace = "chromahub.rhythm.app"
compileSdk = 37
defaultConfig {
applicationId = "chromahub.rhythm.app"
minSdk = 26
targetSdk = 37
versionCode = 514141085
versionName = "5.1.414.1085 Beta"
}
buildFeatures {
compose = true
buildConfig = true
}
}[versions]
kotlin = "2.4.0"
composeBom = "2026.06.00"
media3 = "1.10.1"
[libraries]
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }- Abstraction over data sources
- Testable business logic
- Single source of truth
- StateFlow for reactive updates
- LiveData alternative
- Lifecycle-aware
- ViewModel creation
- Widget instantiation
- AppSettings
- Repository instances
- Jetpack Compose Documentation
- Android Architecture Guide
- Media3 Documentation
- Kotlin Coroutines
- Material Design 3
Questions? Check Contributing Guide or ask in Telegram or Discord!