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

@ -15,8 +15,8 @@ android {
applicationId = "com.rada" applicationId = "com.rada"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 3 versionCode = 5
versionName = "0.1.2" versionName = "0.1.3"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View file

@ -49,6 +49,16 @@ class RadioBrowserRepository @Inject constructor() {
mergeResults(byName, byTag, limit) 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. */ /** Name hits keep their order; tag hits append, de-duplicated by id. */
internal fun mergeResults( internal fun mergeResults(
byName: List<Station>, byName: List<Station>,

View file

@ -88,6 +88,7 @@ fun DiscoverScreen(
val rankingsGenre by viewModel.rankingsGenre.collectAsState() val rankingsGenre by viewModel.rankingsGenre.collectAsState()
val rankingsLoading by viewModel.rankingsLoading.collectAsState() val rankingsLoading by viewModel.rankingsLoading.collectAsState()
val rankingsError by viewModel.rankingsError.collectAsState() val rankingsError by viewModel.rankingsError.collectAsState()
val browseStations by viewModel.browseStations.collectAsState()
val addresses by viewModel.addresses.collectAsState() val addresses by viewModel.addresses.collectAsState()
val selectedArtistId by viewModel.selectedArtistId.collectAsState() val selectedArtistId by viewModel.selectedArtistId.collectAsState()
val artistDetail by viewModel.artistDetail.collectAsState() val artistDetail by viewModel.artistDetail.collectAsState()
@ -232,6 +233,15 @@ fun DiscoverScreen(
selected = rankingsGenre, selected = rankingsGenre,
onSelect = { viewModel.refreshRankings(it) }, onSelect = { viewModel.refreshRankings(it) },
) )
val radio = browseStations
if (radio != null) {
RadioGenreList(
genre = rankingsGenre ?: "",
stations = radio,
loading = rankingsLoading,
onPlay = { viewModel.playFoundStation(it) },
)
} else
RankingsList( RankingsList(
title = if (rankingsGenre != null) "Top in ${rankingsGenre}" else "Top on Wavlake", title = if (rankingsGenre != null) "Top in ${rankingsGenre}" else "Top on Wavlake",
tracks = rankings, 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 @Composable
private fun RankingsList( private fun RankingsList(
title: String, title: String,

View file

@ -23,8 +23,33 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** Curated genre shortcuts — exact Wavlake catalog names only. */ /** Genres the Wavlake rankings endpoint accepts (probed against the live API). */
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock") 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. */ /** What the Discover search looks for; user-toggled chips. */
enum class DiscoverType(val label: String) { enum class DiscoverType(val label: String) {
@ -57,6 +82,7 @@ class WavlakeViewModel @Inject constructor(
private val playbackManager: PlaybackManager, private val playbackManager: PlaybackManager,
private val playlists: PlaylistRepository, private val playlists: PlaylistRepository,
private val stations: com.rada.data.repository.StationRepository, 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, @dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context,
) : ViewModel() { ) : ViewModel() {
@ -104,6 +130,14 @@ class WavlakeViewModel @Inject constructor(
private val _rankingsError = MutableStateFlow<String?>(null) private val _rankingsError = MutableStateFlow<String?>(null)
val rankingsError: StateFlow<String?> = _rankingsError.asStateFlow() 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. */ /** Resolved Lightning Addresses by artist id — populated best-effort. */
private val _addresses = MutableStateFlow<Map<String, String>>(emptyMap()) private val _addresses = MutableStateFlow<Map<String, String>>(emptyMap())
val addresses: StateFlow<Map<String, String>> = _addresses.asStateFlow() val addresses: StateFlow<Map<String, String>> = _addresses.asStateFlow()
@ -177,14 +211,19 @@ class WavlakeViewModel @Inject constructor(
refreshRankings(null) 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> { private fun genreMatches(query: String): List<String> {
val q = query.lowercase() val norm = { s: String -> s.lowercase().replace(" ", "") }
return (DISCOVER_GENRES + stations.getAll().map { it.genre }.filter { it.isNotBlank() }) val q = norm(query)
.map { it to it.lowercase() } val pool = DISCOVER_GENRES + SUBGENRE_TAGS +
.filter { (_, low) -> low.contains(q) || q.contains(low.substringBefore('/')) } stations.getAll().map { it.genre }.filter { it.isNotBlank() }
.map { it.first } return pool
.distinct() .distinct()
.filter { low ->
val l = norm(low)
l.contains(q) || q.contains(l) ||
l.substringBefore('/').let { it.isNotEmpty() && q.contains(it) }
}
.take(8) .take(8)
} }
@ -194,6 +233,11 @@ class WavlakeViewModel @Inject constructor(
fun refreshRankings(genre: String?) { fun refreshRankings(genre: String?) {
_rankingsGenre.value = genre _rankingsGenre.value = genre
if (genre != null && !isWavlakeGenre(genre)) {
browseRadioGenre(genre)
return
}
_browseStations.value = null
viewModelScope.launch { viewModelScope.launch {
_rankingsLoading.value = true _rankingsLoading.value = true
_rankingsError.value = null _rankingsError.value = null
@ -313,26 +357,24 @@ class WavlakeViewModel @Inject constructor(
playbackManager.playStation(station.url, station.name) 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) { fun openGenre(genre: String) {
// Reached from a search-result genre card: the rankings live in the // Reached from a search-result genre card: the browse view lives in
// Idle branch, so drop the query or the reload would stay invisible. // the Idle branch, so drop the query or the update would stay invisible.
_searchQuery.value = "" _searchQuery.value = ""
_searchState.value = WavlakeSearchState.Idle _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 { viewModelScope.launch {
_rankingsLoading.value = true _rankingsLoading.value = true
_rankingsError.value = null _rankingsError.value = null
when (val r = repository.rankings(days = 7, genre = genre, limit = 25)) { _browseStations.value = runCatching { radioBrowser.byTag(genre) }
is WavlakeResult.Ok -> { .getOrDefault(emptyList())
_rankings.value = r.value
resolveAddresses(r.value)
}
is WavlakeResult.Failed -> {
_rankings.value = emptyList()
_rankingsError.value = r.message
}
}
_rankingsLoading.value = false _rankingsLoading.value = false
} }
} }