diff --git a/PRODUCT.md b/PRODUCT.md
index e7d25d2..a545b51 100644
--- a/PRODUCT.md
+++ b/PRODUCT.md
@@ -26,7 +26,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live
## Operating Context
- Single-user personal device use; headphone and speaker listening.
-- Streams are live and can drop; playback is a foreground service with notification controls.
+- Streams are live and can drop; playback is a foreground service with notification controls. Swiping the task away stops playback and clears the notification (closed means stopped); screen-off listening with the task alive keeps playing.
- Library is local (Room DB, `radamp.db`); saved songs, playlists, and imported files live on-device.
- Stations are built-in defaults plus user-added streams (direct URL or M3U playlist import).
@@ -45,7 +45,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live
## Evidence on Hand
-- 43 built-in stations, all verified live 2026-09-07 (HTTP 200 + audio payload; probe results were ephemeral in /tmp, not committed).
+- 11 built-in stations (cliamp.stream music channels in playlist order: Lofi → … → NCS Chill), all verified live 2026-09-09 (HTTP 200 + audio payload; probe results were ephemeral, not committed). Media Monarchy ships as a built-in stream in the Streams tab instead (talk, not music).
- Working recorder pipeline (`recording/`: IcyStreamReader, SongBoundaryDetector, RollingAudioBuffer, MetadataEmbedder).
- Design scratch work in `design/` (glass theme CSS + demo).
- Absences that must not be fabricated: no testimonials, no user base, no press, no pricing, no privacy policy text.
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index 040a549..bb952aa 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -1,2 +1,13 @@
-dontwarn javax.annotation.**
-keep class com.rada.data.local.entity.** { *; }
+
+# jaudiotagger maps tag frames via reflection; without keeps the release
+# minifier breaks its writer (silent NPE, files saved untagged).
+-keep class org.jaudiotagger.** { *; }
+-keepattributes Signature, InnerClasses, EnclosingMethod
+# jaudiotagger also references desktop-only image/test APIs that Android
+# never executes (artwork scaling, test helpers).
+-dontwarn org.jaudiotagger.**
+-dontwarn java.awt.**
+-dontwarn javax.imageio.**
+-dontwarn javax.swing.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3285b91..5df91ae 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,16 @@
+
+
+
+
+
+
+
+
+
+
>
+ /**
+ * Union of songs across several playlists. DISTINCT because a song can
+ * sit in more than one selected list.
+ */
+ @Query(
+ """
+ SELECT DISTINCT s.* FROM saved_songs s
+ INNER JOIN playlist_songs ps ON s.id = ps.songId
+ WHERE ps.playlistId IN (:playlistIds)
+ """
+ )
+ fun getSongsInPlaylists(playlistIds: List): Flow>
+
@Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId")
fun getPlaylistSongCount(playlistId: Long): Flow
diff --git a/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt b/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt
index be6e767..cafbe77 100644
--- a/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt
+++ b/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt
@@ -18,4 +18,14 @@ data class SavedSongEntity(
val format: String = "mp3",
val dateSaved: Long = System.currentTimeMillis(),
val isFavorite: Boolean = false,
+ /** Track origin: "local" (default) or "wavlake" (streamable catalog import). */
+ val source: String = "local",
+ /** Canonical source URL (Wavlake track page). Empty for local files. */
+ val sourceUrl: String = "",
+ /** Artwork URL (Wavlake imports). Empty for local files. */
+ val artworkUrl: String = "",
+ /** Wavlake artist id (imports only) — links plays back to zap addresses. */
+ val wavlakeArtistId: String = "",
+ /** Wavlake artist npub (imports only) — enables Nostr address resolution. */
+ val wavlakeArtistNpub: String = "",
)
diff --git a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt
index 24f8f28..456c193 100644
--- a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt
+++ b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt
@@ -45,6 +45,11 @@ class PlaylistRepository @Inject constructor(
return playlistDao.getSongsInPlaylist(playlistId)
}
+ /** Union of songs across several playlists (for multi-list shuffle). */
+ fun getSongsInPlaylists(playlistIds: List): Flow> {
+ return playlistDao.getSongsInPlaylists(playlistIds)
+ }
+
fun getPlaylistSongCount(playlistId: Long): Flow {
return playlistDao.getPlaylistSongCount(playlistId)
}
diff --git a/app/src/main/java/com/rada/data/repository/StationRepository.kt b/app/src/main/java/com/rada/data/repository/StationRepository.kt
index efee1bf..5421fbd 100644
--- a/app/src/main/java/com/rada/data/repository/StationRepository.kt
+++ b/app/src/main/java/com/rada/data/repository/StationRepository.kt
@@ -24,6 +24,10 @@ class StationRepository @Inject constructor(
private val stations = mutableListOf()
private val nameOverrides = mutableMapOf()
+ /** Built-in station ids the user deleted. Persisted; survives restarts. */
+ private val _hiddenIds = MutableStateFlow(loadHiddenIds())
+ val hiddenIds: StateFlow> = _hiddenIds.asStateFlow()
+
init {
nameOverrides.putAll(loadNameOverrides())
rebuild()
@@ -38,10 +42,42 @@ class StationRepository @Inject constructor(
saveCustomStations()
}
+ /**
+ * Deletes any station. Custom stations are removed; built-ins are
+ * hidden (persisted) so they stay gone across restarts. See
+ * [unhideStation]/[restoreHiddenStations] for the way back.
+ */
fun removeStation(id: String) {
+ if (DefaultStations.getById(id) != null) {
+ if (id !in _hiddenIds.value) {
+ _hiddenIds.value = _hiddenIds.value + id
+ saveHiddenIds(_hiddenIds.value)
+ rebuild()
+ }
+ return
+ }
stations.removeAll { it.id == id }
if (nameOverrides.remove(id) != null) saveNameOverrides()
saveCustomStations()
+ rebuild()
+ }
+
+ /** Un-hides one previously hidden built-in station. No-op otherwise. */
+ fun unhideStation(id: String) {
+ if (id in _hiddenIds.value) {
+ _hiddenIds.value = _hiddenIds.value - id
+ saveHiddenIds(_hiddenIds.value)
+ rebuild()
+ }
+ }
+
+ /** Restores all hidden built-in stations. */
+ fun restoreHiddenStations() {
+ if (_hiddenIds.value.isNotEmpty()) {
+ _hiddenIds.value = emptySet()
+ saveHiddenIds(emptySet())
+ rebuild()
+ }
}
fun isCustomStation(id: String): Boolean {
@@ -125,7 +161,7 @@ class StationRepository @Inject constructor(
private fun rebuild() {
stations.clear()
- stations.addAll(DefaultStations.getAll())
+ stations.addAll(DefaultStations.getAll().filter { it.id !in _hiddenIds.value })
stations.addAll(loadCustomStations())
for (i in stations.indices) {
val station = stations[i]
@@ -135,8 +171,7 @@ class StationRepository @Inject constructor(
}
}
- private fun loadNameOverrides(): Map {
- val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
+ private fun loadNameOverrides(): Map { val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
return try {
val obj = JSONObject(json)
obj.keys().asSequence().associateWith { key -> obj.getString(key) }
@@ -169,8 +204,7 @@ class StationRepository @Inject constructor(
}
}
- private fun saveCustomStations() {
- val custom = stations.filter { isCustomStation(it.id) }
+ private fun saveCustomStations() { val custom = stations.filter { isCustomStation(it.id) }
val arr = JSONArray()
custom.forEach { s ->
arr.put(JSONObject().apply {
@@ -183,9 +217,26 @@ class StationRepository @Inject constructor(
prefs.edit().putString(KEY_CUSTOM_STATIONS, arr.toString()).apply()
}
+ private fun loadHiddenIds(): Set {
+ val json = prefs.getString(KEY_HIDDEN_STATIONS, null) ?: return emptySet()
+ return try {
+ val arr = JSONArray(json)
+ (0 until arr.length()).mapTo(mutableSetOf()) { i -> arr.getString(i) }
+ } catch (_: Exception) {
+ emptySet()
+ }
+ }
+
+ private fun saveHiddenIds(ids: Set) {
+ val arr = JSONArray()
+ ids.forEach { arr.put(it) }
+ prefs.edit().putString(KEY_HIDDEN_STATIONS, arr.toString()).apply()
+ }
+
companion object {
private const val KEY_CUSTOM_STATIONS = "custom_stations"
private const val KEY_NAME_OVERRIDES = "station_name_overrides"
private const val KEY_FAVORITE_STATIONS = "favorite_stations"
+ private const val KEY_HIDDEN_STATIONS = "hidden_stations"
}
}
diff --git a/app/src/main/java/com/rada/data/repository/StreamRepository.kt b/app/src/main/java/com/rada/data/repository/StreamRepository.kt
index 8e53b42..da80e12 100644
--- a/app/src/main/java/com/rada/data/repository/StreamRepository.kt
+++ b/app/src/main/java/com/rada/data/repository/StreamRepository.kt
@@ -21,6 +21,34 @@ class StreamRepository @Inject constructor(
private var cache: List? = null
+ init {
+ ensureBuiltins()
+ }
+
+ /**
+ * Media Monarchy ships as a built-in stream (talk, not music — it lives
+ * here instead of the Stations tab). Seeded exactly once per install so
+ * a user delete is respected and never resurrected.
+ */
+ private fun ensureBuiltins() {
+ if (prefs.getBoolean(KEY_BUILTINS_SEEDED, false)) return
+ val current = getAll().toMutableList()
+ if (current.none { it.url == BUILTIN_MONARCHY_URL }) {
+ current.add(
+ 0,
+ StreamEntry(
+ id = "media-monarchy",
+ name = "Media Monarchy",
+ url = BUILTIN_MONARCHY_URL,
+ source = "Built-in",
+ group = "Talk",
+ ),
+ )
+ save(current)
+ }
+ prefs.edit().putBoolean(KEY_BUILTINS_SEEDED, true).apply()
+ }
+
fun getAll(): List {
cache?.let { return it }
val raw = prefs.getString(key, "[]") ?: "[]"
@@ -61,4 +89,9 @@ class StreamRepository @Inject constructor(
cache = list
prefs.edit().putString(key, json.encodeToString(streamEntriesSerializer, list)).apply()
}
+
+ companion object {
+ private const val KEY_BUILTINS_SEEDED = "builtins_seeded"
+ private const val BUILTIN_MONARCHY_URL = "http://216.158.245.68:8000/listen"
+ }
}
diff --git a/app/src/main/java/com/rada/data/wavlake/StationFeature.kt b/app/src/main/java/com/rada/data/wavlake/StationFeature.kt
new file mode 100644
index 0000000..d6a6b42
--- /dev/null
+++ b/app/src/main/java/com/rada/data/wavlake/StationFeature.kt
@@ -0,0 +1,112 @@
+package com.rada.data.wavlake
+
+import com.rada.domain.model.WavlakeTrack
+import kotlinx.serialization.Serializable
+import kotlin.random.Random
+
+/**
+ * Optional, configurable featuring of Wavlake tracks alongside (never
+ * inside) live radio stations. Stations are live streams, so featuring is
+ * an opt-in shelf the user taps — it never replaces station content,
+ * never interrupts playback, and any API failure degrades to "no picks".
+ */
+@Serializable
+data class WavlakeStationSettings(
+ val enabled: Boolean = true,
+ /** Probability a station view refreshes its picks (0.0–1.0). */
+ val featureProbability: Double = 0.1,
+ val maxFeaturedTracksPerHour: Int = 3,
+ val maxFeaturedTracksPerQueue: Int = 1,
+ val matchingMode: MatchingMode = MatchingMode.EXACT_OR_RELATED,
+)
+
+enum class MatchingMode { EXACT, RELATED, EXACT_OR_RELATED }
+
+val defaultWavlakeStationSettings = WavlakeStationSettings()
+
+/**
+ * Station genre -> exact Wavlake catalog genre names
+ * (https://github.com/wavlake/genre-list). Only names from that list are
+ * ever sent to the API — unknown names return HTTP 400 there.
+ */
+object WavlakeGenreMatcher {
+ private val related: Map> = mapOf(
+ "Lofi" to listOf("Downtempo", "Ambient", "Electronic"),
+ "Synthwave" to listOf("Electro", "Electronica", "Electronic", "Electronic Rock"),
+ "EDM" to listOf(
+ "Dance/EDM", "House", "Jackin House", "Dubstep", "Techno", "Trance",
+ "Breakbeat", "Garage", "Hard Dance", "Jungle/Drum'n'bass", "Hi-NRG / Eurodance",
+ ),
+ "NCS" to listOf(
+ "Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "House", "Dubstep",
+ "Jungle/Drum'n'bass", "Trance", "Techno",
+ ),
+ "Electronic" to listOf("Electronic", "Electronica", "Downtempo", "Ambient"),
+ "Hip-Hop" to listOf("Hip-Hop/Rap", "Hip-Hop", "Rap"),
+ "Pop" to listOf("Pop", "Indie Pop"),
+ "Rock" to listOf("Rock", "Alternative Rock", "Indie Rock"),
+ )
+
+ /** Station "A / B" strings use the same split convention as the app. */
+ fun splitStationGenres(genre: String): Pair> {
+ val parts = genre.split(" / ").map { it.trim() }.filter { it.isNotEmpty() }
+ return (parts.firstOrNull() ?: "") to parts.drop(1)
+ }
+
+ /** Exact Wavlake genre names compatible with a station, per [MatchingMode]. */
+ fun compatibleGenres(
+ primary: String,
+ secondary: List = emptyList(),
+ mode: MatchingMode = MatchingMode.EXACT_OR_RELATED,
+ ): List {
+ val out = LinkedHashSet()
+ val keys = (listOf(primary) + secondary).map { it.trim() }.filter { it.isNotEmpty() }
+ if (mode == MatchingMode.EXACT || mode == MatchingMode.EXACT_OR_RELATED) {
+ keys.filter { isWavlakeGenreName(it) }.forEach { out.add(it) }
+ }
+ if (mode == MatchingMode.RELATED || mode == MatchingMode.EXACT_OR_RELATED) {
+ keys.forEach { g -> related[g]?.let { out.addAll(it) } }
+ }
+ return out.toList()
+ }
+
+ private fun isWavlakeGenreName(name: String): Boolean =
+ related.values.any { it.contains(name) }
+}
+
+/** Inputs for one featuring decision. All IDs are Wavlake track/artist IDs. */
+data class FeatureSelection(
+ val candidates: List,
+ val settings: WavlakeStationSettings = defaultWavlakeStationSettings,
+ /** Track IDs already queued or playing — never duplicated. */
+ val excludedTrackIds: Set = emptySet(),
+ /** Artist IDs blocked by the user. */
+ val blockedArtistIds: Set = emptySet(),
+ /** Artist IDs featured recently — deprioritized to keep rotation fresh. */
+ val recentArtistIds: Set = emptySet(),
+ /** How many tracks were featured in the last hour (cap gate). */
+ val featuredThisHour: Int = 0,
+ val random: Random = Random.Default,
+)
+
+object FeatureSelector {
+ fun select(input: FeatureSelection): List {
+ val s = input.settings
+ if (!s.enabled) return emptyList()
+ if (input.featuredThisHour >= s.maxFeaturedTracksPerHour) return emptyList()
+ if (s.maxFeaturedTracksPerQueue <= 0) return emptyList()
+ if (input.random.nextDouble() >= s.featureProbability.coerceIn(0.0, 1.0)) return emptyList()
+
+ val pool = input.candidates
+ .filter { it.audioUrl?.isNotBlank() == true }
+ .filterNot { it.id in input.excludedTrackIds }
+ .filterNot { it.artistId in input.blockedArtistIds }
+ .distinctBy { it.id }
+ if (pool.isEmpty()) return emptyList()
+
+ // Fresh artists first, stable order otherwise; then take the cap.
+ val fresh = pool.filterNot { it.artistId in input.recentArtistIds }
+ val ordered = (fresh.ifEmpty { pool }).shuffled(input.random)
+ return ordered.take(s.maxFeaturedTracksPerQueue)
+ }
+}
diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt
new file mode 100644
index 0000000..c4cc385
--- /dev/null
+++ b/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt
@@ -0,0 +1,227 @@
+package com.rada.data.wavlake
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.util.concurrent.TimeUnit
+
+/**
+ * Raw DTOs mirror wavlake/developer-docs swagger.yaml exactly (all fields
+ * optional-tolerant; unknown keys ignored for forward compatibility).
+ * Base https://wavlake.com/api/v1. No authentication (the spec's
+ * securityDefinitions are commented out; /lnurl needs a Wavlake-issued
+ * appId and is intentionally NOT used — zaps go through the app's
+ * existing NIP-57 wallet flow).
+ */
+
+// ── DTOs ─────────────────────────────────────────────────────────────────
+
+@Serializable
+data class SearchResultDto(
+ val id: String? = null,
+ val name: String? = null,
+ val type: String? = null, // artist | album | track
+ val albumArtUrl: String? = null,
+ val artistArtUrl: String? = null,
+ val albumId: String? = null,
+ val albumTitle: String? = null,
+ val artistId: String? = null,
+ val artist: String? = null,
+ val duration: Int? = null,
+)
+
+@Serializable
+data class TrackDto(
+ val id: String? = null,
+ val title: String? = null,
+ val albumTitle: String? = null,
+ val artist: String? = null,
+ val artistId: String? = null,
+ val albumId: String? = null,
+ val artistArtUrl: String? = null,
+ val albumArtUrl: String? = null,
+ val mediaUrl: String? = null,
+ val duration: Int? = null,
+ val releaseDate: String? = null,
+ val msatTotal: String? = null,
+ val artistNpub: String? = null,
+ val genre: String? = null,
+ val url: String? = null,
+)
+
+@Serializable
+data class CatalogDto(
+ val tracks: List = emptyList(),
+ val nextCursor: String? = null,
+ val total: Int? = null,
+ val limit: Int? = null,
+)
+
+@Serializable
+data class AlbumSummaryDto(
+ val id: String? = null,
+ val title: String? = null,
+ val albumArtUrl: String? = null,
+ val releaseDate: String? = null,
+)
+
+@Serializable
+data class ArtistDto(
+ val id: String? = null,
+ val name: String? = null,
+ val bio: String? = null,
+ val artistArtUrl: String? = null,
+ val albums: List = emptyList(),
+)
+
+@Serializable
+data class AlbumDto(
+ val id: String? = null,
+ val title: String? = null,
+ val artist: String? = null,
+ val artistUrl: String? = null,
+ val artistArtUrl: String? = null,
+ val albumArtUrl: String? = null,
+ val releaseDate: String? = null,
+ val tracks: List = emptyList(),
+)
+
+@Serializable
+data class ErrorDto(val error: String? = null)
+
+// ── Result ───────────────────────────────────────────────────────────────
+
+sealed interface WavlakeResult {
+ data class Ok(val value: T) : WavlakeResult
+ /** API failure never throws — local radio keeps playing regardless. */
+ data class Failed(val kind: FailureKind, val message: String) : WavlakeResult
+}
+
+enum class FailureKind { NETWORK, HTTP, RATE_LIMITED, PARSE, EMPTY, AUTH }
+
+// ── Client ───────────────────────────────────────────────────────────────
+
+class WavlakeApi(
+ private val baseUrl: String = "https://wavlake.com/api/v1",
+ private val client: OkHttpClient = defaultClient(),
+) {
+ private val json = Json {
+ ignoreUnknownKeys = true
+ explicitNulls = false
+ coerceInputValues = true
+ }
+
+ suspend fun search(term: String): WavlakeResult> =
+ get("content/search", mapOf("term" to term)) { body ->
+ json.decodeFromString(
+ kotlinx.serialization.builtins.ListSerializer(SearchResultDto.serializer()),
+ body,
+ )
+ }
+
+ suspend fun rankings(
+ days: Int = 7,
+ genre: String? = null,
+ limit: Int = 25,
+ ): WavlakeResult> {
+ val params = mutableMapOf("sort" to "sats", "days" to days.toString(), "limit" to limit.toString())
+ if (!genre.isNullOrBlank()) params["genre"] = genre
+ return get("content/rankings", params) { body ->
+ json.decodeFromString(
+ kotlinx.serialization.builtins.ListSerializer(TrackDto.serializer()),
+ body,
+ )
+ }
+ }
+
+ suspend fun catalog(
+ limit: Int = 50,
+ cursor: String? = null,
+ artistId: String? = null,
+ genre: String? = null,
+ ): WavlakeResult {
+ val params = mutableMapOf("limit" to limit.coerceIn(1, 100).toString())
+ if (!cursor.isNullOrBlank()) params["cursor"] = cursor
+ if (!artistId.isNullOrBlank()) params["artistId"] = artistId
+ if (!genre.isNullOrBlank()) params["genre"] = genre
+ return get("content/tracks", params) { body ->
+ json.decodeFromString(CatalogDto.serializer(), body)
+ }
+ }
+
+ suspend fun track(trackId: String): WavlakeResult =
+ get("content/track/$trackId", emptyMap()) { body ->
+ json.decodeFromString(TrackDto.serializer(), body)
+ }
+
+ suspend fun artist(artistId: String): WavlakeResult =
+ get("content/artist/$artistId", emptyMap()) { body ->
+ json.decodeFromString(ArtistDto.serializer(), body)
+ }
+
+ suspend fun album(albumId: String): WavlakeResult =
+ get("content/album/$albumId", emptyMap()) { body ->
+ json.decodeFromString(AlbumDto.serializer(), body)
+ }
+
+ private suspend fun get(
+ path: String,
+ params: Map,
+ parse: (String) -> T,
+ ): WavlakeResult = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
+ val url = try {
+ (baseUrl.trimEnd('/') + "/" + path).toHttpUrl().newBuilder()
+ .also { b -> params.forEach { (k, v) -> b.addQueryParameter(k, v) } }
+ .build()
+ } catch (e: Exception) {
+ return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Bad request URL.")
+ }
+ val response = try {
+ client.newCall(
+ Request.Builder()
+ .url(url)
+ .header("Accept", "application/json")
+ .header("User-Agent", "SatsAmp/0.1 (wavlake catalog client)")
+ .build(),
+ ).execute()
+ } catch (e: Exception) {
+ // Offline, DNS, TLS, timeouts — all surface here, never thrown.
+ return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Network unavailable.")
+ }
+ response.use { r ->
+ if (r.code == 429) {
+ val retryAfter = r.header("Retry-After")?.toLongOrNull()
+ return@withContext WavlakeResult.Failed(
+ FailureKind.RATE_LIMITED,
+ "Rate limited" + (retryAfter?.let { " — retry in ${it}s" } ?: "."),
+ )
+ }
+ if (!r.isSuccessful) {
+ val serverMsg = runCatching {
+ json.decodeFromString(ErrorDto.serializer(), r.body?.string().orEmpty()).error
+ }.getOrNull()
+ val kind = if (r.code == 401 || r.code == 403) FailureKind.AUTH else FailureKind.HTTP
+ return@withContext WavlakeResult.Failed(kind, serverMsg ?: "Request failed (${r.code}).")
+ }
+ val body = try {
+ r.body?.string().orEmpty()
+ } catch (_: Exception) {
+ return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Empty response.")
+ }
+ try {
+ WavlakeResult.Ok(parse(body))
+ } catch (_: Exception) {
+ WavlakeResult.Failed(FailureKind.PARSE, "Couldn't read the catalog response.")
+ }
+ }
+ }
+
+ companion object {
+ fun defaultClient(): OkHttpClient = OkHttpClient.Builder()
+ .connectTimeout(15, TimeUnit.SECONDS)
+ .readTimeout(20, TimeUnit.SECONDS)
+ .build()
+ }
+}
diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt
new file mode 100644
index 0000000..da4e33b
--- /dev/null
+++ b/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt
@@ -0,0 +1,441 @@
+package com.rada.data.wavlake
+
+import android.content.Context
+import com.rada.data.local.entity.SavedSongEntity
+import com.rada.data.repository.LibraryRepository
+import com.rada.domain.model.SOURCE_WAVLAKE
+import com.rada.domain.model.WavlakeArtist
+import com.rada.domain.model.WavlakeTrack
+import com.rada.nostr.Nip19
+import com.rada.nostr.NostrService
+import com.rada.payments.hasValidLightningAddress
+import dagger.hilt.android.qualifiers.ApplicationContext
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.withTimeoutOrNull
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.json.Json
+import org.json.JSONObject
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * Wavlake catalog gateway (official public API, no auth — see [WavlakeApi]).
+ *
+ * Hard guarantees:
+ * - Every function returns normally; API trouble surfaces as
+ * [WavlakeResult.Failed] or empty lists — local radio never stops.
+ * - No secrets: nothing here needs credentials, so no .env is required.
+ * - The catalog exposes NO Lightning Address (only artist npub). Addresses
+ * resolve via the artist's Nostr kind-0 profile (lud16) and are validated
+ * with [hasValidLightningAddress] — the ONLY zap gate.
+ */
+@Singleton
+class WavlakeRepository @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val api: WavlakeApi,
+ private val nostr: NostrService,
+ private val library: LibraryRepository,
+) {
+ private val prefs by lazy {
+ context.getSharedPreferences("wavlake", Context.MODE_PRIVATE)
+ }
+ private val json = Json { ignoreUnknownKeys = true }
+
+ // ── Normalization (pure top-level fns, JVM-testable) ─────────────────
+
+ suspend fun search(term: String): WavlakeResult> {
+ if (term.isBlank()) return WavlakeResult.Ok(emptyList())
+ return when (val r = api.search(term.trim())) {
+ is WavlakeResult.Ok -> WavlakeResult.Ok(
+ r.value.mapNotNull { dto ->
+ val id = dto.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ val name = dto.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+ when (dto.type) {
+ "artist" -> WavlakeSearchHit.Artist(
+ WavlakeArtist(
+ id = id, name = name,
+ imageUrl = dto.artistArtUrl ?: dto.albumArtUrl,
+ ),
+ )
+ "album" -> WavlakeSearchHit.Album(
+ id = id, title = name,
+ artist = dto.artist,
+ artworkUrl = dto.albumArtUrl,
+ )
+ else -> WavlakeSearchHit.Track(
+ WavlakeTrack(
+ id = id, title = name,
+ artistId = dto.artistId.orEmpty(),
+ artistName = dto.artist?.takeIf { it.isNotBlank() }
+ ?: "Unknown Artist",
+ artworkUrl = dto.albumArtUrl ?: dto.artistArtUrl,
+ durationMs = dto.duration?.let { if (it > 0) it * 1000L else null },
+ ),
+ )
+ }
+ }.distinctBy { it.stableId() },
+ )
+ is WavlakeResult.Failed -> r
+ }
+ }
+
+ suspend fun rankings(
+ days: Int = 7,
+ genre: String? = null,
+ limit: Int = 25,
+ ): WavlakeResult> =
+ when (val r = api.rankings(days.coerceIn(1, 90), genre, limit.coerceIn(1, 100))) {
+ is WavlakeResult.Ok -> WavlakeResult.Ok(
+ r.value.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id },
+ )
+ is WavlakeResult.Failed -> r
+ }
+
+ suspend fun artistDetail(artistId: String): WavlakeResult {
+ val artist = when (val r = api.artist(artistId)) {
+ is WavlakeResult.Ok -> normalizeWavlakeArtist(r.value)
+ ?: return WavlakeResult.Failed(FailureKind.EMPTY, "Artist has no usable metadata.")
+ is WavlakeResult.Failed -> return r
+ }
+ // The artist endpoint carries no genre; derive it from the tracks.
+ val tracks = when (val r = api.catalog(limit = 100, artistId = artistId)) {
+ is WavlakeResult.Ok -> r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) }
+ is WavlakeResult.Failed -> emptyList() // tracks optional; artist still shown
+ }.distinctBy { it.id }
+ val genres = tracks.mapNotNull { it.genre }.distinct()
+ return WavlakeResult.Ok(
+ WavlakeArtistDetail(
+ artist = artist.copy(
+ genre = genres.firstOrNull(),
+ genres = genres,
+ imageUrl = artist.imageUrl
+ ?: tracks.firstNotNullOfOrNull { it.artworkUrl },
+ ),
+ tracks = tracks,
+ ),
+ )
+ }
+
+ suspend fun albumTracks(albumId: String): WavlakeResult> =
+ when (val r = api.album(albumId)) {
+ is WavlakeResult.Ok -> WavlakeResult.Ok(
+ r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id },
+ )
+ is WavlakeResult.Failed -> r
+ }
+
+ suspend fun trackDetail(trackId: String): WavlakeResult =
+ when (val r = api.track(trackId)) {
+ is WavlakeResult.Ok ->
+ normalizeWavlakeTrack(r.value)?.let { WavlakeResult.Ok(it) }
+ ?: WavlakeResult.Failed(FailureKind.EMPTY, "Track has no usable metadata.")
+ is WavlakeResult.Failed -> r
+ }
+
+ /**
+ * Fallback for search-index IDs that no longer resolve (stale entries
+ * 400 on /content/track): match by exact title inside the artist's
+ * catalog page. Same artist + same title = the same song.
+ */
+ suspend fun matchArtistTrack(artistId: String, title: String): WavlakeTrack? {
+ if (artistId.isBlank() || title.isBlank()) return null
+ return when (val r = api.catalog(limit = 100, artistId = artistId)) {
+ is WavlakeResult.Ok -> r.value.tracks
+ .mapNotNull { normalizeWavlakeTrack(it) }
+ .firstOrNull {
+ it.title.equals(title, ignoreCase = true) &&
+ it.audioUrl?.isNotBlank() == true
+ }
+ is WavlakeResult.Failed -> null
+ }
+ }
+
+ // ── Lightning Address (Nostr kind-0 lud16 — the ONLY zap source) ─────
+
+ private val addressCache = java.util.concurrent.ConcurrentHashMap()
+
+ /**
+ * Addresses the user entered themselves (stored on successful zap).
+ * These are real, validated addresses — remembering them is what lets
+ * the bolt appear for artists the catalog exposes no npub for.
+ */
+ fun getKnownAddress(artistId: String): String? {
+ val raw = prefs.getString(KEY_KNOWN_PREFIX + artistId, null) ?: return null
+ return raw.takeIf { hasValidLightningAddress(it) }
+ }
+
+ fun rememberAddress(artistId: String, address: String): Boolean {
+ if (artistId.isBlank()) return false
+ val clean = address.trim()
+ if (!hasValidLightningAddress(clean)) return false
+ prefs.edit().putString(KEY_KNOWN_PREFIX + artistId, clean).apply()
+ return true
+ }
+
+ /** Best-effort lud16 for an artist npub (bech32 or hex). Null when unknown. */
+ suspend fun resolveLightningAddress(artistId: String?, artistNpub: String?): String? {
+ if (!artistId.isNullOrBlank()) {
+ getKnownAddress(artistId)?.let { return it }
+ }
+ val npub = artistNpub?.takeIf { it.isNotBlank() } ?: return null
+ addressCache[npub]?.let { return it }
+ val hex = npubToHex(npub) ?: return null
+ // Mobile relays can be slow; one retry on miss. Misses only log —
+ // the strict gate simply shows no bolt.
+ var lud16: String? = null
+ repeat(2) {
+ lud16 = withTimeoutOrNull(12_000) {
+ nostr.getProfile(hex).firstOrNull { it != null }?.let { ev ->
+ runCatching {
+ JSONObject(ev!!.content).optString("lud16").takeIf { it.isNotBlank() }
+ }.getOrNull()
+ }
+ }
+ if (lud16 != null) return@repeat
+ }
+ val valid = lud16?.takeIf { hasValidLightningAddress(it) }
+ if (valid == null) {
+ android.util.Log.w("Wavlake", "No lud16 for artist npub ${hex.take(12)}…")
+ } else {
+ addressCache[npub] = valid
+ }
+ return valid
+ }
+
+ private fun npubToHex(npub: String): String? {
+ val v = npub.trim()
+ if (v.startsWith("npub1")) {
+ return runCatching {
+ val (hrp, bytes) = Nip19.decode(v) ?: return null
+ if (hrp != "npub" || bytes.size != 32) return null
+ bytes.joinToString("") { "%02x".format(it) }
+ }.getOrNull()
+ }
+ if (v.length == 64 && v.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) return v.lowercase()
+ return null
+ }
+
+ // ── Import (streamable library entries — no downloads) ───────────────
+
+ sealed interface ImportOutcome {
+ data class Imported(val songId: Long) : ImportOutcome
+ data class AlreadyInLibrary(val songId: Long) : ImportOutcome
+ data class Unplayable(val reason: String) : ImportOutcome
+ }
+
+ /** Imports a Wavlake track as a streamable library entry (dedupe-safe). */
+ suspend fun importTrack(track: WavlakeTrack): ImportOutcome {
+ val audioUrl = track.audioUrl?.takeIf { it.isNotBlank() }
+ ?: return ImportOutcome.Unplayable("No playable audio for this track.")
+ library.getByPath(audioUrl)?.let { return ImportOutcome.AlreadyInLibrary(it.id) }
+ library.getByArtistTitle(track.artistName, track.title)?.let {
+ return ImportOutcome.AlreadyInLibrary(it.id)
+ }
+ val id = library.insert(
+ SavedSongEntity(
+ fileName = "${track.artistName} - ${track.title}.mp3",
+ filePath = audioUrl,
+ artist = track.artistName,
+ title = track.title,
+ album = track.genre ?: "Wavlake",
+ stationName = "Wavlake",
+ durationMs = track.durationMs ?: 0,
+ fileSize = 0,
+ format = "mp3",
+ source = SOURCE_WAVLAKE,
+ sourceUrl = track.sourceUrl.orEmpty(),
+ artworkUrl = track.artworkUrl.orEmpty(),
+ wavlakeArtistId = track.artistId,
+ wavlakeArtistNpub = track.artistNpubHex.orEmpty(),
+ ),
+ )
+ return ImportOutcome.Imported(id)
+ }
+
+ // ── Featuring (genre-matched, opt-in shelf — never auto-inserted) ────
+
+ fun settings(): WavlakeStationSettings = runCatching {
+ prefs.getString(KEY_SETTINGS, null)?.let {
+ Json.decodeFromString(WavlakeStationSettings.serializer(), it)
+ }
+ }.getOrNull() ?: defaultWavlakeStationSettings
+
+ fun saveSettings(settings: WavlakeStationSettings) {
+ prefs.edit()
+ .putString(KEY_SETTINGS, Json.encodeToString(WavlakeStationSettings.serializer(), settings))
+ .apply()
+ }
+
+ fun isStationEnabled(stationId: String): Boolean {
+ if (!settings().enabled) return false
+ val map = stationMap()
+ return map[stationId] ?: true
+ }
+
+ fun setStationEnabled(stationId: String, enabled: Boolean) {
+ val map = stationMap().toMutableMap()
+ map[stationId] = enabled
+ prefs.edit().putString(KEY_STATION_MAP, Json.encodeToString(mapSerializer(), map)).apply()
+ }
+
+ fun blockedArtistIds(): Set = runCatching {
+ prefs.getString(KEY_BLOCKED, null)?.let {
+ Json.decodeFromString(ListSerializer(String.serializer()), it)
+ }.orEmpty().toSet()
+ }.getOrDefault(emptySet())
+
+ fun setArtistBlocked(artistId: String, blocked: Boolean) {
+ val set = blockedArtistIds().toMutableSet()
+ if (blocked) set.add(artistId) else set.remove(artistId)
+ prefs.edit()
+ .putString(KEY_BLOCKED, Json.encodeToString(ListSerializer(String.serializer()), set.toList()))
+ .apply()
+ }
+
+ private fun stationMap(): Map = runCatching {
+ prefs.getString(KEY_STATION_MAP, null)?.let {
+ Json.decodeFromString(mapSerializer(), it)
+ }
+ }.getOrNull().orEmpty()
+
+ private fun mapSerializer() =
+ MapSerializer(String.serializer(), Boolean.serializer())
+
+ private data class FeatureEvent(val atMs: Long, val artistId: String)
+
+ private fun recentEvents(): List {
+ val now = System.currentTimeMillis()
+ return runCatching {
+ prefs.getString(KEY_FEATURED, null)?.let { raw ->
+ Json.decodeFromString(
+ ListSerializer(FeatureEventSer.serializer()),
+ raw,
+ ).map { FeatureEvent(it.atMs, it.artistId) }
+ }
+ }.getOrNull().orEmpty().filter { now - it.atMs < 3_600_000L }
+ }
+
+ @kotlinx.serialization.Serializable
+ private data class FeatureEventSer(val atMs: Long, val artistId: String)
+
+ private fun recordFeatured(artistIds: List) {
+ val now = System.currentTimeMillis()
+ val updated = (recentEvents() + artistIds.map { FeatureEvent(now, it) })
+ .filter { now - it.atMs < 3_600_000L }
+ prefs.edit().putString(
+ KEY_FEATURED,
+ Json.encodeToString(
+ ListSerializer(FeatureEventSer.serializer()),
+ updated.map { FeatureEventSer(it.atMs, it.artistId) },
+ ),
+ ).apply()
+ }
+
+ /**
+ * Genre-matched Wavlake picks for a station. Network-bound, failure-safe
+ * (empty on any trouble), and purely advisory — the caller decides
+ * whether to show them; nothing here touches playback or the queue.
+ */
+ suspend fun featuredForStation(
+ stationId: String,
+ stationGenre: String,
+ excludedTrackIds: Set = emptySet(),
+ ): List = coroutineScope {
+ val settings = settings()
+ if (!isStationEnabled(stationId)) return@coroutineScope emptyList()
+ val (primary, secondary) = WavlakeGenreMatcher.splitStationGenres(stationGenre)
+ val genres = WavlakeGenreMatcher.compatibleGenres(primary, secondary, settings.matchingMode)
+ if (genres.isEmpty()) return@coroutineScope emptyList()
+
+ val events = recentEvents()
+ val perGenre = genres.take(3).map { genre ->
+ async {
+ when (val r = api.rankings(days = 30, genre = genre, limit = 25)) {
+ is WavlakeResult.Ok -> r.value.mapNotNull { normalizeWavlakeTrack(it) }
+ is WavlakeResult.Failed -> emptyList()
+ }
+ }
+ }.awaitAll().flatten().distinctBy { it.id }
+
+ val picked = FeatureSelector.select(
+ FeatureSelection(
+ candidates = perGenre,
+ settings = settings,
+ excludedTrackIds = excludedTrackIds,
+ blockedArtistIds = blockedArtistIds(),
+ recentArtistIds = events.map { it.artistId }.toSet(),
+ featuredThisHour = events.size,
+ ),
+ )
+ if (picked.isNotEmpty()) recordFeatured(picked.map { it.artistId })
+ picked
+ }
+
+ companion object {
+ private const val KEY_SETTINGS = "wavlake_settings"
+ private const val KEY_STATION_MAP = "wavlake_station_enabled"
+ private const val KEY_BLOCKED = "wavlake_blocked_artists"
+ private const val KEY_FEATURED = "wavlake_featured_hour"
+ private const val KEY_KNOWN_PREFIX = "wavlake_addr_"
+ }
+}
+
+sealed interface WavlakeSearchHit { fun stableId(): String
+ data class Artist(val artist: WavlakeArtist) : WavlakeSearchHit {
+ override fun stableId() = "artist:${artist.id}"
+ }
+ data class Album(
+ val id: String,
+ val title: String,
+ val artist: String?,
+ val artworkUrl: String?,
+ ) : WavlakeSearchHit {
+ override fun stableId() = "album:$id"
+ }
+ data class Track(val track: WavlakeTrack) : WavlakeSearchHit {
+ override fun stableId() = "track:${track.id}"
+ }
+}
+
+data class WavlakeArtistDetail(
+ val artist: WavlakeArtist,
+ val tracks: List,
+)
+
+/** Pure DTO normalization (null-tolerant, JVM-testable). */
+fun normalizeWavlakeTrack(dto: TrackDto): WavlakeTrack? {
+ val id = dto.id?.takeIf { it.isNotBlank() } ?: return null
+ val title = dto.title?.takeIf { it.isNotBlank() } ?: return null
+ val audioUrl = dto.mediaUrl?.takeIf { it.isNotBlank() }
+ return WavlakeTrack(
+ id = id,
+ title = title,
+ artistId = dto.artistId?.takeIf { it.isNotBlank() }.orEmpty(),
+ artistName = dto.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist",
+ genre = dto.genre?.takeIf { it.isNotBlank() },
+ genres = listOfNotNull(dto.genre?.takeIf { it.isNotBlank() }),
+ artworkUrl = dto.albumArtUrl?.takeIf { it.isNotBlank() }
+ ?: dto.artistArtUrl?.takeIf { it.isNotBlank() },
+ durationMs = dto.duration?.let { if (it > 0) it * 1000L else null },
+ audioUrl = audioUrl,
+ sourceUrl = dto.url?.takeIf { it.isNotBlank() },
+ artistNpubHex = dto.artistNpub?.takeIf { it.isNotBlank() },
+ albumId = dto.albumId?.takeIf { it.isNotBlank() },
+ )
+}
+
+/** Pure DTO normalization (null-tolerant, JVM-testable). */
+fun normalizeWavlakeArtist(dto: ArtistDto): WavlakeArtist? {
+ val id = dto.id?.takeIf { it.isNotBlank() } ?: return null
+ return WavlakeArtist(
+ id = id,
+ name = dto.name?.takeIf { it.isNotBlank() } ?: "Unknown Artist",
+ imageUrl = dto.artistArtUrl?.takeIf { it.isNotBlank() },
+ bio = dto.bio?.takeIf { it.isNotBlank() },
+ )
+}
diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt
new file mode 100644
index 0000000..89e5578
--- /dev/null
+++ b/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt
@@ -0,0 +1,104 @@
+package com.rada.data.wavlake
+
+import com.rada.nostr.MusicKinds
+import com.rada.nostr.NostrEvent
+import com.rada.nostr.UnsignedEvent
+import java.security.MessageDigest
+import java.util.UUID
+
+/**
+ * Wavlake's official track-zap protocol, decoded from their MIT-licensed
+ * mobile client (wavlake/mobile: useZapContent + utils/nostr.ts — patterns
+ * only, no copied code):
+ *
+ * - Recipient is the PLATFORM key ([WAVLAKE_FEED_PUBKEY]); Wavlake splits
+ * downstream. No artist address or npub is required.
+ * - The NIP-57 zap request carries an `a` tag with the track's Nostr
+ * address (`32123::`), a `timestamp` tag, and three
+ * podcast `i` tags whose GUIDs are UUIDv5 (RFC 4122) of the feed URLs.
+ * - `GET https://www.wavlake.com/api/zap?amount={msats}&nostr={eventJson}`
+ * returns LUD-06-style `{pr}` (or `{status:"ERROR"}` / `{success:false}`).
+ *
+ * This is what makes every catalog track payable even when the artist
+ * exposes no Lightning Address anywhere reachable.
+ */
+object WavlakeZap {
+ const val FEED_PUBKEY =
+ "7759fb24cec56fc57550754ca8f6d2c60183da2537c8f38108fdf283b20a0e58"
+ const val TRACK_KIND = 32123
+ const val RELAY = "wss://relay.wavlake.com/"
+ const val ZAP_ENDPOINT = "https://www.wavlake.com/api/zap"
+ const val ZAP_HOST = "www.wavlake.com"
+
+ /** UUID namespace from the official client (podcast feed GUIDs). */
+ val FEED_NAMESPACE: UUID = UUID.fromString("ead4c236-bf58-58c6-a2c6-a6b28d128cb6")
+
+ fun feedUrl(albumId: String): String = "wavlake.com/feed/music/$albumId"
+
+ /** RFC 4122 §4.3 UUIDv5 (SHA-1). Java only ships v3/v4. */
+ fun uuidV5(namespace: UUID, name: String): UUID {
+ val md = MessageDigest.getInstance("SHA-1")
+ val ns = ByteArray(16).also {
+ val bb = java.nio.ByteBuffer.wrap(it)
+ bb.putLong(namespace.mostSignificantBits)
+ bb.putLong(namespace.leastSignificantBits)
+ }
+ val hash = md.digest(ns + name.toByteArray(Charsets.UTF_8))
+ hash[6] = ((hash[6].toInt() and 0x0f) or 0x50).toByte() // version 5
+ hash[8] = ((hash[8].toInt() and 0x3f) or 0x80).toByte() // variant 10
+ val bb = java.nio.ByteBuffer.wrap(hash)
+ return UUID(bb.long, bb.long)
+ }
+
+ fun trackAddressPointer(trackId: String): String = "$TRACK_KIND:$FEED_PUBKEY:$trackId"
+
+ fun itemTags(trackId: String, albumId: String): List> {
+ val feedGuid = uuidV5(FEED_NAMESPACE, feedUrl(albumId)).toString()
+ return listOf(
+ listOf("i", "podcast:item:guid:$trackId"),
+ listOf("i", "podcast:guid:$feedGuid"),
+ listOf("i", "podcast:publisher:guid:$feedGuid"),
+ )
+ }
+
+ /**
+ * Unsigned NIP-57 zap request for a track. The caller signs (identity
+ * key preferred, ephemeral per NIP-57 otherwise) — see ZapService.
+ */
+ fun buildZapRequest(
+ trackId: String,
+ albumId: String,
+ amountSats: Long,
+ comment: String,
+ relays: List,
+ signerPubkeyHex: String,
+ ): UnsignedEvent {
+ require(trackId.isNotBlank() && albumId.isNotBlank())
+ require(amountSats > 0)
+ val tags = mutableListOf(
+ listOf("p", FEED_PUBKEY),
+ listOf("relays", RELAY) + relays.take(2),
+ listOf("amount", (amountSats * 1000).toString()),
+ listOf("a", trackAddressPointer(trackId), RELAY),
+ listOf("timestamp", (System.currentTimeMillis() / 1000).toString()),
+ )
+ tags.addAll(itemTags(trackId, albumId))
+ return NostrEvent.unsigned(
+ pubkey = signerPubkeyHex,
+ kind = MusicKinds.ZAP_REQUEST,
+ content = comment,
+ tags = tags,
+ )
+ }
+
+ fun invoiceUrl(amountSats: Long, zapRequestJson: String): String {
+ val encoded = java.net.URLEncoder.encode(zapRequestJson, "UTF-8")
+ return "$ZAP_ENDPOINT?amount=${amountSats * 1000}&nostr=$encoded"
+ }
+
+ /** Strict invoice sanity: mainnet bolt11 only, from the fixed endpoint. */
+ fun isPlausibleInvoice(bolt11: String): Boolean {
+ val v = bolt11.trim().lowercase()
+ return v.startsWith("lnbc") && v.length > 20 && v.none { it.isWhitespace() }
+ }
+}
diff --git a/app/src/main/java/com/rada/di/AppModule.kt b/app/src/main/java/com/rada/di/AppModule.kt
index 7de48e5..5d961aa 100644
--- a/app/src/main/java/com/rada/di/AppModule.kt
+++ b/app/src/main/java/com/rada/di/AppModule.kt
@@ -7,6 +7,7 @@ import com.rada.data.local.dao.SavedSongDao
import com.rada.data.repository.LibraryRepository
import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.StationRepository
+import com.rada.data.wavlake.WavlakeApi
import com.rada.player.PlaybackManager
import dagger.Module
import dagger.Provides
@@ -61,4 +62,8 @@ object AppModule {
@ApplicationContext context: Context,
libraryRepository: LibraryRepository,
): PlaybackManager = PlaybackManager(context, libraryRepository)
+
+ @Provides
+ @Singleton
+ fun provideWavlakeApi(): WavlakeApi = WavlakeApi()
}
diff --git a/app/src/main/java/com/rada/domain/model/WavlakeModels.kt b/app/src/main/java/com/rada/domain/model/WavlakeModels.kt
new file mode 100644
index 0000000..21273bf
--- /dev/null
+++ b/app/src/main/java/com/rada/domain/model/WavlakeModels.kt
@@ -0,0 +1,52 @@
+package com.rada.domain.model
+
+/**
+ * Normalized Wavlake catalog models. Field names follow the official
+ * Wavlake Developer API (wavlake/developer-docs, swagger.yaml, MIT):
+ * search/rankings/tracks/track/artist/album responses. No invented fields.
+ */
+
+const val SOURCE_WAVLAKE = "wavlake"
+const val SOURCE_LOCAL = "local"
+const val SOURCE_OTHER = "other"
+
+data class WavlakeArtist(
+ val id: String,
+ val name: String,
+ val genre: String? = null,
+ val genres: List = emptyList(),
+ val imageUrl: String? = null,
+ val bio: String? = null,
+ val source: String = SOURCE_WAVLAKE,
+ val sourceUrl: String? = null,
+ /** Lightning Address ONLY (e.g. artist@example.com). Never LNURL/lud06/lud16/npub here. */
+ val lightningAddress: String? = null,
+ /** Nostr pubkey hex, when the API exposes it (used for the NIP-57 zap flow). */
+ val npubHex: String? = null,
+) {
+ /** Derived ONLY from [lightningAddress]. See [com.rada.payments.hasValidLightningAddress]. */
+ val canZap: Boolean
+ get() = com.rada.payments.hasValidLightningAddress(lightningAddress)
+}
+
+data class WavlakeTrack(
+ val id: String,
+ val title: String,
+ val artistId: String,
+ val artistName: String,
+ val genre: String? = null,
+ val genres: List = emptyList(),
+ val artworkUrl: String? = null,
+ val durationMs: Long? = null,
+ val audioUrl: String? = null,
+ val source: String = SOURCE_WAVLAKE,
+ val sourceUrl: String? = null,
+ /** Nostr pubkey hex of the artist, when exposed (zap flow). */
+ val artistNpubHex: String? = null,
+ /** Album GUID (official track-zap `i`-tags + parent content id). */
+ val albumId: String? = null,
+) {
+ /** Officially payable: the track-zap endpoint only needs track + album ids. */
+ val isZapPayable: Boolean
+ get() = id.isNotBlank() && !albumId.isNullOrBlank() && !audioUrl.isNullOrBlank()
+}
diff --git a/app/src/main/java/com/rada/nostr/NostrKeys.kt b/app/src/main/java/com/rada/nostr/NostrKeys.kt
index c882b7c..35409f1 100644
--- a/app/src/main/java/com/rada/nostr/NostrKeys.kt
+++ b/app/src/main/java/com/rada/nostr/NostrKeys.kt
@@ -1,6 +1,8 @@
package com.rada.nostr
import android.content.Context
+import android.content.Intent
+import android.net.Uri
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
@@ -18,6 +20,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.json.JSONObject
+/** Well-known Amber package, used only for store fallback text. NIP-55 login itself is signer-agnostic. */
+const val AMBER_PACKAGE = "com.greenart7c3.nostrsigner"
+
/**
* Optional Nostr identity for the device (NIP-01 keys).
*
@@ -39,18 +44,71 @@ class NostrKeys @Inject constructor(
/** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */
val pubkey: StateFlow = _pubkey.asStateFlow()
- val hasIdentity: Boolean get() = prefs.contains("ct")
+ /** True when the user has a Keystore identity (encrypted private key) or a manually imported pubkey. */
+ val hasIdentity: Boolean get() = prefs.contains("ct") || prefs.contains("imported_pubkey")
+
+ /** True when the user has an identity of any kind (local or imported pubkey). */
+ val hasPubkey: Boolean get() = _pubkey.value != null
+
+ /** True only when the private key lives on this device (Keystore) and local
+ * signing works. An imported or external-signer (Amber) pubkey alone does
+ * NOT allow local signing — callers must fall back (e.g. ephemeral keys). */
+ val canSignLocally: Boolean get() = prefs.contains("ct")
init {
if (hasIdentity) {
- // Derive pubkey once at startup (needs the key; decrypt, derive, wipe).
- runCatching {
- val priv = decryptSecret()
- if (priv != null) _pubkey.value = xOnlyPubkey(priv)
+ // If a Keystore identity exists, derive pubkey from it.
+ if (prefs.contains("ct")) {
+ runCatching {
+ val priv = decryptSecret()
+ if (priv != null) _pubkey.value = xOnlyPubkey(priv)
+ }
+ }
+ // If a manually imported pubkey exists, use it.
+ val imported = prefs.getString("imported_pubkey", null)
+ if (imported != null && _pubkey.value == null) {
+ _pubkey.value = imported
}
}
}
+ /** Package name of the external signer (NIP-55), if the user logged in that way. */
+ val signerPackage: String?
+ get() = prefs.getString("signer_package", null)
+
+ /** True when any NIP-55 external signer (Amber, etc.) is installed. Requires the nostrsigner entry. */
+ fun isExternalSignerInstalled(): Boolean = runCatching {
+ val probe = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:"))
+ if (context.packageManager.queryIntentActivities(probe, 0).isNotEmpty()) return true
+ // Fallback: direct Amber package check (needs the queries entry).
+ context.packageManager.getPackageInfo(AMBER_PACKAGE, 0)
+ true
+ }.getOrDefault(false)
+
+ /** Build the NIP-55 `get_public_key` intent. Launch via rememberLauncherForActivityResult. */
+ fun buildGetPubkeyIntent(): Intent =
+ Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")).apply {
+ putExtra("type", "get_public_key")
+ putExtra(
+ "permissions",
+ """[{"type":"sign_event"},{"type":"nip44_encrypt"},{"type":"nip44_decrypt"}]""",
+ )
+ }
+
+ /** Import a raw 64-char hex pubkey (NIP-55 results are hex, not npub). */
+ fun importHexPubkey(hex: String): Boolean {
+ val clean = hex.trim().lowercase()
+ if (!clean.matches(Regex("[0-9a-f]{64}"))) return false
+ _pubkey.value = clean
+ prefs.edit().putString("imported_pubkey", clean).apply()
+ return true
+ }
+
+ /** Persist the signer package from a `get_public_key` result for future sign_event calls. */
+ fun saveSignerPackage(pkg: String) {
+ prefs.edit().putString("signer_package", pkg).apply()
+ }
+
/** Create a fresh identity. Returns the npub. Refuses if one exists (no silent overwrite). */
fun createIdentity(): String {
check(!hasIdentity) { "identity already exists" }
@@ -72,6 +130,17 @@ class NostrKeys @Inject constructor(
return true
}
+ /** Import just a pubkey (manual paste fallback).
+ * Allows relay connectivity for profile lookups without a Keystore identity. */
+ fun importPubkey(npub: String): Boolean {
+ val hex = runCatching { Nip19.decode(npub.trim()) }.getOrNull()?.let { (hrp, bytes) ->
+ if (hrp == "npub" && bytes.size == 32) bytes.joinToString("") { "%02x".format(it) } else null
+ } ?: return false
+ _pubkey.value = hex
+ prefs.edit().putString("imported_pubkey", hex).apply()
+ return true
+ }
+
fun npub(): String? = _pubkey.value?.let { Nip19.npub(it) }
/**
diff --git a/app/src/main/java/com/rada/nostr/NostrService.kt b/app/src/main/java/com/rada/nostr/NostrService.kt
index e8d09ec..fcc6a68 100644
--- a/app/src/main/java/com/rada/nostr/NostrService.kt
+++ b/app/src/main/java/com/rada/nostr/NostrService.kt
@@ -43,6 +43,7 @@ class NostrService @Inject constructor(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val defaultRelays = listOf(
+ "wss://relay.wavlake.com",
"wss://relay.damus.io",
"wss://relay.primal.net",
"wss://nos.lol",
@@ -52,10 +53,13 @@ class NostrService @Inject constructor(
get() {
val saved = prefs.getString("relays", null)
?.split("\n")?.filter { it.isNotBlank() }
- return if (saved.isNullOrEmpty()) defaultRelays else saved
+ val base = if (saved.isNullOrEmpty()) defaultRelays else saved
+ // Wavlake's own relay always rides along (first): artist kind-0s
+ // live there even when users customize the rest. Deduped.
+ return (listOf("wss://relay.wavlake.com") + base).distinct()
}
- private val relays = mutableMapOf()
+ private val relays = java.util.concurrent.ConcurrentHashMap()
private val _relayStates = MutableStateFlow>(emptyList())
val relayStates: StateFlow> = _relayStates.asStateFlow()
@@ -212,6 +216,9 @@ class NostrService @Inject constructor(
put("authors", JSONArray().put(pubkeyHex))
put("limit", 1)
}
+ // The merged flow only carries what subscriptions request — without
+ // this REQ the lookup below could only catch someone else's traffic.
+ val subs = relays.values.map { conn -> conn to conn.subscribe(filter) }
val job = scope.launch {
events.first { it.kind == MusicKinds.METADATA && it.pubkey == pubkeyHex }
.let { out.value = it }
@@ -219,6 +226,7 @@ class NostrService @Inject constructor(
scope.launch {
kotlinx.coroutines.delay(timeoutMs)
job.cancel()
+ subs.forEach { (conn, id) -> runCatching { conn.unsubscribe(id) } }
}
return out
}
diff --git a/app/src/main/java/com/rada/nostr/RelayConnection.kt b/app/src/main/java/com/rada/nostr/RelayConnection.kt
index c137ff1..532bbd1 100644
--- a/app/src/main/java/com/rada/nostr/RelayConnection.kt
+++ b/app/src/main/java/com/rada/nostr/RelayConnection.kt
@@ -1,5 +1,6 @@
package com.rada.nostr
+import android.util.Log
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
@@ -59,6 +60,7 @@ class RelayConnection(
Request.Builder().url(url).build(),
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
+ Log.d(TAG, "connected: $url")
_state.tryEmit(State.Connected)
// Re-send subscriptions and queued events after (re)connect.
subs.values.forEach { w -> runCatching { webSocket.send(w.toString()) } }
@@ -70,11 +72,13 @@ class RelayConnection(
override fun onMessage(webSocket: WebSocket, text: String) = handleFrame(text)
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
+ Log.w(TAG, "connection failed: $url: ${t.message}")
socket = null
_state.tryEmit(State.Disconnected(t.message))
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
+ Log.d(TAG, "closed: $url ($code)")
socket = null
_state.tryEmit(State.Disconnected(reason))
}
@@ -107,15 +111,18 @@ class RelayConnection(
// NIP-01: ["REQ", sub_id, filter]
subs[id] = JSONArray().put("REQ").put(id).put(filter)
val ws = socket
- if (ws != null) runCatching { ws.send(subs[id].toString()) }
+ // send() on a non-open socket fails silently — the pending REQ is
+ // re-sent from onOpen, so delivery is still guaranteed.
+ val sent = if (ws != null) runCatching { ws.send(subs[id].toString()) }.getOrDefault(false) else false
+ Log.d(TAG, "REQ $id -> $url (immediate=$sent)")
return id
}
fun unsubscribe(id: String) {
subs.remove(id)
- runCatching {
- socket?.send(JSONObject().apply { put("CLOSE", id) }.toString())
- }
+ // NIP-01 CLOSE is an array, not an object.
+ val close = JSONArray().put("CLOSE").put(id).toString()
+ runCatching { socket?.send(close) }
}
fun publish(event: NostrEvent) {
@@ -134,4 +141,8 @@ class RelayConnection(
runCatching { socket?.close(1000, null) }
socket = null
}
+
+ companion object {
+ private const val TAG = "Relay"
+ }
}
diff --git a/app/src/main/java/com/rada/payments/LightningAddress.kt b/app/src/main/java/com/rada/payments/LightningAddress.kt
new file mode 100644
index 0000000..a1d50bd
--- /dev/null
+++ b/app/src/main/java/com/rada/payments/LightningAddress.kt
@@ -0,0 +1,27 @@
+package com.rada.payments
+
+/**
+ * Strict Lightning Address validation. A zap affordance may ONLY be driven
+ * by [hasValidLightningAddress]; never by Wavlake membership, Nostr keys,
+ * LNURL, lud06/lud16, payment URLs, or any other Lightning metadata.
+ */
+fun hasValidLightningAddress(lightningAddress: String?): Boolean {
+ if (lightningAddress == null) return false
+ val value = lightningAddress.trim()
+ if (value.isEmpty()) return false
+ val parts = value.split("@")
+ if (parts.size != 2) return false
+ val username = parts[0].trim()
+ val domain = parts[1].trim()
+ if (username.isEmpty() || domain.isEmpty()) return false
+ if (domain.contains(" ")) return false
+ if (!domain.contains(".")) return false
+ return true
+}
+
+/** Normalized form of a valid address, or null. */
+fun normalizedLightningAddress(lightningAddress: String?): String? {
+ if (!hasValidLightningAddress(lightningAddress)) return null
+ val parts = lightningAddress!!.trim().split("@")
+ return "${parts[0].trim()}@${parts[1].trim()}"
+}
diff --git a/app/src/main/java/com/rada/payments/NwcProvider.kt b/app/src/main/java/com/rada/payments/NwcProvider.kt
index 9f296fc..04b839d 100644
--- a/app/src/main/java/com/rada/payments/NwcProvider.kt
+++ b/app/src/main/java/com/rada/payments/NwcProvider.kt
@@ -164,10 +164,10 @@ class NwcProvider @Inject constructor(
*/
private suspend fun nwcRequest(method: String, params: JSONObject): PaymentResult {
val pairing = parseStored() ?: return PaymentResult.Failed(
- "not connected", "Connect your wallet in Settings → Decentralized.",
+ "not connected", "Connect your wallet in Settings → Nostr.",
)
val ourPub = keys.pubkey.value ?: return PaymentResult.Failed(
- "no identity", "Create a Nostr identity first (Settings → Decentralized → Nostr).",
+ "no identity", "Create a Nostr identity first (Settings → Nostr).",
)
val deferred = CompletableDeferred()
diff --git a/app/src/main/java/com/rada/payments/PaymentService.kt b/app/src/main/java/com/rada/payments/PaymentService.kt
index ff966d4..87d4ccc 100644
--- a/app/src/main/java/com/rada/payments/PaymentService.kt
+++ b/app/src/main/java/com/rada/payments/PaymentService.kt
@@ -60,7 +60,7 @@ class PaymentService(
val provider = providers().firstOrNull { it.isConfigured }
?: return PaymentResult.Failed(
"no wallet",
- "Connect a wallet first (Settings → Decentralized → Bitcoin).",
+ "Connect a wallet first (Settings → Nostr → Bitcoin).",
)
val memo = listOfNotNull(trackTitle?.takeIf { it.isNotBlank() }, "via SatsAmp")
.joinToString(" · ")
diff --git a/app/src/main/java/com/rada/payments/ZapService.kt b/app/src/main/java/com/rada/payments/ZapService.kt
index b7ec591..c5f19cf 100644
--- a/app/src/main/java/com/rada/payments/ZapService.kt
+++ b/app/src/main/java/com/rada/payments/ZapService.kt
@@ -1,6 +1,8 @@
package com.rada.payments
import android.util.Base64
+import android.util.Log
+import com.rada.data.wavlake.WavlakeZap
import com.rada.nostr.MusicKinds
import com.rada.nostr.NostrEvent
import com.rada.nostr.NostrKeys
@@ -40,6 +42,14 @@ class ZapService @Inject constructor(
data class Failed(val reason: String) : ZapOutcome
}
+ /** Official Wavlake track-zap outcome (no artist address involved). */
+ sealed interface WavlakeZapOutcome {
+ data object Sent : WavlakeZapOutcome
+ /** Invoice minted — payable in-app, or copied to any wallet. */
+ data class Invoice(val bolt11: String, val sats: Long, val requestId: String) : WavlakeZapOutcome
+ data class Failed(val reason: String) : WavlakeZapOutcome
+ }
+
private val http = OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
@@ -166,4 +176,110 @@ class ZapService @Inject constructor(
else -> ZapOutcome.Failed("Unexpected wallet response.")
}
}
+
+ /**
+ * Official Wavlake track zap (wavlake/mobile protocol): mint an invoice
+ * for a catalog track. Needs no artist address — the platform key is the
+ * recipient and Wavlake splits downstream. Wallet-free: returns the
+ * invoice for copy-out when no wallet is connected.
+ */
+ suspend fun wavlakeTrackInvoice(
+ trackId: String,
+ albumId: String,
+ sats: Long,
+ comment: String = "",
+ ): WavlakeZapOutcome = withContext(Dispatchers.IO) {
+ if (sats <= 0) return@withContext WavlakeZapOutcome.Failed("Enter an amount first.")
+ if (trackId.isBlank() || albumId.isBlank()) {
+ return@withContext WavlakeZapOutcome.Failed("This track can't be zapped yet.")
+ }
+ // Identity key preferred (official client behavior) when the private
+ // key is on this device; ephemeral per NIP-57 otherwise (including
+ // external-signer logins like Amber, which hold no local key).
+ // Either way a real schnorr signature.
+ val identityPub = keys.pubkey.value?.takeIf { it.isNotBlank() && keys.canSignLocally }
+ val ephemeral = if (identityPub == null) {
+ ByteArray(32).also { SecureRandom().nextBytes(it) }
+ .also {
+ if (!Secp256k1.get().secKeyVerify(it)) {
+ return@withContext WavlakeZapOutcome.Failed("rng busy, retry")
+ }
+ }
+ } else null
+ val signerPub = identityPub
+ ?: Secp256k1.get().pubkeyCreate(ephemeral!!).copyOfRange(1, 33).toHex()
+ val zapReq = WavlakeZap.buildZapRequest(
+ trackId = trackId,
+ albumId = albumId,
+ amountSats = sats,
+ comment = comment,
+ relays = nostr.relaysConfig(),
+ signerPubkeyHex = signerPub,
+ ).sign { digest ->
+ if (ephemeral != null) Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex()
+ else keys.signDigest(digest)
+ } ?: return@withContext WavlakeZapOutcome.Failed("Couldn't build the zap request.")
+
+ // Best-effort publish so relays (and receipts) can see it.
+ runCatching {
+ nostr.ensureConnected()
+ nostr.publishRaw(zapReq)
+ }
+
+ // Fixed endpoint only — never follow redirects off-host (SSRF guard).
+ val invoice = runCatching {
+ val url = WavlakeZap.invoiceUrl(sats, zapReq.toJson())
+ val request = okhttp3.Request.Builder().url(url).build()
+ if (request.url.host != WavlakeZap.ZAP_HOST || request.url.scheme != "https") {
+ return@runCatching null
+ }
+ http.newCall(request).execute().use { r ->
+ if (!r.isSuccessful) return@runCatching null
+ val body = JSONObject(r.body?.string().orEmpty())
+ if (body.optString("status") == "ERROR" ||
+ body.optBoolean("success", true) == false
+ ) {
+ return@withContext WavlakeZapOutcome.Failed(
+ body.optString("reason", body.optString("error", "Zap rejected.")),
+ )
+ }
+ body.optString("pr").takeIf { it.isNotBlank() }
+ }
+ }.getOrNull() ?: return@withContext WavlakeZapOutcome.Failed("Couldn't reach the Lightning endpoint.")
+ if (!WavlakeZap.isPlausibleInvoice(invoice)) {
+ Log.w("Zap", "Implausible invoice from zap endpoint")
+ return@withContext WavlakeZapOutcome.Failed("Invalid invoice returned.")
+ }
+ WavlakeZapOutcome.Invoice(invoice, sats, zapReq.id)
+ }
+
+ /**
+ * Full official track zap: invoice, then pay through the connected
+ * wallet, then wait for the receipt — success is reported only on
+ * confirmation, like the address flow.
+ */
+ suspend fun zapWavlakeTrack(
+ trackId: String,
+ albumId: String,
+ sats: Long,
+ comment: String = "",
+ ): WavlakeZapOutcome = withContext(Dispatchers.IO) {
+ val invoice = when (val r = wavlakeTrackInvoice(trackId, albumId, sats, comment)) {
+ is WavlakeZapOutcome.Invoice -> r
+ is WavlakeZapOutcome.Sent -> return@withContext r
+ is WavlakeZapOutcome.Failed -> return@withContext r
+ }
+ when (val pay = paymentService.payInvoiceDirect(invoice.bolt11, sats)) {
+ is PaymentResult.Paid -> {
+ nostr.waitForZapReceipt(
+ requestId = invoice.requestId,
+ targetPubkey = WavlakeZap.FEED_PUBKEY,
+ timeoutMs = 10_000,
+ )
+ WavlakeZapOutcome.Sent
+ }
+ is PaymentResult.Failed -> WavlakeZapOutcome.Failed(pay.userFacing)
+ else -> WavlakeZapOutcome.Failed("Unexpected wallet response.")
+ }
+ }
}
diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt
index 7ae19f8..d656776 100644
--- a/app/src/main/java/com/rada/player/PlaybackManager.kt
+++ b/app/src/main/java/com/rada/player/PlaybackManager.kt
@@ -2,6 +2,11 @@ package com.rada.player
import android.content.ComponentName
import android.content.Context
+import android.media.AudioDeviceCallback
+import android.media.AudioDeviceInfo
+import android.media.AudioManager
+import android.os.Handler
+import android.os.Looper
import android.os.SystemClock
import android.util.Log
import androidx.media3.common.MediaItem
@@ -62,6 +67,50 @@ class PlaybackManager(
private var reconnectJob: Job? = null
private var reconnectAttempt = 0
private var readyAtMs = 0L
+ /**
+ * Bluetooth A2DP route tracking: car stereos often drop A2DP while
+ * keeping hands-free connected (or vanish without the noisy broadcast),
+ * so engine-off doesn't reliably pause via ExoPlayer alone. When the
+ * last BT audio output disappears mid-playback, pause like Spotify.
+ * Needs no Bluetooth permissions — output-device observation only.
+ */
+ private val audioManager: AudioManager? =
+ context.getSystemService(AudioManager::class.java)
+ private var wasBtA2dpConnected = false
+ private var deviceCallbackRegistered = false
+ private val deviceCallback = object : AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(devices: Array) {
+ updateBtRoute()
+ }
+
+ override fun onAudioDevicesRemoved(devices: Array) {
+ val hadBt = wasBtA2dpConnected
+ updateBtRoute()
+ if (hadBt && !wasBtA2dpConnected && isPlaying) {
+ Log.d(TAG, "Bluetooth audio route lost — pausing")
+ try {
+ controller?.pause()
+ } catch (e: Exception) {
+ Log.w(TAG, "Pause on BT loss failed", e)
+ }
+ }
+ }
+ }
+
+ private fun updateBtRoute() {
+ wasBtA2dpConnected = audioManager
+ ?.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
+ .orEmpty()
+ .any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP }
+ }
+ /** Delayed ICY-reader-only restart (see startIcyReader listener). */
+ private var icyRestartJob: Job? = null
+ /**
+ * True after an explicit user Stop. Guards the auto-reconnect path: a
+ * late player error must never resurrect a stream the user stopped.
+ * Cleared whenever new playback starts.
+ */
+ private var userStopped = false
var listener: PlaybackListener? = null
@@ -117,8 +166,7 @@ class PlaybackManager(
controller?.setPlaybackSpeed(clamped)
}
- fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) {
- controller?.repeatMode = mode
+ fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) { controller?.repeatMode = mode
}
fun setShuffleEnabled(enabled: Boolean) {
@@ -144,8 +192,8 @@ class PlaybackManager(
_sleepRemainingMsFlow.value = 0L
sleepJob = null
// Pause rather than stop: the station stays loaded for when the
- // user wakes up.
- controller?.pause()
+ // user wakes up. Controller calls are main-thread confined.
+ withContext(Dispatchers.Main) { controller?.pause() }
}
}
@@ -182,6 +230,19 @@ class PlaybackManager(
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
saveSongUseCase = SaveSongUseCase(context, libraryRepository)
+ if (!deviceCallbackRegistered) {
+ updateBtRoute()
+ try {
+ audioManager?.registerAudioDeviceCallback(
+ deviceCallback,
+ Handler(Looper.getMainLooper()),
+ )
+ deviceCallbackRegistered = true
+ } catch (e: Exception) {
+ Log.w(TAG, "BT route tracking unavailable", e)
+ }
+ }
+
val sessionToken = SessionToken(
context,
ComponentName(context, PlaybackService::class.java)
@@ -194,6 +255,13 @@ class PlaybackManager(
}
fun disconnect() {
+ if (deviceCallbackRegistered) {
+ try {
+ audioManager?.unregisterAudioDeviceCallback(deviceCallback)
+ } catch (_: Exception) {
+ }
+ deviceCallbackRegistered = false
+ }
stopAll()
scope.cancel()
controller?.removeListener(playerListener)
@@ -205,6 +273,7 @@ class PlaybackManager(
fun playStation(url: String, name: String) {
currentStationUrl = url
currentStationName = name
+ userStopped = false
_currentStationUrlFlow.value = url
_currentStationNameFlow.value = name
_isLocalFlow.value = false
@@ -253,6 +322,7 @@ class PlaybackManager(
*/
fun playLocalQueue(tracks: List, startIndex: Int, shuffle: Boolean) {
if (tracks.isEmpty()) return
+ userStopped = false
currentStationUrl = null
currentStationName = ""
_currentStationUrlFlow.value = null
@@ -297,10 +367,26 @@ class PlaybackManager(
fun togglePlayPause() {
val ctrl = controller ?: return
- if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
+ if (ctrl.isPlaying) {
+ ctrl.pause()
+ return
+ }
+ // Resume-from-stop restarts the station fully: stop() tears down the
+ // recorder (buffer IDLE, reader stopped), so a bare play() would
+ // bring back audio with a dead save button. A normal pause (buffer
+ // READY) resumes directly.
+ val url = currentStationUrl
+ if (!_isLocalFlow.value && url != null &&
+ (bufferState == BufferState.IDLE || bufferState == BufferState.ERROR)
+ ) {
+ playStation(url, currentStationName)
+ return
+ }
+ ctrl.play()
}
fun stop() {
+ userStopped = true
stopAll()
controller?.stop()
isPlaying = false
@@ -320,12 +406,22 @@ class PlaybackManager(
updateSaveState(SaveState.SAVING, null)
scope.launch {
- val result = saveSongUseCase.save(
- buffer = buffer,
- metadataTimeline = metadataTimeline,
- currentMetadata = metadataTimeline.currentMetadata,
- stationName = currentStationName,
- )
+ // A throw anywhere below must still reset saveState (and surface),
+ // otherwise every later tap hits the SAVING guard silently.
+ val result = try {
+ saveSongUseCase.save(
+ buffer = buffer,
+ metadataTimeline = metadataTimeline,
+ currentMetadata = metadataTimeline.currentMetadata,
+ stationName = currentStationName,
+ )
+ } catch (e: Exception) {
+ Log.e(TAG, "Save failed", e)
+ SaveSongUseCase.SaveResult(
+ success = false,
+ error = "Save failed: ${e.message ?: e.javaClass.simpleName}",
+ )
+ }
withContext(Dispatchers.Main) {
lastSaveResult = result
@@ -400,8 +496,34 @@ class PlaybackManager(
override fun onError(error: String) {
Log.w(TAG, "ICY reader error: $error")
+ // ExoPlayer often rides out a transient drop on its own
+ // buffer without erroring; if it does, the recorder would
+ // otherwise go stale silently (frozen buffer, dead save).
+ // Restart just the reader while a station is still wanted.
+ // NOTE: `isPlaying` is the wrong signal here — it is false
+ // while stalled/buffering, exactly when a restart is needed.
+ // Real deaths go through the full reconnect path, and
+ // stopAll() cancels this job.
+ if (!userStopped && !_isLocalFlow.value &&
+ icyRestartJob?.isActive != true
+ ) {
+ val url = currentStationUrl
+ if (url != null) {
+ icyRestartJob = runCatching {
+ scope.launch {
+ delay(ICY_RESTART_DELAY_MS)
+ if (!userStopped && !_isLocalFlow.value &&
+ currentStationUrl == url
+ ) {
+ Log.d(TAG, "Restarting ICY reader")
+ startIcyReader(url)
+ }
+ icyRestartJob = null
+ }
+ }.getOrNull()
+ }
+ }
}
-
override fun onConnected(metaInterval: Int) {
Log.d(TAG, "ICY connected, meta interval: $metaInterval bytes")
}
@@ -411,6 +533,8 @@ class PlaybackManager(
}
private fun stopIcyReader() {
+ icyRestartJob?.cancel()
+ icyRestartJob = null
icyReader?.stop()
icyReader = null
}
@@ -462,11 +586,20 @@ class PlaybackManager(
}
Player.STATE_BUFFERING -> listener?.onPlaybackStateChanged(false)
Player.STATE_IDLE -> listener?.onPlaybackStateChanged(false)
- Player.STATE_ENDED -> listener?.onPlaybackStateChanged(false)
+ Player.STATE_ENDED -> {
+ // A finished track is not playing: without this the UI
+ // keeps a stale pause icon over silence.
+ isPlaying = false
+ _isPlayingFlow.value = false
+ listener?.onPlaybackStateChanged(false)
+ }
}
}
override fun onPlayerError(error: PlaybackException) {
+ // A late error for playback the user already stopped is stale
+ // noise: the buffer is IDLE and no reconnect is wanted.
+ if (userStopped) return
val description = describeError(error)
updateBufferState(BufferState.ERROR)
stopIcyReader()
@@ -495,6 +628,9 @@ class PlaybackManager(
private fun scheduleReconnect(): Long? {
val url = currentStationUrl ?: return null
+ // Never resurrect playback the user explicitly stopped, or a queue
+ // they have since replaced with local tracks.
+ if (userStopped || _isLocalFlow.value) return null
if (reconnectJob?.isActive == true) return null
if (readyAtMs != 0L && SystemClock.elapsedRealtime() - readyAtMs > RECONNECT_STABLE_MS) {
@@ -508,9 +644,23 @@ class PlaybackManager(
reconnectJob = scope.launch {
delay(delayMs)
- if (currentStationUrl == url) {
+ // MediaController is application-thread confined: marshal the
+ // retry onto Main. Calling playStation() here on Dispatchers.IO
+ // used to crash the whole process with IllegalStateException on
+ // the first stream drop instead of recovering from it.
+ if (currentStationUrl == url && !userStopped && !_isLocalFlow.value) {
+ reconnectJob = null
+ try {
+ withContext(Dispatchers.Main) {
+ if (currentStationUrl == url && !userStopped && !_isLocalFlow.value) {
+ playStation(url, currentStationName)
+ }
+ }
+ } catch (e: Exception) {
+ Log.w(TAG, "Reconnect failed", e)
+ }
+ } else {
reconnectJob = null
- playStation(url, currentStationName)
}
}
return delayMs
@@ -521,5 +671,6 @@ class PlaybackManager(
private const val RECONNECT_BASE_DELAY_MS = 1000L
private const val RECONNECT_MAX_DELAY_MS = 30_000L
private const val RECONNECT_STABLE_MS = 10_000L
+ private const val ICY_RESTART_DELAY_MS = 5_000L
}
}
diff --git a/app/src/main/java/com/rada/player/PlaybackService.kt b/app/src/main/java/com/rada/player/PlaybackService.kt
index 764509c..a3d193f 100644
--- a/app/src/main/java/com/rada/player/PlaybackService.kt
+++ b/app/src/main/java/com/rada/player/PlaybackService.kt
@@ -3,24 +3,40 @@ package com.rada.player
import android.app.PendingIntent
import android.content.Intent
import androidx.annotation.OptIn
+import androidx.media3.common.AudioAttributes
+import androidx.media3.common.C
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
-import com.rada.ui.MainActivity
import dagger.hilt.android.AndroidEntryPoint
+import com.rada.ui.MainActivity
+import javax.inject.Inject
@AndroidEntryPoint
class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
+ @Inject lateinit var playbackManager: PlaybackManager
+
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
- val player = ExoPlayer.Builder(this).build()
+ // Spotify contract: the stream is classified as media and the player
+ // yields audio focus (pauses for calls, ducks for transient
+ // notifications) and resumes when focus returns. Without this, phone
+ // calls never pause the music and Bluetooth can't hand over to them.
+ val audioAttributes = AudioAttributes.Builder()
+ .setUsage(C.USAGE_MEDIA)
+ .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
+ .build()
+ val player = ExoPlayer.Builder(this)
+ .setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true)
+ .setHandleAudioBecomingNoisy(true)
+ .build()
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
@@ -38,9 +54,21 @@ class PlaybackService : MediaSessionService() {
return mediaSession
}
+ /**
+ * Closed means stopped: swiping the task away is an explicit kill, so
+ * stop playback (audio + recorder) and drop the notification. Screen-off
+ * pocket listening is unaffected — the task still exists then.
+ */
override fun onTaskRemoved(rootIntent: Intent?) {
- val player = mediaSession?.player
- if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
+ try {
+ playbackManager.stop()
+ } catch (_: Exception) {
+ try {
+ mediaSession?.player?.pause()
+ } catch (_: Exception) {
+ // Nothing more we can do; still stop below.
+ }
+ } finally {
stopSelf()
}
}
diff --git a/app/src/main/java/com/rada/recording/MetadataEmbedder.kt b/app/src/main/java/com/rada/recording/MetadataEmbedder.kt
index e0b6915..a3c1aef 100644
--- a/app/src/main/java/com/rada/recording/MetadataEmbedder.kt
+++ b/app/src/main/java/com/rada/recording/MetadataEmbedder.kt
@@ -38,8 +38,10 @@ class MetadataEmbedder(private val context: Context) {
// Store the jaudiotagger AudioFile reference (Bug #6).
audio = AudioFileIO.read(audioFile)
- // Set metadata fields on the stored reference's tag.
- val tag = audio.tag
+ // Extracted segments are raw MP3 frames with NO tag yet, and
+ // getTag() returns null for them — the old NullPointerException.
+ // getTagOrCreateAndSetDefault() creates an ID3v2 tag instead.
+ val tag = audio.tagOrCreateAndSetDefault
tag.setField(FieldKey.ARTIST, artist.ifEmpty { "Unknown Artist" })
tag.setField(FieldKey.TITLE, title.ifEmpty { "Unknown Title" })
tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "SatsAmp" })
diff --git a/app/src/main/java/com/rada/ui/MainActivity.kt b/app/src/main/java/com/rada/ui/MainActivity.kt
index 1260ba3..b4fd011 100644
--- a/app/src/main/java/com/rada/ui/MainActivity.kt
+++ b/app/src/main/java/com/rada/ui/MainActivity.kt
@@ -1,14 +1,19 @@
package com.rada.ui
+import android.Manifest
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
+import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
+import androidx.core.content.ContextCompat
import androidx.navigation.compose.rememberNavController
import com.rada.player.PlaybackManager
import com.rada.ui.navigation.NavGraph
@@ -22,10 +27,22 @@ class MainActivity : ComponentActivity() {
@Inject lateinit var playbackManager: PlaybackManager
+ /**
+ * Playback controls + the way back into the app after swiping it away
+ * live in the media notification — which Android 13+ hides until the
+ * user grants POST_NOTIFICATIONS. Ask once per install on first launch;
+ * a denial just means no notification (playback still works).
+ */
+ private val notificationPermission =
+ registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
+ getPreferences(MODE_PRIVATE).edit().putBoolean(PREF_NOTIF_ASKED, true).apply()
+ }
+
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
playbackManager.connect()
+ askNotificationPermissionOnce()
setContent {
SatsAmpTheme {
@@ -45,4 +62,21 @@ class MainActivity : ComponentActivity() {
playbackManager.disconnect()
super.onDestroy()
}
+
+ private fun askNotificationPermissionOnce() {
+ if (Build.VERSION.SDK_INT < 33) return
+ val prefs = getPreferences(MODE_PRIVATE)
+ if (prefs.getBoolean(PREF_NOTIF_ASKED, false)) return
+ if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
+ PackageManager.PERMISSION_GRANTED
+ ) {
+ prefs.edit().putBoolean(PREF_NOTIF_ASKED, true).apply()
+ return
+ }
+ notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ }
+
+ companion object {
+ private const val PREF_NOTIF_ASKED = "notif_asked"
+ }
}
diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt
index 9e93d7f..82227ef 100644
--- a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt
+++ b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt
@@ -36,6 +36,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.activity.compose.rememberLauncherForActivityResult
+import androidx.activity.result.contract.ActivityResultContracts
+import android.app.Activity
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -66,9 +69,26 @@ fun DecentralizedSheet(
val busy by viewModel.busy.collectAsState()
val clipboard = LocalClipboardManager.current
+ val signerLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.StartActivityForResult(),
+ ) { res ->
+ if (res.resultCode != Activity.RESULT_OK) {
+ viewModel.onSignerError()
+ } else {
+ val rejected = res.data?.getBooleanExtra("rejected", false) == true
+ viewModel.onSignerResult(
+ res.data?.getStringExtra("result"),
+ res.data?.getStringExtra("package"),
+ rejected,
+ )
+ }
+ }
+
var showAdvanced by remember { mutableStateOf(false) }
var showImport by remember { mutableStateOf(false) }
+ var showPubkeyImport by remember { mutableStateOf(false) }
var nsecInput by remember { mutableStateOf("") }
+ var pubkeyInput by remember { mutableStateOf("") }
var nwcInput by remember { mutableStateOf("") }
var nameInput by remember { mutableStateOf("") }
var aboutInput by remember { mutableStateOf("") }
@@ -82,7 +102,7 @@ fun DecentralizedSheet(
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
Spacer(Modifier.width(8.dp))
- Text("Decentralized", fontWeight = FontWeight.Bold)
+ Text("Nostr", fontWeight = FontWeight.Bold)
}
},
text = {
@@ -101,6 +121,31 @@ fun DecentralizedSheet(
style = MaterialTheme.typography.bodySmall,
color = tokens.textSecondary,
)
+ // Always shown: if no signer is installed the launch fails
+ // and the user gets a clear message instead of a missing button.
+ Button(
+ onClick = {
+ runCatching {
+ signerLauncher.launch(viewModel.amberLoginIntent())
+ }.onFailure {
+ if (viewModel.isSignerInstalled) viewModel.onSignerError()
+ else viewModel.onSignerNotFound()
+ }
+ },
+ colors = ButtonDefaults.buttonColors(
+ containerColor = tokens.accentFill,
+ contentColor = tokens.accentOnFill,
+ ),
+ shape = RoundedCornerShape(SatsAmpGlass.rInput),
+ modifier = Modifier.fillMaxWidth(),
+ ) { Text("Login with Amber") }
+ if (!viewModel.isSignerInstalled) {
+ Text(
+ "No signer app detected — install Amber on this phone, or import your npub below.",
+ style = MaterialTheme.typography.bodySmall,
+ color = tokens.textSecondary,
+ )
+ }
Button(
onClick = { viewModel.createIdentity() },
colors = ButtonDefaults.buttonColors(
@@ -113,6 +158,9 @@ fun DecentralizedSheet(
TextButton(onClick = { showImport = !showImport }) {
Text("Import existing nsec", color = tokens.accentText)
}
+ TextButton(onClick = { showPubkeyImport = !showPubkeyImport }) {
+ Text("Import pubkey (e.g. from Amber)", color = tokens.accentText)
+ }
if (showImport) {
OutlinedTextField(
value = nsecInput,
@@ -127,6 +175,25 @@ fun DecentralizedSheet(
enabled = nsecInput.isNotBlank(),
) { Text("Import") }
}
+ if (showPubkeyImport) {
+ OutlinedTextField(
+ value = pubkeyInput,
+ onValueChange = { pubkeyInput = it },
+ placeholder = { Text("npub1… (from Amber or another wallet)") },
+ singleLine = true,
+ colors = fieldColors(tokens),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ TextButton(
+ onClick = {
+ if (pubkeyInput.isNotBlank()) {
+ viewModel.importPubkey(pubkeyInput)
+ pubkeyInput = ""
+ }
+ },
+ enabled = pubkeyInput.isNotBlank(),
+ ) { Text("Import pubkey") }
+ }
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
@@ -157,6 +224,9 @@ fun DecentralizedSheet(
style = MaterialTheme.typography.labelSmall,
color = tokens.textMuted,
)
+ TextButton(onClick = { viewModel.signOut() }) {
+ Text("Sign out", color = tokens.textSecondary)
+ }
}
Spacer(Modifier.height(4.dp))
diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt
index 0db3cb3..a50adb8 100644
--- a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt
+++ b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt
@@ -1,5 +1,6 @@
package com.rada.ui.decentralized
+import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.nostr.NostrKeys
@@ -35,6 +36,8 @@ class DecentralizedViewModel @Inject constructor(
val walletConnected = MutableStateFlow(nwc.connected)
val message = MutableStateFlow(null)
val busy = MutableStateFlow(false)
+ val isSignerInstalled: Boolean get() = keys.isExternalSignerInstalled()
+ val signerPackage: String? get() = keys.signerPackage
private val _relays = MutableStateFlow(nostr.relaysConfig())
val relays: StateFlow> = _relays.asStateFlow()
@@ -59,6 +62,50 @@ class DecentralizedViewModel @Inject constructor(
}
}
+ fun importPubkey(npub: String) {
+ if (keys.importPubkey(npub)) {
+ nostr.setEnabled(true)
+ nostrEnabled.value = true
+ message.value = "Pubkey imported ✅ — relays connected for profile lookups"
+ } else {
+ message.value = "That doesn't look like a valid npub."
+ }
+ }
+
+ fun amberLoginIntent(): Intent = keys.buildGetPubkeyIntent()
+
+ /** Handle the NIP-55 `get_public_key` activity result. */
+ fun onSignerResult(resultHex: String?, signerPkg: String?, rejected: Boolean): Boolean {
+ if (rejected) {
+ message.value = "Sign-in rejected in the signer app."
+ return false
+ }
+ if (resultHex == null || !keys.importHexPubkey(resultHex)) {
+ message.value = "Signer didn't return a valid pubkey — try again."
+ return false
+ }
+ if (signerPkg != null) keys.saveSignerPackage(signerPkg)
+ nostr.setEnabled(true)
+ nostrEnabled.value = true
+ message.value = "Connected via external signer ✅"
+ return true
+ }
+
+ fun onSignerError() {
+ message.value = "Signer app failed to respond — try again."
+ }
+
+ fun onSignerNotFound() {
+ message.value = "No signer app found — install Amber (F-Droid / Zapstore / Play), then try again."
+ }
+
+ fun signOut() {
+ keys.clearIdentity()
+ nostr.setEnabled(false)
+ nostrEnabled.value = false
+ message.value = "Signed out — identity erased from this device."
+ }
+
fun publishProfile(name: String, about: String) {
busy.value = true
val ev = nostr.publishProfile(name, about, "")
diff --git a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
index 49a9019..116a0e8 100644
--- a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
+++ b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt
@@ -12,6 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -32,10 +33,13 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
+import com.rada.payments.hasValidLightningAddress
import com.rada.ui.theme.LocalTokens
/**
@@ -48,6 +52,9 @@ fun TipSheet(
artistName: String,
trackTitle: String?,
onDismiss: () -> Unit,
+ initialAddress: String = "",
+ /** Wavlake artist id — a successful zap remembers its address for next time. */
+ artistId: String? = null,
viewModel: TipViewModel = hiltViewModel(),
) {
val tokens = LocalTokens.current
@@ -58,7 +65,11 @@ fun TipSheet(
var amount by remember { mutableStateOf(100L) }
var customAmount by remember { mutableStateOf("") }
- var address by remember { mutableStateOf(recents.firstOrNull() ?: "") }
+ var address by remember(initialAddress) {
+ mutableStateOf(
+ initialAddress.ifBlank { recents.firstOrNull() ?: "" },
+ )
+ }
var comment by remember { mutableStateOf("") }
val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount
@@ -126,6 +137,31 @@ fun TipSheet(
onValueChange = { address = it },
placeholder = { Text("name@domain (Lightning address)") },
singleLine = true,
+ // Copy path needs no wallet: a valid address can always
+ // be taken to any wallet app. Shown only for valid
+ // addresses — never a dead or decorative button.
+ trailingIcon = {
+ if (hasValidLightningAddress(address)) {
+ val clipboard = LocalClipboardManager.current
+ androidx.compose.material3.IconButton(onClick = {
+ clipboard.setText(AnnotatedString(address.trim()))
+ // User-attested + syntactically valid: remembering
+ // here closes the wallet-less loop (paste once
+ // from the official page → bolt forever).
+ if (!artistId.isNullOrBlank()) {
+ viewModel.rememberArtistAddress(artistId, address)
+ }
+ viewModel.status.value =
+ "Address copied — paste it in any wallet app."
+ }) {
+ Icon(
+ Icons.Default.ContentCopy,
+ contentDescription = "Copy Lightning address",
+ tint = tokens.accentText,
+ )
+ }
+ }
+ },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = tokens.accentText,
unfocusedBorderColor = tokens.borderInteractive,
@@ -160,7 +196,7 @@ fun TipSheet(
},
confirmButton = {
Button(
- onClick = { viewModel.tip(address, finalSats, artistName, trackTitle) },
+ onClick = { viewModel.tip(address, finalSats, artistName, trackTitle, artistId) },
enabled = !sending && address.contains("@") && finalSats > 0,
colors = ButtonDefaults.buttonColors(
containerColor = tokens.accentFill,
@@ -181,7 +217,7 @@ fun TipSheet(
dismissButton = {
if (!hasWallet) {
Text(
- "connect a wallet first\n(Settings ⚙ → Decentralized)",
+ "No wallet connected — copy the address\nabove and pay from any wallet app.",
style = MaterialTheme.typography.labelSmall,
color = tokens.textMuted,
)
diff --git a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
index 3066316..93e6176 100644
--- a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
+++ b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt
@@ -3,6 +3,7 @@ package com.rada.ui.decentralized
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import android.content.Context
+import com.rada.data.wavlake.WavlakeRepository
import com.rada.payments.PaymentResult
import com.rada.payments.PaymentService
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -17,6 +18,7 @@ import kotlinx.coroutines.launch
class TipViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val paymentService: PaymentService,
+ private val wavlake: WavlakeRepository,
) : ViewModel() {
private val prefs = context.getSharedPreferences("tips", Context.MODE_PRIVATE)
@@ -31,7 +33,13 @@ class TipViewModel @Inject constructor(
)
val recentAddresses: StateFlow> = _recentAddresses.asStateFlow()
- fun tip(address: String, sats: Long, artistName: String, trackTitle: String?) {
+ fun tip(
+ address: String,
+ sats: Long,
+ artistName: String,
+ trackTitle: String?,
+ artistId: String? = null,
+ ) {
val clean = address.trim().removePrefix("lightning:")
if (!clean.contains("@")) {
status.value = "Enter a name@domain Lightning address."
@@ -49,6 +57,11 @@ class TipViewModel @Inject constructor(
prefs.edit()
.putString("recent", _recentAddresses.value.joinToString("\n"))
.apply()
+ // Remember validated addresses per artist: the bolt can
+ // appear (prefilled) from the next encounter on.
+ if (!artistId.isNullOrBlank()) {
+ wavlake.rememberAddress(artistId, clean)
+ }
}
is PaymentResult.Failed -> status.value = r.userFacing
else -> status.value = "Invoice created."
@@ -57,4 +70,9 @@ class TipViewModel @Inject constructor(
hasWallet.value = paymentService.hasAnyWallet
}
}
+
+ /** Stores a user-attested address so the bolt can appear next time. */
+ fun rememberArtistAddress(artistId: String, address: String) {
+ wavlake.rememberAddress(artistId, address)
+ }
}
diff --git a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
new file mode 100644
index 0000000..2972df0
--- /dev/null
+++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt
@@ -0,0 +1,1149 @@
+package com.rada.ui.discover
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Bolt
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material.icons.filled.Tune
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilterChip
+import androidx.compose.material3.FilterChipDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.OutlinedTextFieldDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Slider
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.hilt.navigation.compose.hiltViewModel
+import com.rada.data.wavlake.MatchingMode
+import com.rada.data.wavlake.WavlakeSearchHit
+import com.rada.data.wavlake.WavlakeStationSettings
+import com.rada.domain.model.WavlakeTrack
+import com.rada.payments.hasValidLightningAddress
+import com.rada.ui.components.AppHomeButton
+import com.rada.ui.components.EqualizerBars
+import com.rada.ui.theme.LocalTokens
+import com.rada.ui.theme.SatsAmpGlass
+import kotlinx.coroutines.flow.first
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun DiscoverScreen(
+ onBack: () -> Unit,
+ onHome: () -> Unit = {},
+ viewModel: WavlakeViewModel = hiltViewModel(),
+) {
+ val searchQuery by viewModel.searchQuery.collectAsState()
+ val searchState by viewModel.searchState.collectAsState()
+ val rankings by viewModel.rankings.collectAsState()
+ val rankingsGenre by viewModel.rankingsGenre.collectAsState()
+ val rankingsLoading by viewModel.rankingsLoading.collectAsState()
+ val rankingsError by viewModel.rankingsError.collectAsState()
+ val addresses by viewModel.addresses.collectAsState()
+ val selectedArtistId by viewModel.selectedArtistId.collectAsState()
+ val artistDetail by viewModel.artistDetail.collectAsState()
+ val artistLoading by viewModel.artistLoading.collectAsState()
+ val notice by viewModel.notice.collectAsState()
+ val snackbarHostState = remember { SnackbarHostState() }
+ var showSettings by remember { mutableStateOf(false) }
+ var zapTarget by remember { mutableStateOf(null) }
+ var trackZap by remember { mutableStateOf(null) }
+ var pendingZapArtistId by remember { mutableStateOf(null) }
+
+ LaunchedEffect(pendingZapArtistId) {
+ if (pendingZapArtistId == null) return@LaunchedEffect
+ val addr = snapshotFlow { addresses[pendingZapArtistId!!] }
+ .first { it != null } ?: ""
+ zapTarget = ZapTarget(
+ pendingZapArtistId!!,
+ "",
+ null,
+ addr,
+ )
+ pendingZapArtistId = null
+ }
+
+ LaunchedEffect(notice) {
+ notice?.let {
+ snackbarHostState.showSnackbar(it)
+ viewModel.consumeNotice()
+ }
+ }
+
+ if (showSettings) {
+ WavlakeSettingsDialog(
+ current = viewModel.settings(),
+ onDismiss = { showSettings = false },
+ onSave = {
+ viewModel.saveSettings(it)
+ showSettings = false
+ },
+ )
+ }
+
+ zapTarget?.let { target ->
+ com.rada.ui.decentralized.TipSheet(
+ artistName = target.artistName,
+ trackTitle = target.trackTitle,
+ initialAddress = target.address,
+ artistId = target.artistId,
+ onDismiss = { zapTarget = null },
+ )
+ }
+
+ trackZap?.let { track ->
+ WavlakeZapSheet(
+ track = track,
+ onDismiss = { trackZap = null },
+ )
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(if (selectedArtistId != null) "Artist" else "Discover") },
+ navigationIcon = {
+ Row(horizontalArrangement = Arrangement.Start) {
+ AppHomeButton(onClick = onHome)
+ IconButton(onClick = {
+ if (selectedArtistId != null) viewModel.closeArtist() else onBack()
+ }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ }
+ },
+ actions = {
+ if (selectedArtistId == null) {
+ IconButton(onClick = { showSettings = true }) {
+ Icon(Icons.Default.Tune, contentDescription = "Wavlake settings")
+ }
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
+ )
+ },
+ containerColor = Color.Transparent,
+ snackbarHost = { SnackbarHost(snackbarHostState) },
+ ) { padding ->
+ if (selectedArtistId != null) {
+ ArtistDetailView(
+ artistId = selectedArtistId!!,
+ modifier = Modifier.padding(padding),
+ viewModel = viewModel,
+ )
+ } else {
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(padding),
+ ) {
+ OutlinedTextField(
+ value = searchQuery,
+ onValueChange = { viewModel.search(it) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ placeholder = { Text("Search Wavlake artists, tracks…") },
+ singleLine = true,
+ leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") },
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedContainerColor = LocalTokens.current.panel,
+ unfocusedContainerColor = LocalTokens.current.panel,
+ focusedBorderColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
+ unfocusedBorderColor = LocalTokens.current.border,
+ focusedTextColor = LocalTokens.current.text,
+ unfocusedTextColor = LocalTokens.current.text,
+ ),
+ shape = RoundedCornerShape(12.dp),
+ )
+
+ when (val s = searchState) {
+ WavlakeSearchState.Idle -> {
+ GenreChips(
+ genres = DISCOVER_GENRES,
+ selected = rankingsGenre,
+ onSelect = { viewModel.refreshRankings(it) },
+ )
+ RankingsList(
+ title = if (rankingsGenre != null) "Top in ${rankingsGenre}" else "Top on Wavlake",
+ tracks = rankings,
+ loading = rankingsLoading,
+ error = rankingsError,
+ onRetry = { viewModel.refreshRankings(rankingsGenre) },
+ viewModel = viewModel,
+ onZap = { track, address ->
+ if (address != null) {
+ zapTarget = ZapTarget(
+ track.artistId,
+ track.artistName,
+ track.title,
+ address,
+ )
+ } else {
+ trackZap = track
+ }
+ },
+ )
+ }
+ WavlakeSearchState.Loading -> {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(22.dp),
+ strokeWidth = 2.dp,
+ color = LocalTokens.current.accentText,
+ )
+ }
+ }
+ is WavlakeSearchState.Results -> {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit ->
+ when (hit) {
+ is WavlakeSearchHit.Artist -> ArtistRow(
+ name = hit.artist.name,
+ genre = hit.artist.genre,
+ imageUrl = hit.artist.imageUrl,
+ artistId = hit.artist.id,
+ address = addresses[hit.artist.id],
+ onClick = {
+ viewModel.openArtist(hit.artist.id, hit.artist.name)
+ },
+ onZap = {
+ viewModel.resolveArtistAddress(hit.artist.id)
+ pendingZapArtistId = hit.artist.id
+ },
+ )
+ is WavlakeSearchHit.Album -> AlbumRow(
+ title = hit.title,
+ artist = hit.artist,
+ artworkUrl = hit.artworkUrl,
+ onClick = { /* albums open via artist detail */ },
+ )
+ is WavlakeSearchHit.Track -> TrackRow(
+ track = com.rada.domain.model.WavlakeTrack(
+ id = hit.track.id,
+ title = hit.track.title,
+ artistId = hit.track.artistId,
+ artistName = hit.track.artistName,
+ genre = hit.track.genre,
+ genres = hit.track.genres,
+ artworkUrl = hit.track.artworkUrl,
+ durationMs = hit.track.durationMs,
+ audioUrl = null,
+ artistNpubHex = null,
+ ),
+ showZap = true,
+ onPlay = {
+ viewModel.playSearchTrack(
+ hit.track.id,
+ hit.track.artistId,
+ hit.track.title,
+ )
+ },
+ onSave = {
+ viewModel.saveSearchTrack(
+ hit.track.id,
+ hit.track.artistId,
+ hit.track.title,
+ )
+ },
+ onZap = { t ->
+ addresses[t.artistId]?.let { addr ->
+ zapTarget = ZapTarget(
+ t.artistId,
+ t.artistName,
+ t.title,
+ addr,
+ )
+ } ?: viewModel.resolveForZap(
+ t.id, t.artistId, t.title,
+ ) { full ->
+ if (full != null) trackZap = full
+ }
+ },
+ )
+ }
+ }
+ }
+ }
+ is WavlakeSearchState.Error -> {
+ Text(
+ text = s.message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = LocalTokens.current.textSecondary,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private data class ZapTarget(
+ val artistId: String,
+ val artistName: String,
+ val trackTitle: String?,
+ val address: String,
+)
+
+@Composable
+private fun GenreChips(
+ genres: List,
+ selected: String?,
+ onSelect: (String?) -> Unit,
+) {
+ LazyRow(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(
+ horizontal = 16.dp,
+ vertical = 4.dp,
+ ),
+ ) {
+ item(key = "chip-all") {
+ GenreChip(label = "Top", selected = selected == null, onClick = { onSelect(null) })
+ }
+ items(genres, key = { "chip-$it" }) { genre ->
+ GenreChip(label = genre, selected = selected == genre, onClick = { onSelect(genre) })
+ }
+ }
+}
+
+@Composable
+private fun GenreChip(label: String, selected: Boolean, onClick: () -> Unit) {
+ FilterChip(
+ selected = selected,
+ onClick = onClick,
+ label = { Text(label) },
+ colors = FilterChipDefaults.filterChipColors(
+ containerColor = LocalTokens.current.panel,
+ labelColor = LocalTokens.current.textSecondary,
+ selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f),
+ selectedLabelColor = LocalTokens.current.accentText,
+ ),
+ border = FilterChipDefaults.filterChipBorder(
+ borderColor = LocalTokens.current.border,
+ selectedBorderColor = LocalTokens.current.accentFill,
+ enabled = true,
+ selected = selected,
+ ),
+ )
+}
+
+@Composable
+private fun RankingsList(
+ title: String,
+ tracks: List,
+ loading: Boolean,
+ error: String?,
+ onRetry: () -> Unit,
+ viewModel: WavlakeViewModel,
+ onZap: (WavlakeTrack, String?) -> Unit,
+) {
+ val addresses by viewModel.addresses.collectAsState()
+ Column(modifier = Modifier.fillMaxSize()) {
+ Text(
+ text = title.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,
+ )
+ }
+ }
+ error != null -> {
+ Text(
+ text = error,
+ style = MaterialTheme.typography.bodyMedium,
+ color = LocalTokens.current.textSecondary,
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp),
+ )
+ TextButton(onClick = onRetry) { Text("Retry") }
+ }
+ tracks.isEmpty() -> {
+ Text(
+ text = "Nothing here yet.",
+ 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(tracks, key = { it.id }, contentType = { "track" }) { track ->
+ TrackRow(
+ track = track,
+ showZap = addresses[track.artistId] != null || track.isZapPayable,
+ onPlay = { viewModel.playTrack(track) },
+ onSave = { viewModel.saveTrack(track) },
+ onZap = { t -> onZap(t, addresses[t.artistId]) },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Track row: glyph tile + title/artist + play + save + bolt (see callers). */
+@Composable
+fun TrackRow(
+ track: WavlakeTrack,
+ showZap: Boolean,
+ onPlay: () -> Unit,
+ onSave: () -> Unit,
+ onZap: (WavlakeTrack) -> Unit,
+) {
+ val tokens = LocalTokens.current
+ Card(
+ onClick = onPlay,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong),
+ shape = RoundedCornerShape(SatsAmpGlass.rInput),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(tokens.accentFill.copy(alpha = 0.15f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = track.artistName.firstOrNull()?.uppercase() ?: "♪",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = tokens.accentText,
+ )
+ }
+ Spacer(modifier = Modifier.width(14.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = track.title,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = tokens.text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = listOfNotNull(
+ track.artistName.takeIf { it.isNotBlank() },
+ track.genre?.takeIf { it.isNotBlank() },
+ ).joinToString(" · ").ifEmpty { "Wavlake" },
+ style = MaterialTheme.typography.bodySmall,
+ color = tokens.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ // Bolt rule: address path when known, else the official
+ // track-zap path when the track is payable. Never rendered
+ // when neither holds.
+ if (showZap) {
+ IconButton(onClick = { onZap(track) }, modifier = Modifier.size(48.dp)) {
+ Icon(
+ Icons.Default.Bolt,
+ contentDescription = "Zap ${track.artistName}",
+ tint = tokens.accentFill,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
+ IconButton(onClick = onPlay, modifier = Modifier.size(48.dp)) {
+ Icon(
+ Icons.Default.PlayArrow,
+ contentDescription = "Play ${track.title}",
+ tint = tokens.textSecondary,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ IconButton(onClick = onSave, modifier = Modifier.size(48.dp)) {
+ Icon(
+ Icons.Default.Add,
+ contentDescription = "Save ${track.title} to Library",
+ tint = tokens.textSecondary,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun ArtistRow(
+ name: String,
+ genre: String?,
+ imageUrl: String?,
+ artistId: String? = null,
+ address: String? = null,
+ onClick: () -> Unit = {},
+ onZap: () -> Unit = {},
+) {
+ val tokens = LocalTokens.current
+ val hasAddress = hasValidLightningAddress(address)
+ Card(
+ onClick = onClick,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 12.dp),
+ colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong),
+ shape = RoundedCornerShape(SatsAmpGlass.rInput),
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(40.dp)
+ .clip(CircleShape)
+ .background(tokens.accentFill.copy(alpha = 0.15f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = name.firstOrNull()?.uppercase() ?: "?",
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = tokens.accentText,
+ )
+ }
+ Spacer(modifier = Modifier.width(14.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = name,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.SemiBold,
+ color = tokens.text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = genre?.takeIf { it.isNotBlank() } ?: "Wavlake artist",
+ style = MaterialTheme.typography.bodySmall,
+ color = tokens.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ if (hasAddress) {
+ IconButton(onClick = onZap, modifier = Modifier.size(48.dp)) {
+ Icon(
+ Icons.Default.Bolt,
+ contentDescription = "Zap $name",
+ tint = tokens.accentFill,
+ modifier = Modifier.size(22.dp),
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun AlbumRow(
+ title: String,
+ artist: String?,
+ artworkUrl: String?,
+ artistId: String? = null,
+ address: String? = null,
+ onClick: () -> Unit = {},
+ onZap: () -> Unit = {},
+) {
+ ArtistRow(name = title, genre = artist, imageUrl = artworkUrl, artistId = artistId, address = address, onClick = onClick, onZap = onZap)
+}
+
+@Composable
+fun ArtistDetailView(
+ artistId: String,
+ modifier: Modifier = Modifier,
+ viewModel: WavlakeViewModel = hiltViewModel(),
+) {
+ val detail by viewModel.artistDetail.collectAsState()
+ val loading by viewModel.artistLoading.collectAsState()
+ val addresses by viewModel.addresses.collectAsState()
+ val tokens = LocalTokens.current
+ // The single zap sheet, fed by whichever bolt was tapped (header or row).
+ var zapAddress by remember { mutableStateOf(null) }
+ zapAddress?.let { addr ->
+ com.rada.ui.decentralized.TipSheet(
+ artistName = detail?.artist?.name ?: "Artist",
+ trackTitle = null,
+ initialAddress = addr,
+ artistId = artistId,
+ onDismiss = { zapAddress = null },
+ )
+ }
+
+ Column(modifier = modifier.fillMaxSize()) {
+ if (loading && detail == null) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(24.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(22.dp),
+ strokeWidth = 2.dp,
+ color = tokens.accentText,
+ )
+ }
+ return
+ }
+ val artist = detail?.artist ?: return
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(72.dp)
+ .clip(CircleShape)
+ .background(tokens.accentFill.copy(alpha = 0.15f)),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = artist.name.firstOrNull()?.uppercase() ?: "?",
+ style = MaterialTheme.typography.headlineMedium,
+ fontWeight = FontWeight.Bold,
+ color = tokens.accentText,
+ )
+ }
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = artist.name,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = tokens.text,
+ )
+ Text(
+ text = (artist.genres.ifEmpty { listOfNotNull(artist.genre) })
+ .joinToString(" · ").ifEmpty { "Wavlake artist" },
+ style = MaterialTheme.typography.bodySmall,
+ color = tokens.textSecondary,
+ )
+ }
+ // STRICT gate: no address, no bolt — nothing rendered at all.
+ val headerAddress = addresses[artistId]
+ if (headerAddress != null) {
+ IconButton(
+ onClick = { zapAddress = headerAddress },
+ modifier = Modifier.size(48.dp),
+ ) {
+ Icon(
+ Icons.Default.Bolt,
+ contentDescription = "Zap ${artist.name}",
+ tint = tokens.accentFill,
+ modifier = Modifier.size(24.dp),
+ )
+ }
+ }
+ }
+ if (!artist.bio.isNullOrBlank()) {
+ Text(
+ text = artist.bio,
+ style = MaterialTheme.typography.bodyMedium,
+ color = tokens.textSecondary,
+ modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
+ )
+ }
+ val tracks = detail?.tracks.orEmpty()
+ // Official Wavlake page (track URL is an official API field): the
+ // one-tap path to zap artists whose Lightning Address the catalog
+ // doesn't expose. External browser — no address invented here.
+ val wavlakeUrl = tracks.firstNotNullOfOrNull { it.sourceUrl }
+ if (wavlakeUrl != null) {
+ val context = androidx.compose.ui.platform.LocalContext.current
+ TextButton(onClick = {
+ runCatching {
+ context.startActivity(
+ android.content.Intent(
+ android.content.Intent.ACTION_VIEW,
+ android.net.Uri.parse(wavlakeUrl),
+ ),
+ )
+ }
+ }) {
+ Text(
+ "View on wavlake.com",
+ modifier = Modifier.padding(horizontal = 8.dp),
+ )
+ }
+ }
+ if (tracks.isEmpty() && !loading) {
+ Text(
+ text = "No tracks found for this artist.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = tokens.textSecondary,
+ modifier = Modifier.padding(horizontal = 16.dp),
+ )
+ } else {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ verticalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ items(tracks, key = { it.id }, contentType = { "track" }) { track ->
+ var detailTrackZap by remember(track.id) {
+ mutableStateOf(null)
+ }
+ TrackRow(
+ track = track,
+ showZap = addresses[track.artistId] != null || track.isZapPayable,
+ onPlay = { viewModel.playTrack(track) },
+ onSave = { viewModel.saveTrack(track) },
+ onZap = { t ->
+ addresses[t.artistId]?.let { addr ->
+ zapAddress = addr
+ } ?: run {
+ if (t.isZapPayable) detailTrackZap = t
+ }
+ },
+ )
+ detailTrackZap?.let { zt ->
+ WavlakeZapSheet(
+ track = zt,
+ onDismiss = { detailTrackZap = null },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun WavlakeSettingsDialog(
+ current: WavlakeStationSettings,
+ onDismiss: () -> Unit,
+ onSave: (WavlakeStationSettings) -> Unit,
+) {
+ var enabled by remember { mutableStateOf(current.enabled) }
+ var probability by remember { mutableStateOf(current.featureProbability.toFloat()) }
+ var mode by remember { mutableStateOf(current.matchingMode) }
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ title = { Text("Wavlake featuring") },
+ text = {
+ Column {
+ Text(
+ "Genre-matched Wavlake picks appear on the Player for compatible stations. Tapping plays — stations are never interrupted.",
+ style = MaterialTheme.typography.bodySmall,
+ color = LocalTokens.current.textSecondary,
+ )
+ Spacer(modifier = Modifier.height(12.dp))
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(
+ "Enabled",
+ modifier = Modifier.weight(1f),
+ color = LocalTokens.current.text,
+ )
+ Switch(checked = enabled, onCheckedChange = { enabled = it })
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "Refresh chance: ${(probability * 100).toInt()}%",
+ color = LocalTokens.current.text,
+ )
+ Slider(
+ value = probability,
+ onValueChange = { probability = it },
+ valueRange = 0f..1f,
+ )
+ Spacer(modifier = Modifier.height(4.dp))
+ Text("Matching", color = LocalTokens.current.text)
+ Spacer(modifier = Modifier.height(4.dp))
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ MatchingMode.entries.forEach { m ->
+ TextButton(onClick = { mode = m }) {
+ Text(
+ m.name.lowercase().replace('_', '-'),
+ color = if (mode == m) LocalTokens.current.accentText
+ else LocalTokens.current.textSecondary,
+ fontWeight = if (mode == m) FontWeight.Bold else FontWeight.Normal,
+ )
+ }
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ onSave(
+ current.copy(
+ enabled = enabled,
+ featureProbability = probability.toDouble(),
+ matchingMode = mode,
+ ),
+ )
+ },
+ ) { Text("Save") }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) { Text("Cancel") }
+ },
+ )
+}
+
+/**
+ * Opt-in genre-matched picks for the current station. Shown on the Player;
+ * tapping a pick plays it (explicit choice), nothing auto-inserts.
+ */
+@Composable
+fun WavlakeFeaturedRow(
+ stationId: String,
+ stationGenre: String,
+ viewModel: WavlakeViewModel = hiltViewModel(),
+) {
+ val tokens = LocalTokens.current
+ var picks by remember(stationId) { mutableStateOf>(emptyList()) }
+ var loading by remember(stationId) { mutableStateOf(true) }
+
+ LaunchedEffect(stationId) {
+ loading = true
+ picks = viewModel.featuredFor(stationId, stationGenre)
+ loading = false
+ }
+
+ if (!loading && picks.isEmpty()) return
+
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Text(
+ text = "FEATURING WAVLAKE",
+ style = MaterialTheme.typography.labelLarge,
+ fontWeight = FontWeight.Bold,
+ color = tokens.accentText,
+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp),
+ )
+ if (loading) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ color = tokens.accentText,
+ )
+ }
+ } else {
+ androidx.compose.foundation.lazy.LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp),
+ ) {
+ items(picks, key = { it.id }) { track ->
+ FeaturedChip(
+ track = track,
+ onPlay = { viewModel.playTrack(track) },
+ onSave = { viewModel.saveTrack(track) },
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Official Wavlake track zap (no artist address needed): amount → invoice
+ * via wavlake.com, then pay in-app (wallet) or copy the invoice out to any
+ * wallet. Success is shown only after confirmation.
+ */
+@Composable
+fun WavlakeZapSheet(
+ track: WavlakeTrack,
+ onDismiss: () -> Unit,
+ viewModel: WavlakeZapViewModel = hiltViewModel(),
+) {
+ val tokens = LocalTokens.current
+ val state by viewModel.state.collectAsState()
+ var amount by remember(track.id) { mutableStateOf(100L) }
+ var customAmount by remember(track.id) { mutableStateOf("") }
+ var comment by remember(track.id) { mutableStateOf("") }
+ val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount
+
+ LaunchedEffect(track.id) { viewModel.reset() }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ containerColor = tokens.panel,
+ titleContentColor = tokens.text,
+ shape = RoundedCornerShape(20.dp),
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
+ Spacer(Modifier.size(8.dp))
+ Text(
+ "Zap ${track.title}",
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ },
+ text = {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Text(
+ "by ${track.artistName} · via Wavlake",
+ style = MaterialTheme.typography.bodySmall,
+ color = tokens.textMuted,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ when (val s = state) {
+ TrackZapState.Editing -> {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ listOf(21L, 100L, 500L, 1000L).forEach { preset ->
+ FilterChip(
+ selected = customAmount.isBlank() && amount == preset,
+ onClick = { amount = preset; customAmount = "" },
+ label = { Text("$preset") },
+ colors = FilterChipDefaults.filterChipColors(
+ selectedContainerColor = tokens.accentSoft,
+ selectedLabelColor = tokens.accentText,
+ labelColor = tokens.textSecondary,
+ ),
+ )
+ }
+ }
+ OutlinedTextField(
+ value = customAmount,
+ onValueChange = { customAmount = it.filter(Char::isDigit) },
+ placeholder = { Text("Custom amount in sats") },
+ singleLine = true,
+ suffix = { Text("sats", color = tokens.textMuted) },
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = tokens.accentText,
+ unfocusedBorderColor = tokens.borderInteractive,
+ focusedTextColor = tokens.text,
+ unfocusedTextColor = tokens.text,
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = comment,
+ onValueChange = { comment = it },
+ placeholder = { Text("Message (optional)") },
+ singleLine = true,
+ colors = OutlinedTextFieldDefaults.colors(
+ focusedBorderColor = tokens.accentText,
+ unfocusedBorderColor = tokens.borderInteractive,
+ focusedTextColor = tokens.text,
+ unfocusedTextColor = tokens.text,
+ ),
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ TrackZapState.Sending -> {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(24.dp),
+ strokeWidth = 2.dp,
+ color = tokens.accentText,
+ )
+ }
+ }
+ is TrackZapState.InvoiceReady -> {
+ Text(
+ "Invoice for ${s.sats} sats — copy it into any wallet app, no connected wallet needed.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = tokens.text,
+ )
+ Text(
+ s.bolt11.take(42) + "…",
+ style = MaterialTheme.typography.bodySmall,
+ color = tokens.textMuted,
+ )
+ }
+ TrackZapState.Sent -> {
+ Text(
+ "⚡ Zap confirmed. The artist gets paid via Wavlake.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = tokens.accentText,
+ )
+ }
+ is TrackZapState.Failed -> {
+ Text(
+ s.reason,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ when (val s = state) {
+ TrackZapState.Editing -> {
+ TextButton(
+ onClick = { viewModel.send(track, finalSats, comment) },
+ enabled = finalSats > 0,
+ ) { Text("Send $finalSats sats") }
+ }
+ is TrackZapState.InvoiceReady -> {
+ val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current
+ TextButton(onClick = {
+ clipboard.setText(androidx.compose.ui.text.AnnotatedString(s.bolt11))
+ }) { Text("Copy invoice") }
+ }
+ is TrackZapState.Failed -> {
+ TextButton(onClick = { viewModel.reset() }) { Text("Back") }
+ }
+ else -> {}
+ }
+ },
+ dismissButton = {
+ when (state) {
+ TrackZapState.Editing -> {
+ TextButton(
+ onClick = { viewModel.fetchInvoice(track, finalSats, comment) },
+ enabled = finalSats > 0,
+ ) { Text("Invoice only") }
+ }
+ else -> {
+ TextButton(onClick = onDismiss) {
+ Text(if (state == TrackZapState.Sent) "Done" else "Close")
+ }
+ }
+ }
+ },
+ )
+}
+
+@Composable
+private fun FeaturedChip( track: WavlakeTrack,
+ onPlay: () -> Unit,
+ onSave: () -> Unit,
+) {
+ val tokens = LocalTokens.current
+ Card(
+ onClick = onPlay,
+ colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong),
+ shape = RoundedCornerShape(SatsAmpGlass.rInput),
+ ) {
+ Row(
+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ EqualizerBars(active = false, color = tokens.accentText)
+ Spacer(modifier = Modifier.width(8.dp))
+ Column {
+ Text(
+ text = track.title,
+ style = MaterialTheme.typography.bodyMedium,
+ fontWeight = FontWeight.SemiBold,
+ color = tokens.text,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = track.artistName,
+ style = MaterialTheme.typography.bodySmall,
+ color = tokens.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Spacer(modifier = Modifier.width(8.dp))
+ IconButton(onClick = onSave, modifier = Modifier.size(40.dp)) {
+ Icon(
+ Icons.Default.Add,
+ contentDescription = "Save ${track.title} to Library",
+ tint = tokens.textSecondary,
+ modifier = Modifier.size(18.dp),
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
new file mode 100644
index 0000000..c76862e
--- /dev/null
+++ b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt
@@ -0,0 +1,304 @@
+package com.rada.ui.discover
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.rada.data.repository.PlaylistRepository
+import com.rada.data.wavlake.WavlakeRepository
+import com.rada.data.wavlake.WavlakeResult
+import com.rada.data.wavlake.WavlakeSearchHit
+import com.rada.data.wavlake.WavlakeStationSettings
+import com.rada.domain.model.WavlakeArtist
+import com.rada.domain.model.WavlakeTrack
+import com.rada.payments.hasValidLightningAddress
+import com.rada.player.PlaybackManager
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.debounce
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.launchIn
+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")
+
+sealed interface WavlakeSearchState {
+ data object Idle : WavlakeSearchState
+ data object Loading : WavlakeSearchState
+ data class Results(val hits: List) : WavlakeSearchState
+ data class Error(val message: String) : WavlakeSearchState
+}
+
+@OptIn(FlowPreview::class)
+@HiltViewModel
+class WavlakeViewModel @Inject constructor(
+ private val repository: WavlakeRepository,
+ private val playbackManager: PlaybackManager,
+ private val playlists: PlaylistRepository,
+) : ViewModel() {
+
+ private val _searchQuery = MutableStateFlow("")
+ val searchQuery: StateFlow = _searchQuery.asStateFlow()
+
+ private val _searchState = MutableStateFlow(WavlakeSearchState.Idle)
+ val searchState: StateFlow = _searchState.asStateFlow()
+
+ private val _rankings = MutableStateFlow>(emptyList())
+ val rankings: StateFlow> = _rankings.asStateFlow()
+
+ private val _rankingsGenre = MutableStateFlow(null)
+ val rankingsGenre: StateFlow = _rankingsGenre.asStateFlow()
+
+ private val _rankingsLoading = MutableStateFlow(false)
+ val rankingsLoading: StateFlow = _rankingsLoading.asStateFlow()
+
+ private val _rankingsError = MutableStateFlow(null)
+ val rankingsError: StateFlow = _rankingsError.asStateFlow()
+
+ /** Resolved Lightning Addresses by artist id — populated best-effort. */
+ private val _addresses = MutableStateFlow