Compare commits

...

8 commits

Author SHA1 Message Date
avi
bf9222ab0d fix(playlists): re-adding a song to a playlist crashed with SQLiteConstraintException
(playlistId, songId) is the playlist_songs PK, so a second @Insert of the
same pair threw UNIQUE constraint failed and killed the app (reproduced
twice in the crash buffer on 10-03). Switch to OnConflictStrategy.IGNORE:
duplicate add is now a silent no-op. Verified on-device on 0.1.5 (7):
re-adding 'The Humbling River' to FAVORITES keeps the song list intact,
0 FATALs, no new crash-buffer entries.
2026-10-03 19:43:44 -05:00
avi
d0c5ce86fd chore(release): v0.1.4 (versionCode 6) — re-cut of 80b0300 code
v0.1.3 was sideloaded to the test phone during verification before the
release was published, so self-update from that phone saw equal versions
and reported 'up to date'. Release number moved ahead of the installed
build so the update path has something to offer.
2026-10-01 20:09:52 -05:00
avi
80b0300141 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.
2026-10-01 18:11:59 -05:00
avi
31d47f4cb5 fix(discover): genre card from search results did nothing — openGenre reloaded rankings but the view stayed in the Results branch so the update was invisible. Clear the query and return to Idle rankings view, showing 'Top in <genre>'. 2026-10-01 10:44:30 -05:00
avi
4c63421400 chore(release): v0.1.2 (versionCode 3) — crash-fix build sideloaded to phone 2026-10-01 10:33:23 -05:00
avi
598556dbdd fix(crash): home button painted adaptive mipmap.ic_launcher — on API 26+ that resolves to the adaptive-icon XML and painterResource throws IllegalArgumentException ('Only VectorDrawables and rasterized asset types'), killing the app on first frame. Add rasterized ic_home_mark PNGs and paint those instead; bump to 0.1.2 (versionCode 3). 2026-10-01 10:33:15 -05:00
avi
90afcf3b6a feat(icon): adaptive launcher icon with rounded legacy fallbacks
The launcher icon was a flat square PNG with opaque black corners, so it
rendered with sharp edges. Extracted the artwork onto a proper adaptive
icon (solid #111 background layer, artwork centered in the safe zone,
monochrome layer for themed icons) and re-cut the legacy mipmaps with
rounded corners for pre-26 launchers.
2026-10-01 10:09:05 -05:00
avi
838809ec0c chore(release): v0.1.1 (versionCode 2) — first Forgejo release; enables in-app self-update path 2026-09-30 12:28:02 -05:00
38 changed files with 207 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 = 1 versionCode = 7
versionName = "0.1.0" versionName = "0.1.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }

View file

@ -3,6 +3,7 @@ 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
@ -30,7 +31,9 @@ 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)
@Insert // IGNORE: (playlistId, songId) is the PK — re-adding a song that is
// 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,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

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

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,22 +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) {
_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 { 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
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 514 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 684 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,6 @@
<?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

@ -0,0 +1,6 @@
<?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: 7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.6 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: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Before After
Before After