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:
parent
31d47f4cb5
commit
80b0300141
4 changed files with 188 additions and 24 deletions
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue