Compare commits

..

No commits in common. "1c9d07251a5c8616f849af14a55eccc49b02588d" and "ee0d6b61491f0d4e927f865a78f0da47dfe44021" have entirely different histories.

61 changed files with 668 additions and 7955 deletions

View file

@ -26,7 +26,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live
## Operating Context
- Single-user personal device use; headphone and speaker listening.
- Streams are live and can drop; playback is a foreground service with notification controls. Swiping the task away stops playback and clears the notification (closed means stopped); screen-off listening with the task alive keeps playing.
- Streams are live and can drop; playback is a foreground service with notification controls.
- Library is local (Room DB, `radamp.db`); saved songs, playlists, and imported files live on-device.
- Stations are built-in defaults plus user-added streams (direct URL or M3U playlist import).
@ -45,7 +45,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live
## Evidence on Hand
- 11 built-in stations (cliamp.stream music channels in playlist order: Lofi → … → NCS Chill), all verified live 2026-09-09 (HTTP 200 + audio payload; probe results were ephemeral, not committed). Media Monarchy ships as a built-in stream in the Streams tab instead (talk, not music).
- 43 built-in stations, all verified live 2026-09-07 (HTTP 200 + audio payload; probe results were ephemeral in /tmp, not committed).
- Working recorder pipeline (`recording/`: IcyStreamReader, SongBoundaryDetector, RollingAudioBuffer, MetadataEmbedder).
- Design scratch work in `design/` (glass theme CSS + demo).
- Absences that must not be fabricated: no testimonials, no user base, no press, no pricing, no privacy policy text.

View file

@ -104,15 +104,9 @@ dependencies {
// not exist on Maven Central), which made the build fail during
// dependency resolution before Kotlin was ever compiled.
implementation("net.jthink:jaudiotagger:2.2.5")
// Nostr/Lightning crypto: BIP-340 schnorr + ECDH via ACINQ's secp256k1
// Kotlin-multiplatform binding (prebuilt .so for all Android ABIs).
implementation("fr.acinq.secp256k1:secp256k1-kmp:0.17.3")
implementation("fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.17.3")
// Testing
testImplementation("junit:junit:4.13.2")
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-extract:0.17.3")
testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-linux:0.17.3")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation(composeBom)
androidTestImplementation("androidx.compose.ui:ui-test-junit4")

View file

@ -1,13 +1,2 @@
-dontwarn javax.annotation.**
-keep class com.rada.data.local.entity.** { *; }
# jaudiotagger maps tag frames via reflection; without keeps the release
# minifier breaks its writer (silent NPE, files saved untagged).
-keep class org.jaudiotagger.** { *; }
-keepattributes Signature, InnerClasses, EnclosingMethod
# jaudiotagger also references desktop-only image/test APIs that Android
# never executes (artwork scaling, test helpers).
-dontwarn org.jaudiotagger.**
-dontwarn java.awt.**
-dontwarn javax.imageio.**
-dontwarn javax.swing.**

View file

@ -8,16 +8,6 @@
<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,106 +2,376 @@ 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 = "cliamp-lofi",
name = "Lofi Stream",
url = "https://radio.cliamp.stream/lofi/stream",
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 = "24/7 lofi beats to chill and code to",
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 = "cliamp-synthwave",
name = "Synthwave Stream",
url = "https://radio.cliamp.stream/synthwave/stream",
id = "synthwave-neon-city",
name = "Synthwave Neon City",
url = "https://stream.laut.fm/synthwave",
genre = "Synthwave",
description = "Retro synthwave nights — neon drives at midnight",
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",
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 Stream",
name = "EDM Pulse",
url = "https://radio.cliamp.stream/edm/stream",
genre = "EDM",
description = "Non-stop EDM pulse",
genre = "EDM / Dance",
description = "Non-stop EDM pulse — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-radio",
name = "NCS Stream",
url = "https://radio.cliamp.stream/ncs/stream",
genre = "NCS",
description = "NoCopyrightSounds — unlimited gaming music",
id = "cliamp-synthwave",
name = "Synthwave Nights",
url = "https://radio.cliamp.stream/synthwave/stream",
genre = "Synthwave",
description = "Retro synthwave nights — neon drives at midnight, from cliamp.stream",
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",
country = "US",
),
Station(
id = "ncs-house",
name = "NCS House Stream",
name = "NCS House",
url = "https://radio.cliamp.stream/ncs-house/stream",
genre = "NCS",
description = "NoCopyrightSounds house mixes",
genre = "Electronic / House",
description = "NoCopyrightSounds house mixes — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-dubstep",
name = "NCS Dubstep Stream",
name = "NCS Dubstep",
url = "https://radio.cliamp.stream/ncs-dubstep/stream",
genre = "NCS",
description = "NoCopyrightSounds dubstep — heavy bass drops",
genre = "Electronic / Dubstep",
description = "NoCopyrightSounds dubstep — heavy bass drops, from cliamp.stream",
country = "US",
),
Station(
id = "ncs-dnb",
name = "NCS Drum & Bass Stream",
name = "NCS Drum & Bass",
url = "https://radio.cliamp.stream/ncs-dnb/stream",
genre = "NCS",
description = "NoCopyrightSounds drum & bass",
genre = "Electronic / Drum & Bass",
description = "NoCopyrightSounds drum & bass — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-trap",
name = "NCS Trap Stream",
name = "NCS Trap",
url = "https://radio.cliamp.stream/ncs-trap/stream",
genre = "NCS",
description = "NoCopyrightSounds trap beats",
genre = "Electronic / Trap",
description = "NoCopyrightSounds trap beats — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-phonk",
name = "NCS Phonk Stream",
name = "NCS Phonk",
url = "https://radio.cliamp.stream/ncs-phonk/stream",
genre = "NCS",
description = "NoCopyrightSounds phonk — drift phonk and cowbells",
genre = "Phonk",
description = "NoCopyrightSounds phonk — drift phonk and cowbells, from cliamp.stream",
country = "US",
),
Station(
id = "ncs-pop",
name = "NCS Pop Stream",
name = "NCS Pop",
url = "https://radio.cliamp.stream/ncs-pop/stream",
genre = "NCS",
description = "NoCopyrightSounds pop hits",
genre = "Electronic / Pop",
description = "NoCopyrightSounds pop hits — from cliamp.stream",
country = "US",
),
Station(
id = "ncs-chill",
name = "NCS Chill Stream",
name = "NCS Chill",
url = "https://radio.cliamp.stream/ncs-chill/stream",
genre = "NCS",
description = "NoCopyrightSounds chill mixes",
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",
country = "US",
),
)

View file

@ -14,7 +14,7 @@ import com.rada.data.local.entity.SavedSongEntity
@Database(
entities = [SavedSongEntity::class, PlaylistEntity::class, PlaylistSongEntity::class],
version = 4,
version = 2,
exportSchema = false,
)
abstract class SatsAmpDatabase : RoomDatabase() {
@ -50,27 +50,12 @@ 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, MIGRATION_2_3, MIGRATION_3_4).build()
).addMigrations(MIGRATION_1_2).build()
}
}
}

View file

@ -46,19 +46,6 @@ 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

@ -14,9 +14,6 @@ interface SavedSongDao {
@Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC")
fun getAllByDate(): Flow<List<SavedSongEntity>>
@Query("SELECT * FROM saved_songs ORDER BY id ASC")
suspend fun getAllOnce(): List<SavedSongEntity>
@Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC")
fun getAllByArtist(): Flow<List<SavedSongEntity>>

View file

@ -18,14 +18,4 @@ 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

@ -1,68 +0,0 @@
package com.rada.data.repository
import android.util.Log
import com.rada.data.local.dao.PlaylistDao
import com.rada.data.local.dao.SavedSongDao
import com.rada.data.local.entity.SavedSongEntity
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
/**
* Collapses duplicate library songs (same artist + title, case-insensitive).
* Duplicates arise mainly from save-from-air: saving the same broadcast twice
* wrote a second file ("Song (1).mp3") and a second row.
*
* Keep rule: the largest file (the most complete recording; equal sizes tie
* to the oldest entry). Dropped songs' playlist links are re-pointed to the
* kept song before their rows and files are deleted.
*/
@Singleton
class DedupeLibrary @Inject constructor(
private val savedSongDao: SavedSongDao,
private val playlistDao: PlaylistDao,
) {
data class Result(val removed: Int)
suspend fun run(): Result {
val all = savedSongDao.getAllOnce()
val groups = all.groupBy {
"${it.artist.trim().lowercase()}${it.title.trim().lowercase()}"
}
var removed = 0
for ((_, group) in groups) {
if (group.size < 2) continue
// Largest file wins; ties → oldest (smallest id).
val keeper = group.maxWithOrNull(
compareBy<SavedSongEntity> { it.fileSize }.thenBy { -it.id }
) ?: continue
for (dup in group.filter { it.id != keeper.id }) {
// Preserve group membership: re-point links, skip if the
// keeper is already in that playlist.
for (playlistId in playlistDao.getPlaylistIdsForSong(dup.id)) {
if (!playlistDao.isSongInPlaylist(playlistId, keeper.id)) {
playlistDao.addSongToPlaylist(
com.rada.data.local.entity.PlaylistSongEntity(
playlistId = playlistId,
songId = keeper.id,
),
)
}
}
savedSongDao.deleteById(dup.id)
removed++
try {
if (dup.filePath != keeper.filePath) File(dup.filePath).delete()
} catch (_: Exception) {
}
}
Log.d(TAG, "Deduped ${group.size - 1} copy(ies) of " +
"${keeper.artist} — ${keeper.title}")
}
return Result(removed)
}
companion object {
private const val TAG = "DedupeLibrary"
}
}

View file

@ -45,11 +45,6 @@ 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,10 +24,6 @@ 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()
@ -42,42 +38,10 @@ 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 {
@ -161,7 +125,7 @@ class StationRepository @Inject constructor(
private fun rebuild() {
stations.clear()
stations.addAll(DefaultStations.getAll().filter { it.id !in _hiddenIds.value })
stations.addAll(DefaultStations.getAll())
stations.addAll(loadCustomStations())
for (i in stations.indices) {
val station = stations[i]
@ -171,7 +135,8 @@ 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) }
@ -204,7 +169,8 @@ 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 {
@ -217,26 +183,9 @@ 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,34 +21,6 @@ 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, "[]") ?: "[]"
@ -89,9 +61,4 @@ 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

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

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

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

@ -1,104 +0,0 @@
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,7 +7,6 @@ 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
@ -62,8 +61,4 @@ object AppModule {
@ApplicationContext context: Context,
libraryRepository: LibraryRepository,
): PlaybackManager = PlaybackManager(context, libraryRepository)
@Provides
@Singleton
fun provideWavlakeApi(): WavlakeApi = WavlakeApi()
}

View file

@ -1,24 +0,0 @@
package com.rada.di
import com.rada.payments.NwcProvider
import com.rada.payments.PaymentService
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object PaymentsModule {
/**
* Provider priority list. NWC first (user-connected wallet); LNURL and
* Cashu providers land in later phases and just append here — no other
* call site changes.
*/
@Provides
@Singleton
fun providePaymentService(nwc: NwcProvider): PaymentService =
PaymentService(providers = { listOf(nwc) })
}

View file

@ -1,52 +0,0 @@
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,156 +0,0 @@
package com.rada.nostr
/**
* BIP-173 bech32 + NIP-19 TLV entities (npub, nsec, nprofile, nevent, naddr).
* Pure Kotlin, no external deps.
*/
object Bech32 {
private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3)
private fun polymod(values: IntArray): Int {
var chk = 1
for (v in values) {
val b = chk ushr 25
chk = ((chk and 0x1ffffff) shl 5) xor v
for (i in 0..4) if (((b ushr i) and 1) != 0) chk = chk xor GENERATOR[i]
}
return chk
}
private fun hrpExpand(hrp: String): IntArray {
val ret = IntArray(hrp.length * 2 + 1)
for (i in hrp.indices) ret[i] = hrp[i].code ushr 5
// ret[hrp.length] stays 0 (separator)
for (i in hrp.indices) ret[hrp.length + 1 + i] = hrp[i].code and 31
return ret
}
private fun verifyChecksum(hrp: String, data: IntArray): Boolean =
polymod(hrpExpand(hrp) + data) == 1
private fun createChecksum(hrp: String, data: IntArray): IntArray {
val values = hrpExpand(hrp) + data + IntArray(6)
val mod = polymod(values) xor 1
return IntArray(6) { (mod ushr (5 * (5 - it))) and 31 }
}
fun encode(hrp: String, data5bit: IntArray): String {
val combined = data5bit + createChecksum(hrp, data5bit)
return hrp + "1" + combined.joinToString("") { CHARSET[it].toString() }
}
/** Returns (hrp, data5bit) or null on invalid input. */
fun decode(bech: String): Pair<String, IntArray>? {
val lower = bech.lowercase()
val pos = lower.lastIndexOf('1')
if (pos < 1 || pos + 7 > lower.length) return null
val hrp = lower.substring(0, pos)
val data = IntArray(lower.length - pos - 1)
for (i in data.indices) {
val idx = CHARSET.indexOf(lower[pos + 1 + i])
if (idx == -1) return null
data[i] = idx
}
if (!verifyChecksum(hrp, data)) return null
return hrp to data.dropLast(6).toIntArray()
}
fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray {
var acc = 0
var bits = 0
val out = ArrayList<Int>()
val maxv = (1 shl toBits) - 1
for (b in data) {
val value = b.toInt() and 0xff
acc = (acc shl fromBits) or value
bits += fromBits
while (bits >= toBits) {
bits -= toBits
out.add((acc ushr bits) and maxv)
}
}
if (pad && bits > 0) out.add((acc shl (toBits - bits)) and maxv)
return out.toIntArray()
}
fun convertBitsBack(data5: IntArray, toBits: Int = 8): ByteArray {
var acc = 0
var bits = 0
val out = ArrayList<Byte>()
val maxv = (1 shl toBits) - 1
for (value in data5) {
acc = (acc shl 5) or value
bits += 5
if (bits >= toBits) {
bits -= toBits
out.add(((acc ushr bits) and maxv).toByte())
}
}
return out.toByteArray()
}
}
/** NIP-19 entities. */
object Nip19 {
fun npub(pubkeyHex: String): String =
Bech32.encode("npub", Bech32.convertBits(hexToBytes(pubkeyHex), 8, 5, true))
fun nsec(privkeyHex: String): String =
Bech32.encode("nsec", Bech32.convertBits(hexToBytes(privkeyHex), 8, 5, true))
/** nprofile = TLV {0: pubkey, 1: relay hints} */
fun nprofile(pubkeyHex: String, relays: List<String>): String {
val tlv = ArrayList<Byte>()
fun tag(type: Int, payload: ByteArray) {
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
}
tag(0, hexToBytes(pubkeyHex))
relays.take(3).forEach { tag(1, it.toByteArray()) }
return Bech32.encode("nprofile",
Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
}
/** nevent = TLV {1: event_id, 3: relay, [5 kind]} */
fun nevent(eventIdHex: String, relays: List<String> = emptyList(), kind: Int? = null): String {
val tlv = ArrayList<Byte>()
fun tag(type: Int, payload: ByteArray) {
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
}
tag(1, hexToBytes(eventIdHex))
relays.take(3).forEach { tag(3, it.toByteArray()) }
kind?.let { tag(5, byteArrayOf((it shr 8).toByte(), (it and 0xff).toByte())) }
return Bech32.encode("nevent", Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
}
/** naddr = TLV {0: identifier(d), 1: relays, 2: pubkey, [3: kind]} — for 3044x music. */
fun naddr(dTag: String, pubkeyHex: String, kind: Int, relays: List<String> = emptyList()): String {
val tlv = ArrayList<Byte>()
fun tag(type: Int, payload: ByteArray) {
tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList())
}
tag(0, dTag.toByteArray())
relays.take(3).forEach { tag(3, it.toByteArray()) }
tag(2, hexToBytes(pubkeyHex))
tag(3, byteArrayOf((kind shr 8).toByte(), (kind and 0xff).toByte()))
return Bech32.encode("naddr", Bech32.convertBits(tlv.toByteArray(), 8, 5, true))
}
fun decode(bech: String): Pair<String, ByteArray>? {
val (hrp, data5) = Bech32.decode(bech) ?: return null
return hrp to Bech32.convertBitsBack(data5)
}
}
fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
fun hexToBytes(hex: String): ByteArray {
val clean = hex.trim().removePrefix("0x")
require(clean.length % 2 == 0 && clean.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
"invalid hex"
}
return ByteArray(clean.length / 2) {
((Character.digit(clean[it * 2], 16) shl 4) + Character.digit(clean[it * 2 + 1], 16)).toByte()
}
}

View file

@ -1,48 +0,0 @@
package com.rada.nostr
import fr.acinq.secp256k1.Secp256k1
import java.util.Base64 as JBase64
import java.security.SecureRandom
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec
/**
* NIP-04: ECDH shared secret + AES-256-CBC for encrypted DMs and the NWC
* (NIP-47) JSON-RPC envelope. Uses the app-held key via [keys].
*/
object Nip04 {
/**
* NIP-04 shared secret = raw x-coordinate of priv*otherPub, NOT hashed.
* (ACINQ's ecdh() applies libsecp256k1's default sha256 hashfp, which is
* *not* what Nostr wants — pubKeyTweakMul gives the serialized point.)
*/
private fun ecdh(priv: ByteArray, pubkeyHex: String): ByteArray {
val compressed = byteArrayOf(0x02) + hexToBytes(pubkeyHex)
val point = Secp256k1.get().pubKeyTweakMul(compressed, priv)
return point.copyOfRange(1, 33) // x-coordinate, parity-independent
}
fun encrypt(priv: ByteArray, recipientPubkeyHex: String, plaintext: String): String {
val shared = ecdh(priv, recipientPubkeyHex)
val iv = ByteArray(16).also { SecureRandom().nextBytes(it) }
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(shared, "AES"), IvParameterSpec(iv))
val ct = cipher.doFinal(plaintext.toByteArray())
return JBase64.getEncoder().encodeToString(ct) + "?iv=" +
JBase64.getEncoder().encodeToString(iv)
}
fun decrypt(priv: ByteArray, senderPubkeyHex: String, payload: String): String? = runCatching {
val (ctB64, ivB64) = payload.split("?iv=", limit = 2)
val shared = ecdh(priv, senderPubkeyHex)
val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding")
cipher.init(
Cipher.DECRYPT_MODE,
SecretKeySpec(shared, "AES"),
IvParameterSpec(JBase64.getDecoder().decode(ivB64)),
)
String(cipher.doFinal(JBase64.getDecoder().decode(ctB64)))
}.getOrNull()
}

View file

@ -1,143 +0,0 @@
package com.rada.nostr
import fr.acinq.secp256k1.Secp256k1
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import java.security.MessageDigest
/**
* NIP-01 event. Minimal, immutable, serialization exactly per spec so the
* event id (SHA-256 of [serialized]) verifies on any relay/client.
*/
data class NostrEvent(
val id: String,
val pubkey: String,
val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
val sig: String,
) {
/** Canonical NIP-01 serialization: [0,pubkey,created_at,kind,tags,content]. */
fun serialized(): String {
val obj = buildJsonArray {
add(JsonPrimitive(0))
add(JsonPrimitive(pubkey))
add(JsonPrimitive(createdAt))
add(JsonPrimitive(kind))
add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
add(JsonPrimitive(content))
}
return CANONICAL.encodeToString(JsonElement.serializer(), obj)
}
fun toJson(): String {
val obj = buildJsonObject {
put("id", id)
put("pubkey", pubkey)
put("created_at", createdAt)
put("kind", kind)
put("tags", buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
put("content", content)
put("sig", sig)
}
return CANONICAL.encodeToString(JsonElement.serializer(), obj)
}
fun tag(name: String): String? = tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1)
fun tagValues(name: String): List<String> = tags.filter { it.firstOrNull() == name }.mapNotNull { it.getOrNull(1) }
/** Recompute id and verify schnorr sig. Never trust unverified relay data. */
fun verify(): Boolean = runCatching {
val expectedId = sha256Hex(serialized().toByteArray())
if (expectedId != id) return false
Secp256k1.get().verifySchnorr(
hexToBytes(sig),
hexToBytes(id),
hexToBytes(pubkey),
)
}.getOrDefault(false)
companion object {
internal val CANONICAL = Json { prettyPrint = false; encodeDefaults = true }
fun sha256Hex(bytes: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(bytes).toHex()
fun unsigned(
pubkey: String,
kind: Int,
content: String,
tags: List<List<String>>,
createdAt: Long = System.currentTimeMillis() / 1000,
): UnsignedEvent = UnsignedEvent(pubkey, createdAt, kind, tags, content)
fun fromJson(raw: String): NostrEvent? = runCatching {
val el = CANONICAL.parseToJsonElement(raw)
val o = el as? kotlinx.serialization.json.JsonObject ?: return null
NostrEvent(
id = o["id"]?.jsonPrimitive?.content ?: return null,
pubkey = o["pubkey"]?.jsonPrimitive?.content ?: return null,
createdAt = o["created_at"]?.jsonPrimitive?.content?.toLongOrNull() ?: return null,
kind = o["kind"]?.jsonPrimitive?.content?.toIntOrNull() ?: return null,
tags = (o["tags"] as? JsonArray)?.map { a ->
a.jsonArray.map { it.jsonPrimitive.content }
} ?: emptyList(),
content = (o["content"] as? JsonPrimitive)?.content ?: "",
sig = o["sig"]?.jsonPrimitive?.content ?: return null,
)
}.getOrNull()
}
}
data class UnsignedEvent(
val pubkey: String,
val createdAt: Long,
val kind: Int,
val tags: List<List<String>>,
val content: String,
) {
fun serialized(): String {
val obj = buildJsonArray {
add(JsonPrimitive(0))
add(JsonPrimitive(pubkey))
add(JsonPrimitive(createdAt))
add(JsonPrimitive(kind))
add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } })
add(JsonPrimitive(content))
}
return NostrEvent.CANONICAL.encodeToString(JsonElement.serializer(), obj)
}
/** Sign with the app-held key (signer callback keeps key ownership in NostrKeys). */
fun sign(signer: (ByteArray) -> String?): NostrEvent? {
val digest = MessageDigest.getInstance("SHA-256").digest(serialized().toByteArray())
val sig = signer(digest) ?: return null
val id = digest.toHex()
return NostrEvent(id, pubkey, createdAt, kind, tags, content, sig)
}
}
/** Music kinds from the Wavlake music NIPs (parameterized replaceable, NIP-33). */
object MusicKinds {
const val TRACK = 30440
const val ALBUM = 30441
const val ARTIST = 30442
const val PLAYLIST = 30443
const val METADATA = 0
const val TEXT_NOTE = 1
const val FOLLOW_LIST = 3
const val DELETION = 5
const val REACTION = 7
const val ZAP_REQUEST = 9734
const val ZAP_RECEIPT = 9735
}

View file

@ -1,223 +0,0 @@
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
import dagger.hilt.android.qualifiers.ApplicationContext
import fr.acinq.secp256k1.Secp256k1
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.MutableStateFlow
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).
*
* Security posture:
* - Private key is encrypted with an AndroidKeyStore AES-GCM key that is
* non-exportable; only the ciphertext blob ever leaves the Keystore.
* - The plaintext key exists in memory only while signing an event.
* - Nostr is entirely optional: without an identity every other app
* feature works unchanged, and nothing here ever blocks playback.
*/
@Singleton
class NostrKeys @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val alias = "satsamp_nostr_key"
private val prefs = context.getSharedPreferences("nostr_identity", Context.MODE_PRIVATE)
private val _pubkey = MutableStateFlow<String?>(null)
/** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */
val pubkey: StateFlow<String?> = _pubkey.asStateFlow()
/** 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) {
// 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" }
val rnd = java.security.SecureRandom()
var key = ByteArray(32)
do { rnd.nextBytes(key) } while (!Secp256k1.get().secKeyVerify(key))
val pub = xOnlyPubkey(key)
encryptAndStore(key)
_pubkey.value = pub
return Nip19.npub(pub)
}
/** Import an nsec (paste flow). Stored encrypted; the plaintext is wiped after use. */
fun importNsec(nsec: String): Boolean {
val (hrp, bytes) = Nip19.decode(nsec.trim()) ?: return false
if (hrp != "nsec" || bytes.size != 32) return false
if (!Secp256k1.get().secKeyVerify(bytes)) return false
encryptAndStore(bytes)
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) }
/**
* Sign `digest` (32-byte SHA-256 of serialized event) with the stored key.
* Returns 64-byte BIP-340 schnorr sig hex, or null when no identity.
*/
fun signDigest(digest: ByteArray): String? {
val priv = decryptSecret() ?: return null
try {
return Secp256k1.get().signSchnorr(digest, priv, null).toHex()
} finally {
priv.fill(0)
}
}
/** NIP-47/NIP-44 style ECDH needs the full priv transiently too. */
fun <T> withPrivateKey(block: (ByteArray) -> T): T? {
val priv = decryptSecret() ?: return null
try { return block(priv) } finally { priv.fill(0) }
}
/** Erase the identity (ciphertext + keystore key). */
fun clearIdentity() {
prefs.edit().clear().apply()
runCatching {
KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
.deleteEntry(alias)
}
_pubkey.value = null
}
// ── Keystore plumbing ────────────────────────────────────────────────
private fun keystoreKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey(alias, null) as? SecretKey)?.let { return it }
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(256)
.build(),
)
return gen.generateKey()
}
private fun encryptAndStore(priv: ByteArray) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, keystoreKey())
val ct = cipher.doFinal(priv)
prefs.edit()
.putString("ct", Base64.encodeToString(ct, Base64.NO_WRAP))
.putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
priv.fill(0)
}
private fun decryptSecret(): ByteArray? {
val ct = prefs.getString("ct", null) ?: return null
val iv = prefs.getString("iv", null) ?: return null
return runCatching {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
keystoreKey(),
GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)),
)
cipher.doFinal(Base64.decode(ct, Base64.NO_WRAP))
}.getOrNull()
}
companion object {
/** compressed pubkey (33B) minus the 02/03 prefix = x-only (32B), NIP-01 style. */
fun xOnlyPubkey(priv: ByteArray): String {
val compressed = Secp256k1.get().pubkeyCreate(priv)
return compressed.copyOfRange(1, 33).toHex()
}
}
}

View file

@ -1,233 +0,0 @@
package com.rada.nostr
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.json.JSONArray
import org.json.JSONObject
data class RelayInfo(val url: String, val connected: Boolean)
/**
* The single Nostr entry point (publish/subscribe/query/profile/music).
* UI must never talk to RelayConnection directly.
*
* Failure posture: relays are best-effort. If every relay is down, flows
* simply never emit; nothing else in the app notices or degrades.
*/
@Singleton
class NostrService @Inject constructor(
@ApplicationContext context: Context,
private val keys: NostrKeys,
) {
private val prefs = context.getSharedPreferences("nostr_settings", Context.MODE_PRIVATE)
private val client = OkHttpClient.Builder()
.pingInterval(30, TimeUnit.SECONDS)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(0, TimeUnit.MILLISECONDS) // websockets: no read timeout
.build()
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",
)
private val relayUrls: List<String>
get() {
val saved = prefs.getString("relays", null)
?.split("\n")?.filter { it.isNotBlank() }
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 = java.util.concurrent.ConcurrentHashMap<String, RelayConnection>()
private val _relayStates = MutableStateFlow<List<RelayInfo>>(emptyList())
val relayStates: StateFlow<List<RelayInfo>> = _relayStates.asStateFlow()
/** Merged verified events from all relays (deduped by id downstream). */
private val merged = kotlinx.coroutines.flow.MutableSharedFlow<NostrEvent>(
extraBufferCapacity = 512,
)
@Volatile private var started = false
/** Call once when the user enables Nostr; safe to call repeatedly. */
fun ensureConnected() {
if (started) return
synchronized(this) {
if (started) return
started = true
relayUrls.forEach { url ->
val conn = RelayConnection(url, client)
relays[url] = conn
scope.launch {
conn.state.collect { st ->
_relayStates.value = relays.map { (u, c) ->
RelayInfo(u, st is RelayConnection.State.Connected)
}
}
}
scope.launch { conn.events.collect { merged.emit(it) } }
conn.connect()
}
_relayStates.value = relays.map { RelayInfo(it.key, false) }
}
}
val events get() = merged as kotlinx.coroutines.flow.SharedFlow<NostrEvent>
val enabled: Boolean
get() = prefs.getBoolean("enabled", false) && keys.hasIdentity
fun setEnabled(on: Boolean) {
prefs.edit().putBoolean("enabled", on).apply()
if (on) ensureConnected()
else {
relays.values.forEach { it.close() }
started = false
}
}
fun relaysConfig(): List<String> = relayUrls
fun setRelays(list: List<String>) {
prefs.edit().putString("relays", list.filter { it.isNotBlank() }.joinToString("\n")).apply()
relays.values.forEach { it.close() }
relays.clear()
started = false
if (enabled) ensureConnected()
}
// ── publish ───────────────────────────────────────────────────────────
/** Publish a pre-signed event (zap requests use ephemeral keys). */
fun publishRaw(event: NostrEvent) {
ensureConnected()
relays.values.forEach { it.publish(event) }
}
/** Await the lnurl server's zap receipt (kind 9735) referencing [requestId]. */
suspend fun waitForZapReceipt(requestId: String, targetPubkey: String, timeoutMs: Long) {
ensureConnected()
runCatching {
kotlinx.coroutines.withTimeout(timeoutMs) {
events.first {
it.kind == MusicKinds.ZAP_RECEIPT &&
it.tagValues("e").contains(requestId) &&
it.pubkey == targetPubkey
}
}
}
}
fun publish(kind: Int, content: String, tags: List<List<String>>): NostrEvent? {
val ev = NostrEvent.unsigned(
pubkey = keys.pubkey.value ?: return null,
kind = kind, content = content, tags = tags,
).sign { digest -> keys.signDigest(digest) } ?: return null
relays.values.forEach { it.publish(ev) }
return ev
}
/** Kind 0 metadata (NIP-01 profile). */
fun publishProfile(name: String, about: String, pictureUrl: String): NostrEvent? {
val content = JSONObject().apply {
put("name", name)
put("about", about)
if (pictureUrl.isNotBlank()) put("picture", pictureUrl)
}.toString()
return publish(MusicKinds.METADATA, content, emptyList())
}
/** Kind 7 reaction ("+") to an event, optional lightning zap request. */
fun publishReaction(target: NostrEvent, content: String = "+"): NostrEvent? =
publish(
MusicKinds.REACTION, content,
listOf(
listOf("e", target.id),
listOf("p", target.pubkey),
),
)
// ── music metadata (Wavlake music NIPs 30440-30443) ──────────────────
/** Publish a track as kind 30440 with a NOM-flavored JSON payload. */
fun publishTrack(
dTag: String,
title: String,
creator: String,
enclosureUrl: String,
mimeType: String = "audio/mpeg",
durationSec: Long = 0,
): NostrEvent? {
val content = JSONObject().apply {
put("title", title)
put("guid", dTag)
put("creator", creator)
put("type", mimeType)
put("duration", durationSec)
put("published_at", (System.currentTimeMillis() / 1000).toString())
put("enclosure", enclosureUrl)
put("version", "0.1")
}.toString()
return publish(MusicKinds.TRACK, content, listOf(listOf("d", dTag)))
}
// ── query (one-shot-ish) ──────────────────────────────────────────────
/** Subscribe with [filter]; collected events (verified) flow into [onEvent]. */
fun query(filter: JSONObject, onEvent: (NostrEvent) -> Unit): String {
ensureConnected()
val ids = relays.values.map { it.subscribe(filter) }
scope.launch {
val seen = HashSet<String>()
events.collect { ev ->
if (seen.add(ev.id)) onEvent(ev)
}
}
return ids.firstOrNull() ?: ""
}
/** Fetch a profile (kind 0) for [pubkeyHex]; emits once if any relay has it. */
fun getProfile(pubkeyHex: String, timeoutMs: Long = 6000): kotlinx.coroutines.flow.Flow<NostrEvent?> {
val out = kotlinx.coroutines.flow.MutableStateFlow<NostrEvent?>(null)
ensureConnected()
val filter = JSONObject().apply {
put("kinds", JSONArray().put(MusicKinds.METADATA))
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 }
}
scope.launch {
kotlinx.coroutines.delay(timeoutMs)
job.cancel()
subs.forEach { (conn, id) -> runCatching { conn.unsubscribe(id) } }
}
return out
}
}

View file

@ -1,148 +0,0 @@
package com.rada.nostr
import android.util.Log
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.json.JSONArray
import org.json.JSONObject
/**
* Minimal NIP-01 relay client over OkHttp WebSocket — no new dependencies.
* Design guarantees for the app:
* - Every method is safe to call with relays down: failures surface as
* closed flows / nulls, never exceptions into the UI, never blocking
* playback.
* - Events arriving from relays are [NostrEvent.verify]-checked before
* they are emitted; untrusted relay data can't inject fakes.
*/
class RelayConnection(
val url: String,
private val client: OkHttpClient,
) {
sealed interface State {
data object Connecting : State
data object Connected : State
data class Disconnected(val reason: String?) : State
}
private val _state = MutableSharedFlow<State>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
val state: SharedFlow<State> = _state.asSharedFlow()
private val _events = MutableSharedFlow<NostrEvent>(
replay = 0, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
/** Verified events matching any subscription on this relay. */
val events: SharedFlow<NostrEvent> = _events.asSharedFlow()
/** reqId -> original filter JSON, so we can CLOSE. */
private val subs = ConcurrentHashMap<String, JSONArray>()
private val reqCounter = AtomicLong(0)
@Volatile private var socket: WebSocket? = null
private val pendingOut = ArrayDeque<String>()
val isConnected: Boolean get() = socket != null
fun connect() {
if (socket != null) return
_state.tryEmit(State.Connecting)
runCatching {
socket = client.newWebSocket(
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()) } }
while (pendingOut.isNotEmpty()) {
webSocket.send(pendingOut.removeFirst())
}
}
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))
}
},
)
}.onFailure {
_state.tryEmit(State.Disconnected(it.message))
}
}
private fun handleFrame(text: String) {
runCatching {
val arr = JSONArray(text)
when (arr.optString(0)) {
"EVENT" -> {
val ev = NostrEvent.fromJson(arr.optString(2))
// Trust boundary: id + schnorr signature must verify.
if (ev != null && ev.verify()) _events.tryEmit(ev)
}
// ["OK", evt_id, ok|fail, msg] and ["EOSE", sub_id] are handled
// by callers that need them via their own flow when extended;
// for now presence is enough.
}
}
}
/** NIP-01 REQ. Returns subscription id; results arrive on [events]. */
fun subscribe(filter: JSONObject): String {
val id = "s${reqCounter.incrementAndGet()}"
// NIP-01: ["REQ", sub_id, filter]
subs[id] = JSONArray().put("REQ").put(id).put(filter)
val ws = socket
// 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)
// 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) {
val msg = JSONArray().apply {
put("EVENT")
put(JSONObject(event.toJson()))
}.toString()
val ws = socket
if (ws != null && ws.send(msg)) return
// Queue for the next successful connect; bounded.
if (pendingOut.size < 32) pendingOut.addLast(msg)
}
fun close() {
subs.clear()
runCatching { socket?.close(1000, null) }
socket = null
}
companion object {
private const val TAG = "Relay"
}
}

View file

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

@ -1,286 +0,0 @@
package com.rada.payments
import android.content.Context
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import com.rada.nostr.Nip04
import com.rada.nostr.NostrEvent
import com.rada.nostr.NostrKeys
import com.rada.nostr.NostrService
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.KeyStore
import java.util.Base64 as JBase64
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONObject
/**
* Nostr Wallet Connect (NIP-47) provider: the app talks to the user's
* wallet *through their relay* using encrypted kind-13194 info events and
* kind-13195/23194/23195 request/response DMs. The app never holds wallet
* keys; it holds only the pairing connection string (encrypted at rest).
*
* Connection string format (from Alby Hub / coinos etc.):
* nostr+walletconnect://<walletPubkey>?relay=<wss://..>&secret=<hex>
*/
@Singleton
class NwcProvider @Inject constructor(
@ApplicationContext private val context: Context,
private val nostr: NostrService,
private val keys: NostrKeys,
) : PaymentProvider {
override val id = "nwc"
private val prefs = context.getSharedPreferences("nwc", Context.MODE_PRIVATE)
private var walletPubkey: String? = null
private var relayUrl: String? = null
private var secretHex: String? = null
val connected: Boolean get() = parseStored() != null
override val isConfigured: Boolean get() = connected
data class Pairing(val walletPubkey: String, val relayUrl: String, val secretHex: String)
/** Parse + validate a pairing string. Stores it; returns false if malformed. */
fun connect(pairingUri: String): Boolean {
val parsed = parse(pairingUri.trim()) ?: return false
// store encrypted (it contains a secret key!)
storeSecret(pairingUri.trim())
walletPubkey = parsed.walletPubkey
relayUrl = parsed.relayUrl
secretHex = parsed.secretHex
return true
}
fun disconnect() {
prefs.edit().clear().apply()
walletPubkey = null; relayUrl = null; secretHex = null
}
private fun parse(uri: String): Pairing? = runCatching {
require(uri.startsWith("nostr+walletconnect://")) { "not an NWC uri" }
val rest = uri.removePrefix("nostr+walletconnect://")
val pubkey = rest.substringBefore("?")
require(pubkey.length == 64 && pubkey.all { it in '0'..'9' || it in 'a'..'f' }) { "bad pubkey" }
val qs = rest.substringAfter("?", "")
val params = qs.split("&").mapNotNull {
val k = it.substringBefore("="); val v = it.substringAfter("=", "")
k to java.net.URLDecoder.decode(v, "UTF-8")
}.toMap()
val relay = params["relay"] ?: return null
val secret = params["secret"] ?: return null
require(secret.length == 64) { "bad secret" }
Pairing(pubkey, relay, secret)
}.getOrNull()
private fun parseStored(): Pairing? {
if (walletPubkey != null) return Pairing(walletPubkey!!, relayUrl!!, secretHex!!)
val ct = prefs.getString("pairing_ct", null) ?: return null
val iv = prefs.getString("pairing_iv", null) ?: return null
val uri = decrypt(ct, iv) ?: return null
val p = parse(uri) ?: return null
walletPubkey = p.walletPubkey; relayUrl = p.relayUrl; secretHex = p.secretHex
return p
}
// ── provider API ──────────────────────────────────────────────────────
override suspend fun payLightningAddress(
address: String,
sats: Long,
memo: String?,
): PaymentResult {
// NIP-47 pay_invoice needs a bolt11; a lightning *address* first
// resolves via LUD-06 to an invoice.
val identifier = if (address.contains("@")) {
resolveLightningAddress(address, sats, memo)
?: return PaymentResult.Failed("lnurl failed", "Couldn't reach that Lightning address.")
} else address
return nwcRequest("pay_invoice", JSONObject().apply {
put("invoice", identifier)
})
}
override suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult =
nwcRequest("pay_invoice", JSONObject().apply { put("invoice", bolt11) })
override suspend fun createInvoice(sats: Long, memo: String?): PaymentResult =
nwcRequest("make_invoice", JSONObject().apply {
put("amount", sats * 1000) // msats
memo?.let { put("description", it) }
})
/** NIP-06/LUD-16: lightning address -> {callback}?amount= -> bolt11 */
private suspend fun resolveLightningAddress(
address: String,
sats: Long,
memo: String?,
): String? = withContext(Dispatchers.IO) {
runCatching {
val (name, domain) = address.split("@", limit = 2)
val https = "https://$domain/.well-known/lnurlp/$name"
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
val lnl = client.newCall(okhttp3.Request.Builder().url(https).build())
.execute().use { r ->
if (!r.isSuccessful) return@runCatching null
JSONObject(r.body?.string() ?: return@runCatching null)
}
if (lnl.optString("tag") != "payRequest") return@runCatching null
val min = lnl.optLong("minSendable", 1000L) // msats
val max = lnl.optLong("maxSendable", Long.MAX_VALUE)
val msats = (sats * 1000).coerceIn(min, max)
val cb = lnl.getString("callback")
val url = "$cb${if (cb.contains("?")) "&" else "?"}amount=$msats" +
(memo?.let { "&comment=${java.net.URLEncoder.encode(it, "UTF-8")}" } ?: "")
val resp = client.newCall(okhttp3.Request.Builder().url(url).build())
.execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) }
if (resp.has("error")) {
return@runCatching null
}
resp.optString("pr").takeIf { it.isNotBlank() }
}.getOrNull()
}
/**
* Send a NIP-47 request event, await the wallet's encrypted response.
* Timeout-safe: returns Failed, never hangs the caller.
*/
private suspend fun nwcRequest(method: String, params: JSONObject): PaymentResult {
val pairing = parseStored() ?: return PaymentResult.Failed(
"not connected", "Connect your wallet in Settings → Nostr.",
)
val ourPub = keys.pubkey.value ?: return PaymentResult.Failed(
"no identity", "Create a Nostr identity first (Settings → Nostr).",
)
val deferred = CompletableDeferred<JSONObject>()
// Subscribe to the wallet's response kind for our pubkey (NIP-47: 23195).
val filter = JSONObject().apply {
put("kinds", org.json.JSONArray().put(23195))
put("authors", org.json.JSONArray().put(pairing.walletPubkey))
put("#p", org.json.JSONArray().put(ourPub))
put("limit", 5)
}
val requestId = java.util.UUID.randomUUID().toString()
val payload = JSONObject().apply {
put("method", method)
put("params", params)
}
val enc = keys.withPrivateKey { priv ->
Nip04.encrypt(priv, pairing.walletPubkey, payload.toString())
} ?: return PaymentResult.Failed("crypto", "Couldn't encrypt the request.")
// Publish request (kind 23194) to the NWC relay AND app relays.
val ev = NostrEvent.unsigned(
pubkey = ourPub, kind = 23194, content = enc,
tags = listOf(listOf("p", pairing.walletPubkey)),
).sign { d -> keys.signDigest(d) }
?: return PaymentResult.Failed("signing", "Couldn't sign the request.")
// A dedicated relay connection to the NWC relay from the pairing.
val nwcRelay = com.rada.nostr.RelayConnection(
pairing.relayUrl,
okhttp3.OkHttpClient.Builder()
.pingInterval(20, java.util.concurrent.TimeUnit.SECONDS).build(),
)
return try {
nwcRelay.connect()
// Listen for the response on the NWC relay only.
val listen = CoroutineScope(Dispatchers.IO + SupervisorJob())
val listenJob = listen.launch {
nwcRelay.events.collect { e ->
if (e.kind == 23195 && !deferred.isCompleted) {
val plain = keys.withPrivateKey { priv ->
Nip04.decrypt(priv, pairing.walletPubkey, e.content)
} ?: return@collect
runCatching { JSONObject(plain) }
.onSuccess { deferred.complete(it) }
}
}
}
nwcRelay.publish(ev)
val result = withTimeoutOrNull(15_000) { deferred.await() }
listenJob.cancel()
result?.let { json ->
when {
json.has("error") -> PaymentResult.Failed(
json.optString("error"),
"Wallet declined: ${json.optString("error_message", json.optString("error"))}",
)
json.has("result_type") -> {
val res = json.optJSONObject("result")
when (method) {
"make_invoice" -> PaymentResult.InvoiceCreated(
res?.optString("invoice") ?: "", pairing.walletPubkey,
)
"pay_invoice" -> PaymentResult.Paid
"get_info" -> PaymentResult.Paid
else -> PaymentResult.Paid
}
}
else -> PaymentResult.Failed("bad response", "Unexpected wallet response.")
}
} ?: PaymentResult.Failed("timeout", "Wallet didn't respond in time.")
} finally {
nwcRelay.close()
}
}
/** Wallet info (NIP-47 get_info) — names the connected wallet. */
suspend fun getInfo(): String? {
val r = nwcRequest("get_info", JSONObject())
return if (r is PaymentResult.Paid) "connected" else null
}
// ── keystore at-rest protection for the pairing secret ───────────────
private fun ksKey(): SecretKey {
val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
(ks.getKey("satsamp_nwc", null) as? SecretKey)?.let { return it }
val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
gen.init(
KeyGenParameterSpec.Builder("satsamp_nwc",
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
return gen.generateKey()
}
private fun storeSecret(uri: String) {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, ksKey())
prefs.edit()
.putString("pairing_ct", Base64.encodeToString(cipher.doFinal(uri.toByteArray()), Base64.NO_WRAP))
.putString("pairing_iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP))
.apply()
}
private fun decrypt(ctB64: String, ivB64: String): String? = runCatching {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE, ksKey(),
GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)),
)
String(cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP)))
}.getOrNull()
}

View file

@ -1,112 +0,0 @@
package com.rada.payments
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Payment abstraction — the rest of the app only ever talks to
* [PaymentService]; provider specifics (NWC, LNURL, Lightning Address,
* Cashu later) stay behind [PaymentProvider].
*
* Guarantees the app relies on:
* - No payment failure ever propagates into playback code.
* - Nothing auto-spends: pay() is only invoked from explicit UI actions
* or bounded value-for-value logic with user-set caps.
*/
sealed interface PaymentResult {
data class InvoiceCreated(val bolt11: String, val lightningAddress: String?) : PaymentResult
data object Paid : PaymentResult
data class Failed(val reason: String, val userFacing: String) : PaymentResult
}
interface PaymentProvider {
val id: String
val isConfigured: Boolean
/** Pay [sats] to a lightning address / lnurl (LUD-06/LUD-07/LUD-16 flow). */
suspend fun payLightningAddress(address: String, sats: Long, memo: String?): PaymentResult
/** Create an invoice to ourselves (NWC make_invoice) — for receiving. */
suspend fun createInvoice(sats: Long, memo: String?): PaymentResult
/** Pay a bolt11 invoice directly (zap flow). */
suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult
}
/**
* Central payment service. Providers are consulted in priority order;
* when none is configured every method fails fast with a clear reason
* and the UI renders the plain "⚡ Support" flow as connect-prompt.
*/
class PaymentService(
private val providers: () -> List<PaymentProvider>,
) {
private val _history = MutableStateFlow<List<PaymentRecord>>(emptyList())
val history: StateFlow<List<PaymentRecord>> = _history.asStateFlow()
val hasAnyWallet: Boolean get() = providers().any { it.isConfigured }
suspend fun tipArtist(
lightningAddress: String?,
sats: Long,
artistName: String,
trackTitle: String? = null,
): PaymentResult {
if (sats <= 0) return PaymentResult.Failed("invalid amount", "Enter an amount first.")
if (lightningAddress.isNullOrBlank()) {
return PaymentResult.Failed("no address", "$artistName has no Lightning address set.")
}
val provider = providers().firstOrNull { it.isConfigured }
?: return PaymentResult.Failed(
"no wallet",
"Connect a wallet first (Settings → Nostr → Bitcoin).",
)
val memo = listOfNotNull(trackTitle?.takeIf { it.isNotBlank() }, "via SatsAmp")
.joinToString(" · ")
val result = runCatching { provider.payLightningAddress(lightningAddress, sats, memo) }
.getOrElse {
PaymentResult.Failed("provider error: ${it.message}", "Payment failed — try again.")
}
_history.value = listOf(
PaymentRecord(
timestampMs = System.currentTimeMillis(),
artist = artistName,
track = trackTitle,
sats = sats,
method = provider.id,
success = result is PaymentResult.Paid || result is PaymentResult.InvoiceCreated,
detail = when (result) {
is PaymentResult.Failed -> result.reason
is PaymentResult.InvoiceCreated -> result.bolt11.take(24)
else -> null
},
),
) + _history.value
return result
}
suspend fun payInvoiceDirect(bolt11: String, sats: Long): PaymentResult {
val provider = providers().firstOrNull { it.isConfigured }
?: return PaymentResult.Failed("no wallet", "No wallet connected.")
return runCatching { provider.payInvoice(bolt11, sats) }
.getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Payment failed.") }
}
suspend fun createInvoice(sats: Long, memo: String?): PaymentResult {
val provider = providers().firstOrNull { it.isConfigured }
?: return PaymentResult.Failed("no wallet", "No wallet connected.")
return runCatching { provider.createInvoice(sats, memo) }
.getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Invoice failed.") }
}
}
data class PaymentRecord(
val timestampMs: Long,
val artist: String,
val track: String?,
val sats: Long,
val method: String,
val success: Boolean,
val detail: String?,
)

View file

@ -1,285 +0,0 @@
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
import com.rada.nostr.NostrService
import com.rada.nostr.hexToBytes
import com.rada.nostr.toHex
import fr.acinq.secp256k1.Secp256k1
import java.security.SecureRandom
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.json.JSONArray
import org.json.JSONObject
/**
* NIP-57 zaps, end to end:
* target npub -> kind-0 profile -> lud16 -> LUD-06 lnurl ->
* zap request (kind 9734, signed by an ephemeral key per NIP-57) ->
* lnurl callback with `nostr=` -> bolt11 -> paid via [PaymentService]
* (NWC today; any provider later).
*
* Every failure is reported as ZapOutcome.Failed with a human line; the
* caller must treat zaps as strictly optional garnish on playback.
*/
@Singleton
private object StopCollect : Exception()
class ZapService @Inject constructor(
private val nostr: NostrService,
private val keys: NostrKeys,
private val paymentService: PaymentService,
) {
sealed interface ZapOutcome {
data object Sent : ZapOutcome
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()
suspend fun zapRecipient(
targetNpubHex: String,
sats: Long,
comment: String = "",
eventId: String? = null,
): ZapOutcome {
if (sats <= 0) return ZapOutcome.Failed("Enter an amount first.")
val relays = nostr.relaysConfig()
// 1. Target profile -> lud16 (lightning address).
val lud16: String? = withContext(Dispatchers.IO) {
var found: String? = null
try {
kotlinx.coroutines.withTimeout(6_000) {
nostr.getProfile(targetNpubHex).collect { ev ->
if (ev != null && found == null) {
found = JSONObject(ev.content).optString("lud16")
.takeIf { it.isNotBlank() }
if (found != null) throw StopCollect
}
}
}
} catch (e: StopCollect) { /* found set */ }
catch (e: kotlinx.coroutines.TimeoutCancellationException) { /* none */ }
found
} ?: return ZapOutcome.Failed("No Lightning address (lud16) on that profile.")
val address: String = lud16 ?: return ZapOutcome.Failed("No Lightning address set.")
return zapLightningAddress(address, sats, comment, targetNpubHex, eventId, relays)
}
suspend fun zapLightningAddress(
lud16: String,
sats: Long,
comment: String,
targetNpubHex: String,
eventId: String?,
relays: List<String>,
): ZapOutcome = withContext(Dispatchers.IO) {
// 2. LUD-06: https://domain/.well-known/lnurlp/name
val lnl = runCatching {
val (name, domain) = lud16.split("@", limit = 2)
http.newCall(
okhttp3.Request.Builder()
.url("https://$domain/.well-known/lnurlp/$name").build(),
).execute().use { r ->
if (!r.isSuccessful) return@runCatching null
JSONObject(r.body?.string() ?: return@runCatching null)
}
}.getOrNull() ?: return@withContext ZapOutcome.Failed("Couldn't reach the Lightning endpoint.")
if (lnl.optString("tag") != "payRequest")
return@withContext ZapOutcome.Failed("Endpoint isn't a pay request.")
val min = lnl.optLong("minSendable", 1000L) / 1000
val max = lnl.optLong("maxSendable", Long.MAX_VALUE) / 1000
if (sats < min || sats > max)
return@withContext ZapOutcome.Failed("Amount must be between $min and $max sats.")
// 3. Zap request event (NIP-57): signed by an EPHEMERAL key, not the
// user's identity key; `relays` tag points at our relays so the
// lnurl server can fetch it and verify.
val ephemeral = ByteArray(32).also { SecureRandom().nextBytes(it) }
.also { if (!Secp256k1.get().secKeyVerify(it)) return@withContext ZapOutcome.Failed("rng busy, retry") }
val ephPub = Secp256k1.get().pubkeyCreate(ephemeral).copyOfRange(1, 33).toHex()
val tags = mutableListOf(
listOf("p", targetNpubHex),
listOf("relays", *relays.take(3).toTypedArray()),
listOf("amount", (sats * 1000).toString()),
)
if (eventId != null) tags.add(listOf("e", eventId))
val zapReq = NostrEvent.unsigned(
pubkey = ephPub, kind = MusicKinds.ZAP_REQUEST, content = comment, tags = tags,
).sign { digest -> Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex() }
?: return@withContext ZapOutcome.Failed("Couldn't build the zap request.")
// Publish the zap request so the lnurl server can fetch it.
nostr.ensureConnected()
// (NostrService.publish signs with the *identity* key — for zap
// requests we must publish the pre-signed ephemeral event directly.)
nostr.publishRaw(zapReq)
// 4. Callback with nostr= param -> invoice.
val allowsNostr = lnl.optBoolean("allowsNostr", false)
if (!allowsNostr && comment.isNotEmpty()) {
// still payable without comment? proceed without nostr tag
}
val cb = lnl.optString("callback")
if (cb.isBlank()) return@withContext ZapOutcome.Failed("Endpoint missing callback.")
val url = buildString {
append(cb)
append(if (cb.contains("?")) "&" else "?")
append("amount=").append(sats * 1000)
if (allowsNostr) {
append("&nostr=").append(
java.net.URLEncoder.encode(zapReq.toJson(), "UTF-8"),
)
}
}
val resp = runCatching {
http.newCall(okhttp3.Request.Builder().url(url).build())
.execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) }
}.getOrNull() ?: return@withContext ZapOutcome.Failed("Zap callback unreachable.")
if (resp.has("error")) {
return@withContext ZapOutcome.Failed(resp.optString("reason", "Zap rejected."))
}
val bolt11 = resp.optString("pr")
if (bolt11.isBlank()) return@withContext ZapOutcome.Failed("No invoice returned.")
// 5. Pay via the connected wallet.
when (val pay = paymentService.payInvoiceDirect(bolt11, sats)) {
is PaymentResult.Paid -> {
nostr.waitForZapReceipt(zapReq.id, targetNpubHex, timeoutMs = 10_000)
ZapOutcome.Sent
}
is PaymentResult.Failed -> ZapOutcome.Failed(pay.userFacing)
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,11 +2,6 @@ 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
@ -67,50 +62,6 @@ 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
@ -139,71 +90,6 @@ class PlaybackManager(
private val _lastStreamMetaFlow = MutableStateFlow<StreamMetadata?>(null)
val lastStreamMetaFlow: StateFlow<StreamMetadata?> = _lastStreamMetaFlow.asStateFlow()
// ── Queue / repeat / shuffle / speed / sleep timer ─────────────────
/** Playback speed, 0.5×–2.0×. Survives station and track changes. */
private val _speedFlow = MutableStateFlow(1.0f)
val speedFlow: StateFlow<Float> = _speedFlow.asStateFlow()
private val _repeatModeFlow = MutableStateFlow(androidx.media3.common.Player.REPEAT_MODE_OFF)
val repeatModeFlow: StateFlow<Int> = _repeatModeFlow.asStateFlow()
private val _shuffleEnabledFlow = MutableStateFlow(false)
val shuffleEnabledFlow: StateFlow<Boolean> = _shuffleEnabledFlow.asStateFlow()
/** Milliseconds left on the sleep timer; 0 when off. */
private val _sleepRemainingMsFlow = MutableStateFlow(0L)
val sleepRemainingMsFlow: StateFlow<Long> = _sleepRemainingMsFlow.asStateFlow()
/** Library song id of the current queue item (null when not queue-playing). */
private val _currentLocalTrackIdFlow = MutableStateFlow<Long?>(null)
val currentLocalTrackIdFlow: StateFlow<Long?> = _currentLocalTrackIdFlow.asStateFlow()
private var sleepJob: Job? = null
fun setSpeed(speed: Float) {
val clamped = speed.coerceIn(0.5f, 2.0f)
_speedFlow.value = clamped
controller?.setPlaybackSpeed(clamped)
}
fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) { controller?.repeatMode = mode
}
fun setShuffleEnabled(enabled: Boolean) {
controller?.shuffleModeEnabled = enabled
}
/** Start (or restart) the sleep timer; pauses playback when it fires. */
fun startSleepTimer(minutes: Int) {
sleepJob?.cancel()
if (minutes <= 0) {
_sleepRemainingMsFlow.value = 0L
return
}
val totalMs = minutes * 60_000L
val endAt = SystemClock.elapsedRealtime() + totalMs
sleepJob = scope.launch {
while (true) {
val remaining = endAt - SystemClock.elapsedRealtime()
if (remaining <= 0L) break
_sleepRemainingMsFlow.value = remaining
delay(1_000L)
}
_sleepRemainingMsFlow.value = 0L
sleepJob = null
// Pause rather than stop: the station stays loaded for when the
// user wakes up. Controller calls are main-thread confined.
withContext(Dispatchers.Main) { controller?.pause() }
}
}
fun cancelSleepTimer() {
sleepJob?.cancel()
sleepJob = null
_sleepRemainingMsFlow.value = 0L
}
private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE)
val bufferStateFlow: StateFlow<BufferState> = _bufferStateFlow.asStateFlow()
@ -230,19 +116,6 @@ 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)
@ -255,13 +128,6 @@ class PlaybackManager(
}
fun disconnect() {
if (deviceCallbackRegistered) {
try {
audioManager?.unregisterAudioDeviceCallback(deviceCallback)
} catch (_: Exception) {
}
deviceCallbackRegistered = false
}
stopAll()
scope.cancel()
controller?.removeListener(playerListener)
@ -273,17 +139,12 @@ class PlaybackManager(
fun playStation(url: String, name: String) {
currentStationUrl = url
currentStationName = name
userStopped = false
_currentStationUrlFlow.value = url
_currentStationNameFlow.value = name
_isLocalFlow.value = false
_localMediaFlow.value = null
_lastStreamMetaFlow.value = null
_currentLocalTrackIdFlow.value = null
val ctrl = controller ?: return
// A live stream has no queue: shuffle must not leak from local playback.
ctrl.shuffleModeEnabled = false
_shuffleEnabledFlow.value = false
stopAll()
initBuffer()
@ -301,92 +162,46 @@ class PlaybackManager(
.build()
ctrl.setMediaItem(mediaItem)
ctrl.setPlaybackSpeed(_speedFlow.value)
ctrl.prepare()
ctrl.play()
startIcyReader(url)
}
fun playLocalFile(filePath: String, title: String, artist: String, id: Long? = null) {
playLocalQueue(listOf(LocalTrack(id, filePath, title, artist)), startIndex = 0, shuffle = false)
}
/** A local library track for queue playback. `id` links UI state to the player. */
data class LocalTrack(val id: Long?, val filePath: String, val title: String, val artist: String)
/**
* Plays a local queue (library songs / playlist). ExoPlayer handles
* advance, shuffle order and repeat natively, so transport next/prev
* and end-of-track behave like a real music player.
*/
fun playLocalQueue(tracks: List<LocalTrack>, startIndex: Int, shuffle: Boolean) {
if (tracks.isEmpty()) return
userStopped = false
fun playLocalFile(filePath: String, title: String, artist: String) {
currentStationUrl = null
currentStationName = ""
// Publish so the shared NowPlaying bar shows library playback too.
_currentStationNameFlow.value = if (artist.isBlank()) title else "$title — $artist"
_currentStationUrlFlow.value = null
_isLocalFlow.value = true
val first = tracks[startIndex.coerceIn(0, tracks.lastIndex)]
_currentStationNameFlow.value =
if (first.artist.isBlank()) first.title else "${first.title} — ${first.artist}"
_localMediaFlow.value = first.title to first.artist
_localMediaFlow.value = title to artist
_lastStreamMetaFlow.value = null
val ctrl = controller ?: return
stopAll()
val items = tracks.map { track ->
MediaItem.Builder()
.setUri(track.filePath)
.setMediaId(track.id?.toString() ?: track.filePath)
val mediaItem = MediaItem.Builder()
.setUri(filePath)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(track.artist)
.setTitle(track.title)
.setArtist(artist)
.setTitle(title)
.build()
)
.build()
}
ctrl.setMediaItems(items, startIndex.coerceIn(0, items.lastIndex), 0L)
ctrl.shuffleModeEnabled = shuffle
_shuffleEnabledFlow.value = shuffle
ctrl.setPlaybackSpeed(_speedFlow.value)
ctrl.setMediaItem(mediaItem)
ctrl.prepare()
ctrl.play()
}
/** Transport next within the current queue (no-op for a single stream). */
fun seekToNextItem() {
controller?.let { if (it.mediaItemCount > 1) it.seekToNext() }
}
fun seekToPreviousItem() {
controller?.let { if (it.mediaItemCount > 1) it.seekToPrevious() }
}
fun togglePlayPause() {
val ctrl = controller ?: return
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()
if (ctrl.isPlaying) ctrl.pause() else ctrl.play()
}
fun stop() {
userStopped = true
stopAll()
controller?.stop()
isPlaying = false
@ -406,22 +221,12 @@ class PlaybackManager(
updateSaveState(SaveState.SAVING, null)
scope.launch {
// A throw anywhere below must still reset saveState (and surface),
// otherwise every later tap hits the SAVING guard silently.
val result = try {
saveSongUseCase.save(
val result = 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
@ -496,34 +301,8 @@ 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")
}
@ -533,8 +312,6 @@ class PlaybackManager(
}
private fun stopIcyReader() {
icyRestartJob?.cancel()
icyRestartJob = null
icyReader?.stop()
icyReader = null
}
@ -557,27 +334,6 @@ class PlaybackManager(
listener?.onPlaybackStateChanged(playing)
}
override fun onRepeatModeChanged(mode: Int) {
_repeatModeFlow.value = mode
}
override fun onShuffleModeEnabledChanged(enabled: Boolean) {
_shuffleEnabledFlow.value = enabled
}
/** Queue advanced (auto or manual): publish the new item's identity. */
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (_isLocalFlow.value && mediaItem != null) {
val title = mediaItem.mediaMetadata.title?.toString().orEmpty()
val artist = mediaItem.mediaMetadata.artist?.toString().orEmpty()
_localMediaFlow.value = title to artist
_currentStationNameFlow.value =
if (artist.isBlank()) title else "$title — $artist"
val itemId = mediaItem.mediaId.toLongOrNull()
_currentLocalTrackIdFlow.value = itemId
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
when (playbackState) {
Player.STATE_READY -> {
@ -586,20 +342,11 @@ class PlaybackManager(
}
Player.STATE_BUFFERING -> listener?.onPlaybackStateChanged(false)
Player.STATE_IDLE -> 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)
}
Player.STATE_ENDED -> 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()
@ -628,9 +375,6 @@ 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) {
@ -644,25 +388,11 @@ class PlaybackManager(
reconnectJob = scope.launch {
delay(delayMs)
// 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) {
if (currentStationUrl == url) {
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
}
}
return delayMs
}
@ -671,6 +401,5 @@ 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,40 +3,24 @@ 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 dagger.hilt.android.AndroidEntryPoint
import com.rada.ui.MainActivity
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
@Inject lateinit var playbackManager: PlaybackManager
@OptIn(UnstableApi::class)
override fun onCreate() {
super.onCreate()
// 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 player = ExoPlayer.Builder(this).build()
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
@ -54,21 +38,9 @@ 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?) {
try {
playbackManager.stop()
} catch (_: Exception) {
try {
mediaSession?.player?.pause()
} catch (_: Exception) {
// Nothing more we can do; still stop below.
}
} finally {
val player = mediaSession?.player
if (player == null || !player.playWhenReady || player.mediaItemCount == 0) {
stopSelf()
}
}

View file

@ -38,10 +38,8 @@ class MetadataEmbedder(private val context: Context) {
// Store the jaudiotagger AudioFile reference (Bug #6).
audio = AudioFileIO.read(audioFile)
// 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
// Set metadata fields on the stored reference's tag.
val tag = audio.tag
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

@ -47,21 +47,6 @@ class SaveSongUseCase(
)
}
// Refuse duplicates before touching storage: the same artist+title is
// already in the library (case-insensitive, trimmed).
val dupArtist = boundaries.artist.trim()
val dupTitle = boundaries.title.trim()
if (dupArtist.isNotEmpty() && dupTitle.isNotEmpty()) {
val existing = libraryRepository.getByArtistTitle(dupArtist, dupTitle)
if (existing != null) {
return SaveResult(
success = false,
songId = existing.id,
error = "Already in your library: $dupTitle",
)
}
}
// Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes)
val minBytes = 160_000L
if (boundaries.durationBytes < minBytes) {

View file

@ -1,19 +1,14 @@
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
@ -27,22 +22,10 @@ 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 {
@ -62,21 +45,4 @@ 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

@ -1,78 +0,0 @@
package com.rada.ui.components
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.rada.ui.theme.LocalReduceMotion
/**
* The three-bar "now playing" indicator — the compact signal that a row is
* the active source (Spotify's equalizer). Bars pulse out of phase; paused
* freezes them mid-height so the shape still reads as "the equalizer", and
* reduce-motion renders a static, even-bar version.
*/
@Composable
fun EqualizerBars(
active: Boolean,
color: Color,
modifier: Modifier = Modifier,
barWidth: Dp = 2.5.dp,
) {
val reduce = LocalReduceMotion.current
val transition = rememberInfiniteTransition(label = "eq")
// Out-of-phase sine-ish pulses; different durations keep bars unsynced.
val b1 by transition.animateFloat(
initialValue = 0.25f, targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(520), repeatMode = RepeatMode.Reverse,
), label = "b1",
)
val b2 by transition.animateFloat(
initialValue = 1f, targetValue = 0.3f,
animationSpec = infiniteRepeatable(
animation = tween(660), repeatMode = RepeatMode.Reverse,
), label = "b2",
)
val b3 by transition.animateFloat(
initialValue = 0.45f, targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(440), repeatMode = RepeatMode.Reverse,
), label = "b3",
)
// Frozen heights for the paused pose (mid-range, uneven = still "eq").
val h1 = if (reduce || !active) 0.6f else b1
val h2 = if (reduce || !active) 1f else b2
val h3 = if (reduce || !active) 0.45f else b3
Canvas(modifier = modifier.size(14.dp)) {
val gap = this.size.width / 8f
val w = barWidth.toPx()
val totalW = w * 3 + gap * 2
var x = (this.size.width - totalW) / 2f
val r = CornerRadius(w / 2f, w / 2f)
for (h in floatArrayOf(h1, h2, h3)) {
val barH = this.size.height * h
drawRoundRect(
color = color,
topLeft = Offset(x, this.size.height - barH),
size = Size(w, barH),
cornerRadius = r,
)
x += w + gap
}
}
}

View file

@ -1,381 +0,0 @@
package com.rada.ui.decentralized
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
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.material.icons.filled.Check
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
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.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
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
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.ui.theme.LocalTokens
import com.rada.ui.theme.SatsAmpGlass
/**
* Decentralized settings sheet — progressive disclosure per the design:
* listeners see "identity + wallet", advanced rows (relays, profile) sit
* behind a toggle. All state via [DecentralizedViewModel].
*/
@Composable
fun DecentralizedSheet(
onDismiss: () -> Unit,
viewModel: DecentralizedViewModel = hiltViewModel(),
) {
val tokens = LocalTokens.current
val npub by viewModel.npub.collectAsState()
val walletConnected by viewModel.walletConnected.collectAsState()
val relays by viewModel.relays.collectAsState()
val message by viewModel.message.collectAsState()
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("") }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = tokens.panel,
titleContentColor = tokens.text,
shape = RoundedCornerShape(SatsAmpGlass.rCard),
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
Spacer(Modifier.width(8.dp))
Text("Nostr", fontWeight = FontWeight.Bold)
}
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// ── Nostr identity ────────────────────────────────────
SectionLabel("Nostr identity")
val pk = npub
if (pk == null) {
Text(
"Optional. Gives you a profile, follows, reactions and zaps. Everything else in SatsAmp works without it.",
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(
containerColor = tokens.accentFill,
contentColor = tokens.accentOnFill,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
modifier = Modifier.fillMaxWidth(),
) { Text("Create Nostr identity") }
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,
onValueChange = { nsecInput = it },
placeholder = { Text("nsec… (stored encrypted)") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.fillMaxWidth(),
)
TextButton(
onClick = { viewModel.importNsec(nsecInput) },
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,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(tokens.glassFillStrong)
.border(1.dp, tokens.border, RoundedCornerShape(12.dp))
.clickable { clipboard.setText(AnnotatedString(pk)) }
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Text(
pk,
style = MaterialTheme.typography.bodySmall,
color = tokens.text,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Icon(
Icons.Default.ContentCopy, contentDescription = "Copy npub",
tint = tokens.textSecondary,
modifier = Modifier.size(16.dp),
)
}
Text(
"tap to copy",
style = MaterialTheme.typography.labelSmall,
color = tokens.textMuted,
)
TextButton(onClick = { viewModel.signOut() }) {
Text("Sign out", color = tokens.textSecondary)
}
}
Spacer(Modifier.height(4.dp))
// ── Wallet ────────────────────────────────────────────
SectionLabel("Lightning wallet")
if (!walletConnected) {
Text(
"Connect a NIP-47 wallet (Alby Hub, Coinos, etc.) with its pairing string to zap and tip artists.",
style = MaterialTheme.typography.bodySmall,
color = tokens.textSecondary,
)
OutlinedTextField(
value = nwcInput,
onValueChange = { nwcInput = it },
placeholder = { Text("nostr+walletconnect://…") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { viewModel.connectWallet(nwcInput) },
enabled = nwcInput.isNotBlank() && busy.not(),
colors = ButtonDefaults.buttonColors(
containerColor = tokens.accentFill,
contentColor = tokens.accentOnFill,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
modifier = Modifier.fillMaxWidth(),
) {
if (busy) CircularProgressIndicator(
modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
color = tokens.accentOnFill,
) else Text("Connect wallet")
}
} else {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(tokens.accentSoft)
.padding(horizontal = 12.dp, vertical = 10.dp),
) {
Icon(Icons.Default.Check, contentDescription = null,
tint = tokens.accentText, modifier = Modifier.size(16.dp))
Spacer(Modifier.width(8.dp))
Text("Wallet connected", color = tokens.accentText,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f))
TextButton(onClick = { viewModel.disconnectWallet() }) {
Text("Disconnect", color = tokens.textSecondary)
}
}
}
Spacer(Modifier.height(4.dp))
// ── Advanced ──────────────────────────────────────────
TextButton(onClick = { showAdvanced = !showAdvanced }) {
Text(
if (showAdvanced) "Hide advanced" else "Advanced: relays & profile",
color = tokens.accentText,
)
}
if (showAdvanced) {
SectionLabel("Relays")
relays.forEach { r ->
Text(
r,
style = MaterialTheme.typography.bodySmall,
color = tokens.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
TextButton(
onClick = {
viewModel.setRelays(
listOf("wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"),
)
},
) { Text("Reset to defaults", color = tokens.textSecondary) }
SectionLabel("Publish profile")
OutlinedTextField(
value = nameInput,
onValueChange = { nameInput = it },
placeholder = { Text("Display name") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = aboutInput,
onValueChange = { aboutInput = it },
placeholder = { Text("About (optional)") },
singleLine = true,
colors = fieldColors(tokens),
modifier = Modifier.fillMaxWidth(),
)
Button(
onClick = { viewModel.publishProfile(nameInput, aboutInput) },
enabled = nameInput.isNotBlank() && busy.not(),
colors = ButtonDefaults.buttonColors(
containerColor = tokens.accentFill,
contentColor = tokens.accentOnFill,
),
shape = RoundedCornerShape(SatsAmpGlass.rInput),
modifier = Modifier.fillMaxWidth(),
) { Text("Publish to relays") }
}
message?.let {
Text(
it,
style = MaterialTheme.typography.bodySmall,
color = tokens.accentText,
)
TextButton(onClick = { viewModel.consumeMessage() }) {
Text("Dismiss", color = tokens.textMuted)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) { Text("Close", color = tokens.accentText) }
},
)
}
@Composable
private fun SectionLabel(text: String) {
Text(
text.uppercase(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = LocalTokens.current.textMuted,
)
}
@Composable
private fun fieldColors(tokens: com.rada.ui.theme.UiTokens) =
OutlinedTextFieldDefaults.colors(
focusedBorderColor = tokens.accentText,
unfocusedBorderColor = tokens.borderInteractive,
focusedTextColor = tokens.text,
unfocusedTextColor = tokens.text,
cursorColor = tokens.accentText,
focusedPlaceholderColor = tokens.textMuted,
unfocusedPlaceholderColor = tokens.textMuted,
)

View file

@ -1,140 +0,0 @@
package com.rada.ui.decentralized
import android.content.Intent
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.nostr.NostrKeys
import com.rada.nostr.NostrService
import com.rada.payments.NwcProvider
import com.rada.payments.PaymentService
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
@HiltViewModel
class DecentralizedViewModel @Inject constructor(
private val keys: NostrKeys,
private val nostr: NostrService,
private val nwc: NwcProvider,
val paymentService: PaymentService,
) : ViewModel() {
/** NIP-19 npub for display/share (never raw hex). */
val npub: kotlinx.coroutines.flow.StateFlow<String?> =
keys.pubkey
.map { hex -> hex?.let { com.rada.nostr.Nip19.npub(it) } }
.stateIn(
viewModelScope,
kotlinx.coroutines.flow.SharingStarted.Eagerly,
keys.npub(),
)
val nostrEnabled = MutableStateFlow(nostr.enabled)
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()
fun createIdentity() {
runCatching { keys.createIdentity() }
.onSuccess {
nostr.setEnabled(true)
nostrEnabled.value = true
message.value = "Nostr identity created ✅"
}
.onFailure { message.value = "Couldn't create identity: ${it.message}" }
}
fun importNsec(nsec: String) {
if (keys.importNsec(nsec)) {
nostr.setEnabled(true)
nostrEnabled.value = true
message.value = "Identity imported ✅"
} else {
message.value = "That doesn't look like a valid nsec."
}
}
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, "")
busy.value = false
message.value = if (ev != null)
"Profile published to ${nostr.relaysConfig().size} relays"
else "Publish failed — check relays are reachable."
}
fun setRelays(list: List<String>) {
nostr.setRelays(list)
_relays.value = nostr.relaysConfig()
message.value = "Relays updated"
}
fun connectWallet(uri: String) {
if (nwc.connect(uri)) {
walletConnected.value = true
message.value = "Wallet connected via Nostr Wallet Connect ⚡"
} else {
message.value = "That pairing string isn't valid (nostr+walletconnect://…)."
}
}
fun disconnectWallet() {
nwc.disconnect()
walletConnected.value = false
message.value = "Wallet disconnected"
}
fun consumeMessage() { message.value = null }
}

View file

@ -1,227 +0,0 @@
package com.rada.ui.decentralized
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
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
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilterChip
import androidx.compose.material3.FilterChipDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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
/**
* "⚡ Support" — the whole listener-facing payment surface. Presets +
* optional comment + one address field (remembered). Advanced wiring
* (NWC, relays) lives in the Decentralized sheet, not here.
*/
@Composable
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
val status by viewModel.status.collectAsState()
val sending by viewModel.sending.collectAsState()
val hasWallet by viewModel.hasWallet.collectAsState()
val recents by viewModel.recentAddresses.collectAsState()
var amount by remember { mutableStateOf(100L) }
var customAmount by remember { mutableStateOf("") }
var address by remember(initialAddress) {
mutableStateOf(
initialAddress.ifBlank { recents.firstOrNull() ?: "" },
)
}
var comment by remember { mutableStateOf("") }
val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount
AlertDialog(
onDismissRequest = onDismiss,
containerColor = tokens.panel,
titleContentColor = tokens.text,
shape = RoundedCornerShape(20.dp),
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText)
Spacer(Modifier.size(8.dp))
Text("Support $artistName", fontWeight = FontWeight.Bold)
}
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
trackTitle?.takeIf { it.isNotBlank() }?.let {
Text(
"playing · $it",
style = MaterialTheme.typography.bodySmall,
color = tokens.textMuted,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
listOf(21L, 100L, 500L, 1000L).forEach { preset ->
FilterChip(
selected = customAmount.isBlank() && amount == preset,
onClick = { amount = preset; customAmount = "" },
label = { Text("$preset") },
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = tokens.accentSoft,
selectedLabelColor = tokens.accentText,
labelColor = tokens.textSecondary,
),
)
}
}
OutlinedTextField(
value = customAmount,
onValueChange = { customAmount = it.filter(Char::isDigit) },
placeholder = { Text("Custom amount in sats") },
singleLine = true,
suffix = { Text("sats", color = tokens.textMuted) },
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = tokens.accentText,
unfocusedBorderColor = tokens.borderInteractive,
focusedTextColor = tokens.text,
unfocusedTextColor = tokens.text,
cursorColor = tokens.accentText,
focusedPlaceholderColor = tokens.textMuted,
unfocusedPlaceholderColor = tokens.textMuted,
),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = address,
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,
focusedTextColor = tokens.text,
unfocusedTextColor = tokens.text,
cursorColor = tokens.accentText,
focusedPlaceholderColor = tokens.textMuted,
unfocusedPlaceholderColor = tokens.textMuted,
),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = comment,
onValueChange = { comment = it },
placeholder = { Text("Message (optional)") },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = tokens.accentText,
unfocusedBorderColor = tokens.borderInteractive,
focusedTextColor = tokens.text,
unfocusedTextColor = tokens.text,
cursorColor = tokens.accentText,
focusedPlaceholderColor = tokens.textMuted,
unfocusedPlaceholderColor = tokens.textMuted,
),
modifier = Modifier.fillMaxWidth(),
)
status?.let {
Text(it, style = MaterialTheme.typography.bodySmall, color = tokens.accentText)
}
}
},
confirmButton = {
Button(
onClick = { viewModel.tip(address, finalSats, artistName, trackTitle, artistId) },
enabled = !sending && address.contains("@") && finalSats > 0,
colors = ButtonDefaults.buttonColors(
containerColor = tokens.accentFill,
contentColor = tokens.accentOnFill,
),
shape = RoundedCornerShape(14.dp),
) {
if (sending) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp), strokeWidth = 2.dp,
color = tokens.accentOnFill,
)
} else {
Text("Send $finalSats sats")
}
}
},
dismissButton = {
if (!hasWallet) {
Text(
"No wallet connected — copy the address\nabove and pay from any wallet app.",
style = MaterialTheme.typography.labelSmall,
color = tokens.textMuted,
)
}
},
)
}

