-
-
Notifications
You must be signed in to change notification settings - Fork 55
Architecture
Anjishnu Nandi edited this page Jan 15, 2026
·
3 revisions
Technical documentation of Rhythm's app structure, design patterns, and architectural decisions.
Rhythm follows Clean Architecture principles with MVVM (Model-View-ViewModel) pattern, ensuring separation of concerns, testability, and maintainability.
┌─────────────────────────────────────────────┐
│ Presentation Layer │
│ ┌──────────────────────────────────────┐ │
│ │ Composables (UI Components) │ │
│ │ - Screens, Components, Dialogs │ │
│ └──────────────────────────────────────┘ │
│ ↕ │
│ ┌──────────────────────────────────────┐ │
│ │ ViewModels (State Management) │ │
│ │ - Business Logic Coordination │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────┐
│ Domain Layer │
│ ┌──────────────────────────────────────┐ │
│ │ Repository Interfaces │ │
│ │ - Abstractions for data access │ │
│ └──────────────────────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ Domain Models │ │
│ │ - Song, Album, Artist, Playlist │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
↕
┌─────────────────────────────────────────────┐
│ Data Layer │
│ ┌──────────────────────────────────────┐ │
│ │ Repository Implementations │ │
│ │ - Concrete data access logic │ │
│ └──────────────────────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ Data Sources │ │
│ │ - MediaStore, APIs, Local Storage │ │
│ └──────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
Primary application module containing all features and UI.
app/src/main/java/chromahub/rhythm/app/
├── activities/ # App entry points
│ └── MainActivity.kt # Single activity architecture
│
├── features/ # Feature modules
│ └── local/ # Local music playback
│ ├── data/ # Data layer
│ │ ├── repository/ # Repository implementations
│ │ └── sources/ # MediaStore, file system
│ ├── domain/ # Business logic
│ │ └── models/ # Domain entities
│ └── presentation/ # UI layer
│ ├── screens/ # Screen composables
│ ├── components/ # Reusable UI components
│ └── viewmodel/ # ViewModels
│
├── shared/ # Shared across features
│ ├── data/ # Shared data models
│ │ ├── model/ # Data classes
│ │ └── repository/ # Shared repositories
│ └── presentation/ # Shared UI components
│ ├── components/ # Common composables
│ └── theme/ # Material 3 theme
│
├── infrastructure/ # App infrastructure
│ ├── service/ # Background services
│ │ └── MediaPlaybackService.kt
│ ├── widget/ # Home screen widgets
│ │ ├── glance/ # Modern Glance widgets
│ │ └── legacy/ # RemoteViews widgets
│ └── worker/ # Background workers
│ ├── BackupWorker.kt
│ └── UpdateWorker.kt
│
└── util/ # Utility classes
├── AudioDeviceManager.kt
├── AutoEQManager.kt
└── Extensions.kt
All UI built with Compose using Material 3 design system.
@Composable
fun PlayerScreen(
viewModel: MusicViewModel = viewModel(),
onNavigateBack: () -> Unit
) {
val playbackState by viewModel.playbackState.collectAsState()
val currentSong by viewModel.currentSong.collectAsState()
Scaffold(
topBar = { PlayerTopBar(onNavigateBack) },
content = { padding ->
PlayerContent(
song = currentSong,
playbackState = playbackState,
onPlayPause = { viewModel.togglePlayback() },
modifier = Modifier.padding(padding)
)
}
)
}Screen Composables
└── Layout Composables (Scaffold, Column, Row)
└── UI Components (Card, Button, Text)
└── Custom Components (AlbumArt, ProgressBar)
State management and business logic coordination.
class MusicViewModel(application: Application) : AndroidViewModel(application) {
private val repository = MusicRepository(application)
// State flows for reactive UI
private val _currentSong = MutableStateFlow<Song?>(null)
val currentSong: StateFlow<Song?> = _currentSong.asStateFlow()
private val _playbackState = MutableStateFlow(PlaybackState.Idle)
val playbackState: StateFlow<PlaybackState> = _playbackState.asStateFlow()
// Business logic methods
fun playSong(song: Song) {
viewModelScope.launch {
_currentSong.value = song
_playbackState.value = PlaybackState.Playing
// Coordinate with service
}
}
}Single-activity architecture with Compose Navigation.
@Composable
fun RhythmNavHost(navController: NavHostController) {
NavHost(
navController = navController,
startDestination = "home"
) {
composable("home") { HomeScreen() }
composable("player") { PlayerScreen() }
composable("library") { LibraryScreen() }
// More routes...
}
}Abstracts data sources from ViewModels.
interface MusicRepository {
fun getAllSongs(): Flow<List<Song>>
suspend fun getSongById(id: Long): Song?
suspend fun updateSong(song: Song)
}
class MusicRepositoryImpl(
private val mediaStore: MediaStoreDataSource
) : MusicRepository {
override fun getAllSongs(): Flow<List<Song>> = flow {
val songs = mediaStore.querySongs()
emit(songs)
}
}Primary source for music files on device.
class MediaStoreDataSource(private val context: Context) {
fun querySongs(): List<Song> {
val songs = mutableListOf<Song>()
val uri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
context.contentResolver.query(
uri,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
while (cursor.moveToNext()) {
songs.add(mapCursorToSong(cursor))
}
}
return songs
}
}Settings, playlists, and app data.
class AppSettings(context: Context) {
private val prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
var theme: String
get() = prefs.getString("theme", "system") ?: "system"
set(value) = prefs.edit().putString("theme", value).apply()
}┌─────────────────────────────────────┐
│ MediaPlaybackService │
│ (Foreground Service) │
│ │
│ ┌──────────────────────────────┐ │
│ │ ExoPlayer │ │
│ │ - Audio engine │ │
│ │ - Playback control │ │
│ │ - Queue management │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ MediaSession │ │
│ │ - Media controls interface │ │
│ │ - Bluetooth integration │ │
│ └──────────────────────────────┘ │
│ │
│ ┌──────────────────────────────┐ │
│ │ MediaNotification │ │
│ │ - Persistent notification │ │
│ │ - Playback controls │ │
│ └──────────────────────────────┘ │
└─────────────────────────────────────┘
class MediaPlaybackService : Service() {
private lateinit var player: ExoPlayer
private lateinit var mediaSession: MediaSession
override fun onCreate() {
super.onCreate()
initializePlayer()
createMediaSession()
startForegroundService()
}
private fun initializePlayer() {
player = ExoPlayer.Builder(this)
.setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.build()
}
}class RhythmMusicWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
provideContent {
val data = getWidgetData()
GlanceTheme {
WidgetContent(data)
}
}
}
}
@Composable
fun WidgetContent(data: WidgetData) {
Box(modifier = GlanceModifier.fillMaxSize()) {
// Widget UI
}
}class WidgetUpdateWorker : CoroutineWorker() {
override suspend fun doWork(): Result {
GlanceAppWidgetManager(context)
.getGlanceIds(RhythmMusicWidget::class.java)
.forEach { glanceId ->
RhythmMusicWidget().update(context, glanceId)
}
return Result.success()
}
}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 = 36
defaultConfig {
applicationId = "chromahub.rhythm.app"
minSdk = 26
targetSdk = 36
versionCode = 40310853
versionName = "4.0.310.853"
}
buildFeatures {
compose = true
buildConfig = true
}
}[versions]
kotlin = "1.9.22"
compose = "1.6.0"
exoplayer = "1.9.0"
[libraries]
compose-ui = { module = "androidx.compose.ui:ui", version.ref = "compose" }
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "exoplayer" }- 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!