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 { private object Keys {
val HOST = stringPreferencesKey("host") val HOST = stringPreferencesKey("host")
val PORT = intPreferencesKey("port") 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 package ca.ksamad.encore.playback
import android.content.Context import android.content.Context
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.data.ConnectionSettings import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.encore.data.SettingsRepository import ca.ksamad.encore.data.SettingsRepository
import ca.ksamad.encore.mpd.MpdClient import ca.ksamad.encore.mpd.MpdClient
@@ -47,6 +48,14 @@ class MpdConnectionManager(context: Context) {
val settings: StateFlow<ConnectionSettings> = val settings: StateFlow<ConnectionSettings> =
settingsRepo.settings.stateIn(scope, SharingStarted.Eagerly, ConnectionSettings.DEFAULT) 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. // True until the startup auto-connect decision has been made.
private val _bootstrapping = MutableStateFlow(true) private val _bootstrapping = MutableStateFlow(true)
val bootstrapping: StateFlow<Boolean> = _bootstrapping val bootstrapping: StateFlow<Boolean> = _bootstrapping
@@ -1,12 +1,19 @@
package ca.ksamad.encore.ui package ca.ksamad.encore.ui
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn 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.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions 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.focus.focusRequester
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager 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.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp 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.mpd.model.MpdAlbum
import ca.ksamad.encore.playback.AlbumArt import ca.ksamad.encore.playback.AlbumArt
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -62,6 +72,8 @@ fun AlbumsScreen(
onBack: () -> Unit, onBack: () -> Unit,
onOpenAlbum: (MpdAlbum) -> Unit, onOpenAlbum: (MpdAlbum) -> Unit,
) { ) {
val viewMode by vm.albumViewMode.collectAsStateWithLifecycle()
// null = still loading. // null = still loading.
val albums by val albums by
produceState<List<MpdAlbum>?>(initialValue = null) { 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 -> { else -> {
LazyColumn(modifier = Modifier.padding(innerPadding)) { LazyColumn(modifier = Modifier.padding(innerPadding)) {
items(filtered, key = { "${it.name} ${it.albumArtist}" }) { album -> 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; * Inline search box that lives in the top bar while searching. Auto-focuses and opens the keyboard;
* the surrounding [TopAppBar] handles clearing/closing. * the surrounding [TopAppBar] handles clearing/closing.
@@ -4,6 +4,7 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import ca.ksamad.encore.EncoreApplication import ca.ksamad.encore.EncoreApplication
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.data.ConnectionSettings import ca.ksamad.encore.data.ConnectionSettings
import ca.ksamad.encore.mpd.MpdConnectionState import ca.ksamad.encore.mpd.MpdConnectionState
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@@ -39,6 +40,7 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
val currentSong = manager.currentSong val currentSong = manager.currentSong
val serverHost = manager.serverHost val serverHost = manager.serverHost
val settings = manager.settings val settings = manager.settings
val albumViewMode = manager.albumViewMode
val screen: StateFlow<AppScreen> = val screen: StateFlow<AppScreen> =
combine( combine(
@@ -86,6 +88,8 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application)
fun setConsume(on: Boolean) = manager.setConsume(on) fun setConsume(on: Boolean) = manager.setConsume(on)
fun setAlbumViewMode(mode: AlbumViewMode) = manager.setAlbumViewMode(mode)
fun playAlbum( fun playAlbum(
album: String, album: String,
albumArtist: String?, albumArtist: String?,
@@ -23,6 +23,9 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold 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.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
@@ -36,6 +39,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import ca.ksamad.encore.data.AlbumViewMode
import ca.ksamad.encore.mpd.model.MpdStatistics import ca.ksamad.encore.mpd.model.MpdStatistics
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date 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( Text(
"Server statistics", "Server statistics",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,