View file

@ -1,78 +0,0 @@
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
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@HiltViewModel
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)
val status = MutableStateFlow<String?>(null)
val sending = MutableStateFlow(false)
val hasWallet = MutableStateFlow(paymentService.hasAnyWallet)
/** Recently used lightning addresses (most recent first, max 8). */
private val _recentAddresses = MutableStateFlow(
prefs.getString("recent", "")!!.split("\n").filter { it.contains("@") },
)
val recentAddresses: StateFlow<List<String>> = _recentAddresses.asStateFlow()
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."
return
}
sending.value = true
status.value = null
viewModelScope.launch {
when (val r = paymentService.tipArtist(clean, sats, artistName, trackTitle)) {
is PaymentResult.Paid -> {
status.value = "⚡ $sats sats sent to $artistName"
_recentAddresses.value =
(listOf(clean) + _recentAddresses.value.filter { it != clean })
.distinct().take(8)
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."
}
sending.value = false
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

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

@ -1,76 +0,0 @@
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,9 +24,6 @@ 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
@ -36,8 +33,6 @@ 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
@ -50,7 +45,6 @@ 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
@ -84,10 +78,6 @@ import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.SortOrder
import com.rada.ui.theme.AppearanceDialog
import com.rada.ui.components.AppHomeButton
import com.rada.ui.components.EqualizerBars
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.pressDip
import androidx.compose.foundation.interaction.MutableInteractionSource
import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class)
@ -107,7 +97,6 @@ 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() }
@ -207,19 +196,6 @@ 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,
@ -235,54 +211,12 @@ 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,
@ -308,13 +242,6 @@ 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 {
@ -339,33 +266,20 @@ fun LibraryScreen(
tabLpHandler.removeCallbacks(timer)
}
},
selected = tabSelected,
selected = selectedTabIndex == index,
onClick = {
if (uiState.isMultiSelect) {
if (index == 0) viewModel.exitMultiSelect()
else viewModel.togglePlaylistMulti(filteredPlaylists[index - 1].id)
} else if (index == 0) {
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 (tabSelected) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
color = if (selectedTabIndex == index) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
)
},
)
@ -444,11 +358,8 @@ fun LibraryScreen(
)
Spacer(modifier = Modifier.height(16.dp))
Text(
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"
},
text = if (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,
)
@ -464,7 +375,6 @@ fun LibraryScreen(
isPlaying = song.id == uiState.currentPlayingId,
onClick = { viewModel.playSong(song) },
onToggleFavorite = { viewModel.toggleFavorite(song.id) },
onAddToPlaylist = { songForPlaylist = song },
onDelete = { viewModel.deleteSong(song) },
)
}
@ -480,7 +390,8 @@ 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 = {
@ -506,205 +417,6 @@ 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
@ -748,32 +460,21 @@ private fun SongListItem(
isPlaying: Boolean,
onClick: () -> Unit,
onToggleFavorite: () -> Unit,
onAddToPlaylist: () -> Unit,
onDelete: () -> Unit,
) {
val interaction = remember { MutableInteractionSource() }
Row(
modifier = Modifier
.fillMaxWidth()
.pressDip(interaction, LocalReduceMotion.current)
.clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick)
.clickable(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (isPlaying) {
EqualizerBars(
active = true,
color = LocalTokens.current.accentFill,
modifier = Modifier.size(20.dp),
)
} else {
Icon(
Icons.Default.MusicNote,
contentDescription = null,
tint = LocalTokens.current.textSecondary,
tint = if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.textSecondary,
modifier = Modifier.size(24.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@ -807,13 +508,6 @@ 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

@ -4,7 +4,6 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rada.data.local.entity.PlaylistEntity
import com.rada.data.local.entity.SavedSongEntity
import com.rada.data.repository.DedupeLibrary
import com.rada.data.repository.LibraryRepository
import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.SortOrder
@ -18,11 +17,11 @@ 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
import javax.inject.Inject
import kotlin.random.Random
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
@ -31,7 +30,6 @@ class LibraryViewModel @Inject constructor(
private val playlistRepository: PlaylistRepository,
private val playbackManager: PlaybackManager,
private val songImporter: SongImporter,
private val dedupeLibrary: DedupeLibrary,
) : ViewModel() {
private val _uiState = MutableStateFlow(LibraryUiState())
@ -75,8 +73,6 @@ 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())
@ -92,61 +88,34 @@ 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, multiSelect, multiSelectedIds
) { query, sort, playlistId, multi, ids ->
SongQuery(query, sort, playlistId, multi, ids)
}.flatMapLatest { q ->
val songsFlow = combine(searchQuery, sortOrder, selectedPlaylistId) { query, sort, playlistId ->
Triple(query, sort, playlistId)
}.flatMapLatest { (query, sort, playlistId) ->
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.
q.playlistId != null -> playlistRepository.getSongsInPlaylist(q.playlistId)
q.query.isEmpty() -> libraryRepository.getAll(q.sort)
else -> libraryRepository.search(q.query)
playlistId != null -> playlistRepository.getSongsInPlaylist(playlistId)
query.isEmpty() -> libraryRepository.getAll(sort)
else -> libraryRepository.search(query)
}
}
// Sort direction and in-folder sorting are applied here so every view
// (All Songs, folder tabs, search results, multi-select union) obeys
// the visible sort chips.
// (All Songs, folder tabs, search results) obeys the visible sort chips.
viewModelScope.launch {
combine(songsFlow, sortOrder, sortAscending, multiSelect, multiSelectedIds) { songs, sort, ascending, multi, ids ->
SongListView(songs, sort, ascending, multi, ids)
}.collect { view ->
combine(songsFlow, sortOrder, sortAscending) { songs, sort, ascending ->
Triple(sortSongs(songs, sort, ascending), sort, ascending)
}.collect { (sortedSongs, sort, ascending) ->
_uiState.update {
it.copy(
songs = sortSongs(view.songs, view.sort, view.ascending),
songs = sortedSongs,
isLoading = false,
sortOrder = view.sort,
sortAscending = view.ascending,
isMultiSelect = view.multi,
multiSelectedIds = view.ids,
sortOrder = sort,
sortAscending = ascending,
)
}
}
}
}
init {
viewModelScope.launch {
playbackManager.currentLocalTrackIdFlow.collect { id ->
if (id != null) _uiState.update { it.copy(currentPlayingId = id) }
}
}
// Collapse duplicate saves (same artist+title) once per app session.
viewModelScope.launch {
val result = runCatching { dedupeLibrary.run() }.getOrNull()
if (result != null && result.removed > 0) {
_importMessage.value =
"Removed ${result.removed} duplicate song" +
(if (result.removed == 1) "" else "s")
}
}
}
private fun sortSongs(
songs: List<SavedSongEntity>,
order: SortOrder,
@ -169,30 +138,58 @@ class LibraryViewModel @Inject constructor(
}
fun playSong(song: SavedSongEntity) {
// Queue the visible list from the tapped song so transport next/prev
// and end-of-track advance through the library natively (ExoPlayer).
val queue = _uiState.value.songs
val startIdx = queue.indexOfFirst { it.id == song.id }.coerceAtLeast(0)
playQueue(queue, startIdx, shuffle = false)
playbackManager.playLocalFile(
filePath = song.filePath,
title = song.title,
artist = song.artist,
)
_uiState.update { it.copy(currentPlayingId = song.id, shuffleQueue = emptyList(), shuffleIndex = -1) }
}
fun shufflePlay() {
val queue = _uiState.value.songs
if (queue.isEmpty()) return
playQueue(queue, startIndex = 0, shuffle = true)
}
private fun playQueue(queue: List<SavedSongEntity>, startIndex: Int, shuffle: Boolean) {
playbackManager.playLocalQueue(
tracks = queue.map {
PlaybackManager.LocalTrack(it.id, it.filePath, it.title, it.artist)
},
startIndex = startIndex,
shuffle = shuffle,
val songs = _uiState.value.songs
if (songs.isEmpty()) return
val shuffled = songs.shuffled(Random)
_uiState.update { it.copy(shuffleQueue = shuffled, shuffleIndex = 0) }
val first = shuffled.first()
playbackManager.playLocalFile(
filePath = first.filePath,
title = first.title,
artist = first.artist,
)
_uiState.update { it.copy(currentPlayingId = queue[startIndex].id) }
_uiState.update { it.copy(currentPlayingId = first.id) }
}
fun playNextInShuffle(): Boolean {
val queue = _uiState.value.shuffleQueue
val idx = _uiState.value.shuffleIndex
if (queue.isEmpty() || idx < 0) return false
val nextIdx = idx + 1
if (nextIdx >= queue.size) return false
val next = queue[nextIdx]
playbackManager.playLocalFile(
filePath = next.filePath,
title = next.title,
artist = next.artist,
)
_uiState.update { it.copy(currentPlayingId = next.id, shuffleIndex = nextIdx) }
return true
}
fun playPreviousInShuffle(): Boolean {
val queue = _uiState.value.shuffleQueue
val idx = _uiState.value.shuffleIndex
if (queue.isEmpty() || idx <= 0) return false
val prevIdx = idx - 1
val prev = queue[prevIdx]
playbackManager.playLocalFile(
filePath = prev.filePath,
title = prev.title,
artist = prev.artist,
)
_uiState.update { it.copy(currentPlayingId = prev.id, shuffleIndex = prevIdx) }
return true
}
fun toggleFavorite(songId: Long) {
viewModelScope.launch {
@ -200,7 +197,8 @@ 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)
@ -209,25 +207,6 @@ 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
}
@ -255,38 +234,6 @@ 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) {
@ -317,28 +264,9 @@ data class LibraryUiState(
val selectedPlaylistId: Long? = null,
val sortOrder: SortOrder = SortOrder.DATE_SAVED,
val sortAscending: Boolean = true,
val shuffleQueue: List<SavedSongEntity> = emptyList(),
val shuffleIndex: Int = -1,
val isImporting: Boolean = false,
/** Multi-select union mode: tabs toggle membership, list shows the union. */
val isMultiSelect: Boolean = false,
val multiSelectedIds: Set<Long> = emptySet(),
) {
val isShuffleMode: Boolean get() = shuffleQueue.isNotEmpty() && shuffleIndex >= 0
}
/** 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

@ -29,7 +29,7 @@ import com.rada.ui.streams.StreamsScreen
fun NavGraph(navController: NavHostController) {
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showNowPlayingBar = currentRoute != Screen.Player.route && currentRoute != Screen.Tabs.route
val showNowPlayingBar = currentRoute != Screen.Player.route
val reduce = LocalReduceMotion.current
Column(modifier = Modifier.fillMaxSize()) {
@ -38,25 +38,6 @@ fun NavGraph(navController: NavHostController) {
startDestination = Screen.Player.route,
modifier = Modifier.weight(1f),
) {
composable(
route = Screen.Tabs.route,
arguments = listOf(
navArgument("index") { type = NavType.IntType; defaultValue = 0 },
navArgument("playlistId") { type = NavType.LongType; defaultValue = -1L },
),
enterTransition = { detailEnter(reduce) },
exitTransition = { detailExit(reduce) },
popEnterTransition = { detailPopEnter(reduce) },
popExitTransition = { detailPopExit(reduce) },
) { backStackEntry ->
val args = backStackEntry.arguments
TabsScreen(
initialIndex = args?.getInt("index") ?: 0,
initialPlaylistId = args?.getLong("playlistId")?.takeIf { it > 0 },
onBack = { navController.popBackStack() },
onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } },
)
}
composable(
Screen.Player.route,
enterTransition = { hubEnter(reduce) },
@ -65,11 +46,10 @@ fun NavGraph(navController: NavHostController) {
popExitTransition = { hubExit(reduce) },
) {
PlayerScreen(
onNavigateToStations = { navController.navigate(Screen.Tabs.createRoute(0)) },
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)) },
onNavigateToStations = { navController.navigate(Screen.Stations.route) },
onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) },
onNavigateToStreams = { navController.navigate(Screen.Streams.route) },
onNavigateToPlaylists = { navController.navigate(Screen.Playlists.route) },
)
}
composable(

View file

@ -10,8 +10,4 @@ sealed class Screen(val route: String) {
data object Stations : Screen("stations")
data object Streams : Screen("streams")
data object Playlists : Screen("playlists")
data object Tabs : Screen("tabs?index={index}&playlistId={playlistId}") {
fun createRoute(index: Int = 0, playlistId: Long? = null): String =
"tabs?index=$index" + (playlistId?.let { "&playlistId=$it" } ?: "")
}
}

View file

@ -1,130 +0,0 @@
package com.rada.ui.navigation
import androidx.compose.foundation.layout.Column
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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.foundation.layout.padding
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import com.rada.ui.library.LibraryScreen
import com.rada.ui.player.NowPlayingBar
import com.rada.ui.playlists.PlaylistManagerScreen
import com.rada.ui.stations.StationBrowserScreen
import com.rada.ui.streams.StreamsScreen
import com.rada.ui.theme.LocalTokens
import kotlinx.coroutines.launch
/**
* 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(
initialIndex: Int,
initialPlaylistId: Long? = null,
onBack: () -> Unit,
onHome: () -> Unit,
) {
val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 }
val scope = rememberCoroutineScope()
// Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) }
Scaffold(
containerColor = MaterialTheme.colorScheme.background,
bottomBar = {
Column {
NowPlayingBar(
onNavigateToPlayer = onHome,
)
NavigationBar(
containerColor = LocalTokens.current.panel.copy(alpha = 0.94f),
) {
val items = listOf(
Triple("Stations", Icons.Default.Radio, 0),
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(
selected = pagerState.settledPage == page,
onClick = {
scope.launch { pagerState.animateScrollToPage(page) }
},
icon = { Icon(icon, contentDescription = label) },
label = { Text(label) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
),
)
}
}
}
},
) { padding ->
HorizontalPager(
state = pagerState,
modifier = Modifier
.fillMaxSize()
.padding(bottom = padding.calculateBottomPadding()),
// Keep neighbors composed so swiping shows real content instantly
// and their ViewModels survive page changes.
beyondViewportPageCount = 1,
) { page ->
when (page) {
0 -> StationBrowserScreen(
onBack = onBack,
onHome = onHome,
onStationSelected = { /* stay on tab; bar shows the station */ },
)
1 -> com.rada.ui.discover.DiscoverScreen(
onBack = onBack,
onHome = onHome,
)
2 -> LibraryScreen(
onBack = onBack,
onHome = onHome,
initialPlaylistId = libraryPlaylistId,
)
3 -> StreamsScreen(
onBack = onBack,
onHome = onHome,
onStreamSelected = { },
)
4 -> PlaylistManagerScreen(
onBack = onBack,
onHome = onHome,
onPlaylistSelected = { playlistId ->
libraryPlaylistId = playlistId
scope.launch { pagerState.animateScrollToPage(2) }
},
)
}
}
}
}

View file

@ -1,14 +1,8 @@
package com.rada.ui.player
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.togetherWith
import com.rada.ui.components.EqualizerBars
import com.rada.ui.theme.metadataEnter
import com.rada.ui.theme.metadataExit
import com.rada.ui.theme.LocalReduceMotion
import com.rada.ui.theme.barEnter
import com.rada.ui.theme.verticalDragToDismiss
import com.rada.ui.theme.barExit
import androidx.compose.foundation.background
import androidx.compose.foundation.border
@ -28,7 +22,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Radio
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.Bolt
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@ -38,8 +31,6 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -64,39 +55,7 @@ fun NowPlayingBar(
val isPlaying by viewModel.isPlaying.collectAsState()
val bufferState by viewModel.bufferState.collectAsState()
val isLocal by viewModel.isLocal.collectAsState()
val streamMeta by viewModel.streamMeta.collectAsState()
val localMedia by viewModel.localMedia.collectAsState()
val isError = bufferState == PlaybackManager.BufferState.ERROR
val isBuffering =
bufferState == PlaybackManager.BufferState.BUFFERING ||
bufferState == PlaybackManager.BufferState.INITIALIZING
// Swipe-down (or quick drag-down) dismisses the bar until a *new*
// source starts playing — a floating thing should answer to gestures.
var dismissedFor by remember { mutableStateOf<String?>(null) }
val sourceKey = stationName
if (dismissedFor != null && dismissedFor != sourceKey) dismissedFor = null
val isVisible = stationName.isNotEmpty() && dismissedFor != sourceKey
// Spotify-style two-line bar: track (or station) + artist (or station).
val title: String
val subtitle: String
if (isLocal) {
title = localMedia?.first?.takeIf { it.isNotEmpty() } ?: stationName
subtitle = localMedia?.second.orEmpty()
} else {
val meta = streamMeta
val track = meta?.displayTitle?.takeIf { it.isNotEmpty() }
if (track != null) {
// Radio playing a tagged track: track on top, "artist · Station"
// below so the track is always attributable to its station.
title = track
val artist = meta?.displayArtist.orEmpty()
subtitle = if (artist.isNotEmpty()) "$artist · $stationName" else stationName
} else {
title = stationName
subtitle = ""
}
}
val isVisible = stationName.isNotEmpty()
AnimatedVisibility(
visible = isVisible,
@ -106,10 +65,6 @@ fun NowPlayingBar(
Column(
modifier = Modifier
.fillMaxWidth()
.verticalDragToDismiss(
thresholdFraction = 0.4f,
onDismiss = { dismissedFor = sourceKey },
)
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(SatsAmpGlass.rCard))
// glass: textSafe 90% overlay + 6% white + highlight border
@ -150,31 +105,13 @@ fun NowPlayingBar(
.background(LocalTokens.current.accentFill),
contentAlignment = Alignment.Center,
) {
if (isPlaying && !isBuffering) {
EqualizerBars(
active = true,
color = LocalTokens.current.accentOnFill,
barWidth = 3.dp,
modifier = Modifier.size(22.dp),
)
} else if (isBuffering && !isError) {
EqualizerBars(
active = false,
color = LocalTokens.current.accentOnFill.copy(alpha = 0.7f),
barWidth = 3.dp,
modifier = Modifier
.size(22.dp)
.padding(2.dp),
)
} else {
Icon(
if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio,
contentDescription = "Paused",
contentDescription = if (isLocal) "Now playing" else "Now playing station",
tint = LocalTokens.current.accentOnFill,
modifier = Modifier.size(24.dp),
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Column(
modifier = Modifier
@ -185,14 +122,8 @@ fun NowPlayingBar(
onClick = onNavigateToPlayer,
),
) {
Column {
AnimatedContent(
targetState = title,
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "npTitle",
) { t ->
Text(
text = t,
text = stationName,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
@ -200,71 +131,6 @@ fun NowPlayingBar(
color = LocalTokens.current.text,
)
}
if (isError) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = { viewModel.retry() },
),
) {
Text(
"Couldn't reach · ",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
Text(
"Retry",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
color = tokens.accentText,
)
}
} else if (subtitle.isNotEmpty()) {
AnimatedContent(
targetState = subtitle,
transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) },
label = "npSubtitle",
) { st ->
Text(
text = st,
style = MaterialTheme.typography.bodySmall,
color = LocalTokens.current.textSecondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
// ⚡ 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),
) {
Icon(
Icons.Default.Bolt,
contentDescription = "Support artist",
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)
}
if (showTip) {
com.rada.ui.decentralized.TipSheet(
artistName = stationName,
trackTitle = title.takeIf { subtitle.isNotEmpty() },
initialAddress = zapTarget?.second.orEmpty(),
artistId = zapTarget?.first,
onDismiss = { showTip = false },
)
}
IconButton(
onClick = { viewModel.togglePlayPause() },
modifier = Modifier.size(44.dp),

View file

@ -1,24 +1,14 @@
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
@ -26,42 +16,8 @@ class NowPlayingViewModel @Inject constructor(
val isPlaying: StateFlow<Boolean> = playbackManager.isPlayingFlow
val bufferState: StateFlow<PlaybackManager.BufferState> = playbackManager.bufferStateFlow
val isLocal: StateFlow<Boolean> = playbackManager.isLocalFlow
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()
}
/** Reconnect the current station (mini-player error state). */
fun retry() {
val url = playbackManager.currentStationUrl ?: return
playbackManager.playStation(url, playbackManager.currentStationName)
}
}

View file

@ -15,15 +15,11 @@ 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
@ -38,12 +34,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
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.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
@ -56,12 +48,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
@ -86,16 +78,13 @@ 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.material3.TextButton
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
@ -110,11 +99,7 @@ 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
@ -123,7 +108,6 @@ import com.rada.ui.theme.PremiumCyan
@Composable
fun PlayerScreen(
onNavigateToStations: () -> Unit,
onNavigateToDiscover: () -> Unit,
onNavigateToLibrary: () -> Unit,
onNavigateToStreams: () -> Unit,
onNavigateToPlaylists: () -> Unit,
@ -133,10 +117,8 @@ 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) }
val importSongsPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
@ -207,26 +189,14 @@ fun PlayerScreen(
playlists = playlists,
newPlaylistName = uiState.newPlaylistName,
onNewPlaylistNameChange = viewModel::updateNewPlaylistName,
onPlaylistsConfirmed = viewModel::addToPlaylists,
onPlaylistSelected = viewModel::addToPlaylist,
onCreatePlaylist = { viewModel.createPlaylistAndAdd(uiState.newPlaylistName) },
onDismiss = viewModel::dismissPlaylistPicker,
)
}
if (showAppearance) {
AppearanceDialog(
onDismiss = { showAppearance = false },
onOpenDecentralized = {
showAppearance = false
showDecentralized = true
},
)
}
if (showDecentralized) {
com.rada.ui.decentralized.DecentralizedSheet(
onDismiss = { showDecentralized = false },
)
AppearanceDialog(onDismiss = { showAppearance = false })
}
Scaffold(
@ -277,17 +247,6 @@ 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,
@ -329,9 +288,6 @@ fun PlayerScreen(
uiState = uiState,
viewModel = viewModel,
modifier = Modifier.padding(padding),
onSaveDisabled = { message ->
scope.launch { snackbarHostState.showSnackbar(message) }
},
)
} else {
EmptyStateView(
@ -353,8 +309,7 @@ private fun EmptyStateView(
modifier = modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background)
.verticalScroll(rememberScrollState())
.padding(horizontal = 32.dp, vertical = 24.dp),
.padding(horizontal = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
@ -500,24 +455,21 @@ 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)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 24.dp),
.padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
// Station card
uiState.currentStation?.let { station ->
StationHero(
StationCard(
station = station,
bufferState = uiState.bufferState,
isPlaying = uiState.isPlaying,
)
}
// Local library playback: a card for the song instead of a station.
@ -617,58 +569,38 @@ private fun NowPlayingView(
}
}
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))
}
}
Spacer(modifier = Modifier.height(48.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: 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() }
// Playback controls — conventional transport order: stop · prev · play · next
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { tap(viewModel::previous) },
enabled = transportEnabled,
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.previousStation() },
enabled = uiState.currentStation != null || uiState.isLocal,
) {
Icon(
Icons.Default.SkipPrevious,
@ -680,24 +612,16 @@ private fun NowPlayingView(
Spacer(modifier = Modifier.width(16.dp))
FilledIconButton(
onClick = { tap(viewModel::togglePlayPause) },
enabled = transportEnabled,
modifier = Modifier
.size(72.dp)
.pressDip(playInteraction, reduce),
onClick = { viewModel.togglePlayPause() },
enabled = uiState.currentStation != null || uiState.isLocal,
modifier = Modifier.size(72.dp),
shape = CircleShape,
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = MaterialTheme.colorScheme.primary,
),
interactionSource = playInteraction,
) {
AnimatedContent(
// 0 = waiting, 1 = playing, 2 = paused/stopped.
targetState = when {
waitingForAudio -> 0
uiState.isPlaying -> 1
else -> 2
},
targetState = uiState.isPlaying,
transitionSpec = {
if (reduce)
fadeIn(tween(0)) togetherWith fadeOut(tween(0))
@ -708,82 +632,28 @@ private fun NowPlayingView(
fadeOut(tween(90))
},
label = "playPause",
) { visual ->
when (visual) {
0 -> CircularProgressIndicator(
modifier = Modifier.size(32.dp),
strokeWidth = 3.dp,
)
1 -> Icon(
Icons.Default.Pause,
contentDescription = "Pause",
) { playing ->
Icon(
if (playing) Icons.Default.Pause else Icons.Default.PlayArrow,
contentDescription = if (playing) "Pause" else "Play",
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 = { tap(viewModel::next) },
enabled = transportEnabled,
onClick = { viewModel.nextStation() },
enabled = uiState.currentStation != null,
) {
Icon(
Icons.Default.SkipNext,
contentDescription = if (uiState.isLocal) "Next track" else "Next station",
contentDescription = "Next station",
modifier = Modifier.size(32.dp),
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Playback options: repeat · shuffle (local queue only).
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(
onClick = { viewModel.cycleRepeat() },
enabled = uiState.isLocal,
) {
Icon(
if (uiState.repeatMode == androidx.media3.common.Player.REPEAT_MODE_ONE)
Icons.Default.RepeatOne
else
Icons.Default.Repeat,
contentDescription = "Repeat",
tint = if (uiState.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF)
MaterialTheme.colorScheme.primary
else
LocalTokens.current.textSecondary.copy(
alpha = if (uiState.isLocal) 1f else 0.38f,
),
)
}
IconButton(
onClick = { viewModel.toggleShuffle() },
enabled = uiState.isLocal,
) {
Icon(
Icons.Default.Shuffle,
contentDescription = "Shuffle",
tint = if (uiState.shuffleEnabled)
MaterialTheme.colorScheme.primary
else
LocalTokens.current.textSecondary.copy(
alpha = if (uiState.isLocal) 1f else 0.38f,
),
)
}
}
}
}
@ -792,13 +662,12 @@ private fun PlaylistPickerDialog(
playlists: List<com.rada.data.local.entity.PlaylistEntity>,
newPlaylistName: String,
onNewPlaylistNameChange: (String) -> Unit,
onPlaylistsConfirmed: (List<Long>) -> Unit,
onPlaylistSelected: (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
@ -814,7 +683,7 @@ private fun PlaylistPickerDialog(
tint = MaterialTheme.colorScheme.primary,
)
},
title = { Text("Add to playlists") },
title = { Text("Add to Playlist") },
text = {
Column {
if (playlists.isNotEmpty()) {
@ -845,25 +714,18 @@ private fun PlaylistPickerDialog(
modifier = Modifier.heightIn(max = 200.dp),
) {
items(filteredPlaylists) { 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),
.clickable { onPlaylistSelected(playlist.id) }
.padding(vertical = 12.dp, horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Checkbox(
checked = checked,
onCheckedChange = { isChecked ->
selectedIds =
if (isChecked) selectedIds + playlist.id
else selectedIds - playlist.id
},
Icon(
Icons.AutoMirrored.Filled.PlaylistAdd,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
Spacer(modifier = Modifier.width(12.dp))
Text(
@ -934,14 +796,7 @@ private fun PlaylistPickerDialog(
}
}
},
confirmButton = {
TextButton(
onClick = { onPlaylistsConfirmed(selectedIds.toList()) },
enabled = selectedIds.isNotEmpty(),
) {
Text(if (selectedIds.size == 1) "Add" else "Add to ${selectedIds.size}")
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
@ -955,10 +810,8 @@ 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)
@ -976,18 +829,17 @@ private fun SaveSongButton(
}
FilledIconButton(
// 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,
onClick = onClick,
enabled = canSave && saveState != PlaybackManager.SaveState.SAVING,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(20.dp)),
shape = RoundedCornerShape(20.dp),
colors = IconButtonDefaults.filledIconButtonColors(
containerColor = if (enabled) containerColor else containerColor.copy(alpha = 0.5f),
contentColor = if (enabled) contentColor else contentColor.copy(alpha = 0.5f),
containerColor = containerColor,
contentColor = contentColor,
disabledContainerColor = containerColor.copy(alpha = 0.5f),
disabledContentColor = contentColor.copy(alpha = 0.5f),
),
) {
AnimatedContent(
@ -1036,90 +888,73 @@ private fun SaveSongButton(
}
@Composable
private fun StationHero(
private fun StationCard(
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 = Color.Transparent,
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
shape = RoundedCornerShape(SatsAmpGlass.rCard),
shape = RoundedCornerShape(16.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(16f / 10f)
.background(SatsAmpGradients.accentBrush()),
.size(48.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Radio,
contentDescription = null,
tint = tokens.accentOnFill.copy(alpha = 0.30f),
modifier = Modifier
.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
}
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),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = station.name,
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = tokens.accentOnFill,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
)
if (station.genre.isNotEmpty()) {
Text(
text = station.genre,
style = MaterialTheme.typography.bodyMedium,
color = tokens.accentOnFill.copy(alpha = 0.85f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
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,14 +107,10 @@ 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,
)
}
}
@ -147,49 +143,9 @@ 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()
// Keep local queue state (title/artist across track changes) live.
viewModelScope.launch {
playbackManager.localMediaFlow.collect { local ->
_uiState.update {
it.copy(
localTitle = local?.first.orEmpty(),
localArtist = local?.second.orEmpty(),
)
}
}
}
viewModelScope.launch {
playbackManager.speedFlow.collect { v -> _uiState.update { it.copy(speed = v) } }
}
viewModelScope.launch {
playbackManager.sleepRemainingMsFlow.collect { ms ->
_uiState.update { it.copy(sleepRemainingMs = ms) }
}
}
viewModelScope.launch {
playbackManager.repeatModeFlow.collect { mode ->
_uiState.update { it.copy(repeatMode = mode) }
}
}
viewModelScope.launch {
playbackManager.shuffleEnabledFlow.collect { on ->
_uiState.update { it.copy(shuffleEnabled = on) }
}
}
}
/** Re-apply the last ICY metadata so a reopened screen isn't blank. */
@ -235,38 +191,6 @@ class PlayerViewModel @Inject constructor(
}
}
/** Transport next: queue advance for local playback, station cycle for radio. */
fun next() {
if (_uiState.value.isLocal) { playbackManager.seekToNextItem(); return }
nextStation()
}
fun previous() {
if (_uiState.value.isLocal) { playbackManager.seekToPreviousItem(); return }
previousStation()
}
fun setSpeed(speed: Float) = playbackManager.setSpeed(speed)
fun cycleRepeat() {
val next = when (_uiState.value.repeatMode) {
androidx.media3.common.Player.REPEAT_MODE_OFF ->
androidx.media3.common.Player.REPEAT_MODE_ALL
androidx.media3.common.Player.REPEAT_MODE_ALL ->
androidx.media3.common.Player.REPEAT_MODE_ONE
else -> androidx.media3.common.Player.REPEAT_MODE_OFF
}
playbackManager.setRepeatMode(next)
}
fun toggleShuffle() {
playbackManager.setShuffleEnabled(!_uiState.value.shuffleEnabled)
}
fun startSleepTimer(minutes: Int) = playbackManager.startSleepTimer(minutes)
fun cancelSleepTimer() = playbackManager.cancelSleepTimer()
fun nextStation() {
val stations = _uiState.value.stations
if (stations.isEmpty()) return
@ -298,42 +222,25 @@ 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",
)
}
}
}
/** Adds the picked song to one or more playlists, then closes the picker. */
fun addToPlaylists(playlistIds: List<Long>) {
fun addToPlaylist(playlistId: Long) {
val songId = _uiState.value.pickedSongId ?: return
if (playlistIds.isEmpty()) return
viewModelScope.launch {
playlistIds.forEach { playlistRepository.addSongToPlaylist(it, songId) }
playlistRepository.addSongToPlaylist(playlistId, songId)
_uiState.update {
it.copy(
showPlaylistPicker = false,
pickedSongId = null,
pickedSongTitle = "",
newPlaylistName = "",
playlistPickerMessage = if (playlistIds.size == 1) "Added to playlist"
else "Added to ${playlistIds.size} playlists",
playlistPickerMessage = "Added to playlist",
)
}
}
@ -348,7 +255,6 @@ class PlayerViewModel @Inject constructor(
it.copy(
showPlaylistPicker = false,
pickedSongId = null,
pickedSongTitle = "",
newPlaylistName = "",
playlistPickerMessage = "Created \"$name\" and added song",
)
@ -381,43 +287,16 @@ 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()
// 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
}
}
}
data class PlayerUiState(
val currentStation: Station? = null,
@ -435,13 +314,8 @@ data class PlayerUiState(
val bufferBytesWritten: Long = 0,
val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE,
val lastSaveResult: SaveSongUseCase.SaveResult? = null,
val speed: Float = 1f,
val sleepRemainingMs: Long = 0L,
val repeatMode: Int = androidx.media3.common.Player.REPEAT_MODE_OFF,
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,49 +15,36 @@ 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.MoreVert
import androidx.compose.material.icons.filled.Radio
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
@ -73,11 +60,24 @@ import androidx.compose.ui.unit.dp
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
import com.rada.ui.theme.SatsAmpGlass
import com.rada.ui.theme.SatsAmpGradients
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,
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -92,46 +92,8 @@ 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(
@ -183,7 +145,6 @@ fun StationBrowserScreen(
)
},
containerColor = Color.Transparent,
snackbarHost = { SnackbarHost(snackbarHostState) },
) { padding ->
Column(
modifier = Modifier
@ -212,93 +173,17 @@ 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}" }, contentType = { "station" }) { station ->
items(favorites, key = { "fav-${it.id}" }) { station ->
StationCard(
station = station,
isCustom = viewModel.isCustomStation(station.id),
isFavorite = true,
isPlaying = playingUrl == station.url,
onClick = {
@ -307,21 +192,20 @@ fun StationBrowserScreen(
},
onToggleFavorite = { viewModel.toggleFavorite(station) },
onRename = { renameTarget = station },
onDelete = { viewModel.removeStation(station.id) },
wavlakeEnabled = viewModel.isWavlakeFeaturing(station.id).also { wavlakeVersion },
onToggleWavlake = { viewModel.toggleWavlakeFeaturing(station.id) },
onDelete = { viewModel.removeCustomStation(station.id) },
)
}
item { Spacer(modifier = Modifier.height(8.dp)) }
}
visibleGenres.forEach { (genre, stations) ->
item(key = "genre-$genre") {
stationsByGenre.forEach { (genre, stations) ->
item {
GenreHeader(genre = genre, count = stations.size)
}
items(stations, key = { it.id }, contentType = { "station" }) { station ->
items(stations, key = { it.id }) { station ->
StationCard(
station = station,
isCustom = viewModel.isCustomStation(station.id),
isFavorite = viewModel.isFavorite(station.id),
isPlaying = playingUrl == station.url,
onClick = {
@ -331,10 +215,8 @@ fun StationBrowserScreen(
onToggleFavorite = { viewModel.toggleFavorite(station) },
onRename = { renameTarget = station },
onDelete = {
viewModel.removeStation(station.id)
viewModel.removeCustomStation(station.id)
},
wavlakeEnabled = viewModel.isWavlakeFeaturing(station.id).also { wavlakeVersion },
onToggleWavlake = { viewModel.toggleWavlakeFeaturing(station.id) },
)
}
item {
@ -342,39 +224,6 @@ 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
@ -409,9 +258,10 @@ fun StationBrowserScreen(
is DirectoryState.Results -> {
if (ds.stations.isNotEmpty()) {
item { DirectorySectionHeader("From the directory") }
items(ds.stations, key = { "rb-${it.id}" }, contentType = { "station" }) { station ->
items(ds.stations, key = { "rb-${it.id}" }) { station ->
StationCard(
station = station,
isCustom = false,
isFavorite = viewModel.isFavorite(station.id),
isPlaying = playingUrl == station.url,
onClick = {
@ -439,107 +289,6 @@ 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,
)
}
}
}
@ -680,76 +429,12 @@ private fun RenameStationDialog(
)
}
@Composable
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
val colorInt = genreColors.entries.find { (key, _) ->
genre.contains(key, ignoreCase = true)
}?.value ?: 0xFF7C3AED
Row(
modifier = Modifier
.fillMaxWidth()
@ -760,21 +445,21 @@ private fun GenreHeader(genre: String, count: Int) {
modifier = Modifier
.size(8.dp)
.clip(CircleShape)
.background(tokens.accentFill),
.background(androidx.compose.ui.graphics.Color(colorInt)),
)
Spacer(modifier = Modifier.width(10.dp))
Text(
text = genre.uppercase(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = tokens.accentText,
color = androidx.compose.ui.graphics.Color(colorInt),
letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "$count",
style = MaterialTheme.typography.labelMedium,
color = tokens.textSecondary,
color = LocalTokens.current.textSecondary,
)
}
}
@ -782,6 +467,7 @@ private fun GenreHeader(genre: String, count: Int) {
@Composable
private fun StationCard(
station: Station,
isCustom: Boolean,
isFavorite: Boolean,
isPlaying: Boolean,
onClick: () -> Unit,
@ -789,50 +475,41 @@ 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),
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
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(SatsAmpGlass.rInput),
shape = RoundedCornerShape(12.dp),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (isPlaying) tokens.accentFill
else tokens.accentFill.copy(alpha = 0.15f),
),
.background(tokens.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
if (isPlaying) {
EqualizerBars(active = true, color = tokens.accentOnFill)
} else {
Icon(
Icons.Default.PlayArrow,
if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio,
contentDescription = null,
tint = tokens.accentText,
modifier = Modifier.size(22.dp),
modifier = Modifier.size(20.dp),
)
}
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@ -854,84 +531,41 @@ private fun StationCard(
)
}
}
IconButton(
onClick = onToggleFavorite,
modifier = Modifier.size(48.dp),
) {
IconButton(onClick = onToggleFavorite, modifier = Modifier.size(40.dp)) {
Icon(
imageVector = if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder,
contentDescription = if (isFavorite) "Unstar ${station.name}" else "Star ${station.name}",
contentDescription = if (isFavorite) "Unstar" else "Star",
tint = if (isFavorite) tokens.accentFill else tokens.textSecondary,
modifier = Modifier.size(22.dp),
modifier = Modifier.size(20.dp),
)
}
// Directory stations: offer saving into the local list.
if (onAdd != null) {
IconButton(
onClick = onAdd,
modifier = Modifier.size(48.dp),
) {
IconButton(onClick = onAdd, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.Add,
contentDescription = "Save ${station.name} to stations",
contentDescription = "Save to stations",
tint = tokens.textSecondary,
modifier = Modifier.size(22.dp),
modifier = Modifier.size(20.dp),
)
}
} else {
var showMenu by remember { mutableStateOf(false) }
Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier.size(48.dp),
) {
IconButton(onClick = onRename, modifier = Modifier.size(40.dp)) {
Icon(
Icons.Default.MoreVert,
contentDescription = "More actions for ${station.name}",
Icons.Default.Edit,
contentDescription = "Rename",
tint = tokens.textSecondary,
modifier = Modifier.size(22.dp),
modifier = Modifier.size(20.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 = {
if (isCustom) {
IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) {
Icon(
if (wavlakeEnabled) Icons.Default.Check
else Icons.Default.Add,
contentDescription = null,
Icons.Default.Delete,
contentDescription = "Remove",
tint = tokens.textSecondary,
modifier = Modifier.size(20.dp),
)
},
onClick = {
showMenu = false
onToggleWavlake()
},
)
}
}
}
}

View file

@ -4,9 +4,6 @@ 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
@ -23,13 +20,6 @@ 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
@ -44,7 +34,6 @@ 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())
@ -54,35 +43,29 @@ 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 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 {
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)
}
// 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 }
filtered.sortedBy { it.name.lowercase() }
.groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } }
.toSortedMap(compareBy { it.lowercase() })
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap())
// ── Directory search (radio-browser.info, ~58k stations) ───────────
@ -90,10 +73,6 @@ class StationBrowserViewModel @Inject constructor(
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
@ -114,37 +93,6 @@ 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) {
@ -184,32 +132,9 @@ class StationBrowserViewModel @Inject constructor(
_allStations.value = stationRepository.getAll()
}
/** Deletes any station (built-ins are hidden, customs removed) with Undo. */
fun removeStation(id: String) {
val station = stationRepository.getById(id) ?: return
lastDeleted = station
fun removeCustomStation(id: String) {
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) {
@ -230,15 +155,4 @@ 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,6 +1,7 @@
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
@ -55,7 +56,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.rada.domain.model.StreamEntry
import com.rada.ui.components.AppHomeButton
import com.rada.ui.components.EqualizerBars
import com.rada.ui.theme.LocalTokens
@OptIn(ExperimentalMaterial3Api::class)
@ -211,7 +211,7 @@ fun StreamsScreen(
item(key = "header-$group") {
GroupHeader(group = group, count = entries.size)
}
items(entries, key = { it.id }, contentType = { "stream" }) { entry ->
items(entries, key = { it.id }) { entry ->
StreamCard(
entry = entry,
isPlaying = currentStreamUrl == entry.url,
@ -393,10 +393,10 @@ private fun StreamCard(
onDelete: () -> Unit,
) {
Card(
onClick = onClick,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp),
.padding(horizontal = 12.dp)
.clickable(onClick = onClick),
colors = CardDefaults.cardColors(
containerColor = if (isPlaying) LocalTokens.current.accentFill.copy(alpha = 0.12f) else LocalTokens.current.glassFillStrong,
),
@ -415,21 +415,13 @@ private fun StreamCard(
.background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)),
contentAlignment = Alignment.Center,
) {
if (isPlaying) {
EqualizerBars(
active = true,
color = LocalTokens.current.accentOnFill,
modifier = Modifier.size(16.dp),
)
} else {
Icon(
Icons.Default.PlayArrow,
if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow,
contentDescription = null,
tint = LocalTokens.current.accentText,
tint = if (isPlaying) Color.White else LocalTokens.current.accentText,
modifier = Modifier.size(22.dp),
)
}
}
Spacer(modifier = Modifier.width(14.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
@ -449,7 +441,7 @@ private fun StreamCard(
IconButton(onClick = onRename) {
Icon(
Icons.Default.Edit,
contentDescription = "Rename ${entry.name}",
contentDescription = "Rename",
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)
@ -457,7 +449,7 @@ private fun StreamCard(
IconButton(onClick = onDelete) {
Icon(
Icons.Default.Delete,
contentDescription = "Remove ${entry.name}",
contentDescription = "Remove",
tint = LocalTokens.current.textSecondary,
modifier = Modifier.size(20.dp),
)

View file

@ -45,10 +45,7 @@ internal val AccentSwatches = mapOf(
* the whole UI recomposes live through LocalTokens.
*/
@Composable
fun AppearanceDialog(
onDismiss: () -> Unit,
onOpenDecentralized: (() -> Unit)? = null,
) {
fun AppearanceDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
val tokens = LocalTokens.current
val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark()
@ -144,16 +141,9 @@ fun AppearanceDialog(
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (onOpenDecentralized != null) {
TextButton(onClick = onOpenDecentralized) {
Text("⚡ Nostr", color = tokens.accentText)
}
}
TextButton(onClick = onDismiss) {
Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold)
}
}
},
)
}

View file

@ -1,18 +1,5 @@
package com.rada.ui.theme
import androidx.compose.ui.unit.dp
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.foundation.gestures.detectVerticalDragGestures
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.CubicBezierEasing
@ -74,24 +61,24 @@ fun hubExit(reduce: Boolean): ExitTransition =
// and pop back to the left, so direction teaches the hierarchy. Slide is a
// quarter-travel, not a full page wipe — fast screens stay legible mid-motion.
fun detailEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
fun detailEnter(reduce: Boolean): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) it / 4 else -it / 4 } +
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { it / 4 } +
fadeIn(tween(StateMs, easing = MotionArrival))
fun detailExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
fun detailExit(reduce: Boolean): ExitTransition =
if (reduce) fadeOut(tween(ExitFastMs))
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) -it / 8 else it / 8 } +
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { -it / 8 } +
fadeOut(tween(ExitFastMs, easing = MotionExit))
fun detailPopEnter(reduce: Boolean, forward: Boolean = true): EnterTransition =
fun detailPopEnter(reduce: Boolean): EnterTransition =
if (reduce) fadeIn(tween(ExitFastMs))
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) -it / 8 else it / 8 } +
else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { -it / 4 } +
fadeIn(tween(StateMs, easing = MotionArrival))
fun detailPopExit(reduce: Boolean, forward: Boolean = true): ExitTransition =
fun detailPopExit(reduce: Boolean): ExitTransition =
if (reduce) fadeOut(tween(ExitFastMs))
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) it / 4 else -it / 4 } +
else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { it / 8 } +
fadeOut(tween(ExitFastMs, easing = MotionExit))
// ── Now-playing bar ───────────────────────────────────────────────────────
@ -120,62 +107,3 @@ fun metadataExit(reduce: Boolean): ExitTransition =
/** Duration for press/feedback animations, zeroed under reduce motion. */
fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs
// ── Press feedback ────────────────────────────────────────────────────────
// Taps should feel physical before navigation resolves: the row dips
// (scale 0.97) on press and springs back on release. Transform-only, so
// it's cheap; zeroed under Reduce Motion by the caller.
/**
* Press-dip for list rows: scales to 0.97 while pressed. Use on rows that
* already have a clickable/combinedClickable modifier earlier in the chain.
*/
fun Modifier.pressDip(
interactionSource: MutableInteractionSource,
reduce: Boolean,
): Modifier = composed {
if (reduce) return@composed this
val pressed by interactionSource.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (pressed) 0.97f else 1f,
animationSpec = androidx.compose.animation.core.tween(
durationMillis = if (pressed) 90 else 160,
easing = if (pressed) MotionExit else MotionArrival,
),
label = "pressDip",
)
this.graphicsLayer { scaleX = scale; scaleY = scale }
}
// ── Dismiss-by-drag ───────────────────────────────────────────────────────
// A floating bar should answer to a downward drag: it follows the finger
// (translationY only) and dismisses past a threshold or on a downward fling;
// otherwise it springs back. Reduce Motion: no follow, threshold-only dismiss.
fun Modifier.verticalDragToDismiss(
thresholdFraction: Float = 0.5f,
onDismiss: () -> Unit,
): Modifier = composed {
val reduce = LocalReduceMotion.current
var dragPx by remember { mutableFloatStateOf(0f) }
val density = androidx.compose.ui.platform.LocalDensity.current
val maxDrag = with(density) { 120.dp.toPx() }
this
.graphicsLayer { translationY = if (reduce) 0f else dragPx }
.pointerInput(Unit) {
detectVerticalDragGestures(
onDragEnd = {
if (dragPx > size.height * thresholdFraction) onDismiss()
dragPx = 0f
},
onDragCancel = { dragPx = 0f },
onVerticalDrag = { change, amount ->
if (amount > 0) {
change.consume()
dragPx = (dragPx + amount).coerceIn(0f, maxDrag)
}
},
)
}
}

View file

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

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

@ -1,91 +0,0 @@
package com.rada.nostr
import fr.acinq.secp256k1.Secp256k1
import fr.acinq.secp256k1.jni.NativeSecp256k1JvmLoader
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Protocol conformance tests against official spec vectors.
* Runs on the JVM via secp256k1-kmp-jni-jvm (testImplementation).
*/
class NostrProtocolTest {
init {
// Load the JVM native binding for unit tests only.
NativeSecp256k1JvmLoader.load()
}
@Test
fun `NIP-19 npub encodes known vector`() {
val pub = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e"
assertEquals(
"npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg",
Nip19.npub(pub),
)
}
@Test
fun `NIP-19 nsec encodes and decodes known vector`() {
val priv = "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa"
val enc = Nip19.nsec(priv)
assertEquals("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", enc)
val dec = Nip19.decode(enc)
assertNotNull(dec)
val (hrp, bytes) = dec!!
assertEquals("nsec", hrp)
assertEquals(priv, bytes.toHex())
}
@Test
fun `schnorr sign verifies with BIP-340 semantics`() {
// sk=3 -> x-only pubkey F9308A... (BIP-340 vector 0)
val sk = ByteArray(32).also { it[31] = 3 }
val pub = Secp256k1.get().pubkeyCreate(sk).copyOfRange(1, 33)
assertEquals(
"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
pub.toHex(),
)
val msg = ByteArray(32)
val sig = Secp256k1.get().signSchnorr(msg, sk, null)
assertEquals(64, sig.size)
assertTrue(Secp256k1.get().verifySchnorr(sig, msg, pub))
}
@Test
fun `event id is sha256 of NIP-01 serialization`() {
val unsigned = UnsignedEvent(
pubkey = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",
createdAt = 1700000000,
kind = 1,
tags = listOf(listOf("e", "abc", "wss://relay.x"), listOf("d", "t1")),
content = "hello world",
)
// Canonical serialization per NIP-01 (no whitespace, fixed order).
assertEquals(
"""[0,"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",1700000000,1,[["e","abc","wss://relay.x"],["d","t1"]],"hello world"]""",
unsigned.serialized(),
)
// Sign with sk=3 and verify() must pass end to end.
val ev = unsigned.sign { digest -> Secp256k1.get().signSchnorr(digest, ByteArray(32).also { it[31] = 3 }, null).toHex() }
assertNotNull(ev)
assertTrue("event self-verification (id hash + schnorr)", ev!!.verify())
// Tampering must break verification.
assertFalse(ev.copy(content = "tampered").verify())
}
@Test
fun `nip-04 roundtrip between two identities`() {
val skA = ByteArray(32).also { it[31] = 11 }
val skB = ByteArray(32).also { it[31] = 22 }
val pubA = Secp256k1.get().pubkeyCreate(skA).copyOfRange(1, 33).toHex()
val pubB = Secp256k1.get().pubkeyCreate(skB).copyOfRange(1, 33).toHex()
val msg = "zap 21 sats ⚡"
val enc = Nip04.encrypt(skA, pubB, msg)
org.junit.Assert.assertEquals(msg, Nip04.decrypt(skB, pubA, enc))
}
private fun assertFalse(b: Boolean) = org.junit.Assert.assertFalse(b)
}

View file

@ -1,58 +0,0 @@
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"))
}
}