-
-
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.
Reactive state updates using Kotlin Flow.
class PlayerViewModel : ViewModel() {
private val _state = MutableStateFlow(PlayerState.Idle)
val state: StateFlow<PlayerState> = _state.asStateFlow()
fun updateState(newState: PlayerState) {
_state.value = newState
}
}
@Composable
fun PlayerScreen(viewModel: PlayerViewModel) {
val state by viewModel.state.collectAsState()
when (state) {
is PlayerState.Playing -> ShowPlayingUI()
is PlayerState.Paused -> ShowPausedUI()
is PlayerState.Idle -> ShowIdleUI()
}
}interface LyricsApi {
@GET("get")
suspend fun getLyrics(
@Query("track_name") track: String,
@Query("artist_name") artist: String
): LyricsResponse
}
class LyricsRepository(private val api: LyricsApi) {
suspend fun fetchLyrics(song: Song): Result<Lyrics> {
return try {
val response = api.getLyrics(song.title, song.artist)
Result.success(response.toLyrics())
} catch (e: Exception) {
Result.failure(e)
}
}
}class MusicViewModelTest {
@Test
fun `playSong updates state correctly`() = runTest {
val viewModel = MusicViewModel()
val testSong = Song(/* test data */)
viewModel.playSong(testSong)
assertEquals(testSong, viewModel.currentSong.value)
assertEquals(PlaybackState.Playing, viewModel.playbackState.value)
}
}@Test
fun playerScreen_showsCorrectSongInfo() {
composeTestRule.setContent {
PlayerScreen(song = testSong)
}
composeTestRule
.onNodeWithText(testSong.title)
.assertIsDisplayed()
}- No analytics or tracking code
- All data stored locally
- No server communication except optional features
- Encrypted backups (optional)
object PermissionManager {
fun requestStoragePermission(activity: Activity) {
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
// Request READ_MEDIA_AUDIO
}
else -> {
// Request READ_EXTERNAL_STORAGE
}
}
}
}@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"
)class MediaScanWorker : CoroutineWorker() {
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
// Heavy processing off main thread
scanMediaLibrary()
Result.success()
}
}Currently using manual DI. Future migration to Hilt planned.
class MusicViewModel(application: Application) : AndroidViewModel(application) {
private val repository: MusicRepository = MusicRepositoryImpl(
MediaStoreDataSource(application)
)
}// build.gradle.kts
android {
namespace = "chromahub.rhythm.app"
compileSdk = 37
defaultConfig {
applicationId = "chromahub.rhythm.app"
minSdk = 26
targetSdk = 37
versionCode = 504031056
versionName = "5.0.403.1056"
}
buildFeatures {
compose = true
buildConfig = true
}
}[versions]
kotlin = "2.3.21"
composeBom = "2026.05.01"
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!