From 80b03001414f027a53c905c20255e6dfdaa5a687 Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 1 Oct 2026 18:11:59 -0500 Subject: [PATCH] feat(discover): browse sub-genres Wavlake doesn't index via radio-tag station search MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wavlake rankings 400 on sub-genres (Trance, Classic Rock, Metal...). Discover chips now include 15 sub-genres; tapping one routes through refreshRankings -> browseRadioGenre, filling a new browseStations StateFlow from radio-browser tag search (most-listened first) rendered by RadioGenreList. Wavlake-indexed genres keep the rankings path. Genre matching is now space-insensitive (user types 'oldschool' for 'Old School Hip Hop'). radio-browser tag match is case-SENSITIVE against lowercase stored tags — byTag() lowercases or every sub-genre browse returns zero hits. --- app/build.gradle.kts | 4 +- .../data/repository/RadioBrowserRepository.kt | 10 ++ .../com/rada/ui/discover/DiscoverScreen.kt | 112 ++++++++++++++++++ .../com/rada/ui/discover/WavlakeViewModel.kt | 86 ++++++++++---- 4 files changed, 188 insertions(+), 24 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 637a39b..c0f8e01 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,8 +15,8 @@ android { applicationId = "com.rada" minSdk = 26 targetSdk = 35 - versionCode = 3 - versionName = "0.1.2" + versionCode = 5 + versionName = "0.1.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt b/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt index fd701ae..1410f9b 100644 --- a/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt +++ b/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt @@ -49,6 +49,16 @@ class RadioBrowserRepository @Inject constructor() { mergeResults(byName, byTag, limit) } + /** + * Stations tagged with [tag] (genre browse), most-listened first. + * Tag match is case-SENSITIVE on radio-browser — the directory stores + * tags lowercase, so "Trance" returns zero hits while "trance" works. + */ + suspend fun byTag(tag: String, limit: Int = 40): List = + withContext(Dispatchers.IO) { + fetch(mapOf("tag" to tag.trim().lowercase()), limit) + } + /** Name hits keep their order; tag hits append, de-duplicated by id. */ internal fun mergeResults( byName: List, 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 6a5d856..66dba0c 100644 --- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt +++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt @@ -88,6 +88,7 @@ fun DiscoverScreen( val rankingsGenre by viewModel.rankingsGenre.collectAsState() val rankingsLoading by viewModel.rankingsLoading.collectAsState() val rankingsError by viewModel.rankingsError.collectAsState() + val browseStations by viewModel.browseStations.collectAsState() val addresses by viewModel.addresses.collectAsState() val selectedArtistId by viewModel.selectedArtistId.collectAsState() val artistDetail by viewModel.artistDetail.collectAsState() @@ -232,6 +233,15 @@ fun DiscoverScreen( selected = rankingsGenre, onSelect = { viewModel.refreshRankings(it) }, ) + val radio = browseStations + if (radio != null) { + RadioGenreList( + genre = rankingsGenre ?: "", + stations = radio, + loading = rankingsLoading, + onPlay = { viewModel.playFoundStation(it) }, + ) + } else RankingsList( title = if (rankingsGenre != null) "Top in ${rankingsGenre}" else "Top on Wavlake", tracks = rankings, @@ -535,6 +545,108 @@ private fun GenreChip(label: String, selected: Boolean, onClick: () -> Unit) { ) } +@Composable +private fun RadioGenreList( + genre: String, + stations: List, + loading: Boolean, + onPlay: (com.rada.domain.model.Station) -> Unit, +) { + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = "RADIO · $genre".uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = LocalTokens.current.accentText, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + when { + loading -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.dp, + color = LocalTokens.current.accentText, + ) + } + } + stations.isEmpty() -> { + Text( + text = "No stations found for “$genre”.", + style = MaterialTheme.typography.bodyMedium, + color = LocalTokens.current.textSecondary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(stations, key = { it.id }, contentType = { "rstation" }) { st -> + 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 = { onPlay(st) }, + modifier = Modifier.size(48.dp), + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Play ${st.name}", + tint = LocalTokens.current.textSecondary, + modifier = Modifier.size(22.dp), + ) + } + } + } + } + } + } + } +} + @Composable private fun RankingsList( title: 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 00f3594..7a6d5f4 100644 --- a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt +++ b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt @@ -23,8 +23,33 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import javax.inject.Inject -/** Curated genre shortcuts — exact Wavlake catalog names only. */ -val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock") +/** Genres the Wavlake rankings endpoint accepts (probed against the live API). */ +val WAVLAKE_GENRES = listOf( + "Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock", "Jazz", "Blues", + "Country", "Reggae", "Classical", "Lofi", "Soul", "Alternative", "R&B", + "Latin", "Gospel", "World", "Soundtrack", +) + +/** + * Sub-genres Wavlake doesn't index (rankings 400 on them). Browsing one + * searches live radio stations by tag instead — trance, classic rock, and + * friends have rich station coverage even where Wavlake is silent. + */ +val SUBGENRE_TAGS = listOf( + "Trance", "Techno", "Hard House", "Hardstyle", "Classic Rock", "Oldies", + "Old School Hip Hop", "Metal", "Punk", "Ambient", "Drum and Bass", + "Dubstep", "Disco", "Funk", "Synthwave", +) + +/** Curated genre shortcuts shown as Discover chips. */ +val DISCOVER_GENRES = listOf( + "Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock", "Trance", "Techno", + "Hard House", "Classic Rock", "Oldies", "Old School Hip Hop", "Metal", + "Jazz", "Lofi", "Ambient", "Reggae", +) + +/** True when a genre browses Wavlake rankings; false → radio-tag browse. */ +fun isWavlakeGenre(genre: String): Boolean = genre in WAVLAKE_GENRES /** What the Discover search looks for; user-toggled chips. */ enum class DiscoverType(val label: String) { @@ -57,6 +82,7 @@ class WavlakeViewModel @Inject constructor( private val playbackManager: PlaybackManager, private val playlists: PlaylistRepository, private val stations: com.rada.data.repository.StationRepository, + private val radioBrowser: com.rada.data.repository.RadioBrowserRepository, @dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context, ) : ViewModel() { @@ -104,6 +130,14 @@ class WavlakeViewModel @Inject constructor( private val _rankingsError = MutableStateFlow(null) val rankingsError: StateFlow = _rankingsError.asStateFlow() + /** + * Radio stations while browsing a sub-genre Wavlake doesn't index + * (trance, classic rock…). Non-null means the rankings view shows + * stations instead of Wavlake tracks. + */ + private val _browseStations = MutableStateFlow?>(null) + val browseStations: StateFlow?> = _browseStations.asStateFlow() + /** Resolved Lightning Addresses by artist id — populated best-effort. */ private val _addresses = MutableStateFlow>(emptyMap()) val addresses: StateFlow> = _addresses.asStateFlow() @@ -177,14 +211,19 @@ class WavlakeViewModel @Inject constructor( refreshRankings(null) } - /** Genres matching the query: curated discover genres + every station genre. */ + /** Genres matching the query: curated genres (incl. sub-genres) + saved-station genres. */ 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 } + val norm = { s: String -> s.lowercase().replace(" ", "") } + val q = norm(query) + val pool = DISCOVER_GENRES + SUBGENRE_TAGS + + stations.getAll().map { it.genre }.filter { it.isNotBlank() } + return pool .distinct() + .filter { low -> + val l = norm(low) + l.contains(q) || q.contains(l) || + l.substringBefore('/').let { it.isNotEmpty() && q.contains(it) } + } .take(8) } @@ -194,6 +233,11 @@ class WavlakeViewModel @Inject constructor( fun refreshRankings(genre: String?) { _rankingsGenre.value = genre + if (genre != null && !isWavlakeGenre(genre)) { + browseRadioGenre(genre) + return + } + _browseStations.value = null viewModelScope.launch { _rankingsLoading.value = true _rankingsError.value = null @@ -313,26 +357,24 @@ class WavlakeViewModel @Inject constructor( playbackManager.playStation(station.url, station.name) } - /** Jump the Stations tab filtered to this genre (Discover genre hit). */ + /** Browse a genre: Wavlake rankings when indexed, radio-tag search otherwise. */ fun openGenre(genre: String) { - // Reached from a search-result genre card: the rankings live in the - // Idle branch, so drop the query or the reload would stay invisible. + // Reached from a search-result genre card: the browse view lives in + // the Idle branch, so drop the query or the update would stay invisible. _searchQuery.value = "" _searchState.value = WavlakeSearchState.Idle - _rankingsGenre.value = genre + refreshRankings(genre) + } + + /** Sub-genre browse: stations tagged [genre] on radio-browser, most-listened first. */ + private fun browseRadioGenre(genre: String) { + _rankings.value = emptyList() + _browseStations.value = emptyList() 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 - } - } + _browseStations.value = runCatching { radioBrowser.byTag(genre) } + .getOrDefault(emptyList()) _rankingsLoading.value = false } }