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"
minSdk = 26
targetSdk = 35
versionCode = 7
versionName = "0.1.5"
versionCode = 1
versionName = "0.1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -3,7 +3,6 @@ package com.rada.data.local.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.PlaylistSongEntity
@ -31,9 +30,7 @@ interface PlaylistDao {
@Query("DELETE FROM playlists WHERE id = :playlistId")
suspend fun deletePlaylistById(playlistId: Long)
// 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)
@Insert
suspend fun addSongToPlaylist(playlistSong: PlaylistSongEntity)
@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)
}
/**
* 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>,

View file

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

View file

@ -88,7 +88,6 @@ 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()
@ -233,15 +232,6 @@ 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,
@ -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
private fun RankingsList(
title: String,

View file

@ -23,33 +23,8 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import javax.inject.Inject
/** 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
/** Curated genre shortcuts — exact Wavlake catalog names only. */
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
/** What the Discover search looks for; user-toggled chips. */
enum class DiscoverType(val label: String) {
@ -82,7 +57,6 @@ 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() {
@ -130,14 +104,6 @@ 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()
@ -211,19 +177,14 @@ class WavlakeViewModel @Inject constructor(
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> {
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
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 }
.distinct()
.filter { low ->
val l = norm(low)
l.contains(q) || q.contains(l) ||
l.substringBefore('/').let { it.isNotEmpty() && q.contains(it) }
}
.take(8)
}
@ -233,11 +194,6 @@ 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
@ -357,24 +313,22 @@ class WavlakeViewModel @Inject constructor(
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) {
// 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()
_rankingsGenre.value = genre
viewModelScope.launch {
_rankingsLoading.value = true
_rankingsError.value = null
_browseStations.value = runCatching { radioBrowser.byTag(genre) }
.getOrDefault(emptyList())
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
}
}
_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