diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 39c3a47..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 = 2 - versionName = "0.1.1" + 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/components/AppHomeButton.kt b/app/src/main/java/com/rada/ui/components/AppHomeButton.kt index d55398d..2214a0a 100644 --- a/app/src/main/java/com/rada/ui/components/AppHomeButton.kt +++ b/app/src/main/java/com/rada/ui/components/AppHomeButton.kt @@ -24,7 +24,7 @@ fun AppHomeButton( ) { IconButton(onClick = onClick, modifier = modifier) { Image( - painter = painterResource(R.mipmap.ic_launcher), + painter = painterResource(R.drawable.ic_home_mark), contentDescription = "Home", modifier = Modifier .size(30.dp) 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 819afad..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,22 +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) { - _rankingsGenre.value = genre + // 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 + 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 } } diff --git a/app/src/main/res/drawable-hdpi/ic_home_mark.png b/app/src/main/res/drawable-hdpi/ic_home_mark.png new file mode 100644 index 0000000..36edc50 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_home_mark.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_background.png b/app/src/main/res/drawable-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..0a1acf1 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..32fca30 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png b/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..0deb89c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_home_mark.png b/app/src/main/res/drawable-mdpi/ic_home_mark.png new file mode 100644 index 0000000..a7b124e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_home_mark.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher_background.png b/app/src/main/res/drawable-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..3f1c041 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a8dcb2f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png b/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..18c9084 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_home_mark.png b/app/src/main/res/drawable-xhdpi/ic_home_mark.png new file mode 100644 index 0000000..1b05a6a Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_home_mark.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher_background.png b/app/src/main/res/drawable-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..352eec7 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..eb5976d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png b/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..c2befb9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_home_mark.png b/app/src/main/res/drawable-xxhdpi/ic_home_mark.png new file mode 100644 index 0000000..676fe09 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_home_mark.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png b/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..d1361a6 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b627a98 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png b/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..cd8fd6a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_home_mark.png b/app/src/main/res/drawable-xxxhdpi/ic_home_mark.png new file mode 100644 index 0000000..f98298a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_home_mark.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..22dec82 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_launcher_background.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..984534d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_launcher_foreground.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png b/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png new file mode 100644 index 0000000..0e7c3e5 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_launcher_monochrome.png differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..8fde456 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..8fde456 --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index 7f63d63..36edc50 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png index 7f63d63..ce943ba 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index 5d2061f..a7b124e 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png index 5d2061f..330732b 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 5b9375b..1b05a6a 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png index 5b9375b..ca02b0f 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index dd56295..676fe09 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png index dd56295..be3f625 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 98c0ee5..f98298a 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png index 98c0ee5..2218c2d 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