From 665df77fc48ea16d059217651825f2183a46169d Mon Sep 17 00:00:00 2001 From: Karim Abdul-Samad Date: Thu, 30 Jul 2026 13:42:14 -0400 Subject: [PATCH] feat: add a grid view --- .../ca/ksamad/encore/data/AlbumViewMode.kt | 16 +++++ .../ksamad/encore/data/SettingsRepository.kt | 9 +++ .../encore/playback/MpdConnectionManager.kt | 9 +++ .../ca/ksamad/encore/ui/AlbumsScreen.kt | 62 +++++++++++++++++++ .../ca/ksamad/encore/ui/PlayerViewModel.kt | 4 ++ .../ca/ksamad/encore/ui/SettingsScreen.kt | 39 ++++++++++++ 6 files changed, 139 insertions(+) create mode 100644 app/src/main/kotlin/ca/ksamad/encore/data/AlbumViewMode.kt diff --git a/app/src/main/kotlin/ca/ksamad/encore/data/AlbumViewMode.kt b/app/src/main/kotlin/ca/ksamad/encore/data/AlbumViewMode.kt new file mode 100644 index 0000000..cf46e9c --- /dev/null +++ b/app/src/main/kotlin/ca/ksamad/encore/data/AlbumViewMode.kt @@ -0,0 +1,16 @@ +package ca.ksamad.encore.data + +/** How the albums library is laid out: a compact [List] or a two-column [Grid] of cover art. */ +enum class AlbumViewMode { + List, + Grid, + ; + + companion object { + /** Default when nothing is persisted yet. */ + val DEFAULT = List + + /** Parse a persisted name back to a mode, tolerating unknown/legacy values. */ + fun fromName(name: String?): AlbumViewMode = entries.find { it.name == name } ?: DEFAULT + } +} diff --git a/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt b/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt index dc28959..b28a0f7 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/data/SettingsRepository.kt @@ -24,6 +24,15 @@ class SettingsRepository(private val context: Context) { private object Keys { val HOST = stringPreferencesKey("host") val PORT = intPreferencesKey("port") + val ALBUM_VIEW_MODE = stringPreferencesKey("album_view_mode") + } + + /** How the albums library is laid out; emits on every change, defaulted when unset. */ + val albumViewMode: Flow = + context.dataStore.data.map { prefs -> AlbumViewMode.fromName(prefs[Keys.ALBUM_VIEW_MODE]) } + + suspend fun setAlbumViewMode(mode: AlbumViewMode) { + context.dataStore.edit { prefs -> prefs[Keys.ALBUM_VIEW_MODE] = mode.name } } /** diff --git a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt index 4645ec0..15ece5c 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/playback/MpdConnectionManager.kt @@ -1,6 +1,7 @@ package ca.ksamad.encore.playback import android.content.Context +import ca.ksamad.encore.data.AlbumViewMode import ca.ksamad.encore.data.ConnectionSettings import ca.ksamad.encore.data.SettingsRepository import ca.ksamad.encore.mpd.MpdClient @@ -47,6 +48,14 @@ class MpdConnectionManager(context: Context) { val settings: StateFlow = settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT) + /** Persisted albums-library layout preference. */ + val albumViewMode: StateFlow = + settingsRepo.albumViewMode.stateIn(scope, SharingStarted.Eagerly, AlbumViewMode.DEFAULT) + + fun setAlbumViewMode(mode: AlbumViewMode) { + scope.launch { settingsRepo.setAlbumViewMode(mode) } + } + // True until the startup auto-connect decision has been made. private val _bootstrapping = MutableStateFlow(true) val bootstrapping: StateFlow = _bootstrapping diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt index 72532e3..713877f 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/AlbumsScreen.kt @@ -1,12 +1,19 @@ package ca.ksamad.encore.ui import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions @@ -44,9 +51,12 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ca.ksamad.encore.data.AlbumViewMode import ca.ksamad.encore.mpd.model.MpdAlbum import ca.ksamad.encore.playback.AlbumArt import kotlinx.coroutines.launch @@ -62,6 +72,8 @@ fun AlbumsScreen( onBack: () -> Unit, onOpenAlbum: (MpdAlbum) -> Unit, ) { + val viewMode by vm.albumViewMode.collectAsStateWithLifecycle() + // null = still loading. val albums by produceState?>(initialValue = null) { @@ -173,6 +185,20 @@ fun AlbumsScreen( } } + viewMode == AlbumViewMode.Grid -> { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + modifier = Modifier.padding(innerPadding), + contentPadding = PaddingValues(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album -> + AlbumGridCell(album = album, onTap = { onOpenAlbum(album) }) + } + } + } + else -> { LazyColumn(modifier = Modifier.padding(innerPadding)) { items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album -> @@ -228,6 +254,42 @@ private fun SwipeableAlbumRow( } } +/** + * One album tile for the grid layout: square cover art with the album name in bold underneath and + * the artist in a smaller, muted font below that. Tapping opens the album ([onTap]). + */ +@Composable +private fun AlbumGridCell( + album: MpdAlbum, + onTap: () -> Unit, +) { + Column(modifier = Modifier.clickable(onClick = onTap)) { + ArtImage( + model = AlbumArt(album.name, album.albumArtist), + iconSize = 48.dp, + modifier = + Modifier.fillMaxWidth().aspectRatio(1f).clip(RoundedCornerShape(8.dp)), + ) + Text( + album.name, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 6.dp), + ) + album.albumArtist?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + /** * Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard; * the surrounding [TopAppBar] handles clearing/closing. diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt index 72c59dc..de69d8a 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/PlayerViewModel.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import ca.ksamad.encore.EncoreApplication +import ca.ksamad.encore.data.AlbumViewMode import ca.ksamad.encore.data.ConnectionSettings import ca.ksamad.encore.mpd.MpdConnectionState import kotlinx.coroutines.flow.SharingStarted @@ -39,6 +40,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) val currentSong = manager.currentSong val serverHost = manager.serverHost val settings = manager.settings + val albumViewMode = manager.albumViewMode val screen: StateFlow = combine( @@ -86,6 +88,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) fun setConsume(on: Boolean) = manager.setConsume(on) + fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode) + fun playAlbum( album: String, albumArtist: String?, diff --git a/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt b/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt index 10476cf..aaaef93 100644 --- a/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt +++ b/app/src/main/kotlin/ca/ksamad/encore/ui/SettingsScreen.kt @@ -23,6 +23,9 @@ import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold +import androidx.compose.material3.SegmentedButton +import androidx.compose.material3.SegmentedButtonDefaults +import androidx.compose.material3.SingleChoiceSegmentedButtonRow import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -36,6 +39,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import ca.ksamad.encore.data.AlbumViewMode import ca.ksamad.encore.mpd.model.MpdStatistics import java.text.SimpleDateFormat import java.util.Date @@ -119,6 +123,41 @@ fun SettingsScreen( }, ) + Text( + "Library", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 8.dp, top = 20.dp, bottom = 4.dp), + ) + val albumViewMode by vm.albumViewMode.collectAsStateWithLifecycle() + ListItem( + headlineContent = { Text("Album view") }, + supportingContent = { Text("How the albums library is laid out") }, + trailingContent = { + SingleChoiceSegmentedButtonRow { + AlbumViewMode.entries.forEachIndexed { index, mode -> + SegmentedButton( + selected = albumViewMode == mode, + onClick = { vm.setAlbumViewMode(mode) }, + shape = + SegmentedButtonDefaults.itemShape( + index = index, + count = AlbumViewMode.entries.size, + ), + icon = {}, + ) { + Text( + when (mode) { + AlbumViewMode.List -> "List" + AlbumViewMode.Grid -> "Grid" + } + ) + } + } + } + }, + ) + Text( "Server statistics", style = MaterialTheme.typography.titleSmall,