feat(discover): type-toggle chips (Artists/Tracks/Genres/Stations), bucketed results, local station + genre search

This commit is contained in:
avi 2026-09-30 10:23:21 -05:00
commit 9e84906715
2 changed files with 289 additions and 26 deletions

View file

@ -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.Add
import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.PlayArrow 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.Search
import androidx.compose.material.icons.filled.Tune import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.AlertDialog 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.font.FontWeight
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.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.data.wavlake.MatchingMode import com.rada.data.wavlake.MatchingMode
import com.rada.data.wavlake.WavlakeSearchHit import com.rada.data.wavlake.WavlakeSearchHit
@ -204,6 +206,25 @@ fun DiscoverScreen(
shape = RoundedCornerShape(12.dp), 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) { when (val s = searchState) {
WavlakeSearchState.Idle -> { WavlakeSearchState.Idle -> {
GenreChips( GenreChips(
@ -247,33 +268,142 @@ fun DiscoverScreen(
} }
} }
is WavlakeSearchState.Results -> { is WavlakeSearchState.Results -> {
val r = s.results
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp), verticalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit -> if (r.stations.isNotEmpty()) {
when (hit) { item(key = "sec-stations", contentType = "header") {
is WavlakeSearchHit.Artist -> ArtistRow( SectionHeader("Stations")
name = hit.artist.name, }
genre = hit.artist.genre, items(r.stations, key = { "st-${it.id}" }, contentType = { "station" }) { st ->
imageUrl = hit.artist.imageUrl, Card(
artistId = hit.artist.id, onClick = { viewModel.playFoundStation(st) },
address = addresses[hit.artist.id], modifier = Modifier
onClick = { .fillMaxWidth()
viewModel.openArtist(hit.artist.id, hit.artist.name) .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 = { onZap = {
viewModel.resolveArtistAddress(hit.artist.id) viewModel.resolveArtistAddress(artist.id)
pendingZapArtistId = hit.artist.id pendingZapArtistId = artist.id
}, },
) )
is WavlakeSearchHit.Album -> AlbumRow( }
title = hit.title, }
artist = hit.artist, if (r.tracks.isNotEmpty() || r.albums.isNotEmpty()) {
artworkUrl = hit.artworkUrl, item(key = "sec-tracks", contentType = "header") {
onClick = { /* albums open via artist detail */ }, SectionHeader("Tracks")
) }
is WavlakeSearchHit.Track -> TrackRow( items(r.tracks, key = { "track-${it.track.id}" }, contentType = { "track" }) { hit ->
TrackRow(
track = com.rada.domain.model.WavlakeTrack( track = com.rada.domain.model.WavlakeTrack(
id = hit.track.id, id = hit.track.id,
title = hit.track.title, 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( private data class ZapTarget(
val artistId: String, val artistId: String,
val artistName: String, val artistName: String,

View file

@ -26,10 +26,27 @@ import javax.inject.Inject
/** Curated genre shortcuts — exact Wavlake catalog names only. */ /** Curated genre shortcuts — exact Wavlake catalog names only. */
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock") 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 { sealed interface WavlakeSearchState {
data object Idle : WavlakeSearchState data object Idle : WavlakeSearchState
data object Loading : 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 data class Error(val message: String) : WavlakeSearchState
} }
@ -39,11 +56,39 @@ class WavlakeViewModel @Inject constructor(
private val repository: WavlakeRepository, private val repository: WavlakeRepository,
private val playbackManager: PlaybackManager, private val playbackManager: PlaybackManager,
private val playlists: PlaylistRepository, private val playlists: PlaylistRepository,
private val stations: com.rada.data.repository.StationRepository,
@dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context,
) : ViewModel() { ) : ViewModel() {
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow() 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) private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle)
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow() val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
@ -89,17 +134,60 @@ class WavlakeViewModel @Inject constructor(
return@onEach return@onEach
} }
_searchState.value = WavlakeSearchState.Loading _searchState.value = WavlakeSearchState.Loading
_searchState.value = when (val r = repository.search(query)) { val types = _selectedTypes.value
is WavlakeResult.Ok -> // Local stations need no network; Wavlake hit types share one call.
if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.") val needRemote = types.any {
else WavlakeSearchState.Results(r.value) it != DiscoverType.GENRES && it != DiscoverType.STATIONS
is WavlakeResult.Failed -> WavlakeSearchState.Error(r.message)
} }
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) .launchIn(viewModelScope)
refreshRankings(null) 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) { fun search(query: String) {
_searchQuery.value = query _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) { fun saveTrack(track: WavlakeTrack) {
viewModelScope.launch { viewModelScope.launch {
_notice.value = when (val r = repository.importTrack(track)) { _notice.value = when (val r = repository.importTrack(track)) {