feat(discover): type-toggle chips (Artists/Tracks/Genres/Stations), bucketed results, local station + genre search
This commit is contained in:
parent
bd4f6bb874
commit
9e84906715
2 changed files with 289 additions and 26 deletions
|
|
@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Radio
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Tune
|
||||
import androidx.compose.material3.AlertDialog
|
||||
|
|
@ -61,6 +62,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import com.rada.data.wavlake.MatchingMode
|
||||
import com.rada.data.wavlake.WavlakeSearchHit
|
||||
|
|
@ -204,6 +206,25 @@ fun DiscoverScreen(
|
|||
shape = RoundedCornerShape(12.dp),
|
||||
)
|
||||
|
||||
// What to search: toggle Artists / Tracks / Genres / Stations.
|
||||
val selectedTypes by viewModel.selectedTypes.collectAsState()
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
),
|
||||
) {
|
||||
items(DiscoverType.entries.toList(), key = { "type-${it.name}" }) { type ->
|
||||
GenreChip(
|
||||
label = type.label,
|
||||
selected = type in selectedTypes,
|
||||
onClick = { viewModel.toggleType(type) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
when (val s = searchState) {
|
||||
WavlakeSearchState.Idle -> {
|
||||
GenreChips(
|
||||
|
|
@ -247,33 +268,142 @@ fun DiscoverScreen(
|
|||
}
|
||||
}
|
||||
is WavlakeSearchState.Results -> {
|
||||
val r = s.results
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit ->
|
||||
when (hit) {
|
||||
is WavlakeSearchHit.Artist -> ArtistRow(
|
||||
name = hit.artist.name,
|
||||
genre = hit.artist.genre,
|
||||
imageUrl = hit.artist.imageUrl,
|
||||
artistId = hit.artist.id,
|
||||
address = addresses[hit.artist.id],
|
||||
onClick = {
|
||||
viewModel.openArtist(hit.artist.id, hit.artist.name)
|
||||
},
|
||||
if (r.stations.isNotEmpty()) {
|
||||
item(key = "sec-stations", contentType = "header") {
|
||||
SectionHeader("Stations")
|
||||
}
|
||||
items(r.stations, key = { "st-${it.id}" }, contentType = { "station" }) { st ->
|
||||
Card(
|
||||
onClick = { viewModel.playFoundStation(st) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = LocalTokens.current.glassFillStrong,
|
||||
),
|
||||
shape = RoundedCornerShape(SatsAmpGlass.rInput),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
LocalTokens.current.accentFill.copy(alpha = 0.15f),
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Radio,
|
||||
contentDescription = null,
|
||||
tint = LocalTokens.current.accentText,
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(14.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = st.name,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = LocalTokens.current.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = st.genre.ifBlank { "Radio" },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = LocalTokens.current.textSecondary,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = { viewModel.playFoundStation(st) },
|
||||
modifier = Modifier.size(48.dp),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.PlayArrow,
|
||||
contentDescription = "Play ${st.name}",
|
||||
tint = LocalTokens.current.textSecondary,
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.genres.isNotEmpty()) {
|
||||
item(key = "sec-genres", contentType = "header") {
|
||||
SectionHeader("Genres")
|
||||
}
|
||||
items(r.genres, key = { "gen-$it" }, contentType = { "genre" }) { genre ->
|
||||
Card(
|
||||
onClick = { viewModel.openGenre(genre) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = LocalTokens.current.glassFillStrong,
|
||||
),
|
||||
shape = RoundedCornerShape(SatsAmpGlass.rInput),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = genre,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = LocalTokens.current.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = "Browse",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = LocalTokens.current.accentText,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (r.artists.isNotEmpty()) {
|
||||
item(key = "sec-artists", contentType = "header") {
|
||||
SectionHeader("Artists")
|
||||
}
|
||||
items(r.artists, key = { "artist-${it.id}" }, contentType = { "artist" }) { artist ->
|
||||
ArtistRow(
|
||||
name = artist.name,
|
||||
genre = artist.genre,
|
||||
imageUrl = artist.imageUrl,
|
||||
artistId = artist.id,
|
||||
address = addresses[artist.id],
|
||||
onClick = { viewModel.openArtist(artist.id, artist.name) },
|
||||
onZap = {
|
||||
viewModel.resolveArtistAddress(hit.artist.id)
|
||||
pendingZapArtistId = hit.artist.id
|
||||
viewModel.resolveArtistAddress(artist.id)
|
||||
pendingZapArtistId = artist.id
|
||||
},
|
||||
)
|
||||
is WavlakeSearchHit.Album -> AlbumRow(
|
||||
title = hit.title,
|
||||
artist = hit.artist,
|
||||
artworkUrl = hit.artworkUrl,
|
||||
onClick = { /* albums open via artist detail */ },
|
||||
)
|
||||
is WavlakeSearchHit.Track -> TrackRow(
|
||||
}
|
||||
}
|
||||
if (r.tracks.isNotEmpty() || r.albums.isNotEmpty()) {
|
||||
item(key = "sec-tracks", contentType = "header") {
|
||||
SectionHeader("Tracks")
|
||||
}
|
||||
items(r.tracks, key = { "track-${it.track.id}" }, contentType = { "track" }) { hit ->
|
||||
TrackRow(
|
||||
track = com.rada.domain.model.WavlakeTrack(
|
||||
id = hit.track.id,
|
||||
title = hit.track.title,
|
||||
|
|
@ -317,6 +447,14 @@ fun DiscoverScreen(
|
|||
},
|
||||
)
|
||||
}
|
||||
items(r.albums, key = { "album-${it.id}" }, contentType = { "album" }) { album ->
|
||||
AlbumRow(
|
||||
title = album.title,
|
||||
artist = album.artist,
|
||||
artworkUrl = album.artworkUrl,
|
||||
onClick = { /* albums open via artist detail */ },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -334,6 +472,18 @@ fun DiscoverScreen(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title.uppercase(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
letterSpacing = 0.6.sp,
|
||||
color = LocalTokens.current.accentText,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
private data class ZapTarget(
|
||||
val artistId: String,
|
||||
val artistName: String,
|
||||
|
|
|
|||
|
|
@ -26,10 +26,27 @@ import javax.inject.Inject
|
|||
/** Curated genre shortcuts — exact Wavlake catalog names only. */
|
||||
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
|
||||
|
||||
/** What the Discover search looks for; user-toggled chips. */
|
||||
enum class DiscoverType(val label: String) {
|
||||
ARTISTS("Artists"), TRACKS("Tracks"), GENRES("Genres"), STATIONS("Stations"),
|
||||
}
|
||||
|
||||
/** Search results bucketed by type — only selected types are populated. */
|
||||
data class DiscoverResults(
|
||||
val artists: List<WavlakeArtist> = emptyList(),
|
||||
val tracks: List<WavlakeSearchHit.Track> = emptyList(),
|
||||
val albums: List<WavlakeSearchHit.Album> = emptyList(),
|
||||
val genres: List<String> = emptyList(),
|
||||
val stations: List<com.rada.domain.model.Station> = emptyList(),
|
||||
) {
|
||||
fun isEmpty() = artists.isEmpty() && tracks.isEmpty() && albums.isEmpty() &&
|
||||
genres.isEmpty() && stations.isEmpty()
|
||||
}
|
||||
|
||||
sealed interface WavlakeSearchState {
|
||||
data object Idle : WavlakeSearchState
|
||||
data object Loading : WavlakeSearchState
|
||||
data class Results(val hits: List<WavlakeSearchHit>) : WavlakeSearchState
|
||||
data class Results(val results: DiscoverResults) : WavlakeSearchState
|
||||
data class Error(val message: String) : WavlakeSearchState
|
||||
}
|
||||
|
||||
|
|
@ -39,11 +56,39 @@ class WavlakeViewModel @Inject constructor(
|
|||
private val repository: WavlakeRepository,
|
||||
private val playbackManager: PlaybackManager,
|
||||
private val playlists: PlaylistRepository,
|
||||
private val stations: com.rada.data.repository.StationRepository,
|
||||
@dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _searchQuery = MutableStateFlow("")
|
||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||
|
||||
/** Which result types to search/show; persisted so the choice sticks. */
|
||||
private val _selectedTypes = MutableStateFlow(loadTypes())
|
||||
val selectedTypes: StateFlow<Set<DiscoverType>> = _selectedTypes.asStateFlow()
|
||||
|
||||
fun toggleType(type: DiscoverType) {
|
||||
val current = _selectedTypes.value.toMutableSet()
|
||||
if (type in current) {
|
||||
// Never allow an empty selection — re-tapping the last one keeps it.
|
||||
if (current.size == 1) return
|
||||
current -= type
|
||||
} else current += type
|
||||
_selectedTypes.value = current
|
||||
appContext.getSharedPreferences("discover_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.edit().putStringSet("types", current.map { it.name }.toSet()).apply()
|
||||
// Re-run the current query so results reflect the new selection now.
|
||||
val q = _searchQuery.value
|
||||
if (q.trim().length >= 2) _searchQuery.value = "$q "
|
||||
}
|
||||
|
||||
private fun loadTypes(): Set<DiscoverType> {
|
||||
val saved = appContext.getSharedPreferences("discover_prefs", android.content.Context.MODE_PRIVATE)
|
||||
.getStringSet("types", null)
|
||||
val parsed = saved?.mapNotNull { runCatching { DiscoverType.valueOf(it) }.getOrNull() }?.toSet()
|
||||
return parsed?.takeIf { it.isNotEmpty() } ?: DiscoverType.entries.toSet()
|
||||
}
|
||||
|
||||
private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle)
|
||||
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
|
||||
|
||||
|
|
@ -89,17 +134,60 @@ class WavlakeViewModel @Inject constructor(
|
|||
return@onEach
|
||||
}
|
||||
_searchState.value = WavlakeSearchState.Loading
|
||||
_searchState.value = when (val r = repository.search(query)) {
|
||||
is WavlakeResult.Ok ->
|
||||
if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.")
|
||||
else WavlakeSearchState.Results(r.value)
|
||||
is WavlakeResult.Failed -> WavlakeSearchState.Error(r.message)
|
||||
val types = _selectedTypes.value
|
||||
// Local stations need no network; Wavlake hit types share one call.
|
||||
val needRemote = types.any {
|
||||
it != DiscoverType.GENRES && it != DiscoverType.STATIONS
|
||||
}
|
||||
val hits = if (needRemote) {
|
||||
when (val r = repository.search(query)) {
|
||||
is WavlakeResult.Ok -> r.value
|
||||
is WavlakeResult.Failed -> {
|
||||
// Stations alone can still answer; otherwise surface the error.
|
||||
if (types.contains(DiscoverType.STATIONS)) emptyList()
|
||||
else {
|
||||
_searchState.value = WavlakeSearchState.Error(r.message)
|
||||
return@onEach
|
||||
}
|
||||
}
|
||||
}
|
||||
} else emptyList()
|
||||
|
||||
val matchQ = query.lowercase()
|
||||
val bucket = DiscoverResults(
|
||||
artists = hits.filterIsInstance<WavlakeSearchHit.Artist>()
|
||||
.filter { DiscoverType.ARTISTS in types }.map { it.artist },
|
||||
tracks = hits.filterIsInstance<WavlakeSearchHit.Track>()
|
||||
.filter { DiscoverType.TRACKS in types },
|
||||
albums = hits.filterIsInstance<WavlakeSearchHit.Album>()
|
||||
.filter { DiscoverType.TRACKS in types },
|
||||
genres = (if (DiscoverType.GENRES in types) genreMatches(query) else emptyList()),
|
||||
stations = if (DiscoverType.STATIONS in types)
|
||||
stations.getAll().filter {
|
||||
it.name.lowercase().contains(matchQ) ||
|
||||
it.genre.lowercase().contains(matchQ)
|
||||
}
|
||||
else emptyList(),
|
||||
)
|
||||
_searchState.value =
|
||||
if (bucket.isEmpty()) WavlakeSearchState.Error("No results for “$query”.")
|
||||
else WavlakeSearchState.Results(bucket)
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
refreshRankings(null)
|
||||
}
|
||||
|
||||
/** Genres matching the query: curated discover genres + every station genre. */
|
||||
private fun genreMatches(query: String): List<String> {
|
||||
val q = query.lowercase()
|
||||
return (DISCOVER_GENRES + stations.getAll().map { it.genre }.filter { it.isNotBlank() })
|
||||
.map { it to it.lowercase() }
|
||||
.filter { (_, low) -> low.contains(q) || q.contains(low.substringBefore('/')) }
|
||||
.map { it.first }
|
||||
.distinct()
|
||||
.take(8)
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
_searchQuery.value = query
|
||||
}
|
||||
|
|
@ -220,6 +308,31 @@ class WavlakeViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
/** Start a local/added radio station found through Discover search. */
|
||||
fun playFoundStation(station: com.rada.domain.model.Station) {
|
||||
playbackManager.playStation(station.url, station.name)
|
||||
}
|
||||
|
||||
/** Jump the Stations tab filtered to this genre (Discover genre hit). */
|
||||
fun openGenre(genre: String) {
|
||||
_rankingsGenre.value = genre
|
||||
viewModelScope.launch {
|
||||
_rankingsLoading.value = true
|
||||
_rankingsError.value = null
|
||||
when (val r = repository.rankings(days = 7, genre = genre, limit = 25)) {
|
||||
is WavlakeResult.Ok -> {
|
||||
_rankings.value = r.value
|
||||
resolveAddresses(r.value)
|
||||
}
|
||||
is WavlakeResult.Failed -> {
|
||||
_rankings.value = emptyList()
|
||||
_rankingsError.value = r.message
|
||||
}
|
||||
}
|
||||
_rankingsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun saveTrack(track: WavlakeTrack) {
|
||||
viewModelScope.launch {
|
||||
_notice.value = when (val r = repository.importTrack(track)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue