feat: add a grid view

This commit is contained in:
2026-07-30 23:15:53 -04:00
parent 3abd2c4c96
commit 665df77fc4
6 changed files with 139 additions and 0 deletions
@@ -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
}
}
@@ -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<AlbumViewMode> =
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 }
}
/**
@@ -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<ConnectionSettings> =
settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT)
/** Persisted albums-library layout preference. */
val albumViewMode: StateFlow<AlbumViewMode> =
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<Boolean> = _bootstrapping
@@ -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<List<MpdAlbum>?>(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.
@@ -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<AppScreen> =
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?,
@@ -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,