feat(nostr): Amber external-signer login, pubkey import, ephemeral zap fallback

NIP-55 get_public_key flow (nostrsigner: intent) with signer package
stored for future requests; manual npub import fallback; sign-out.
Rename user-facing Decentralized labels to Nostr. Fix Wavlake track
zaps for external-signer logins via canSignLocally + ephemeral keys.
Artist address resolution + zap buttons in Discover search.
This commit is contained in:
avi 2026-09-10 02:17:48 -05:00
commit 1c9d07251a
49 changed files with 4888 additions and 771 deletions

View file

@ -8,6 +8,16 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- NIP-55 external signer discovery (Amber, etc.) -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrsigner" />
</intent>
<package android:name="com.greenart7c3.nostrsigner" />
</queries>
<application
android:name=".SatsAmpApp"
android:allowBackup="true"

View file

@ -2,376 +2,106 @@ package com.rada.data.local
import com.rada.domain.model.Station
/**
* Built-in lineup: the 11 cliamp.stream music channels in playlist order
* (Lofi → … → NCS Chill). Order is curated display order —
* [StationRepository] / UI preserve it instead of sorting alphabetically.
*
* Media Monarchy lives in the Streams tab (it is talk, not music) and is
* seeded there by [com.rada.data.repository.StreamRepository].
*
* IDs reuse the pre-existing cliamp entries so favorites and renames carry
* over. All URLs verified live (HTTP 200 + audio payload).
*/
object DefaultStations {
private val builtIn = listOf(
// ── Lofi ──
Station(
id = "fluxfm-chillhop",
name = "FLUX FM ChillHop",
url = "http://streams.fluxfm.de/Chillhop/mp3-320",
genre = "Lofi / Chillhop",
description = "Chill beats and lofi hip-hop from FLUX FM Berlin",
country = "DE",
),
Station(
id = "lofi-24-7",
name = "bigFM LoFi Focus",
url = "https://stream.bigfm.de/lofifocus/mp3-128/radiobrowser",
genre = "Lofi",
description = "Lo-fi beats to focus and study — from bigFM",
country = "DE",
),
Station(
id = "chillofi-radio",
name = "Chillofi Radio",
url = "https://azc.rdstream-5677.dez.ovh/listen/chillofi/radio.mp3",
genre = "Lofi / Chillhop",
description = "Relax and study beats from France",
country = "FR",
),
Station(
id = "reyfm-lofi",
name = "REYFM #LOFI",
url = "https://listen.reyfm.de/lofi_320kbps.mp3",
genre = "Lofi",
description = "German lofi radio — chill vibes at 320kbps",
country = "DE",
),
// ── Synthwave ──
Station(
id = "synthwave-city-fm",
name = "Synthwave City FM",
url = "https://synthwave-rex.radioca.st/stream",
genre = "Synthwave",
description = "Retro synth, outrun, and neon-soaked beats",
country = "NL",
),
Station(
id = "synthwave-radio",
name = "Synthwave Radio",
url = "https://stream.synthwaveradio.eu/listen/synthwaveradio.eu/radio.mp3",
genre = "Synthwave",
description = "Synthwave, retrowave, and outrun",
country = "US",
),
Station(
id = "synthwave-neon-city",
name = "Synthwave Neon City",
url = "https://stream.laut.fm/synthwave",
genre = "Synthwave",
description = "Neon-drenched synthwave from laut.fm",
country = "DE",
),
// ── EDM ──
Station(
id = "electrodanceworld",
name = "PulseEDM",
url = "http://pulseedm.cdnstream1.com:8124/1373_128",
genre = "EDM / Dance",
description = "EDM, house, trance, dubstep — PulseEDM Radio",
country = "US",
),
Station(
id = "technolovers-edm",
name = "Technolovers EDM",
url = "https://stream.technolovers.fm/edm",
genre = "EDM",
description = "Club house, electro, and electronic dance music",
country = "DE",
),
Station(
id = "generation-dance",
name = "Generation Dance Radio",
url = "http://generationdance.lu/radio/8030/hd",
genre = "EDM / Dance",
description = "Eurodance, hands up, house, and trance",
country = "LU",
),
// ── Phonk ──
Station(
id = "phonkio-radio",
name = "Phonk Radio",
url = "https://stream.phonkioradio.com/listen/phonkio_radio/radio.mp3",
genre = "Phonk",
description = "24/7 phonk — drift, Memphis, and cowbells",
country = "US",
),
Station(
id = "badradio-phonk",
name = "badradio 24/7",
url = "https://s2.radio.co/s2b2b68744/listen",
genre = "Phonk / Memphis",
description = "OG phonk, Memphis, Houston, trap & trill",
country = "NZ",
),
Station(
id = "dopephonk",
name = "DOPEPHONK",
url = "https://listen.atomic.radio/dopephonk/middlequality",
genre = "Phonk",
description = "Pure phonk vibes 24/7",
country = "US",
),
// ── Classics / Classical ──
Station(
id = "classic-fm-uk",
name = "CFMZ Classical FM",
url = "https://live.amperwave.net/direct/mzmedia-cfmzfmmp3-ibc2",
genre = "Classical",
description = "Classical FM from Canada — baroque to romantic",
country = "CA",
),
Station(
id = "your-classical-relax",
name = "Your Classical - Relax",
url = "http://relax.stream.publicradio.org/relax.mp3",
genre = "Classical / Relax",
description = "Peaceful classical music from American Public Media",
country = "US",
),
Station(
id = "classical-piano",
name = "Peaceful Piano",
url = "https://holiday.stream.publicradio.org/peacefulpiano.mp3",
genre = "Classical Piano",
description = "Peaceful classical piano — Chopin, Debussy, Einaudi",
country = "US",
),
Station(
id = "classical-guitar-181",
name = "181.FM - Classical Guitar",
url = "https://listen.181fm.com/181-classicalguitar_128k.mp3",
genre = "Classical Guitar",
description = "Classical and Spanish guitar from 181.FM",
country = "US",
),
Station(
id = "epic-classical-piano",
name = "EPIC CLASSICAL - Piano",
url = "https://stream.epic-classical.com/classical-piano",
genre = "Classical Piano",
description = "Ambient, chill, and relaxing classical piano",
country = "DE",
),
// ── Strings / Ambient ──
Station(
id = "soma-fm-lush",
name = "Lush",
url = "https://ice1.somafm.com/lush-128-mp3",
genre = "Strings / Ambient",
description = "Sensuous and mellow vocals with lush string textures",
country = "US",
),
Station(
id = "soma-fm-dronezone",
name = "Drone Zone",
url = "https://ice1.somafm.com/dronezone-128-mp3",
genre = "Ambient / Strings",
description = "Atmospheric textures with beats and samples",
country = "US",
),
// ── Electronic / NCS ──
Station(
id = "ncs-radio",
name = "NCS Radio",
url = "https://radio.cliamp.stream/ncs/stream",
genre = "Electronic / NCS",
description = "NoCopyrightSounds — unlimited gaming music",
country = "US",
),
// ── cliamp.stream ──
Station(
id = "cliamp-lofi",
name = "Lofi Hip Hop Radio",
name = "Lofi Stream",
url = "https://radio.cliamp.stream/lofi/stream",
genre = "Lofi / Hip-Hop",
description = "24/7 lofi hip-hop beats to chill and code to — from cliamp.stream",
country = "US",
),
Station(
id = "cliamp-edm",
name = "EDM Pulse",
url = "https://radio.cliamp.stream/edm/stream",
genre = "EDM / Dance",
description = "Non-stop EDM pulse — from cliamp.stream",
genre = "Lofi",
description = "24/7 lofi beats to chill and code to",
country = "US",
),
Station(
id = "cliamp-synthwave",
name = "Synthwave Nights",
name = "Synthwave Stream",
url = "https://radio.cliamp.stream/synthwave/stream",
genre = "Synthwave",
description = "Retro synthwave nights — neon drives at midnight, from cliamp.stream",
description = "Retro synthwave nights — neon drives at midnight",
country = "US",
),
Station(
id = "cliamp-omarchy",
name = "Omarchy Radio",
url = "https://radio.cliamp.stream/omarchy/stream",
genre = "Electronic / Community",
description = "The Omarchy Linux community radio station — from cliamp.stream",
id = "cliamp-edm",
name = "EDM Stream",
url = "https://radio.cliamp.stream/edm/stream",
genre = "EDM",
description = "Non-stop EDM pulse",
country = "US",
),
Station(
id = "ncs-radio",
name = "NCS Stream",
url = "https://radio.cliamp.stream/ncs/stream",
genre = "NCS",
description = "NoCopyrightSounds — unlimited gaming music",
country = "US",
),
Station(
id = "ncs-house",
name = "NCS House",
name = "NCS House Stream",
url = "https://radio.cliamp.stream/ncs-house/stream",
genre = "Electronic / House",
description = "NoCopyrightSounds house mixes — from cliamp.stream",
genre = "NCS",
description = "NoCopyrightSounds house mixes",
country = "US",
),
Station(
id = "ncs-dubstep",
name = "NCS Dubstep",
name = "NCS Dubstep Stream",
url = "https://radio.cliamp.stream/ncs-dubstep/stream",
genre = "Electronic / Dubstep",
description = "NoCopyrightSounds dubstep — heavy bass drops, from cliamp.stream",
genre = "NCS",
description = "NoCopyrightSounds dubstep — heavy bass drops",
country = "US",
),
Station(
id = "ncs-dnb",
name = "NCS Drum & Bass",
name = "NCS Drum & Bass Stream",
url = "https://radio.cliamp.stream/ncs-dnb/stream",
genre = "Electronic / Drum & Bass",
description = "NoCopyrightSounds drum & bass — from cliamp.stream",
genre = "NCS",
description = "NoCopyrightSounds drum & bass",
country = "US",
),
Station(
id = "ncs-trap",
name = "NCS Trap",
name = "NCS Trap Stream",
url = "https://radio.cliamp.stream/ncs-trap/stream",
genre = "Electronic / Trap",
description = "NoCopyrightSounds trap beats — from cliamp.stream",
genre = "NCS",
description = "NoCopyrightSounds trap beats",
country = "US",
),
Station(
id = "ncs-phonk",
name = "NCS Phonk",
name = "NCS Phonk Stream",
url = "https://radio.cliamp.stream/ncs-phonk/stream",
genre = "Phonk",
description = "NoCopyrightSounds phonk — drift phonk and cowbells, from cliamp.stream",
genre = "NCS",
description = "NoCopyrightSounds phonk — drift phonk and cowbells",
country = "US",
),
Station(
id = "ncs-pop",
name = "NCS Pop",
name = "NCS Pop Stream",
url = "https://radio.cliamp.stream/ncs-pop/stream",
genre = "Electronic / Pop",
description = "NoCopyrightSounds pop hits — from cliamp.stream",
genre = "NCS",
description = "NoCopyrightSounds pop hits",
country = "US",
),
Station(
id = "ncs-chill",
name = "NCS Chill",
name = "NCS Chill Stream",
url = "https://radio.cliamp.stream/ncs-chill/stream",
genre = "Electronic / Chill",
description = "NoCopyrightSounds chill mixes — from cliamp.stream",
country = "US",
),
// ── SomaFM classics ──
Station(
id = "soma-fm-groovesalad",
name = "Groove Salad",
url = "https://ice1.somafm.com/groovesalad-128-mp3",
genre = "Ambient / Downtempo",
description = "A nicely chilled plate of ambient beats and grooves",
country = "US",
),
Station(
id = "soma-fm-spacestation",
name = "Space Station Soma",
url = "https://ice1.somafm.com/spacestation-128-mp3",
genre = "Ambient / Electronica",
description = "Tune in, turn on, drift away",
country = "US",
),
Station(
id = "soma-fm-defcon",
name = "DEF CON",
url = "https://ice1.somafm.com/defcon-128-mp3",
genre = "Electronic / Hacker",
description = "The DEF CON hacker convention radio",
country = "US",
),
Station(
id = "soma-fm-thetrip",
name = "The Trip",
url = "https://ice1.somafm.com/thetrip-128-mp3",
genre = "House / Techno",
description = "Progressive house / trance. Tip top tunes.",
country = "US",
),
Station(
id = "soma-fm-fluid",
name = "Fluid",
url = "https://ice1.somafm.com/fluid-128-mp3",
genre = "Instrumental Hip-Hop",
description = "Instrumental hip-hop beats and grooves",
country = "US",
),
Station(
id = "soma-fm-seventies",
name = "Left Coast 70s",
url = "https://ice1.somafm.com/seventies-128-mp3",
genre = "Retro / 70s",
description = "Mellow grooves from the 1970s",
country = "US",
),
// ── Classic Rock ──
Station(
id = "rdmix-classic-rock",
name = "RdMix Classic Rock",
url = "https://cast1.torontocast.com:4610/stream",
genre = "Classic Rock",
description = "Classic rock from the 70s, 80s, and 90s",
country = "CA",
),
// ── Oldies ──
Station(
id = "good-time-oldies",
name = "Good Time Oldies",
url = "http://listen.181fm.com/181-goodtime_128k.mp3",
genre = "Oldies",
description = "The greatest oldies from the 50s and 60s",
country = "US",
),
// ── Jazz ──
Station(
id = "smooth-jazz-101",
name = "101 Smooth Jazz",
url = "http://jking.cdnstream1.com/b22139_128mp3",
genre = "Jazz / Smooth Jazz",
description = "Smooth jazz — saxophone, keys, and chill grooves",
country = "US",
),
// ── Eclectic / Funk / Hip-Hop ──
Station(
id = "kcrw-eclectic",
name = "KCRW Eclectic 24",
url = "https://streams.kcrw.com/e24_mp3",
genre = "Eclectic / Funk / Hip-Hop / Jazz",
description = "Eclectic mix — funk, hip-hop, jazz, alternative, experimental",
country = "US",
),
Station(
id = "radio-paradise-eclectic",
name = "Radio Paradise Eclectic",
url = "http://stream.radioparadise.com/eclectic-320",
genre = "Eclectic / World / Rock / Funk",
description = "Eclectic world mix — rock, funk, world, and everything in between",
genre = "NCS",
description = "NoCopyrightSounds chill mixes",
country = "US",
),
)

View file

@ -14,7 +14,7 @@ import com.rada.data.local.entity.SavedSongEntity
@Database(
entities = [SavedSongEntity::class, PlaylistEntity::class, PlaylistSongEntity::class],
version = 2,
version = 4,
exportSchema = false,
)
abstract class SatsAmpDatabase : RoomDatabase() {
@ -50,12 +50,27 @@ abstract class SatsAmpDatabase : RoomDatabase() {
}
}
private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE saved_songs ADD COLUMN source TEXT NOT NULL DEFAULT 'local'")
db.execSQL("ALTER TABLE saved_songs ADD COLUMN sourceUrl TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE saved_songs ADD COLUMN artworkUrl TEXT NOT NULL DEFAULT ''")
}
}
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE saved_songs ADD COLUMN wavlakeArtistId TEXT NOT NULL DEFAULT ''")
db.execSQL("ALTER TABLE saved_songs ADD COLUMN wavlakeArtistNpub TEXT NOT NULL DEFAULT ''")
}
}
fun create(context: Context): SatsAmpDatabase {
return Room.databaseBuilder(
context.applicationContext,
SatsAmpDatabase::class.java,
"radamp.db",
).addMigrations(MIGRATION_1_2).build()
).addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4).build()
}
}
}

