feat(discover): browse sub-genres Wavlake doesn't index via radio-tag station search

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.
This commit is contained in:
avi 2026-10-01 18:11:59 -05:00
commit 80b0300141
4 changed files with 188 additions and 24 deletions

View file

@ -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<Station> =
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<Station>,

View file

@ -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<com.rada.domain.model.Station>,
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,

View file

@ -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<String?>(null)
val rankingsError: StateFlow<String?> = _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<List<com.rada.domain.model.Station>?>(null)
val browseStations: StateFlow<List<com.rada.domain.model.Station>?> = _browseStations.asStateFlow()
/** Resolved Lightning Addresses by artist id — populated best-effort. */
private val _addresses = MutableStateFlow<Map<String, String>>(emptyMap())
val addresses: StateFlow<Map<String, String>> = _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<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 }
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
}
}