feat: add a grid view
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user