Compare commits

..

No commits in common. "master" and "v0.1.1" have entirely different histories.

38 changed files with 24 additions and 207 deletions

View file

@ -15,8 +15,8 @@ android {
applicationId = "com.rada" applicationId = "com.rada"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 7 versionCode = 1
versionName = "0.1.5" versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View file

@ -3,7 +3,6 @@ package com.rada.data.local.dao
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Delete import androidx.room.Delete
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import com.rada.data.local.entity.PlaylistEntity import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.PlaylistSongEntity import com.rada.data.local.entity.PlaylistSongEntity
@ -31,9 +30,7 @@ interface PlaylistDao {
@Query("DELETE FROM playlists WHERE id = :playlistId") @Query("DELETE FROM playlists WHERE id = :playlistId")
suspend fun deletePlaylistById(playlistId: Long) suspend fun deletePlaylistById(playlistId: Long)
// IGNORE: (playlistId, songId) is the PK — re-adding a song that is @Insert
// already in the playlist must be a no-op, not a crash.
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun addSongToPlaylist(playlistSong: PlaylistSongEntity) suspend fun addSongToPlaylist(playlistSong: PlaylistSongEntity)
@Query("DELETE FROM playlist_songs WHERE playlistId = :playlistId AND songId = :songId") @Query("DELETE FROM playlist_songs WHERE playlistId = :playlistId AND songId = :songId")

View file

@ -49,16 +49,6 @@ 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

@ -24,7 +24,7 @@ fun AppHomeButton(
) { ) {
IconButton(onClick = onClick, modifier = modifier) { IconButton(onClick = onClick, modifier = modifier) {
Image( Image(
painter = painterResource(R.drawable.ic_home_mark), painter = painterResource(R.mipmap.ic_launcher),
contentDescription = "Home", contentDescription = "Home",
modifier = Modifier modifier = Modifier
.size(30.dp) .size(30.dp)

View file

@ -88,7 +88,6 @@ 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()
@ -233,15 +232,6 @@ 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,
@ -545,108 +535,6 @@ 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,33 +23,8 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
/** Genres the Wavlake rankings endpoint accepts (probed against the live API). */ /** Curated genre shortcuts — exact Wavlake catalog names only. */
val WAVLAKE_GENRES = listOf( val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
"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) {
@ -82,7 +57,6 @@ 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() {
@ -130,14 +104,6 @@ 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()
@ -211,19 +177,14 @@ class WavlakeViewModel @Inject constructor(
refreshRankings(null) refreshRankings(null)
} }
/** Genres matching the query: curated genres (incl. sub-genres) + saved-station genres. */ /** Genres matching the query: curated discover genres + every station genre. */
private fun genreMatches(query: String): List<String> { private fun genreMatches(query: String): List<String> {
val norm = { s: String -> s.lowercase().replace(" ", "") } val q = query.lowercase()
val q = norm(query) return (DISCOVER_GENRES + stations.getAll().map { it.genre }.filter { it.isNotBlank() })
val pool = DISCOVER_GENRES + SUBGENRE_TAGS + .map { it to it.lowercase() }
stations.getAll().map { it.genre }.filter { it.isNotBlank() } .filter { (_, low) -> low.contains(q) || q.contains(low.substringBefore('/')) }
return pool .map { it.first }
.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)
} }
@ -233,11 +194,6 @@ 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
@ -357,24 +313,22 @@ class WavlakeViewModel @Inject constructor(
playbackManager.playStation(station.url, station.name) playbackManager.playStation(station.url, station.name)
} }
/** Browse a genre: Wavlake rankings when indexed, radio-tag search otherwise. */ /** Jump the Stations tab filtered to this genre (Discover genre hit). */
fun openGenre(genre: String) { fun openGenre(genre: String) {
// Reached from a search-result genre card: the browse view lives in _rankingsGenre.value = genre
// 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 { viewModelScope.launch {
_rankingsLoading.value = true _rankingsLoading.value = true
_rankingsError.value = null _rankingsError.value = null
_browseStations.value = runCatching { radioBrowser.byTag(genre) } when (val r = repository.rankings(days = 7, genre = genre, limit = 25)) {
.getOrDefault(emptyList()) is WavlakeResult.Ok -> {
_rankings.value = r.value
resolveAddresses(r.value)
}
is WavlakeResult.Failed -> {
_rankings.value = emptyList()
_rankingsError.value = r.message
}
}
_rankingsLoading.value = false _rankingsLoading.value = false
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,022 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

View file

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_monochrome" />
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Before After
Before After