From 9e84906715865860c0223026463bc3045bcf9ca9 Mon Sep 17 00:00:00 2001 From: avi Date: Wed, 30 Sep 2026 10:23:21 -0500 Subject: [PATCH] feat(discover): type-toggle chips (Artists/Tracks/Genres/Stations), bucketed results, local station + genre search --- .../com/rada/ui/discover/DiscoverScreen.kt | 190 ++++++++++++++++-- .../com/rada/ui/discover/WavlakeViewModel.kt | 125 +++++++++++- 2 files changed, 289 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt index a137c94..6a5d856 100644 --- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt +++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt @@ -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, diff --git a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt index c76862e..819afad 100644 --- a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt +++ b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt @@ -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 = emptyList(), + val tracks: List = emptyList(), + val albums: List = emptyList(), + val genres: List = emptyList(), + val stations: List = 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) : 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 = _searchQuery.asStateFlow() + /** Which result types to search/show; persisted so the choice sticks. */ + private val _selectedTypes = MutableStateFlow(loadTypes()) + val selectedTypes: StateFlow> = _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 { + 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.Idle) val searchState: StateFlow = _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() + .filter { DiscoverType.ARTISTS in types }.map { it.artist }, + tracks = hits.filterIsInstance() + .filter { DiscoverType.TRACKS in types }, + albums = hits.filterIsInstance() + .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 { + 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)) {