View file

@ -46,6 +46,19 @@ interface PlaylistDao {
)
fun getSongsInPlaylist(playlistId: Long): Flow<List<SavedSongEntity>>
/**
* 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<Long>): Flow<List<SavedSongEntity>>
@Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId")
fun getPlaylistSongCount(playlistId: Long): Flow<Int>

View file

@ -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 = "",
)

View file

@ -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<Long>): Flow<List<SavedSongEntity>> {
return playlistDao.getSongsInPlaylists(playlistIds)
}
fun getPlaylistSongCount(playlistId: Long): Flow<Int> {
return playlistDao.getPlaylistSongCount(playlistId)
}

View file

@ -24,6 +24,10 @@ class StationRepository @Inject constructor(
private val stations = mutableListOf<Station>()
private val nameOverrides = mutableMapOf<String, String>()
/** Built-in station ids the user deleted. Persisted; survives restarts. */
private val _hiddenIds = MutableStateFlow(loadHiddenIds())
val hiddenIds: StateFlow<Set<String>> = _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<String, String> {
val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap()
private fun loadNameOverrides(): Map<String, String> { 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<String> {
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<String>) {
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"
}
}

View file

@ -21,6 +21,34 @@ class StreamRepository @Inject constructor(
private var cache: List<StreamEntry>? = 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<StreamEntry> {
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"
}
}

View file

@ -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<String, List<String>> = 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<String, List<String>> {
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<String> = emptyList(),
mode: MatchingMode = MatchingMode.EXACT_OR_RELATED,
): List<String> {
val out = LinkedHashSet<String>()
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<WavlakeTrack>,
val settings: WavlakeStationSettings = defaultWavlakeStationSettings,
/** Track IDs already queued or playing — never duplicated. */
val excludedTrackIds: Set<String> = emptySet(),
/** Artist IDs blocked by the user. */
val blockedArtistIds: Set<String> = emptySet(),
/** Artist IDs featured recently — deprioritized to keep rotation fresh. */
val recentArtistIds: Set<String> = 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<WavlakeTrack> {
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)
}
}

View file

@ -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<TrackDto> = 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<AlbumSummaryDto> = 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<TrackDto> = emptyList(),
)
@Serializable
data class ErrorDto(val error: String? = null)
// ── Result ───────────────────────────────────────────────────────────────
sealed interface WavlakeResult<out T> {
data class Ok<T>(val value: T) : WavlakeResult<T>
/** API failure never throws — local radio keeps playing regardless. */
data class Failed(val kind: FailureKind, val message: String) : WavlakeResult<Nothing>
}
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<List<SearchResultDto>> =
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<List<TrackDto>> {
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<CatalogDto> {
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<TrackDto> =
get("content/track/$trackId", emptyMap()) { body ->
json.decodeFromString(TrackDto.serializer(), body)
}
suspend fun artist(artistId: String): WavlakeResult<ArtistDto> =
get("content/artist/$artistId", emptyMap()) { body ->
json.decodeFromString(ArtistDto.serializer(), body)
}
suspend fun album(albumId: String): WavlakeResult<AlbumDto> =
get("content/album/$albumId", emptyMap()) { body ->
json.decodeFromString(AlbumDto.serializer(), body)
}
private suspend fun <T> get(
path: String,
params: Map<String, String>,
parse: (String) -> T,
): WavlakeResult<T> = 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()
}
}

View file

@ -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<List<WavlakeSearchHit>> {
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<List<WavlakeTrack>> =
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<WavlakeArtistDetail> {
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<List<WavlakeTrack>> =
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<WavlakeTrack> =
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<String, String>()
/**
* 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<String> = 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<String, Boolean> = 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<FeatureEvent> {
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<String>) {
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<String> = emptySet(),
): List<WavlakeTrack> = 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<WavlakeTrack>,
)
/** 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() },
)
}

View file

@ -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:<feed-pubkey>:<trackId>`), 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<List<String>> {
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<String>,
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() }
}
}

View file

@ -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()
}

View file

@ -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<String> = 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<String> = 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()
}

View file

@ -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<String?> = _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 <queries> 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 <package> 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) }
/**

View file

@ -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<String, RelayConnection>()
private val relays = java.util.concurrent.ConcurrentHashMap<String, RelayConnection>()
private val _relayStates = MutableStateFlow<List<RelayInfo>>(emptyList())
val relayStates: StateFlow<List<RelayInfo>> = _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
}

View file

@ -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"
}
}

View file

@ -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()}"
}

View file

@ -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<JSONObject>()

View file

@ -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(" · ")

View file

@ -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.")
}
}
}

View file

@ -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<AudioDeviceInfo>) {
updateBtRoute()
}
override fun onAudioDevicesRemoved(devices: Array<AudioDeviceInfo>) {
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<LocalTrack>, 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
}
}

View file

@ -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()
}
}

View file

@ -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" })

View file

@ -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"
}
}

View file

@ -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))

View file

@ -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<String?>(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<List<String>> = _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, "")

View file

@ -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,
)

View file

@ -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<List<String>> = _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)
}
}

File diff suppressed because it is too large Load diff

View file

@ -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<WavlakeSearchHit>) : 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<String> = _searchQuery.asStateFlow()
private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle)
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
private val _rankings = MutableStateFlow<List<WavlakeTrack>>(emptyList())
val rankings: StateFlow<List<WavlakeTrack>> = _rankings.asStateFlow()
private val _rankingsGenre = MutableStateFlow<String?>(null)
val rankingsGenre: StateFlow<String?> = _rankingsGenre.asStateFlow()
private val _rankingsLoading = MutableStateFlow(false)
val rankingsLoading: StateFlow<Boolean> = _rankingsLoading.asStateFlow()
private val _rankingsError = MutableStateFlow<String?>(null)
val rankingsError: StateFlow<String?> = _rankingsError.asStateFlow()
/** Resolved Lightning Addresses by artist id — populated best-effort. */
private val _addresses = MutableStateFlow<Map<String, String>>(emptyMap())
val addresses: StateFlow<Map<String, String>> = _addresses.asStateFlow()
private val _selectedArtistId = MutableStateFlow<String?>(null)
val selectedArtistId: StateFlow<String?> = _selectedArtistId.asStateFlow()
private val _artistDetail = MutableStateFlow<com.rada.data.wavlake.WavlakeArtistDetail?>(null)
val artistDetail: StateFlow<com.rada.data.wavlake.WavlakeArtistDetail?> =
_artistDetail.asStateFlow()
private val _artistLoading = MutableStateFlow(false)
val artistLoading: StateFlow<Boolean> = _artistLoading.asStateFlow()
private val _notice = MutableStateFlow<String?>(null)
val notice: StateFlow<String?> = _notice.asStateFlow()
val allPlaylists = playlists.getAllPlaylists()
init {
_searchQuery
.debounce(450)
.distinctUntilChanged()
.onEach { q ->
val query = q.trim()
if (query.length < 2) {
_searchState.value = WavlakeSearchState.Idle
return@onEach
}
_searchState.value = WavlakeSearchState.Loading
_searchState.value = when (val r = repository.search(query)) {
is WavlakeResult.Ok ->
if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.")
else WavlakeSearchState.Results(r.value)
is WavlakeResult.Failed -> WavlakeSearchState.Error(r.message)
}
}
.launchIn(viewModelScope)
refreshRankings(null)
}
fun search(query: String) {
_searchQuery.value = query
}
fun refreshRankings(genre: String?) {
_rankingsGenre.value = genre
viewModelScope.launch {
_rankingsLoading.value = true
_rankingsError.value = null
when (val r = repository.rankings(days = 7, genre = genre, limit = 25)) {
is WavlakeResult.Ok -> {
_rankings.value = r.value
resolveAddresses(r.value)
}
is WavlakeResult.Failed -> {
_rankings.value = emptyList()
_rankingsError.value = r.message
}
}
_rankingsLoading.value = false
}
}
fun openArtist(artistId: String, name: String, npubHint: String? = null) {
_selectedArtistId.value = artistId
_artistDetail.value = null
viewModelScope.launch {
_artistLoading.value = true
when (val r = repository.artistDetail(artistId)) {
is WavlakeResult.Ok -> {
_artistDetail.value = r.value
val npub = r.value.tracks.firstNotNullOfOrNull { it.artistNpubHex }
?: npubHint
resolveArtistAddress(r.value.artist.id, npub)
resolveAddresses(r.value.tracks)
}
is WavlakeResult.Failed -> {
// Fallback shell so the name is still shown with the error.
_artistDetail.value = com.rada.data.wavlake.WavlakeArtistDetail(
artist = WavlakeArtist(id = artistId, name = name),
tracks = emptyList(),
)
_notice.value = r.message
}
}
_artistLoading.value = false
}
}
fun closeArtist() {
_selectedArtistId.value = null
_artistDetail.value = null
}
/** Best-effort address resolution; bolts appear only on success (strict gate). */
private fun resolveAddresses(tracks: List<WavlakeTrack>) {
// Stored (user-confirmed) addresses first — instant, offline.
val known = tracks.map { it.artistId }.distinct()
.mapNotNull { id -> repository.getKnownAddress(id)?.let { id to it } }
.toMap()
if (known.isNotEmpty()) _addresses.value = _addresses.value + known
val npubs = tracks.mapNotNull { it.artistNpubHex }.distinct().take(12)
if (npubs.isEmpty()) return
viewModelScope.launch {
val byArtist = tracks.mapNotNull { t ->
t.artistNpubHex?.let { npub ->
repository.resolveLightningAddress(t.artistId, npub)
?.let { addr -> t.artistId to addr }
}
}.toMap()
if (byArtist.isNotEmpty()) _addresses.value = _addresses.value + byArtist
}
}
private fun resolveArtistAddress(artistId: String, npub: String?) {
repository.getKnownAddress(artistId)?.let { addr ->
_addresses.value = _addresses.value + (artistId to addr)
}
if (npub.isNullOrBlank()) return
viewModelScope.launch {
repository.resolveLightningAddress(artistId, npub)?.let { addr ->
_addresses.value = _addresses.value + (artistId to addr)
}
}
}
/** Resolve and cache an artist's Lightning Address on-demand (for search results). */
fun resolveArtistAddress(artistId: String) {
val known = repository.getKnownAddress(artistId)
if (known != null) {
_addresses.value = _addresses.value + (artistId to known)
return
}
viewModelScope.launch {
val detail = repository.artistDetail(artistId)
if (detail is WavlakeResult.Ok) {
val npub = detail.value.tracks.firstNotNullOfOrNull { it.artistNpubHex }
if (npub != null) {
val addr = repository.resolveLightningAddress(artistId, npub)
if (addr != null) {
_addresses.value = _addresses.value + (artistId to addr)
}
}
}
}
}
/** Strict gate: bolt iff a VALID Lightning Address is resolved. */
fun addressFor(artistId: String): String? =
_addresses.value[artistId]?.takeIf { hasValidLightningAddress(it) }
fun playTrack(track: WavlakeTrack) {
val url = track.audioUrl ?: return
playbackManager.playLocalQueue(
listOf(PlaybackManager.LocalTrack(null, url, track.title, track.artistName)),
startIndex = 0,
shuffle = false,
)
}
fun saveTrack(track: WavlakeTrack) {
viewModelScope.launch {
_notice.value = when (val r = repository.importTrack(track)) {
is WavlakeRepository.ImportOutcome.Imported -> "Saved “${track.title}” to Library"
is WavlakeRepository.ImportOutcome.AlreadyInLibrary -> "Already in your Library"
is WavlakeRepository.ImportOutcome.Unplayable -> r.reason
}
}
}
fun addTrackToPlaylists(track: WavlakeTrack, playlistIds: List<Long>) {
if (playlistIds.isEmpty()) return
viewModelScope.launch {
val outcome = repository.importTrack(track)
val songId = when (outcome) {
is WavlakeRepository.ImportOutcome.Imported -> outcome.songId
is WavlakeRepository.ImportOutcome.AlreadyInLibrary -> outcome.songId
is WavlakeRepository.ImportOutcome.Unplayable -> {
_notice.value = outcome.reason
return@launch
}
}
playlistIds.forEach { playlists.addSongToPlaylist(it, songId) }
_notice.value = if (playlistIds.size == 1) "Added to playlist"
else "Added to ${playlistIds.size} playlists"
}
}
fun playSearchTrack(trackId: String, artistId: String, title: String) {
viewModelScope.launch {
when (val r = repository.trackDetail(trackId)) {
is WavlakeResult.Ok -> playTrack(r.value)
is WavlakeResult.Failed ->
repository.matchArtistTrack(artistId, title)?.let { playTrack(it) }
?: run { _notice.value = r.message }
}
}
}
fun saveSearchTrack(trackId: String, artistId: String, title: String) {
viewModelScope.launch {
when (val r = repository.trackDetail(trackId)) {
is WavlakeResult.Ok -> saveTrack(r.value)
is WavlakeResult.Failed ->
repository.matchArtistTrack(artistId, title)?.let { saveTrack(it) }
?: run { _notice.value = r.message }
}
}
}
suspend fun featuredFor(stationId: String, genre: String): List<WavlakeTrack> =
repository.featuredForStation(stationId, genre)
/** Resolve a search hit to a full track (for official zap eligibility). */
fun resolveForZap(trackId: String, artistId: String, title: String, onResult: (WavlakeTrack?) -> Unit) {
viewModelScope.launch {
val full = when (val r = repository.trackDetail(trackId)) {
is WavlakeResult.Ok -> r.value
is WavlakeResult.Failed -> repository.matchArtistTrack(artistId, title)
}
if (full != null && full.isZapPayable) onResult(full)
else {
_notice.value = "This track can't be zapped yet."
onResult(null)
}
}
}
fun consumeNotice() {
_notice.value = null
}
fun settings(): WavlakeStationSettings = repository.settings()
fun saveSettings(settings: WavlakeStationSettings) = repository.saveSettings(settings)
fun retrySearch() {
val q = _searchQuery.value
_searchQuery.value = "$q "
_searchQuery.value = q
}
}

View file

@ -0,0 +1,76 @@
package com.rada.ui.discover
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.domain.model.WavlakeTrack
import com.rada.payments.ZapService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* Official Wavlake track-zap sheet state machine. Amount → invoice →
* pay (wallet) or copy-out (no wallet). Success surfaces only after the
* wallet confirms payment.
*/
sealed interface TrackZapState {
data object Editing : TrackZapState
data object Sending : TrackZapState
data class InvoiceReady(val bolt11: String, val sats: Long) : TrackZapState
data object Sent : TrackZapState
data class Failed(val reason: String) : TrackZapState
}
@HiltViewModel
class WavlakeZapViewModel @Inject constructor(
private val zaps: ZapService,
) : ViewModel() {
private val _state = MutableStateFlow<TrackZapState>(TrackZapState.Editing)
val state: StateFlow<TrackZapState> = _state.asStateFlow()
fun reset() {
_state.value = TrackZapState.Editing
}
/** Mint an invoice without paying (no wallet needed) for copy-out. */
fun fetchInvoice(track: WavlakeTrack, sats: Long, comment: String) {
val albumId = track.albumId ?: return
_state.value = TrackZapState.Sending
viewModelScope.launch {
_state.value = try {
when (val r = zaps.wavlakeTrackInvoice(track.id, albumId, sats, comment)) {
is ZapService.WavlakeZapOutcome.Invoice ->
TrackZapState.InvoiceReady(r.bolt11, r.sats)
is ZapService.WavlakeZapOutcome.Sent -> TrackZapState.Sent
is ZapService.WavlakeZapOutcome.Failed ->
TrackZapState.Failed(r.reason)
}
} catch (e: Exception) {
TrackZapState.Failed(e.message ?: "Invoice failed")
}
}
}
/** Full flow: invoice, pay via the connected wallet, await confirmation. */
fun send(track: WavlakeTrack, sats: Long, comment: String) {
val albumId = track.albumId ?: return
_state.value = TrackZapState.Sending
viewModelScope.launch {
_state.value = try {
when (val r = zaps.zapWavlakeTrack(track.id, albumId, sats, comment)) {
is ZapService.WavlakeZapOutcome.Sent -> TrackZapState.Sent
is ZapService.WavlakeZapOutcome.Invoice ->
TrackZapState.InvoiceReady(r.bolt11, r.sats)
is ZapService.WavlakeZapOutcome.Failed ->
TrackZapState.Failed(r.reason)
}
} catch (e: Exception) {
TrackZapState.Failed(e.message ?: "Zap failed")
}
}
}
}

View file

@ -24,6 +24,9 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ArrowDownward
import androidx.compose.material.icons.filled.ArrowUpward
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Checklist
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
@ -33,6 +36,8 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
@ -45,6 +50,7 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Tab
import androidx.compose.material3.TabRowDefaults
import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset
@ -101,6 +107,7 @@ fun LibraryScreen(
var showImportMenu by remember { mutableStateOf(false) }
var showAppearance by remember { mutableStateOf(false) }
var playlistToDelete by remember { mutableStateOf<PlaylistEntity?>(null) }
var songForPlaylist by remember { mutableStateOf<SavedSongEntity?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
@ -200,6 +207,19 @@ fun LibraryScreen(
}
}
}
// Multi-select: combine several folders, shuffle the union.
if (tabLabels.size > 1) {
IconButton(onClick = {
if (uiState.isMultiSelect) viewModel.exitMultiSelect()
else viewModel.enterMultiSelect()
}) {
Icon(
Icons.Default.Checklist,
contentDescription = if (uiState.isMultiSelect) "Exit playlist selection" else "Select multiple playlists",
tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
)
}
}
IconButton(onClick = { showAppearance = true }) {
Icon(
Icons.Default.Settings,
@ -215,12 +235,54 @@ fun LibraryScreen(
},
snackbarHost = { SnackbarHost(snackbarHostState) },
containerColor = Color.Transparent,
bottomBar = {
if (uiState.isMultiSelect) {
MultiShuffleBar(
songCount = uiState.songs.size,
listCount = uiState.multiSelectedIds.size,
onShuffle = { viewModel.shuffleMulti() },
onExit = { viewModel.exitMultiSelect() },
)
}
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
// View header: what you're looking at + live count (Apple-style
// display type; doubles as orientation in multi-select/search).
val selectedPlaylist =
filteredPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }
val headerTitle = when {
uiState.isMultiSelect ->
if (uiState.multiSelectedIds.isEmpty()) "Select lists"
else "${uiState.multiSelectedIds.size} lists"
selectedPlaylist != null -> selectedPlaylist.name
else -> "All Songs"
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(top = 4.dp, bottom = 2.dp),
) {
Text(
text = headerTitle,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = "${uiState.songs.size} songs",
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
)
}
if (tabLabels.size > 1) {
ScrollableTabRow(
selectedTabIndex = selectedTabIndex,
@ -246,6 +308,13 @@ fun LibraryScreen(
onDispose { tabLpHandler.removeCallbacksAndMessages(null) }
}
tabLabels.forEachIndexed { index, label ->
val tabPlaylistId =
if (index == 0) null else filteredPlaylists[index - 1].id
val tabSelected = if (uiState.isMultiSelect) {
index > 0 && tabPlaylistId in uiState.multiSelectedIds
} else {
selectedTabIndex == index
}
Tab(
modifier = if (index == 0) Modifier else Modifier.pointerInput(index) {
awaitEachGesture {
@ -270,20 +339,33 @@ fun LibraryScreen(
tabLpHandler.removeCallbacks(timer)
}
},
selected = selectedTabIndex == index,
selected = tabSelected,
onClick = {
if (index == 0) {
if (uiState.isMultiSelect) {
if (index == 0) viewModel.exitMultiSelect()
else viewModel.togglePlaylistMulti(filteredPlaylists[index - 1].id)
} else if (index == 0) {
viewModel.selectPlaylist(null)
} else {
viewModel.selectPlaylist(filteredPlaylists[index - 1].id)
}
},
icon = if (tabSelected && uiState.isMultiSelect) {
{
Icon(
Icons.Default.Check,
contentDescription = null,
tint = LocalTokens.current.accentFill,
modifier = Modifier.size(16.dp),
)
}
} else null,
text = {
Text(
text = label,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (selectedTabIndex == index) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
color = if (tabSelected) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
)
},
)
@ -362,8 +444,11 @@ fun LibraryScreen(
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = if (uiState.selectedPlaylistId != null) "Playlist is empty"
else "No saved songs yet — tap + to import songs from your device",
text = when {
uiState.isMultiSelect -> "Tap playlist tabs above to combine them"
uiState.selectedPlaylistId != null -> "Playlist is empty"
else -> "No saved songs yet — tap + to import songs from your device"
},
style = MaterialTheme.typography.bodyLarge,
color = LocalTokens.current.textSecondary,
)
@ -379,6 +464,7 @@ fun LibraryScreen(
isPlaying = song.id == uiState.currentPlayingId,
onClick = { viewModel.playSong(song) },
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
onAddToPlaylist = { songForPlaylist = song },
onDelete = { viewModel.deleteSong(song) },
)
}
@ -394,8 +480,7 @@ fun LibraryScreen(
}
playlistToDelete?.let { playlist ->
AlertDialog(
onDismissRequest = { playlistToDelete = null },
AlertDialog( onDismissRequest = { playlistToDelete = null },
title = { Text("Delete \"${playlist.name}\"?") },
text = { Text("You can delete just the folder group — the songs stay in All Songs — or delete the songs and their files too.") },
confirmButton = {
@ -421,6 +506,205 @@ fun LibraryScreen(
},
)
}
songForPlaylist?.let { song ->
AddSongToPlaylistDialog(
songTitle = song.title,
playlists = allPlaylists,
onDismiss = { songForPlaylist = null },
onPlaylistsConfirmed = { playlistIds ->
viewModel.addSongToPlaylists(song.id, playlistIds)
songForPlaylist = null
},
onCreatePlaylist = { name ->
viewModel.createPlaylistAndAdd(song.id, name)
songForPlaylist = null
},
)
}
}
@Composable
private fun AddSongToPlaylistDialog(
songTitle: String,
playlists: List<PlaylistEntity>,
onDismiss: () -> Unit,
onPlaylistsConfirmed: (List<Long>) -> Unit,
onCreatePlaylist: (String) -> Unit,
) {
var showNewInput by remember { mutableStateOf(false) }
var newPlaylistName by remember { mutableStateOf("") }
var selectedIds by remember { mutableStateOf(setOf<Long>()) }
AlertDialog(
onDismissRequest = onDismiss,
icon = {
Icon(
Icons.Default.PlaylistAdd,
contentDescription = null,
tint = LocalTokens.current.accentFill,
)
},
title = { Text("Add to playlists") },
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = songTitle,
style = MaterialTheme.typography.bodyMedium,
color = LocalTokens.current.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Spacer(modifier = Modifier.height(8.dp))
if (playlists.isEmpty()) {
Text(
text = "No playlists yet. Create one below.",
style = MaterialTheme.typography.bodyMedium,
color = LocalTokens.current.textSecondary,
)
} else {
LazyColumn(
modifier = Modifier.weight(1f, fill = false),
) {
items(playlists, key = { it.id }) { playlist ->
val checked = playlist.id in selectedIds
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
selectedIds =
if (checked) selectedIds - playlist.id
else selectedIds + playlist.id
}
.padding(vertical = 6.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
onCheckedChange = { isChecked ->
selectedIds =
if (isChecked) selectedIds + playlist.id
else selectedIds - playlist.id
},
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = playlist.name,
style = MaterialTheme.typography.bodyLarge,
color = LocalTokens.current.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
}
if (showNewInput) {
TextField(
value = newPlaylistName,
onValueChange = { newPlaylistName = it },
modifier = Modifier.fillMaxWidth(),
label = { Text("Playlist name") },
singleLine = true,
colors = TextFieldDefaults.colors(
focusedContainerColor = LocalTokens.current.panel,
unfocusedContainerColor = LocalTokens.current.panel,
focusedIndicatorColor = LocalTokens.current.accentFill.copy(alpha = 0.5f),
unfocusedIndicatorColor = LocalTokens.current.border,
focusedTextColor = LocalTokens.current.text,
unfocusedTextColor = LocalTokens.current.text,
),
shape = RoundedCornerShape(12.dp),
)
Spacer(modifier = Modifier.height(4.dp))
TextButton(
onClick = { onCreatePlaylist(newPlaylistName) },
enabled = newPlaylistName.isNotBlank(),
) {
Text("Create & add")
}
} else {
TextButton(onClick = { showNewInput = true }) {
Text("New playlist…")
}
}
}
},
confirmButton = {
TextButton(
onClick = { onPlaylistsConfirmed(selectedIds.toList()) },
enabled = selectedIds.isNotEmpty(),
) {
Text(if (selectedIds.size == 1) "Add" else "Add to ${selectedIds.size}")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
},
)
}
@Composable
private fun MultiShuffleBar(
songCount: Int,
listCount: Int,
onShuffle: () -> Unit,
onExit: () -> Unit,
) {
Surface(
color = LocalTokens.current.panel,
contentColor = LocalTokens.current.text,
tonalElevation = 2.dp,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onExit) {
Icon(
Icons.Default.Close,
contentDescription = "Exit playlist selection",
tint = LocalTokens.current.textSecondary,
)
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = if (listCount == 0) "Select playlists" else "$songCount songs",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
text = if (listCount == 0) "Tap tabs above to combine them"
else if (listCount == 1) "1 list"
else "$listCount lists",
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = onShuffle,
enabled = songCount > 0,
) {
Icon(
Icons.Default.Shuffle,
contentDescription = null,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(8.dp))
Text("Shuffle")
}
}
}
}
@Composable
@ -464,6 +748,7 @@ private fun SongListItem(
isPlaying: Boolean,
onClick: () -> Unit,
onToggleFavorite: () -> Unit,
onAddToPlaylist: () -> Unit,
onDelete: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
@ -522,6 +807,13 @@ private fun SongListItem(
tint = if (song.isFavorite) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
)
}
IconButton(onClick = onAddToPlaylist) {
Icon(
Icons.Default.PlaylistAdd,
contentDescription = "Add “${song.title}” to playlist",
tint = LocalTokens.current.textSecondary,
)
}
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,

View file

@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@ -74,6 +75,8 @@ class LibraryViewModel @Inject constructor(
private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED)
private val sortAscending = MutableStateFlow(true)
private val selectedPlaylistId = MutableStateFlow<Long?>(null)
private val multiSelect = MutableStateFlow(false)
private val multiSelectedIds = MutableStateFlow<Set<Long>>(emptySet())
val allPlaylists: StateFlow<List<PlaylistEntity>> = playlistRepository.getAllPlaylists()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
@ -89,28 +92,38 @@ class LibraryViewModel @Inject constructor(
// flatMapLatest cancels the previous songs flow when the selection
// changes, so picking a playlist immediately switches to the
// playlist-filtered query instead of staying on the full library.
val songsFlow = combine(searchQuery, sortOrder, selectedPlaylistId) { query, sort, playlistId ->
Triple(query, sort, playlistId)
}.flatMapLatest { (query, sort, playlistId) ->
val songsFlow = combine(
searchQuery, sortOrder, selectedPlaylistId, multiSelect, multiSelectedIds
) { query, sort, playlistId, multi, ids ->
SongQuery(query, sort, playlistId, multi, ids)
}.flatMapLatest { q ->
when {
// Multi-select wins over everything: union of picked lists
// (empty until the first list is tapped).
q.multi ->
if (q.ids.isNotEmpty()) playlistRepository.getSongsInPlaylists(q.ids.toList())
else flowOf(emptyList())
// A playlist selection always wins: query strictly by playlist_id.
playlistId != null -> playlistRepository.getSongsInPlaylist(playlistId)
query.isEmpty() -> libraryRepository.getAll(sort)
else -> libraryRepository.search(query)
q.playlistId != null -> playlistRepository.getSongsInPlaylist(q.playlistId)
q.query.isEmpty() -> libraryRepository.getAll(q.sort)
else -> libraryRepository.search(q.query)
}
}
// Sort direction and in-folder sorting are applied here so every view
// (All Songs, folder tabs, search results) obeys the visible sort chips.
// (All Songs, folder tabs, search results, multi-select union) obeys
// the visible sort chips.
viewModelScope.launch {
combine(songsFlow, sortOrder, sortAscending) { songs, sort, ascending ->
Triple(sortSongs(songs, sort, ascending), sort, ascending)
}.collect { (sortedSongs, sort, ascending) ->
combine(songsFlow, sortOrder, sortAscending, multiSelect, multiSelectedIds) { songs, sort, ascending, multi, ids ->
SongListView(songs, sort, ascending, multi, ids)
}.collect { view ->
_uiState.update {
it.copy(
songs = sortedSongs,
songs = sortSongs(view.songs, view.sort, view.ascending),
isLoading = false,
sortOrder = sort,
sortAscending = ascending,
sortOrder = view.sort,
sortAscending = view.ascending,
isMultiSelect = view.multi,
multiSelectedIds = view.ids,
)
}
}
@ -187,8 +200,7 @@ class LibraryViewModel @Inject constructor(
}
}
fun deleteSong(song: SavedSongEntity) {
viewModelScope.launch {
fun deleteSong(song: SavedSongEntity) { viewModelScope.launch {
libraryRepository.delete(song)
try {
val file = java.io.File(song.filePath)
@ -197,6 +209,25 @@ class LibraryViewModel @Inject constructor(
}
}
fun addSongToPlaylists(songId: Long, playlistIds: List<Long>) {
if (playlistIds.isEmpty()) return
viewModelScope.launch {
playlistIds.forEach { playlistRepository.addSongToPlaylist(it, songId) }
_importMessage.value = if (playlistIds.size == 1) "Added to playlist"
else "Added to ${playlistIds.size} playlists"
}
}
fun createPlaylistAndAdd(songId: Long, name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
viewModelScope.launch {
val playlistId = playlistRepository.createPlaylist(trimmed)
playlistRepository.addSongToPlaylist(playlistId, songId)
_importMessage.value = "Created \"$trimmed\" and added song"
}
}
fun search(query: String) {
searchQuery.value = query
}
@ -224,6 +255,38 @@ class LibraryViewModel @Inject constructor(
_uiState.update { it.copy(selectedPlaylistId = playlistId) }
}
/** Enters multi-select (union view takes over; single selection clears). */
fun enterMultiSelect() {
selectedPlaylistId.value = null
multiSelectedIds.value = emptySet()
multiSelect.value = true
_uiState.update { it.copy(selectedPlaylistId = null) }
}
/** Leaves multi-select and returns to All Songs. */
fun exitMultiSelect() {
multiSelect.value = false
multiSelectedIds.value = emptySet()
selectedPlaylistId.value = null
_uiState.update { it.copy(selectedPlaylistId = null) }
}
/** Toggles one playlist's membership in the multi-select union. */
fun togglePlaylistMulti(playlistId: Long) {
if (!multiSelect.value) return
multiSelectedIds.value =
if (playlistId in multiSelectedIds.value) multiSelectedIds.value - playlistId
else multiSelectedIds.value + playlistId
}
/** Shuffles the current multi-select union, then leaves select mode. */
fun shuffleMulti() {
val queue = _uiState.value.songs
if (queue.isEmpty()) return
playQueue(queue, startIndex = 0, shuffle = true)
exitMultiSelect()
}
fun deletePlaylist(playlist: PlaylistEntity, deleteSongs: Boolean = false) {
viewModelScope.launch {
if (deleteSongs) {
@ -256,5 +319,26 @@ data class LibraryUiState(
val sortAscending: Boolean = true,
val isImporting: Boolean = false,
/** Multi-select union mode: tabs toggle membership, list shows the union. */
val isMultiSelect: Boolean = false,
val multiSelectedIds: Set<Long> = emptySet(),
) {
}
/** One snapshot of the inputs driving the visible song list. */
private data class SongQuery(
val query: String,
val sort: SortOrder,
val playlistId: Long?,
val multi: Boolean,
val ids: Set<Long>,
)
/** Songs + the view flags they were collected under. */
private data class SongListView(
val songs: List<SavedSongEntity>,
val sort: SortOrder,
val ascending: Boolean,
val multi: Boolean,
val ids: Set<Long>,
)

View file

@ -66,9 +66,10 @@ fun NavGraph(navController: NavHostController) {
) {
PlayerScreen(
onNavigateToStations = { navController.navigate(Screen.Tabs.createRoute(0)) },
onNavigateToLibrary = { navController.navigate(Screen.Tabs.createRoute(1)) },
onNavigateToStreams = { navController.navigate(Screen.Tabs.createRoute(2)) },
onNavigateToPlaylists = { navController.navigate(Screen.Tabs.createRoute(3)) },
onNavigateToDiscover = { navController.navigate(Screen.Tabs.createRoute(1)) },
onNavigateToLibrary = { navController.navigate(Screen.Tabs.createRoute(2)) },
onNavigateToStreams = { navController.navigate(Screen.Tabs.createRoute(3)) },
onNavigateToPlaylists = { navController.navigate(Screen.Tabs.createRoute(4)) },
)
}
composable(

View file

@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.Podcasts
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material3.Icon
@ -15,15 +16,12 @@ import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
@ -36,10 +34,10 @@ import com.rada.ui.theme.LocalTokens
import kotlinx.coroutines.launch
/**
* The four peer tabs as a swipeable pager (Spotify's horizontal structure):
* Stations · Library · Streams · Playlists. Synced with the bottom bar;
* swiping follows the finger, tab taps animate. The mini-player sits above
* the nav bar so playback controls are always reachable.
* The five peer tabs as a swipeable pager (Spotify's horizontal structure):
* Stations · Discover · Library · Streams · Playlists. Synced with the
* bottom bar; swiping follows the finger, tab taps animate. The mini-player
* sits above the nav bar so playback controls are always reachable.
*/
@Composable
fun TabsScreen(
@ -48,20 +46,11 @@ fun TabsScreen(
onBack: () -> Unit,
onHome: () -> Unit,
) {
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 3)) { 4 }
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
val scope = rememberCoroutineScope()
val haptics = LocalHapticFeedback.current
// Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
// Light tick as a new page settles — tab changes are tactile, not silent.
// Skipped on first composition (nothing actually changed).
var firstSettle by remember { mutableStateOf(true) }
LaunchedEffect(pagerState.settledPage) {
if (firstSettle) firstSettle = false
else haptics.performHapticFeedback(HapticFeedbackType.LongPress)
}
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
@ -74,9 +63,10 @@ fun TabsScreen(
) {
val items = listOf(
Triple("Stations", Icons.Default.Radio, 0),
Triple("Library", Icons.Default.LibraryMusic, 1),
Triple("Streams", Icons.Default.Podcasts, 2),
Triple("Playlists", Icons.AutoMirrored.Filled.PlaylistAdd, 3),
Triple("Discover", Icons.Default.Explore, 1),
Triple("Library", Icons.Default.LibraryMusic, 2),
Triple("Streams", Icons.Default.Podcasts, 3),
Triple("Playlists", Icons.AutoMirrored.Filled.PlaylistAdd, 4),
)
items.forEach { (label, icon, page) ->
NavigationBarItem(
@ -112,22 +102,26 @@ fun TabsScreen(
onHome = onHome,
onStationSelected = { /* stay on tab; bar shows the station */ },
)
1 -> LibraryScreen(
1 -> com.rada.ui.discover.DiscoverScreen(
onBack = onBack,
onHome = onHome,
)
2 -> LibraryScreen(
onBack = onBack,
onHome = onHome,
initialPlaylistId = libraryPlaylistId,
)
2 -> StreamsScreen(
3 -> StreamsScreen(
onBack = onBack,
onHome = onHome,
onStreamSelected = { },
)
3 -> PlaylistManagerScreen(
4 -> PlaylistManagerScreen(
onBack = onBack,
onHome = onHome,
onPlaylistSelected = { playlistId ->
libraryPlaylistId = playlistId
scope.launch { pagerState.animateScrollToPage(1) }
scope.launch { pagerState.animateScrollToPage(2) }
},
)
}

View file

@ -238,8 +238,12 @@ fun NowPlayingBar(
}
}
}
// ⚡ Support — tip the artist/station from anywhere.
// ⚡ Support — tip the artist/station from anywhere. When the
// playing track maps to an artist with a known address, it
// arrives prefilled (works for every such artist, no wallet
// needed — copy it out if you like); otherwise manual entry.
var showTip by remember { mutableStateOf(false) }
val zapTarget by viewModel.zapTarget.collectAsState()
IconButton(
onClick = { showTip = true },
modifier = Modifier.size(40.dp),
@ -255,6 +259,8 @@ fun NowPlayingBar(
com.rada.ui.decentralized.TipSheet(
artistName = stationName,
trackTitle = title.takeIf { subtitle.isNotEmpty() },
initialAddress = zapTarget?.second.orEmpty(),
artistId = zapTarget?.first,
onDismiss = { showTip = false },
)
}

View file

@ -1,15 +1,24 @@
package com.rada.ui.player
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.repository.LibraryRepository
import com.rada.data.wavlake.WavlakeRepository
import com.rada.player.PlaybackManager
import com.rada.recording.StreamMetadata
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
@HiltViewModel
class NowPlayingViewModel @Inject constructor(
private val playbackManager: PlaybackManager,
private val library: LibraryRepository,
private val wavlake: WavlakeRepository,
) : ViewModel() {
val currentStationName: StateFlow<String> = playbackManager.currentStationNameFlow
@ -20,6 +29,32 @@ class NowPlayingViewModel @Inject constructor(
val streamMeta: StateFlow<StreamMetadata?> = playbackManager.lastStreamMetaFlow
val localMedia: StateFlow<Pair<String, String>?> = playbackManager.localMediaFlow
/**
* (artistId, address) for the playing library track, when it maps to an
* artist with a known-or-resolvable Lightning Address. Works for every
* such artist, local import or Wavlake — radio streams have no identity
* and stay manual. Null means "unknown": the sheet opens blank as today.
*/
val zapTarget: StateFlow<Pair<String, String>?> =
playbackManager.currentLocalTrackIdFlow.flatMapLatest { id ->
flow {
val song = if (id != null) runCatching { library.getById(id) }.getOrNull() else null
if (song == null) {
emit(null)
return@flow
}
val artistId = song.wavlakeArtistId.takeIf { it.isNotBlank() }
?: run { emit(null); return@flow }
val address = wavlake.getKnownAddress(artistId)
?: wavlake.resolveLightningAddress(
artistId,
song.wavlakeArtistNpub.takeIf { it.isNotBlank() },
)
?: run { emit(null); return@flow }
emit(artistId to address)
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null)
fun togglePlayPause() {
playbackManager.togglePlayPause()
}

View file

@ -15,11 +15,15 @@ import com.rada.ui.theme.metadataExit
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.statusBarsPadding
@ -37,11 +41,9 @@ import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Repeat
import androidx.compose.material.icons.filled.RepeatOne
import androidx.compose.material.icons.filled.Shuffle
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Timer
import androidx.compose.material.icons.filled.TimerOff
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.Explore
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Pause
@ -54,12 +56,12 @@ import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SkipNext
import androidx.compose.material.icons.filled.SkipPrevious
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.LibraryMusic
import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FloatingActionButton
@ -84,12 +86,16 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import kotlinx.coroutines.launch
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
@ -104,7 +110,11 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station
import com.rada.player.PlaybackManager
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 com.rada.ui.theme.SatsAmpGradients
import com.rada.ui.theme.pressDip
import com.rada.ui.theme.AppearanceDialog
import com.rada.ui.theme.GradientEnd
import com.rada.ui.theme.GradientMid
@ -113,6 +123,7 @@ import com.rada.ui.theme.PremiumCyan
@Composable
fun PlayerScreen(
onNavigateToStations: () -> Unit,
onNavigateToDiscover: () -> Unit,
onNavigateToLibrary: () -> Unit,
onNavigateToStreams: () -> Unit,
onNavigateToPlaylists: () -> Unit,
@ -122,6 +133,7 @@ fun PlayerScreen(
val playlists by viewModel.playlists.collectAsState()
val importMessage by viewModel.importMessage.collectAsState()
val snackbarHostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
var showImportDialog by remember { mutableStateOf(false) }
var showAppearance by remember { mutableStateOf(false) }
var showDecentralized by remember { mutableStateOf(false) }
@ -195,7 +207,7 @@ fun PlayerScreen(
playlists = playlists,
newPlaylistName = uiState.newPlaylistName,
onNewPlaylistNameChange = viewModel::updateNewPlaylistName,
onPlaylistSelected = viewModel::addToPlaylist,
onPlaylistsConfirmed = viewModel::addToPlaylists,
onCreatePlaylist = { viewModel.createPlaylistAndAdd(uiState.newPlaylistName) },
onDismiss = viewModel::dismissPlaylistPicker,
)
@ -265,6 +277,17 @@ fun PlayerScreen(
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
NavigationBarItem(
selected = false,
onClick = onNavigateToDiscover,
icon = { Icon(Icons.Default.Explore, contentDescription = "Discover") },
label = { Text("Discover") },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
NavigationBarItem(
selected = false,
onClick = onNavigateToLibrary,
@ -306,6 +329,9 @@ fun PlayerScreen(
uiState = uiState,
viewModel = viewModel,
modifier = Modifier.padding(padding),
onSaveDisabled = { message ->
scope.launch { snackbarHostState.showSnackbar(message) }
},
)
} else {
EmptyStateView(
@ -327,7 +353,8 @@ private fun EmptyStateView(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 32.dp),
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@ -473,21 +500,24 @@ private fun NowPlayingView(
uiState: PlayerUiState,
viewModel: PlayerViewModel,
modifier: Modifier = Modifier,
onSaveDisabled: (String) -> Unit = {},
) {
val reduce = LocalReduceMotion.current
Column(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.padding(horizontal = 24.dp),
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Station card
uiState.currentStation?.let { station ->
StationCard(
StationHero(
station = station,
bufferState = uiState.bufferState,
isPlaying = uiState.isPlaying,
)
}
// Local library playback: a card for the song instead of a station.
@ -587,38 +617,58 @@ private fun NowPlayingView(
}
}
Spacer(modifier = Modifier.height(48.dp))
Spacer(modifier = Modifier.height(32.dp))
// Genre-matched Wavlake picks for this station (opt-in shelf:
// tapping plays, nothing auto-inserts, failures show nothing).
uiState.currentStation?.let { station ->
if (!uiState.isLocal && station.genre.isNotBlank()) {
com.rada.ui.discover.WavlakeFeaturedRow(
stationId = station.id,
stationGenre = station.genre,
)
Spacer(modifier = Modifier.height(8.dp))
}
}
// Save button
val saveDisabledHint = when {
uiState.currentStation == null -> null
uiState.bufferState == PlaybackManager.BufferState.ERROR ->
"Couldn't buffer this station — try again"
else -> "Waiting for the audio buffer…"
}
SaveSongButton(
canSave = uiState.currentStation != null && uiState.bufferState == PlaybackManager.BufferState.READY,
saveState = uiState.saveState,
onClick = { viewModel.saveSong() },
onDisabledTap = { saveDisabledHint?.let(onSaveDisabled) },
)
Spacer(modifier = Modifier.height(32.dp))
// Playback controls — conventional transport order: stop · prev · play · next
// Playback controls: prev · play · next.
// Every tap answers instantly (haptic tick + press dip) so feedback
// never waits on the stream catching up.
val haptics = LocalHapticFeedback.current
fun tap(action: () -> Unit) {
haptics.performHapticFeedback(HapticFeedbackType.LongPress)
action()
}
val transportEnabled = uiState.currentStation != null || uiState.isLocal
// Tapped play but audio hasn't resumed yet (reconnecting, initial
// buffering): show it instead of a play icon that promises sound.
val waitingForAudio = transportEnabled && !uiState.isPlaying &&
(uiState.bufferState == PlaybackManager.BufferState.BUFFERING ||
uiState.bufferState == PlaybackManager.BufferState.INITIALIZING)
val playInteraction = remember { MutableInteractionSource() }
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { viewModel.stop() },
enabled = uiState.currentStation != null || uiState.isLocal,
) {
Icon(
Icons.Default.Stop,
contentDescription = "Stop",
modifier = Modifier.size(28.dp),
)
}
Spacer(modifier = Modifier.width(12.dp))
IconButton(
onClick = { viewModel.previous() },
enabled = uiState.currentStation != null || uiState.isLocal,
onClick = { tap(viewModel::previous) },
enabled = transportEnabled,
) {
Icon(
Icons.Default.SkipPrevious,
@ -630,16 +680,24 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = { viewModel.togglePlayPause() },
enabled = uiState.currentStation != null || uiState.isLocal,
modifier = Modifier.size(72.dp),
onClick = { tap(viewModel::togglePlayPause) },
enabled = transportEnabled,
modifier = Modifier
.size(72.dp)
.pressDip(playInteraction, reduce),
shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
interactionSource = playInteraction,
) {
AnimatedContent(
targetState = uiState.isPlaying,
// 0 = waiting, 1 = playing, 2 = paused/stopped.
targetState = when {
waitingForAudio -> 0
uiState.isPlaying -> 1
else -> 2
},
transitionSpec = {
if (reduce)
fadeIn(tween(0)) togetherWith fadeOut(tween(0))
@ -650,20 +708,31 @@ private fun NowPlayingView(
fadeOut(tween(90))
},
label = "playPause",
) { playing ->
Icon(
if (playing) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (playing) "Pause" else "Play",
modifier = Modifier.size(36.dp),
)
) { visual ->
when (visual) {
0 -> CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 3.dp,
)
1 -> Icon(
Icons.Default.Pause,
contentDescription = "Pause",
modifier = Modifier.size(36.dp),
)
else -> Icon(
Icons.Default.PlayArrow,
contentDescription = "Play",
modifier = Modifier.size(36.dp),
)
}
}
}
Spacer(modifier = Modifier.width(16.dp))
IconButton(
onClick = { viewModel.next() },
enabled = uiState.currentStation != null || uiState.isLocal,
onClick = { tap(viewModel::next) },
enabled = transportEnabled,
) {
Icon(
Icons.Default.SkipNext,
@ -675,52 +744,11 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.height(8.dp))
// Playback options: sleep timer · speed · repeat · shuffle
var showSleepDialog by remember { mutableStateOf(false) }
var showSpeedDialog by remember { mutableStateOf(false) }
// Playback options: repeat · shuffle (local queue only).
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
val sleepActive = uiState.sleepRemainingMs > 0
IconButton(onClick = { showSleepDialog = true }) {
Icon(
if (sleepActive) Icons.Default.Timer else Icons.Default.TimerOff,
contentDescription = "Sleep timer",
tint = if (sleepActive)
MaterialTheme.colorScheme.primary
else
LocalTokens.current.textSecondary,
)
}
if (sleepActive) {
val mins = (uiState.sleepRemainingMs / 60_000L).toInt()
val secs = ((uiState.sleepRemainingMs % 60_000L) / 1_000L).toInt()
Text(
"%d:%02d".format(mins, secs),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
IconButton(onClick = { showSpeedDialog = true }) {
Icon(
Icons.Default.Speed,
contentDescription = "Playback speed",
tint = if (uiState.speed != 1f)
MaterialTheme.colorScheme.primary
else
LocalTokens.current.textSecondary,
)
}
if (uiState.speed != 1f) {
Text(
"%.2f×".format(uiState.speed).replace(".00×", "×"),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
)
}
IconButton(
onClick = { viewModel.cycleRepeat() },
enabled = uiState.isLocal,
@ -756,138 +784,21 @@ private fun NowPlayingView(
)
}
}
if (showSleepDialog) {
SleepTimerDialog(
activeMs = uiState.sleepRemainingMs,
onSelect = { minutes ->
if (minutes <= 0) viewModel.cancelSleepTimer()
else viewModel.startSleepTimer(minutes)
showSleepDialog = false
},
onDismiss = { showSleepDialog = false },
)
}
if (showSpeedDialog) {
SpeedDialog(
current = uiState.speed,
onSelect = { viewModel.setSpeed(it); showSpeedDialog = false },
onDismiss = { showSpeedDialog = false },
)
}
}
}
@Composable
private fun SleepTimerDialog(
activeMs: Long,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
val tokens = LocalTokens.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Sleep timer") },
text = {
Column {
Text(
"Pause playback after…",
style = MaterialTheme.typography.bodyMedium,
color = tokens.textSecondary,
)
Spacer(modifier = Modifier.height(12.dp))
val options = listOf(15, 30, 45, 60, 90)
options.forEach { minutes ->
TextButton(
onClick = { onSelect(minutes) },
modifier = Modifier.fillMaxWidth(),
) {
Text(
"$minutes min",
modifier = Modifier.fillMaxWidth(),
color = tokens.text,
)
}
}
if (activeMs > 0) {
TextButton(
onClick = { onSelect(0) },
modifier = Modifier.fillMaxWidth(),
) {
Text("Cancel timer", color = tokens.accentText,
modifier = Modifier.fillMaxWidth())
}
}
}
},
confirmButton = {},
containerColor = tokens.panel,
)
}
@Composable
private fun SpeedDialog(
current: Float,
onSelect: (Float) -> Unit,
onDismiss: () -> Unit,
) {
val tokens = LocalTokens.current
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Playback speed") },
text = {
Column {
val options = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f)
options.forEach { speed ->
val isCurrent = kotlin.math.abs(speed - current) < 0.01f
TextButton(
onClick = { onSelect(speed) },
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
if (speed == 1f) "Normal (1×)"
else "%.2f×".format(speed).replace(".00×", "×"),
modifier = Modifier.weight(1f),
color = if (isCurrent) tokens.accentText else tokens.text,
fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal,
)
if (isCurrent) {
Icon(Icons.Default.Check, contentDescription = null,
tint = tokens.accentText,
modifier = Modifier.size(18.dp))
}
}
}
}
Spacer(modifier = Modifier.height(4.dp))
Text(
"Applies to stations and saved songs.",
style = MaterialTheme.typography.bodySmall,
color = tokens.textMuted,
)
}
},
confirmButton = {},
containerColor = tokens.panel,
)
}
@Composable
private fun PlaylistPickerDialog(
playlists: List<com.rada.data.local.entity.PlaylistEntity>,
newPlaylistName: String,
onNewPlaylistNameChange: (String) -> Unit,
onPlaylistSelected: (Long) -> Unit,
onPlaylistsConfirmed: (List<Long>) -> Unit,
onCreatePlaylist: () -> Unit,
onDismiss: () -> Unit,
) {
var showNewPlaylistInput by remember { mutableStateOf(false) }
var playlistSearchQuery by remember { mutableStateOf("") }
var selectedIds by remember { mutableStateOf(setOf<Long>()) }
val filteredPlaylists = remember(playlists, playlistSearchQuery) {
if (playlistSearchQuery.isBlank()) playlists
@ -903,7 +814,7 @@ private fun PlaylistPickerDialog(
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text("Add to Playlist") },
title = { Text("Add to playlists") },
text = {
Column {
if (playlists.isNotEmpty()) {
@ -934,18 +845,25 @@ private fun PlaylistPickerDialog(
modifier = Modifier.heightIn(max = 200.dp),
) {
items(filteredPlaylists) { playlist ->
val checked = playlist.id in selectedIds
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onPlaylistSelected(playlist.id) }
.padding(vertical = 12.dp, horizontal = 8.dp),
.clickable {
selectedIds =
if (checked) selectedIds - playlist.id
else selectedIds + playlist.id
}
.padding(vertical = 6.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
Checkbox(
checked = checked,
onCheckedChange = { isChecked ->
selectedIds =
if (isChecked) selectedIds + playlist.id
else selectedIds - playlist.id
},
)
Spacer(modifier = Modifier.width(12.dp))
Text(
@ -1016,7 +934,14 @@ private fun PlaylistPickerDialog(
}
}
},
confirmButton = {},
confirmButton = {
TextButton(
onClick = { onPlaylistsConfirmed(selectedIds.toList()) },
enabled = selectedIds.isNotEmpty(),
) {
Text(if (selectedIds.size == 1) "Add" else "Add to ${selectedIds.size}")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
@ -1030,8 +955,10 @@ private fun SaveSongButton(
canSave: Boolean,
saveState: PlaybackManager.SaveState,
onClick: () -> Unit,
onDisabledTap: () -> Unit = {},
) {
val reduce = LocalReduceMotion.current
val enabled = canSave && saveState != PlaybackManager.SaveState.SAVING
val containerColor = when {
saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary
saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981)
@ -1049,17 +976,18 @@ private fun SaveSongButton(
}
FilledIconButton(
onClick = onClick,
enabled = canSave && saveState != PlaybackManager.SaveState.SAVING,
// Always enabled at the framework level so a tap always lands: when
// not ready we explain why instead of eating the press silently.
// Visual enabled/disabled state is driven manually via the colors.
onClick = { if (canSave && saveState != PlaybackManager.SaveState.SAVING) onClick() else onDisabledTap() },
enabled = true,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp)),
shape = RoundedCornerShape(20.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor.copy(alpha = 0.5f),
disabledContentColor = contentColor.copy(alpha = 0.5f),
containerColor = if (enabled) containerColor else containerColor.copy(alpha = 0.5f),
contentColor = if (enabled) contentColor else contentColor.copy(alpha = 0.5f),
),
) {
AnimatedContent(
@ -1108,73 +1036,90 @@ private fun SaveSongButton(
}
@Composable
private fun StationCard(
private fun StationHero(
station: Station,
bufferState: PlaybackManager.BufferState,
isPlaying: Boolean,
) {
// Big-artwork hero: the accent gradient is the "album art" for a live
// station, with the buffer state as a status chip and the equalizer as
// the playing signature. All colors come from the active accent tokens.
val tokens = LocalTokens.current
androidx.compose.material3.Card(
modifier = Modifier.fillMaxWidth(),
colors = androidx.compose.material3.CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
containerColor = Color.Transparent,
),
shape = RoundedCornerShape(16.dp),
shape = RoundedCornerShape(SatsAmpGlass.rCard),
) {
Row(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
.aspectRatio(16f / 10f)
.background(SatsAmpGradients.accentBrush()),
) {
Box(
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = tokens.accentOnFill.copy(alpha = 0.30f),
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
.size(128.dp)
.align(Alignment.Center),
)
val statusLabel = when (bufferState) {
PlaybackManager.BufferState.INITIALIZING,
PlaybackManager.BufferState.BUFFERING -> "Buffering…"
PlaybackManager.BufferState.READY -> "Ready"
PlaybackManager.BufferState.ERROR -> "Error"
PlaybackManager.BufferState.IDLE -> null
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
statusLabel?.let { label ->
androidx.compose.material3.Surface(
modifier = Modifier
.align(Alignment.TopStart)
.padding(12.dp),
color = tokens.textSafe,
shape = RoundedCornerShape(SatsAmpGlass.rInput),
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
color = tokens.text,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
)
}
}
Column(
modifier = Modifier
.align(Alignment.BottomStart)
.padding(16.dp),
) {
if (isPlaying) {
EqualizerBars(
active = true,
color = tokens.accentOnFill,
modifier = Modifier.padding(bottom = 6.dp),
)
}
Text(
text = station.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = tokens.accentOnFill,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (station.genre.isNotEmpty()) {
Text(
text = station.genre,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium,
color = tokens.accentOnFill.copy(alpha = 0.85f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
BufferIndicator(bufferState = bufferState)
}
}
}
@Composable
private fun BufferIndicator(bufferState: PlaybackManager.BufferState) {
val (text, color) = when (bufferState) {
PlaybackManager.BufferState.IDLE -> "" to MaterialTheme.colorScheme.onSurfaceVariant
PlaybackManager.BufferState.INITIALIZING -> "Init" to PremiumCyan
PlaybackManager.BufferState.BUFFERING -> "Buffering" to PremiumCyan
PlaybackManager.BufferState.READY -> "Ready" to MaterialTheme.colorScheme.primary
PlaybackManager.BufferState.ERROR -> "Error" to MaterialTheme.colorScheme.error
}
if (text.isNotEmpty()) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = color,
modifier = Modifier.padding(start = 8.dp),
)
}
}

View file

@ -107,10 +107,14 @@ class PlayerViewModel @Inject constructor(
)
}
if (state == PlaybackManager.SaveState.SUCCESS && result?.songId != null) {
val meta = playbackManager.lastStreamMetaFlow.value
val title = meta?.displayTitle?.takeIf { it.isNotEmpty() }
?: result.fileName?.substringBeforeLast(".mp3").orEmpty()
_uiState.update {
it.copy(
showPlaylistPicker = true,
pickedSongId = result.songId,
pickedSongTitle = title,
)
}
}
@ -131,7 +135,7 @@ class PlayerViewModel @Inject constructor(
}
}
init {
init {
playbackManager.listener = playbackListener
_uiState.update {
it.copy(
@ -143,6 +147,16 @@ class PlayerViewModel @Inject constructor(
localTitle = playbackManager.localMediaFlow.value?.first.orEmpty(),
localArtist = playbackManager.localMediaFlow.value?.second.orEmpty(),
isPlaying = playbackManager.isPlaying,
// Seed live recorder state: no transition event fires for a
// state that is already READY, so a fresh screen must read it.
bufferState = playbackManager.bufferState,
saveState = playbackManager.saveState,
// A save that finished while this screen was away (event went
// to a detached listener) would otherwise lose its playlist
// picker: surface it now instead.
showPlaylistPicker = pendingPicker() != null,
pickedSongId = pendingPicker()?.songId,
pickedSongTitle = pendingPicker()?.title.orEmpty(),
)
}
restoreCachedStreamMeta()
@ -284,25 +298,42 @@ class PlayerViewModel @Inject constructor(
}
fun dismissPlaylistPicker() {
val pendingId = _uiState.value.pickedSongId
val title = _uiState.value.pickedSongTitle
_uiState.update {
it.copy(
showPlaylistPicker = false,
pickedSongId = null,
pickedSongTitle = "",
newPlaylistName = "",
)
}
// Dismissed without adding: the song IS saved, so say so — otherwise
// a successful save leaves no trace at all.
if (pendingId != null) {
_uiState.update {
it.copy(
playlistPickerMessage = if (title.isNotBlank()) "Saved “$title” to Library"
else "Saved to Library",
)
}
}
}
fun addToPlaylist(playlistId: Long) {
/** Adds the picked song to one or more playlists, then closes the picker. */
fun addToPlaylists(playlistIds: List<Long>) {
val songId = _uiState.value.pickedSongId ?: return
if (playlistIds.isEmpty()) return
viewModelScope.launch {
playlistRepository.addSongToPlaylist(playlistId, songId)
playlistIds.forEach { playlistRepository.addSongToPlaylist(it, songId) }
_uiState.update {
it.copy(
showPlaylistPicker = false,
pickedSongId = null,
pickedSongTitle = "",
newPlaylistName = "",
playlistPickerMessage = "Added to playlist",
playlistPickerMessage = if (playlistIds.size == 1) "Added to playlist"
else "Added to ${playlistIds.size} playlists",
)
}
}
@ -317,6 +348,7 @@ class PlayerViewModel @Inject constructor(
it.copy(
showPlaylistPicker = false,
pickedSongId = null,
pickedSongTitle = "",
newPlaylistName = "",
playlistPickerMessage = "Created \"$name\" and added song",
)
@ -349,14 +381,41 @@ class PlayerViewModel @Inject constructor(
isPlaying = playbackManager.isPlaying,
artist = if (isLocal) local?.second.orEmpty() else playbackManager.currentStationName,
title = if (isLocal) local?.first.orEmpty() else it.title,
// Same seeding as init: returning to this screen must reflect
// the recorder's current state, not IDLE — including a fresh
// save whose picker event went to a detached listener.
bufferState = playbackManager.bufferState,
saveState = playbackManager.saveState,
showPlaylistPicker = pendingPicker() != null || it.showPlaylistPicker,
pickedSongId = pendingPicker()?.songId ?: it.pickedSongId,
pickedSongTitle = pendingPicker()?.title ?: it.pickedSongTitle,
)
}
if (!isLocal) restoreCachedStreamMeta()
}
/** A save awaiting its playlist picker, if the manager holds one. */
private fun pendingPicker(): PendingPicker? {
val result = playbackManager.lastSaveResult ?: return null
if (playbackManager.saveState != PlaybackManager.SaveState.SUCCESS) return null
val id = result.songId ?: return null
val title = playbackManager.lastStreamMetaFlow.value?.displayTitle?.takeIf { it.isNotEmpty() }
?: result.fileName?.substringBeforeLast(".mp3").orEmpty()
return PendingPicker(id, title)
}
private data class PendingPicker(val songId: Long, val title: String)
override fun onCleared() {
super.onCleared()
playbackManager.listener = null
// Only detach our own listener: during mini-player navigation the new
// screen attaches first and the old entry is cleared after — a blind
// null here killed the NEW screen's events, leaving its buttons
// (save spinner/check/dialog, pause icon) permanently dead while the
// manager kept working headlessly.
if (playbackManager.listener === playbackListener) {
playbackManager.listener = null
}
}
}
@ -382,6 +441,7 @@ data class PlayerUiState(
val shuffleEnabled: Boolean = false,
val showPlaylistPicker: Boolean = false,
val pickedSongId: Long? = null,
val pickedSongTitle: String = "",
val newPlaylistName: String = "",
val playlistPickerMessage: String? = null,
)

View file

@ -2,12 +2,12 @@ package com.rada.ui.stations
import androidx.compose.foundation.BorderStroke
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.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
@ -15,36 +15,49 @@ 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.lazy.rememberLazyListState
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.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.StarBorder
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
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.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
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
@ -61,24 +74,10 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.Station
import com.rada.ui.components.AppHomeButton
import com.rada.ui.components.EqualizerBars
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.LocalTokens
private val genreColors = mapOf(
"Lofi" to 0xFF7C3AED,
"Synthwave" to 0xFFEC4899,
"EDM" to 0xFF06B6D4,
"Phonk" to 0xFFEF4444,
"Classical" to 0xFFF59E0B,
"Strings" to 0xFF10B981,
"Ambient" to 0xFF3B82F6,
"Electronic" to 0xFF06B6D4,
"Jazz" to 0xFFF59E0B,
"Eclectic" to 0xFFEC4899,
"Oldies" to 0xFFF59E0B,
"Rock" to 0xFFEF4444,
"Hip-Hop" to 0xFFEC4899,
"Custom" to 0xFF7C3AED,
)
import com.rada.ui.theme.SatsAmpGlass
import com.rada.ui.theme.SatsAmpGradients
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -93,8 +92,46 @@ fun StationBrowserScreen(
val favorites by viewModel.favorites.collectAsState()
val directoryState by viewModel.directoryState.collectAsState()
val playingUrl by viewModel.playingUrl.collectAsState()
val hiddenIds by viewModel.hiddenIds.collectAsState()
val notice by viewModel.notice.collectAsState()
val wavlakeVersion by viewModel.wavlakeVersion.collectAsState()
val wavlakeArtists by viewModel.wavlakeArtists.collectAsState()
// Entry-scoped Wavlake VM (powers the artist dialog; independent of Discover).
val wavlakeVm: com.rada.ui.discover.WavlakeViewModel = hiltViewModel()
var wavlakeArtistDialog by remember {
mutableStateOf<com.rada.domain.model.WavlakeArtist?>(null)
}
val snackbarHostState = remember { SnackbarHostState() }
var showAddDialog by remember { mutableStateOf(false) }
var renameTarget by remember { mutableStateOf<Station?>(null) }
val listState = rememberLazyListState()
// Starring moves the station to the pinned top section: bring it into
// view instead of leaving the list scrolled where it was. Only on
// additions — unstarring keeps your scroll position. Skipped on first
// composition (nothing actually changed).
val reduceMotion = LocalReduceMotion.current
var lastFavCount by remember { mutableStateOf<Int?>(null) }
LaunchedEffect(favorites.size) {
val prev = lastFavCount
lastFavCount = favorites.size
if (prev != null && favorites.size > prev) {
if (reduceMotion) listState.scrollToItem(0)
else listState.animateScrollToItem(0)
}
}
LaunchedEffect(notice) {
notice?.let { message ->
val result = snackbarHostState.showSnackbar(
message = message,
actionLabel = "Undo",
withDismissAction = true,
)
if (result == SnackbarResult.ActionPerformed) viewModel.undoDelete()
viewModel.consumeNotice()
}
}
if (showAddDialog) {
AddStationDialog(
@ -146,6 +183,7 @@ fun StationBrowserScreen(
)
},
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
@ -174,17 +212,93 @@ fun StationBrowserScreen(
shape = RoundedCornerShape(12.dp),
)
// ── Browse chips: Apple-style genre filter above the list ──
val genres = stationsByGenre.keys.toList()
var activeGenre by remember { mutableStateOf<String?>(null) }
val effectiveGenre = activeGenre?.takeIf { it in genres }
val visibleGenres = if (searchQuery.isBlank() && effectiveGenre != null) {
stationsByGenre.filterKeys { it == effectiveGenre }
} else {
stationsByGenre
}
if (searchQuery.isBlank() && genres.size > 1) {
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") {
FilterChip(
selected = effectiveGenre == null,
onClick = { activeGenre = null },
label = { Text("All") },
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 = effectiveGenre == null,
),
)
}
items(genres, key = { "chip-$it" }) { genre ->
FilterChip(
selected = effectiveGenre == genre,
onClick = {
activeGenre = if (effectiveGenre == genre) null else genre
},
label = { Text(genre) },
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 = effectiveGenre == genre,
),
)
}
}
}
// ── Now-playing hero: jump back into the Player ──
val nowPlayingStation = remember(stationsByGenre, favorites, playingUrl) {
playingUrl?.let { url ->
(favorites + stationsByGenre.values.flatten()).firstOrNull { it.url == url }
}
}
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
if (searchQuery.isBlank() && nowPlayingStation != null) {
item(key = "now-playing") {
NowPlayingHero(
station = nowPlayingStation,
onOpenPlayer = onHome,
)
}
}
// ── Favorites row: pinned above everything ──
if (favorites.isNotEmpty()) {
item { SectionHeader("Starred", Icons.Default.Star) }
items(favorites, key = { "fav-${it.id}" }) { station ->
items(favorites, key = { "fav-${it.id}" }, contentType = { "station" }) { station ->
StationCard(
station = station,
isCustom = viewModel.isCustomStation(station.id),
isFavorite = true,
isPlaying = playingUrl == station.url,
onClick = {
@ -193,20 +307,21 @@ fun StationBrowserScreen(
},
onToggleFavorite = { viewModel.toggleFavorite(station) },
onRename = { renameTarget = station },
onDelete = { viewModel.removeCustomStation(station.id) },
onDelete = { viewModel.removeStation(station.id) },
wavlakeEnabled = viewModel.isWavlakeFeaturing(station.id).also { wavlakeVersion },
onToggleWavlake = { viewModel.toggleWavlakeFeaturing(station.id) },
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
stationsByGenre.forEach { (genre, stations) ->
item {
visibleGenres.forEach { (genre, stations) ->
item(key = "genre-$genre") {
GenreHeader(genre = genre, count = stations.size)
}
items(stations, key = { it.id }) { station ->
items(stations, key = { it.id }, contentType = { "station" }) { station ->
StationCard(
station = station,
isCustom = viewModel.isCustomStation(station.id),
isFavorite = viewModel.isFavorite(station.id),
isPlaying = playingUrl == station.url,
onClick = {
@ -216,8 +331,10 @@ fun StationBrowserScreen(
onToggleFavorite = { viewModel.toggleFavorite(station) },
onRename = { renameTarget = station },
onDelete = {
viewModel.removeCustomStation(station.id)
viewModel.removeStation(station.id)
},
wavlakeEnabled = viewModel.isWavlakeFeaturing(station.id).also { wavlakeVersion },
onToggleWavlake = { viewModel.toggleWavlakeFeaturing(station.id) },
)
}
item {
@ -225,6 +342,39 @@ fun StationBrowserScreen(
}
}
// ── Restore point for deleted built-ins ──
if (searchQuery.isBlank() && hiddenIds.isNotEmpty()) {
item(key = "restore-hidden") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.Center,
) {
TextButton(onClick = { viewModel.restoreHiddenStations() }) {
Text(
if (hiddenIds.size == 1) "Restore 1 hidden station"
else "Restore ${hiddenIds.size} hidden stations",
)
}
}
}
}
// ── Empty filter state: no built-in matches ──
if (searchQuery.isNotBlank() && stationsByGenre.isEmpty() &&
directoryState is DirectoryState.Idle
) {
item(key = "no-match") {
Text(
text = "No stations match “$searchQuery”.",
style = MaterialTheme.typography.bodyMedium,
color = LocalTokens.current.textSecondary,
modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp),
)
}
}
// ── Directory results (radio-browser.info) ──
when (val ds = directoryState) {
DirectoryState.Idle -> Unit
@ -259,10 +409,9 @@ fun StationBrowserScreen(
is DirectoryState.Results -> {
if (ds.stations.isNotEmpty()) {
item { DirectorySectionHeader("From the directory") }
items(ds.stations, key = { "rb-${it.id}" }) { station ->
items(ds.stations, key = { "rb-${it.id}" }, contentType = { "station" }) { station ->
StationCard(
station = station,
isCustom = false,
isFavorite = viewModel.isFavorite(station.id),
isPlaying = playingUrl == station.url,
onClick = {
@ -289,9 +438,110 @@ fun StationBrowserScreen(
}
}
}
}
// ── Artists on Wavlake (same query, independent backend) ──
val waArtists = wavlakeArtists
if (waArtists is WavlakeArtistSearch.Results) {
item(key = "wl-header") { DirectorySectionHeader("Artists on Wavlake") }
items(
waArtists.artists,
key = { "wl-${it.id}" },
contentType = { "artist" },
) { artist ->
com.rada.ui.discover.ArtistRow(
name = artist.name,
genre = artist.genre,
imageUrl = artist.imageUrl,
onClick = { wavlakeArtistDialog = artist },
)
}
} else if (waArtists is WavlakeArtistSearch.Loading) {
item(key = "wl-loading") {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = LocalTokens.current.accentText,
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = "Searching Wavlake artists…",
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
)
}
}
}
}
}
}
wavlakeArtistDialog?.let { artist ->
WavlakeArtistDialog(
artist = artist,
viewModel = wavlakeVm,
onDismiss = {
wavlakeVm.closeArtist()
wavlakeArtistDialog = null
},
)
}
}
@Composable
private fun WavlakeArtistDialog(
artist: com.rada.domain.model.WavlakeArtist,
viewModel: com.rada.ui.discover.WavlakeViewModel,
onDismiss: () -> Unit,
) {
androidx.compose.runtime.LaunchedEffect(artist.id) {
viewModel.openArtist(artist.id, artist.name)
}
androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) {
Card(
colors = CardDefaults.cardColors(containerColor = LocalTokens.current.panel),
shape = RoundedCornerShape(SatsAmpGlass.rCard),
) {
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.85f),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 8.dp, top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = artist.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.text,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
IconButton(onClick = onDismiss, modifier = Modifier.size(48.dp)) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = LocalTokens.current.textSecondary,
)
}
}
com.rada.ui.discover.ArtistDetailView(
artistId = artist.id,
modifier = Modifier.weight(1f),
viewModel = viewModel,
)
}
}
}
}
@ -431,11 +681,75 @@ private fun RenameStationDialog(
}
@Composable
private fun GenreHeader(genre: String, count: Int) {
val colorInt = genreColors.entries.find { (key, _) ->
genre.contains(key, ignoreCase = true)
}?.value ?: 0xFF7C3AED
private fun NowPlayingHero(
station: Station,
onOpenPlayer: () -> Unit,
) {
val tokens = LocalTokens.current
Card(
onClick = onOpenPlayer,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
colors = CardDefaults.cardColors(containerColor = Color.Transparent),
shape = RoundedCornerShape(SatsAmpGlass.rCard),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.background(SatsAmpGradients.accentBrush())
.padding(14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(tokens.accentOnFill.copy(alpha = 0.22f)),
contentAlignment = Alignment.Center,
) {
EqualizerBars(active = true, color = tokens.accentOnFill)
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Now Playing",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = tokens.accentOnFill.copy(alpha = 0.85f),
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = station.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = tokens.accentOnFill,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (station.genre.isNotEmpty()) {
Text(
text = station.genre,
style = MaterialTheme.typography.bodySmall,
color = tokens.accentOnFill.copy(alpha = 0.85f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Icon(
Icons.AutoMirrored.Filled.ArrowForward,
contentDescription = "Open player",
tint = tokens.accentOnFill.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp),
)
}
}
}
@Composable
private fun GenreHeader(genre: String, count: Int) {
val tokens = LocalTokens.current
Row(
modifier = Modifier
.fillMaxWidth()
@ -446,21 +760,21 @@ private fun GenreHeader(genre: String, count: Int) {
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(androidx.compose.ui.graphics.Color(colorInt)),
.background(tokens.accentFill),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = genre.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = androidx.compose.ui.graphics.Color(colorInt),
color = tokens.accentText,
letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$count",
style = MaterialTheme.typography.labelMedium,
color = LocalTokens.current.textSecondary,
color = tokens.textSecondary,
)
}
}
@ -468,7 +782,6 @@ private fun GenreHeader(genre: String, count: Int) {
@Composable
private fun StationCard(
station: Station,
isCustom: Boolean,
isFavorite: Boolean,
isPlaying: Boolean,
onClick: () -> Unit,
@ -476,42 +789,47 @@ private fun StationCard(
onRename: () -> Unit,
onDelete: () -> Unit,
onAdd: (() -> Unit)? = null,
wavlakeEnabled: Boolean = false,
onToggleWavlake: (() -> Unit)? = null,
) {
val tokens = LocalTokens.current
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
.padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(
containerColor = if (isPlaying) tokens.accentSoft else tokens.glassFillStrong,
),
border = if (isPlaying) {
BorderStroke(1.dp, tokens.accentFill.copy(alpha = 0.6f))
} else null,
shape = RoundedCornerShape(12.dp),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
.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)),
.background(
if (isPlaying) tokens.accentFill
else tokens.accentFill.copy(alpha = 0.15f),
),
contentAlignment = Alignment.Center,
) {
if (isPlaying) {
EqualizerBars(active = true, color = tokens.accentText)
EqualizerBars(active = true, color = tokens.accentOnFill)
} else {
Icon(
Icons.Default.Radio,
Icons.Default.PlayArrow,
contentDescription = null,
tint = tokens.accentText,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(22.dp),
)
}
}
@ -536,42 +854,85 @@ private fun StationCard(
)
}
}
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(40.dp)) {
IconButton(
onClick = onToggleFavorite,
modifier = Modifier.size(48.dp),
) {
Icon(
imageVector = if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = if (isFavorite) "Unstar" else "Star",
contentDescription = if (isFavorite) "Unstar ${station.name}" else "Star ${station.name}",
tint = if (isFavorite) tokens.accentFill else tokens.textSecondary,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(22.dp),
)
}
// Directory stations: offer saving into the local list.
if (onAdd != null) {
IconButton(onClick = onAdd, modifier = Modifier.size(40.dp)) {
IconButton(
onClick = onAdd,
modifier = Modifier.size(48.dp),
) {
Icon(
Icons.Default.Add,
contentDescription = "Save to stations",
contentDescription = "Save ${station.name} to stations",
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(22.dp),
)
}
} else {
IconButton(onClick = onRename, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
)
}
if (isCustom) {
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
var showMenu by remember { mutableStateOf(false) }
Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier.size(48.dp),
) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
Icons.Default.MoreVert,
contentDescription = "More actions for ${station.name}",
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
modifier = Modifier.size(22.dp),
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
) {
DropdownMenuItem(
text = { Text("Rename") },
leadingIcon = {
Icon(Icons.Default.Edit, contentDescription = null)
},
onClick = {
showMenu = false
onRename()
},
)
DropdownMenuItem(
text = { Text("Delete") },
leadingIcon = {
Icon(Icons.Default.Delete, contentDescription = null)
},
onClick = {
showMenu = false
onDelete()
},
)
if (onToggleWavlake != null) {
DropdownMenuItem(
text = { Text("Wavlake picks") },
leadingIcon = {
Icon(
if (wavlakeEnabled) Icons.Default.Check
else Icons.Default.Add,
contentDescription = null,
)
},
onClick = {
showMenu = false
onToggleWavlake()
},
)
}
}
}
}
}

View file

@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.repository.RadioBrowserRepository
import com.rada.data.repository.StationRepository
import com.rada.data.wavlake.WavlakeRepository
import com.rada.data.wavlake.WavlakeResult
import com.rada.data.wavlake.WavlakeSearchHit
import com.rada.domain.model.Station
import com.rada.player.PlaybackManager
import dagger.hilt.android.lifecycle.HiltViewModel
@ -20,6 +23,13 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
/** Wavlake artist search state (Stations search also finds artists). */
sealed interface WavlakeArtistSearch {
data object Idle : WavlakeArtistSearch
data object Loading : WavlakeArtistSearch
data class Results(val artists: List<com.rada.domain.model.WavlakeArtist>) : WavlakeArtistSearch
data class Error(val message: String) : WavlakeArtistSearch
}
/** Directory (radio-browser.info) search state. */
sealed interface DirectoryState {
data object Idle : DirectoryState
@ -34,6 +44,7 @@ class StationBrowserViewModel @Inject constructor(
private val stationRepository: StationRepository,
private val radioBrowserRepository: RadioBrowserRepository,
private val playbackManager: PlaybackManager,
private val wavlakeRepository: WavlakeRepository,
) : ViewModel() {
private val _allStations = MutableStateFlow(stationRepository.getAll())
@ -43,36 +54,46 @@ class StationBrowserViewModel @Inject constructor(
val favorites: StateFlow<List<Station>> = stationRepository.favorites
val hiddenIds: StateFlow<Set<String>> = stationRepository.hiddenIds
/** One-shot delete notice, consumed by the Undo snackbar. */
private val _notice = MutableStateFlow<String?>(null)
val notice: StateFlow<String?> = _notice.asStateFlow()
private var lastDeleted: Station? = null
/** URL of whatever is playing now, so the list can mark the active row. */
val playingUrl: StateFlow<String?> = playbackManager.currentStationUrlFlow
val stations: StateFlow<List<Station>> = combine(_allStations, _searchQuery) { list, query ->
val filtered = if (query.isBlank()) list
else list.filter {
it.name.contains(query, ignoreCase = true) ||
it.genre.contains(query, ignoreCase = true) ||
it.url.contains(query, ignoreCase = true)
}
filtered.sortedBy { it.name.lowercase() }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), stationRepository.getAll())
val stationsByGenre: StateFlow<Map<String, List<Station>>> = combine(_allStations, _searchQuery) { list, query ->
val filtered = if (query.isBlank()) list
else list.filter {
it.name.contains(query, ignoreCase = true) ||
it.genre.contains(query, ignoreCase = true) ||
it.url.contains(query, ignoreCase = true)
}
filtered.sortedBy { it.name.lowercase() }
.groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } }
.toSortedMap(compareBy { it.lowercase() })
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
val stationsByGenre: StateFlow<Map<String, List<Station>>> =
combine(_allStations, _searchQuery, favorites) { list, query, favs ->
// Starred stations move to the pinned top section, so they leave
// their genre group instead of appearing twice.
val favIds = favs.map { it.id }.toSet()
val base = if (query.isBlank()) {
list
} else {
list.filter {
it.name.contains(query, ignoreCase = true) ||
it.genre.contains(query, ignoreCase = true) ||
it.url.contains(query, ignoreCase = true)
}
}
// Curated order: DefaultStations order is the display order (not
// alphabetical), and groups appear in first-seen order so the
// playlist reads Lofi → … → NCS Chill, Media Monarchy first.
base.filter { it.id !in favIds }
.groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } }
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
// ── Directory search (radio-browser.info, ~58k stations) ───────────
private val _directoryState = MutableStateFlow<DirectoryState>(DirectoryState.Idle)
val directoryState: StateFlow<DirectoryState> = _directoryState.asStateFlow()
private val _wavlakeArtists =
MutableStateFlow<WavlakeArtistSearch>(WavlakeArtistSearch.Idle)
val wavlakeArtists: StateFlow<WavlakeArtistSearch> = _wavlakeArtists.asStateFlow()
init {
// Debounced: search the wider directory only once typing settles.
_searchQuery
@ -93,6 +114,37 @@ class StationBrowserViewModel @Inject constructor(
}
}
.launchIn(viewModelScope)
// The same query also searches Wavlake artists on its own debounce,
// so one slow backend never blocks the other. Misses stay silent
// (Idle) — stations remain the primary results.
_searchQuery
.debounce(550)
.distinctUntilChanged()
.onEach { query ->
val q = query.trim()
if (q.length < 2) {
_wavlakeArtists.value = WavlakeArtistSearch.Idle
return@onEach
}
_wavlakeArtists.value = WavlakeArtistSearch.Loading
_wavlakeArtists.value = try {
when (val r = wavlakeRepository.search(q)) {
is WavlakeResult.Ok -> {
val artists = r.value
.filterIsInstance<WavlakeSearchHit.Artist>()
.map { it.artist }
.distinctBy { it.id }
if (artists.isEmpty()) WavlakeArtistSearch.Idle
else WavlakeArtistSearch.Results(artists)
}
is WavlakeResult.Failed -> WavlakeArtistSearch.Idle
}
} catch (_: Exception) {
WavlakeArtistSearch.Idle
}
}
.launchIn(viewModelScope)
}
fun search(query: String) {
@ -132,9 +184,32 @@ class StationBrowserViewModel @Inject constructor(
_allStations.value = stationRepository.getAll()
}
fun removeCustomStation(id: String) {
/** Deletes any station (built-ins are hidden, customs removed) with Undo. */
fun removeStation(id: String) {
val station = stationRepository.getById(id) ?: return
lastDeleted = station
stationRepository.removeStation(id)
_allStations.value = stationRepository.getAll()
_notice.value = "Deleted “${station.name}”"
}
fun undoDelete() {
val station = lastDeleted ?: return
lastDeleted = null
stationRepository.unhideStation(station.id)
if (stationRepository.getById(station.id) == null) {
stationRepository.addStation(station)
}
_allStations.value = stationRepository.getAll()
}
fun consumeNotice() {
_notice.value = null
}
fun restoreHiddenStations() {
stationRepository.restoreHiddenStations()
_allStations.value = stationRepository.getAll()
}
fun renameStation(id: String, newName: String) {
@ -155,4 +230,15 @@ class StationBrowserViewModel @Inject constructor(
fun isCustomStation(id: String): Boolean {
return stationRepository.isCustomStation(id)
}
/** Bumps to refresh per-station Wavlake featuring toggles in the UI. */
private val _wavlakeVersion = MutableStateFlow(0)
val wavlakeVersion: StateFlow<Int> = _wavlakeVersion.asStateFlow()
fun isWavlakeFeaturing(id: String): Boolean = wavlakeRepository.isStationEnabled(id)
fun toggleWavlakeFeaturing(id: String) {
wavlakeRepository.setStationEnabled(id, !wavlakeRepository.isStationEnabled(id))
_wavlakeVersion.value += 1
}
}

View file

@ -1,7 +1,6 @@
package com.rada.ui.streams
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
@ -212,7 +211,7 @@ fun StreamsScreen(
item(key = "header-$group") {
GroupHeader(group = group, count = entries.size)
}
items(entries, key = { it.id }) { entry ->
items(entries, key = { it.id }, contentType = { "stream" }) { entry ->
StreamCard(
entry = entry,
isPlaying = currentStreamUrl == entry.url,
@ -394,10 +393,10 @@ private fun StreamCard(
onDelete: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
.padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(
containerColor = if (isPlaying) LocalTokens.current.accentFill.copy(alpha = 0.12f) else LocalTokens.current.glassFillStrong,
),
@ -450,7 +449,7 @@ private fun StreamCard(
IconButton(onClick = onRename) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename",
contentDescription = "Rename ${entry.name}",
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)
@ -458,7 +457,7 @@ private fun StreamCard(
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove",
contentDescription = "Remove ${entry.name}",
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)

View file

@ -147,7 +147,7 @@ fun AppearanceDialog(
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (onOpenDecentralized != null) {
TextButton(onClick = onOpenDecentralized) {
Text("⚡ Decentralized", color = tokens.accentText)
Text("⚡ Nostr", color = tokens.accentText)
}
}
TextButton(onClick = onDismiss) {

View file

@ -0,0 +1,164 @@
package com.rada.data.wavlake
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import kotlin.random.Random
class WavlakeLogicTest {
private fun track(
id: String,
artistId: String = "a1",
audio: String? = "https://cdn.example/t.mp3",
) = com.rada.domain.model.WavlakeTrack(
id = id, title = "T $id", artistId = artistId,
artistName = "A $artistId", audioUrl = audio,
)
// ── Normalization ────────────────────────────────────────────────────
@Test
fun `track without id or title is dropped`() {
assertNull(normalizeWavlakeTrack(TrackDto(id = "", title = "x")))
assertNull(normalizeWavlakeTrack(TrackDto(id = "1", title = " ")))
assertNull(normalizeWavlakeTrack(TrackDto()))
}
@Test
fun `track tolerates missing artwork duration genre`() {
val t = normalizeWavlakeTrack(TrackDto(id = "1", title = "Song"))!!
assertEquals("Unknown Artist", t.artistName)
assertNull(t.artworkUrl)
assertNull(t.durationMs)
assertNull(t.genre)
assertNull(t.audioUrl)
assertEquals("wavlake", t.source)
}
@Test
fun `track maps all swagger fields`() {
val t = normalizeWavlakeTrack(
TrackDto(
id = "t1", title = "S", artist = "A", artistId = "a",
albumArtUrl = "https://img/a.png", mediaUrl = "https://cdn/t.mp3",
duration = 180, genre = "Dance/EDM", url = "https://wavlake.com/t",
artistNpub = "npub1xyz",
),
)!!
assertEquals(180_000L, t.durationMs)
assertEquals(listOf("Dance/EDM"), t.genres)
assertEquals("https://cdn/t.mp3", t.audioUrl)
assertEquals("npub1xyz", t.artistNpubHex)
}
@Test
fun `artist without id is dropped, blank name defaulted`() {
assertNull(normalizeWavlakeArtist(ArtistDto()))
assertEquals("Unknown Artist", normalizeWavlakeArtist(ArtistDto(id = "a"))!!.name)
}
// ── Genre matching ───────────────────────────────────────────────────
@Test
fun `station genre split convention`() {
val (primary, secondary) = WavlakeGenreMatcher.splitStationGenres("Lofi / Hip-Hop")
assertEquals("Lofi", primary)
assertEquals(listOf("Hip-Hop"), secondary)
}
@Test
fun `lofi maps to related exact names`() {
val genres = WavlakeGenreMatcher.compatibleGenres("Lofi", emptyList())
assertTrue(genres.contains("Downtempo"))
assertTrue(genres.none { it == "Lofi" }) // never invent names
}
@Test
fun `edm keeps exact Dance-EDM plus subs`() {
val genres = WavlakeGenreMatcher.compatibleGenres("EDM", emptyList())
assertTrue(genres.contains("Dance/EDM"))
assertTrue(genres.contains("House"))
}
@Test
fun `talk matches nothing`() {
assertTrue(WavlakeGenreMatcher.compatibleGenres("Talk", emptyList()).isEmpty())
}
@Test
fun `exact mode only`() {
val genres = WavlakeGenreMatcher.compatibleGenres("Pop", emptyList(), MatchingMode.EXACT)
assertEquals(listOf("Pop"), genres)
assertTrue(
WavlakeGenreMatcher.compatibleGenres("Lofi", emptyList(), MatchingMode.EXACT).isEmpty(),
)
}
// ── Selector ─────────────────────────────────────────────────────────
private val settings = defaultWavlakeStationSettings.copy(
featureProbability = 1.0,
maxFeaturedTracksPerHour = 3,
maxFeaturedTracksPerQueue = 1,
)
@Test
fun `disabled or capped selects nothing`() {
val pool = listOf(track("t1"))
assertTrue(
FeatureSelector.select(
FeatureSelection(pool, settings.copy(enabled = false), random = Random(1)),
).isEmpty(),
)
assertTrue(
FeatureSelector.select(
FeatureSelection(pool, settings, featuredThisHour = 3, random = Random(1)),
).isEmpty(),
)
assertTrue(
FeatureSelector.select(
FeatureSelection(pool, settings.copy(featureProbability = 0.0), random = Random(1)),
).isEmpty(),
)
}
@Test
fun `dedupes blocked and unplayable`() {
val pool = listOf(
track("t1", artistId = "blocked"),
track("t2", audio = " "),
track("t3"),
track("t3"),
)
val picked = FeatureSelector.select(
FeatureSelection(
pool, settings,
excludedTrackIds = setOf("t3"),
blockedArtistIds = setOf("blocked"),
random = Random(1),
),
)
assertTrue(picked.isEmpty())
}
@Test
fun `fresh artists win over recent, cap respected`() {
val pool = listOf(
track("old", artistId = "a1"),
track("new1", artistId = "a2"),
track("new2", artistId = "a3"),
)
val picked = FeatureSelector.select(
FeatureSelection(
pool,
settings.copy(maxFeaturedTracksPerQueue = 2),
recentArtistIds = setOf("a1"),
random = Random(7),
),
)
assertEquals(2, picked.size)
assertTrue(picked.none { it.artistId == "a1" })
}
}

View file

@ -0,0 +1,67 @@
package com.rada.data.wavlake
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.UUID
/**
* Official track-zap protocol conformance (patterns decoded from
* wavlake/mobile, MIT — values below are independently computed).
*/
class WavlakeZapTest {
@Test
fun `uuid v5 matches RFC vectors`() {
// Well-known: v5(DNS, www.example.com).
assertEquals(
"2ed6657d-e927-568b-95e1-2665a8aea6a2",
WavlakeZap.uuidV5(UUID.fromString("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), "www.example.com").toString(),
)
}
@Test
fun `uuid v5 feed guid matches workstation python`() {
// Cross-checked with Python's uuid.uuid5.
assertEquals(
"3cd075e1-b31f-57f4-a37a-da0996e20ae1",
WavlakeZap.uuidV5(
WavlakeZap.FEED_NAMESPACE,
"wavlake.com/feed/music/7acbd8ef-15b7-4deb-9802-8b30035b0ec7",
).toString(),
)
}
@Test
fun `zap request carries official tags`() {
val unsigned = WavlakeZap.buildZapRequest(
trackId = "81c45017-5d13-45fd-a751-c9483b579682",
albumId = "7acbd8ef-15b7-4deb-9802-8b30035b0ec7",
amountSats = 100,
comment = "test",
relays = listOf("wss://relay.damus.io"),
signerPubkeyHex = "00".repeat(32),
)
assertEquals(9734, unsigned.kind)
val tags = unsigned.tags
assertTrue(tags.contains(listOf("p", WavlakeZap.FEED_PUBKEY)))
assertTrue(tags.any { it.getOrNull(0) == "relays" && it.contains("wss://relay.wavlake.com/") })
assertTrue(tags.contains(listOf("amount", "100000")))
assertTrue(
tags.any {
it.getOrNull(0) == "a" &&
it.getOrNull(1) == "32123:${WavlakeZap.FEED_PUBKEY}:81c45017-5d13-45fd-a751-c9483b579682"
},
)
assertTrue(tags.any { it.getOrNull(0) == "timestamp" })
assertTrue(tags.count { it.getOrNull(0) == "i" } == 3)
}
@Test
fun `invoice sanity is strict mainnet-only`() {
assertTrue(WavlakeZap.isPlausibleInvoice("lnbc100n1p" + "x".repeat(30)))
assertTrue(!WavlakeZap.isPlausibleInvoice("lntb100n1p" + "x".repeat(30)))
assertTrue(!WavlakeZap.isPlausibleInvoice("not-an-invoice"))
assertTrue(!WavlakeZap.isPlausibleInvoice("lnbc short"))
}
}

View file

@ -0,0 +1,58 @@
package com.rada.payments
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Spec-case coverage for the strict zap-symbol rule: ONLY a well-formed
* Lightning Address enables [hasValidLightningAddress].
*/
class LightningAddressTest {
@Test
fun `valid address passes`() {
assertTrue(hasValidLightningAddress("artist@example.com"))
}
@Test
fun `missing null and blank fail`() {
assertFalse(hasValidLightningAddress(null))
assertFalse(hasValidLightningAddress(""))
assertFalse(hasValidLightningAddress(" "))
}
@Test
fun `wrong at-count fails`() {
assertFalse(hasValidLightningAddress("artistexample.com"))
assertFalse(hasValidLightningAddress("a@b@c.com"))
}
@Test
fun `empty user or domain fails`() {
assertFalse(hasValidLightningAddress("@example.com"))
assertFalse(hasValidLightningAddress("artist@"))
assertFalse(hasValidLightningAddress("artist@ "))
}
@Test
fun `domain without dot or with space fails`() {
assertFalse(hasValidLightningAddress("artist@localhost"))
assertFalse(hasValidLightningAddress("artist@exam ple.com"))
}
@Test
fun `lnurl and keys are not addresses`() {
assertFalse(hasValidLightningAddress("LNURL1DP68GURN8PHKCMN9W..."))
assertFalse(hasValidLightningAddress("7e7e9c42a91bfef19fa9295fda1b72e0ebc1a4c1141673e2794234d86addf4e"))
assertFalse(hasValidLightningAddress("npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg"))
}
@Test
fun `trims and normalizes`() {
assertTrue(hasValidLightningAddress(" artist@example.com "))
assertEquals("artist@example.com", normalizedLightningAddress(" artist@example.com "))
assertEquals(null, normalizedLightningAddress("not-an-address"))
}
}