diff --git a/PRODUCT.md b/PRODUCT.md index a545b51..e7d25d2 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -26,7 +26,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live ## Operating Context - Single-user personal device use; headphone and speaker listening. -- Streams are live and can drop; playback is a foreground service with notification controls. 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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b2bf88..e8dfd23 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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") diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index bb952aa..040a549 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -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.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 5df91ae..3285b91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,16 +8,6 @@ - - - - - - - - - - > - /** - * Union of songs across several playlists. DISTINCT because a song can - * sit in more than one selected list. - */ - @Query( - """ - SELECT DISTINCT s.* FROM saved_songs s - INNER JOIN playlist_songs ps ON s.id = ps.songId - WHERE ps.playlistId IN (:playlistIds) - """ - ) - fun getSongsInPlaylists(playlistIds: List): Flow> - @Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId") fun getPlaylistSongCount(playlistId: Long): Flow diff --git a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt index a0f6761..6a76890 100644 --- a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt +++ b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt @@ -14,9 +14,6 @@ interface SavedSongDao { @Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC") fun getAllByDate(): Flow> - @Query("SELECT * FROM saved_songs ORDER BY id ASC") - suspend fun getAllOnce(): List - @Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC") fun getAllByArtist(): Flow> diff --git a/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt b/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt index cafbe77..be6e767 100644 --- a/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt +++ b/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt @@ -18,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 = "", ) diff --git a/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt b/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt deleted file mode 100644 index 2171301..0000000 --- a/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt +++ /dev/null @@ -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 { 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" - } -} diff --git a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt index 456c193..24f8f28 100644 --- a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt +++ b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt @@ -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): Flow> { - return playlistDao.getSongsInPlaylists(playlistIds) - } - fun getPlaylistSongCount(playlistId: Long): Flow { return playlistDao.getPlaylistSongCount(playlistId) } diff --git a/app/src/main/java/com/rada/data/repository/StationRepository.kt b/app/src/main/java/com/rada/data/repository/StationRepository.kt index 5421fbd..efee1bf 100644 --- a/app/src/main/java/com/rada/data/repository/StationRepository.kt +++ b/app/src/main/java/com/rada/data/repository/StationRepository.kt @@ -24,10 +24,6 @@ class StationRepository @Inject constructor( private val stations = mutableListOf() private val nameOverrides = mutableMapOf() - /** Built-in station ids the user deleted. Persisted; survives restarts. */ - private val _hiddenIds = MutableStateFlow(loadHiddenIds()) - val hiddenIds: StateFlow> = _hiddenIds.asStateFlow() - init { nameOverrides.putAll(loadNameOverrides()) rebuild() @@ -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 { val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap() + private fun loadNameOverrides(): Map { + val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap() return try { val obj = JSONObject(json) obj.keys().asSequence().associateWith { key -> obj.getString(key) } @@ -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 { - val json = prefs.getString(KEY_HIDDEN_STATIONS, null) ?: return emptySet() - return try { - val arr = JSONArray(json) - (0 until arr.length()).mapTo(mutableSetOf()) { i -> arr.getString(i) } - } catch (_: Exception) { - emptySet() - } - } - - private fun saveHiddenIds(ids: Set) { - val arr = JSONArray() - ids.forEach { arr.put(it) } - prefs.edit().putString(KEY_HIDDEN_STATIONS, arr.toString()).apply() - } - companion object { private const val KEY_CUSTOM_STATIONS = "custom_stations" private const val KEY_NAME_OVERRIDES = "station_name_overrides" private const val KEY_FAVORITE_STATIONS = "favorite_stations" - private const val KEY_HIDDEN_STATIONS = "hidden_stations" } } diff --git a/app/src/main/java/com/rada/data/repository/StreamRepository.kt b/app/src/main/java/com/rada/data/repository/StreamRepository.kt index da80e12..8e53b42 100644 --- a/app/src/main/java/com/rada/data/repository/StreamRepository.kt +++ b/app/src/main/java/com/rada/data/repository/StreamRepository.kt @@ -21,34 +21,6 @@ class StreamRepository @Inject constructor( private var cache: List? = null - init { - ensureBuiltins() - } - - /** - * Media Monarchy ships as a built-in stream (talk, not music — it lives - * here instead of the Stations tab). Seeded exactly once per install so - * a user delete is respected and never resurrected. - */ - private fun ensureBuiltins() { - if (prefs.getBoolean(KEY_BUILTINS_SEEDED, false)) return - val current = getAll().toMutableList() - if (current.none { it.url == BUILTIN_MONARCHY_URL }) { - current.add( - 0, - StreamEntry( - id = "media-monarchy", - name = "Media Monarchy", - url = BUILTIN_MONARCHY_URL, - source = "Built-in", - group = "Talk", - ), - ) - save(current) - } - prefs.edit().putBoolean(KEY_BUILTINS_SEEDED, true).apply() - } - fun getAll(): List { cache?.let { return it } val raw = prefs.getString(key, "[]") ?: "[]" @@ -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" - } } diff --git a/app/src/main/java/com/rada/data/wavlake/StationFeature.kt b/app/src/main/java/com/rada/data/wavlake/StationFeature.kt deleted file mode 100644 index d6a6b42..0000000 --- a/app/src/main/java/com/rada/data/wavlake/StationFeature.kt +++ /dev/null @@ -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> = mapOf( - "Lofi" to listOf("Downtempo", "Ambient", "Electronic"), - "Synthwave" to listOf("Electro", "Electronica", "Electronic", "Electronic Rock"), - "EDM" to listOf( - "Dance/EDM", "House", "Jackin House", "Dubstep", "Techno", "Trance", - "Breakbeat", "Garage", "Hard Dance", "Jungle/Drum'n'bass", "Hi-NRG / Eurodance", - ), - "NCS" to listOf( - "Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "House", "Dubstep", - "Jungle/Drum'n'bass", "Trance", "Techno", - ), - "Electronic" to listOf("Electronic", "Electronica", "Downtempo", "Ambient"), - "Hip-Hop" to listOf("Hip-Hop/Rap", "Hip-Hop", "Rap"), - "Pop" to listOf("Pop", "Indie Pop"), - "Rock" to listOf("Rock", "Alternative Rock", "Indie Rock"), - ) - - /** Station "A / B" strings use the same split convention as the app. */ - fun splitStationGenres(genre: String): Pair> { - val parts = genre.split(" / ").map { it.trim() }.filter { it.isNotEmpty() } - return (parts.firstOrNull() ?: "") to parts.drop(1) - } - - /** Exact Wavlake genre names compatible with a station, per [MatchingMode]. */ - fun compatibleGenres( - primary: String, - secondary: List = emptyList(), - mode: MatchingMode = MatchingMode.EXACT_OR_RELATED, - ): List { - val out = LinkedHashSet() - val keys = (listOf(primary) + secondary).map { it.trim() }.filter { it.isNotEmpty() } - if (mode == MatchingMode.EXACT || mode == MatchingMode.EXACT_OR_RELATED) { - keys.filter { isWavlakeGenreName(it) }.forEach { out.add(it) } - } - if (mode == MatchingMode.RELATED || mode == MatchingMode.EXACT_OR_RELATED) { - keys.forEach { g -> related[g]?.let { out.addAll(it) } } - } - return out.toList() - } - - private fun isWavlakeGenreName(name: String): Boolean = - related.values.any { it.contains(name) } -} - -/** Inputs for one featuring decision. All IDs are Wavlake track/artist IDs. */ -data class FeatureSelection( - val candidates: List, - val settings: WavlakeStationSettings = defaultWavlakeStationSettings, - /** Track IDs already queued or playing — never duplicated. */ - val excludedTrackIds: Set = emptySet(), - /** Artist IDs blocked by the user. */ - val blockedArtistIds: Set = emptySet(), - /** Artist IDs featured recently — deprioritized to keep rotation fresh. */ - val recentArtistIds: Set = emptySet(), - /** How many tracks were featured in the last hour (cap gate). */ - val featuredThisHour: Int = 0, - val random: Random = Random.Default, -) - -object FeatureSelector { - fun select(input: FeatureSelection): List { - val s = input.settings - if (!s.enabled) return emptyList() - if (input.featuredThisHour >= s.maxFeaturedTracksPerHour) return emptyList() - if (s.maxFeaturedTracksPerQueue <= 0) return emptyList() - if (input.random.nextDouble() >= s.featureProbability.coerceIn(0.0, 1.0)) return emptyList() - - val pool = input.candidates - .filter { it.audioUrl?.isNotBlank() == true } - .filterNot { it.id in input.excludedTrackIds } - .filterNot { it.artistId in input.blockedArtistIds } - .distinctBy { it.id } - if (pool.isEmpty()) return emptyList() - - // Fresh artists first, stable order otherwise; then take the cap. - val fresh = pool.filterNot { it.artistId in input.recentArtistIds } - val ordered = (fresh.ifEmpty { pool }).shuffled(input.random) - return ordered.take(s.maxFeaturedTracksPerQueue) - } -} diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt deleted file mode 100644 index c4cc385..0000000 --- a/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt +++ /dev/null @@ -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 = emptyList(), - val nextCursor: String? = null, - val total: Int? = null, - val limit: Int? = null, -) - -@Serializable -data class AlbumSummaryDto( - val id: String? = null, - val title: String? = null, - val albumArtUrl: String? = null, - val releaseDate: String? = null, -) - -@Serializable -data class ArtistDto( - val id: String? = null, - val name: String? = null, - val bio: String? = null, - val artistArtUrl: String? = null, - val albums: List = emptyList(), -) - -@Serializable -data class AlbumDto( - val id: String? = null, - val title: String? = null, - val artist: String? = null, - val artistUrl: String? = null, - val artistArtUrl: String? = null, - val albumArtUrl: String? = null, - val releaseDate: String? = null, - val tracks: List = emptyList(), -) - -@Serializable -data class ErrorDto(val error: String? = null) - -// ── Result ─────────────────────────────────────────────────────────────── - -sealed interface WavlakeResult { - data class Ok(val value: T) : WavlakeResult - /** API failure never throws — local radio keeps playing regardless. */ - data class Failed(val kind: FailureKind, val message: String) : WavlakeResult -} - -enum class FailureKind { NETWORK, HTTP, RATE_LIMITED, PARSE, EMPTY, AUTH } - -// ── Client ─────────────────────────────────────────────────────────────── - -class WavlakeApi( - private val baseUrl: String = "https://wavlake.com/api/v1", - private val client: OkHttpClient = defaultClient(), -) { - private val json = Json { - ignoreUnknownKeys = true - explicitNulls = false - coerceInputValues = true - } - - suspend fun search(term: String): WavlakeResult> = - get("content/search", mapOf("term" to term)) { body -> - json.decodeFromString( - kotlinx.serialization.builtins.ListSerializer(SearchResultDto.serializer()), - body, - ) - } - - suspend fun rankings( - days: Int = 7, - genre: String? = null, - limit: Int = 25, - ): WavlakeResult> { - val params = mutableMapOf("sort" to "sats", "days" to days.toString(), "limit" to limit.toString()) - if (!genre.isNullOrBlank()) params["genre"] = genre - return get("content/rankings", params) { body -> - json.decodeFromString( - kotlinx.serialization.builtins.ListSerializer(TrackDto.serializer()), - body, - ) - } - } - - suspend fun catalog( - limit: Int = 50, - cursor: String? = null, - artistId: String? = null, - genre: String? = null, - ): WavlakeResult { - val params = mutableMapOf("limit" to limit.coerceIn(1, 100).toString()) - if (!cursor.isNullOrBlank()) params["cursor"] = cursor - if (!artistId.isNullOrBlank()) params["artistId"] = artistId - if (!genre.isNullOrBlank()) params["genre"] = genre - return get("content/tracks", params) { body -> - json.decodeFromString(CatalogDto.serializer(), body) - } - } - - suspend fun track(trackId: String): WavlakeResult = - get("content/track/$trackId", emptyMap()) { body -> - json.decodeFromString(TrackDto.serializer(), body) - } - - suspend fun artist(artistId: String): WavlakeResult = - get("content/artist/$artistId", emptyMap()) { body -> - json.decodeFromString(ArtistDto.serializer(), body) - } - - suspend fun album(albumId: String): WavlakeResult = - get("content/album/$albumId", emptyMap()) { body -> - json.decodeFromString(AlbumDto.serializer(), body) - } - - private suspend fun get( - path: String, - params: Map, - parse: (String) -> T, - ): WavlakeResult = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { - val url = try { - (baseUrl.trimEnd('/') + "/" + path).toHttpUrl().newBuilder() - .also { b -> params.forEach { (k, v) -> b.addQueryParameter(k, v) } } - .build() - } catch (e: Exception) { - return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Bad request URL.") - } - val response = try { - client.newCall( - Request.Builder() - .url(url) - .header("Accept", "application/json") - .header("User-Agent", "SatsAmp/0.1 (wavlake catalog client)") - .build(), - ).execute() - } catch (e: Exception) { - // Offline, DNS, TLS, timeouts — all surface here, never thrown. - return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Network unavailable.") - } - response.use { r -> - if (r.code == 429) { - val retryAfter = r.header("Retry-After")?.toLongOrNull() - return@withContext WavlakeResult.Failed( - FailureKind.RATE_LIMITED, - "Rate limited" + (retryAfter?.let { " — retry in ${it}s" } ?: "."), - ) - } - if (!r.isSuccessful) { - val serverMsg = runCatching { - json.decodeFromString(ErrorDto.serializer(), r.body?.string().orEmpty()).error - }.getOrNull() - val kind = if (r.code == 401 || r.code == 403) FailureKind.AUTH else FailureKind.HTTP - return@withContext WavlakeResult.Failed(kind, serverMsg ?: "Request failed (${r.code}).") - } - val body = try { - r.body?.string().orEmpty() - } catch (_: Exception) { - return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Empty response.") - } - try { - WavlakeResult.Ok(parse(body)) - } catch (_: Exception) { - WavlakeResult.Failed(FailureKind.PARSE, "Couldn't read the catalog response.") - } - } - } - - companion object { - fun defaultClient(): OkHttpClient = OkHttpClient.Builder() - .connectTimeout(15, TimeUnit.SECONDS) - .readTimeout(20, TimeUnit.SECONDS) - .build() - } -} diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt deleted file mode 100644 index da4e33b..0000000 --- a/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt +++ /dev/null @@ -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> { - if (term.isBlank()) return WavlakeResult.Ok(emptyList()) - return when (val r = api.search(term.trim())) { - is WavlakeResult.Ok -> WavlakeResult.Ok( - r.value.mapNotNull { dto -> - val id = dto.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null - val name = dto.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null - when (dto.type) { - "artist" -> WavlakeSearchHit.Artist( - WavlakeArtist( - id = id, name = name, - imageUrl = dto.artistArtUrl ?: dto.albumArtUrl, - ), - ) - "album" -> WavlakeSearchHit.Album( - id = id, title = name, - artist = dto.artist, - artworkUrl = dto.albumArtUrl, - ) - else -> WavlakeSearchHit.Track( - WavlakeTrack( - id = id, title = name, - artistId = dto.artistId.orEmpty(), - artistName = dto.artist?.takeIf { it.isNotBlank() } - ?: "Unknown Artist", - artworkUrl = dto.albumArtUrl ?: dto.artistArtUrl, - durationMs = dto.duration?.let { if (it > 0) it * 1000L else null }, - ), - ) - } - }.distinctBy { it.stableId() }, - ) - is WavlakeResult.Failed -> r - } - } - - suspend fun rankings( - days: Int = 7, - genre: String? = null, - limit: Int = 25, - ): WavlakeResult> = - when (val r = api.rankings(days.coerceIn(1, 90), genre, limit.coerceIn(1, 100))) { - is WavlakeResult.Ok -> WavlakeResult.Ok( - r.value.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id }, - ) - is WavlakeResult.Failed -> r - } - - suspend fun artistDetail(artistId: String): WavlakeResult { - val artist = when (val r = api.artist(artistId)) { - is WavlakeResult.Ok -> normalizeWavlakeArtist(r.value) - ?: return WavlakeResult.Failed(FailureKind.EMPTY, "Artist has no usable metadata.") - is WavlakeResult.Failed -> return r - } - // The artist endpoint carries no genre; derive it from the tracks. - val tracks = when (val r = api.catalog(limit = 100, artistId = artistId)) { - is WavlakeResult.Ok -> r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) } - is WavlakeResult.Failed -> emptyList() // tracks optional; artist still shown - }.distinctBy { it.id } - val genres = tracks.mapNotNull { it.genre }.distinct() - return WavlakeResult.Ok( - WavlakeArtistDetail( - artist = artist.copy( - genre = genres.firstOrNull(), - genres = genres, - imageUrl = artist.imageUrl - ?: tracks.firstNotNullOfOrNull { it.artworkUrl }, - ), - tracks = tracks, - ), - ) - } - - suspend fun albumTracks(albumId: String): WavlakeResult> = - when (val r = api.album(albumId)) { - is WavlakeResult.Ok -> WavlakeResult.Ok( - r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id }, - ) - is WavlakeResult.Failed -> r - } - - suspend fun trackDetail(trackId: String): WavlakeResult = - when (val r = api.track(trackId)) { - is WavlakeResult.Ok -> - normalizeWavlakeTrack(r.value)?.let { WavlakeResult.Ok(it) } - ?: WavlakeResult.Failed(FailureKind.EMPTY, "Track has no usable metadata.") - is WavlakeResult.Failed -> r - } - - /** - * Fallback for search-index IDs that no longer resolve (stale entries - * 400 on /content/track): match by exact title inside the artist's - * catalog page. Same artist + same title = the same song. - */ - suspend fun matchArtistTrack(artistId: String, title: String): WavlakeTrack? { - if (artistId.isBlank() || title.isBlank()) return null - return when (val r = api.catalog(limit = 100, artistId = artistId)) { - is WavlakeResult.Ok -> r.value.tracks - .mapNotNull { normalizeWavlakeTrack(it) } - .firstOrNull { - it.title.equals(title, ignoreCase = true) && - it.audioUrl?.isNotBlank() == true - } - is WavlakeResult.Failed -> null - } - } - - // ── Lightning Address (Nostr kind-0 lud16 — the ONLY zap source) ───── - - private val addressCache = java.util.concurrent.ConcurrentHashMap() - - /** - * Addresses the user entered themselves (stored on successful zap). - * These are real, validated addresses — remembering them is what lets - * the bolt appear for artists the catalog exposes no npub for. - */ - fun getKnownAddress(artistId: String): String? { - val raw = prefs.getString(KEY_KNOWN_PREFIX + artistId, null) ?: return null - return raw.takeIf { hasValidLightningAddress(it) } - } - - fun rememberAddress(artistId: String, address: String): Boolean { - if (artistId.isBlank()) return false - val clean = address.trim() - if (!hasValidLightningAddress(clean)) return false - prefs.edit().putString(KEY_KNOWN_PREFIX + artistId, clean).apply() - return true - } - - /** Best-effort lud16 for an artist npub (bech32 or hex). Null when unknown. */ - suspend fun resolveLightningAddress(artistId: String?, artistNpub: String?): String? { - if (!artistId.isNullOrBlank()) { - getKnownAddress(artistId)?.let { return it } - } - val npub = artistNpub?.takeIf { it.isNotBlank() } ?: return null - addressCache[npub]?.let { return it } - val hex = npubToHex(npub) ?: return null - // Mobile relays can be slow; one retry on miss. Misses only log — - // the strict gate simply shows no bolt. - var lud16: String? = null - repeat(2) { - lud16 = withTimeoutOrNull(12_000) { - nostr.getProfile(hex).firstOrNull { it != null }?.let { ev -> - runCatching { - JSONObject(ev!!.content).optString("lud16").takeIf { it.isNotBlank() } - }.getOrNull() - } - } - if (lud16 != null) return@repeat - } - val valid = lud16?.takeIf { hasValidLightningAddress(it) } - if (valid == null) { - android.util.Log.w("Wavlake", "No lud16 for artist npub ${hex.take(12)}…") - } else { - addressCache[npub] = valid - } - return valid - } - - private fun npubToHex(npub: String): String? { - val v = npub.trim() - if (v.startsWith("npub1")) { - return runCatching { - val (hrp, bytes) = Nip19.decode(v) ?: return null - if (hrp != "npub" || bytes.size != 32) return null - bytes.joinToString("") { "%02x".format(it) } - }.getOrNull() - } - if (v.length == 64 && v.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) return v.lowercase() - return null - } - - // ── Import (streamable library entries — no downloads) ─────────────── - - sealed interface ImportOutcome { - data class Imported(val songId: Long) : ImportOutcome - data class AlreadyInLibrary(val songId: Long) : ImportOutcome - data class Unplayable(val reason: String) : ImportOutcome - } - - /** Imports a Wavlake track as a streamable library entry (dedupe-safe). */ - suspend fun importTrack(track: WavlakeTrack): ImportOutcome { - val audioUrl = track.audioUrl?.takeIf { it.isNotBlank() } - ?: return ImportOutcome.Unplayable("No playable audio for this track.") - library.getByPath(audioUrl)?.let { return ImportOutcome.AlreadyInLibrary(it.id) } - library.getByArtistTitle(track.artistName, track.title)?.let { - return ImportOutcome.AlreadyInLibrary(it.id) - } - val id = library.insert( - SavedSongEntity( - fileName = "${track.artistName} - ${track.title}.mp3", - filePath = audioUrl, - artist = track.artistName, - title = track.title, - album = track.genre ?: "Wavlake", - stationName = "Wavlake", - durationMs = track.durationMs ?: 0, - fileSize = 0, - format = "mp3", - source = SOURCE_WAVLAKE, - sourceUrl = track.sourceUrl.orEmpty(), - artworkUrl = track.artworkUrl.orEmpty(), - wavlakeArtistId = track.artistId, - wavlakeArtistNpub = track.artistNpubHex.orEmpty(), - ), - ) - return ImportOutcome.Imported(id) - } - - // ── Featuring (genre-matched, opt-in shelf — never auto-inserted) ──── - - fun settings(): WavlakeStationSettings = runCatching { - prefs.getString(KEY_SETTINGS, null)?.let { - Json.decodeFromString(WavlakeStationSettings.serializer(), it) - } - }.getOrNull() ?: defaultWavlakeStationSettings - - fun saveSettings(settings: WavlakeStationSettings) { - prefs.edit() - .putString(KEY_SETTINGS, Json.encodeToString(WavlakeStationSettings.serializer(), settings)) - .apply() - } - - fun isStationEnabled(stationId: String): Boolean { - if (!settings().enabled) return false - val map = stationMap() - return map[stationId] ?: true - } - - fun setStationEnabled(stationId: String, enabled: Boolean) { - val map = stationMap().toMutableMap() - map[stationId] = enabled - prefs.edit().putString(KEY_STATION_MAP, Json.encodeToString(mapSerializer(), map)).apply() - } - - fun blockedArtistIds(): Set = runCatching { - prefs.getString(KEY_BLOCKED, null)?.let { - Json.decodeFromString(ListSerializer(String.serializer()), it) - }.orEmpty().toSet() - }.getOrDefault(emptySet()) - - fun setArtistBlocked(artistId: String, blocked: Boolean) { - val set = blockedArtistIds().toMutableSet() - if (blocked) set.add(artistId) else set.remove(artistId) - prefs.edit() - .putString(KEY_BLOCKED, Json.encodeToString(ListSerializer(String.serializer()), set.toList())) - .apply() - } - - private fun stationMap(): Map = runCatching { - prefs.getString(KEY_STATION_MAP, null)?.let { - Json.decodeFromString(mapSerializer(), it) - } - }.getOrNull().orEmpty() - - private fun mapSerializer() = - MapSerializer(String.serializer(), Boolean.serializer()) - - private data class FeatureEvent(val atMs: Long, val artistId: String) - - private fun recentEvents(): List { - val now = System.currentTimeMillis() - return runCatching { - prefs.getString(KEY_FEATURED, null)?.let { raw -> - Json.decodeFromString( - ListSerializer(FeatureEventSer.serializer()), - raw, - ).map { FeatureEvent(it.atMs, it.artistId) } - } - }.getOrNull().orEmpty().filter { now - it.atMs < 3_600_000L } - } - - @kotlinx.serialization.Serializable - private data class FeatureEventSer(val atMs: Long, val artistId: String) - - private fun recordFeatured(artistIds: List) { - val now = System.currentTimeMillis() - val updated = (recentEvents() + artistIds.map { FeatureEvent(now, it) }) - .filter { now - it.atMs < 3_600_000L } - prefs.edit().putString( - KEY_FEATURED, - Json.encodeToString( - ListSerializer(FeatureEventSer.serializer()), - updated.map { FeatureEventSer(it.atMs, it.artistId) }, - ), - ).apply() - } - - /** - * Genre-matched Wavlake picks for a station. Network-bound, failure-safe - * (empty on any trouble), and purely advisory — the caller decides - * whether to show them; nothing here touches playback or the queue. - */ - suspend fun featuredForStation( - stationId: String, - stationGenre: String, - excludedTrackIds: Set = emptySet(), - ): List = coroutineScope { - val settings = settings() - if (!isStationEnabled(stationId)) return@coroutineScope emptyList() - val (primary, secondary) = WavlakeGenreMatcher.splitStationGenres(stationGenre) - val genres = WavlakeGenreMatcher.compatibleGenres(primary, secondary, settings.matchingMode) - if (genres.isEmpty()) return@coroutineScope emptyList() - - val events = recentEvents() - val perGenre = genres.take(3).map { genre -> - async { - when (val r = api.rankings(days = 30, genre = genre, limit = 25)) { - is WavlakeResult.Ok -> r.value.mapNotNull { normalizeWavlakeTrack(it) } - is WavlakeResult.Failed -> emptyList() - } - } - }.awaitAll().flatten().distinctBy { it.id } - - val picked = FeatureSelector.select( - FeatureSelection( - candidates = perGenre, - settings = settings, - excludedTrackIds = excludedTrackIds, - blockedArtistIds = blockedArtistIds(), - recentArtistIds = events.map { it.artistId }.toSet(), - featuredThisHour = events.size, - ), - ) - if (picked.isNotEmpty()) recordFeatured(picked.map { it.artistId }) - picked - } - - companion object { - private const val KEY_SETTINGS = "wavlake_settings" - private const val KEY_STATION_MAP = "wavlake_station_enabled" - private const val KEY_BLOCKED = "wavlake_blocked_artists" - private const val KEY_FEATURED = "wavlake_featured_hour" - private const val KEY_KNOWN_PREFIX = "wavlake_addr_" - } -} - -sealed interface WavlakeSearchHit { fun stableId(): String - data class Artist(val artist: WavlakeArtist) : WavlakeSearchHit { - override fun stableId() = "artist:${artist.id}" - } - data class Album( - val id: String, - val title: String, - val artist: String?, - val artworkUrl: String?, - ) : WavlakeSearchHit { - override fun stableId() = "album:$id" - } - data class Track(val track: WavlakeTrack) : WavlakeSearchHit { - override fun stableId() = "track:${track.id}" - } -} - -data class WavlakeArtistDetail( - val artist: WavlakeArtist, - val tracks: List, -) - -/** Pure DTO normalization (null-tolerant, JVM-testable). */ -fun normalizeWavlakeTrack(dto: TrackDto): WavlakeTrack? { - val id = dto.id?.takeIf { it.isNotBlank() } ?: return null - val title = dto.title?.takeIf { it.isNotBlank() } ?: return null - val audioUrl = dto.mediaUrl?.takeIf { it.isNotBlank() } - return WavlakeTrack( - id = id, - title = title, - artistId = dto.artistId?.takeIf { it.isNotBlank() }.orEmpty(), - artistName = dto.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist", - genre = dto.genre?.takeIf { it.isNotBlank() }, - genres = listOfNotNull(dto.genre?.takeIf { it.isNotBlank() }), - artworkUrl = dto.albumArtUrl?.takeIf { it.isNotBlank() } - ?: dto.artistArtUrl?.takeIf { it.isNotBlank() }, - durationMs = dto.duration?.let { if (it > 0) it * 1000L else null }, - audioUrl = audioUrl, - sourceUrl = dto.url?.takeIf { it.isNotBlank() }, - artistNpubHex = dto.artistNpub?.takeIf { it.isNotBlank() }, - albumId = dto.albumId?.takeIf { it.isNotBlank() }, - ) -} - -/** Pure DTO normalization (null-tolerant, JVM-testable). */ -fun normalizeWavlakeArtist(dto: ArtistDto): WavlakeArtist? { - val id = dto.id?.takeIf { it.isNotBlank() } ?: return null - return WavlakeArtist( - id = id, - name = dto.name?.takeIf { it.isNotBlank() } ?: "Unknown Artist", - imageUrl = dto.artistArtUrl?.takeIf { it.isNotBlank() }, - bio = dto.bio?.takeIf { it.isNotBlank() }, - ) -} diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt deleted file mode 100644 index 89e5578..0000000 --- a/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt +++ /dev/null @@ -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::`), a `timestamp` tag, and three - * podcast `i` tags whose GUIDs are UUIDv5 (RFC 4122) of the feed URLs. - * - `GET https://www.wavlake.com/api/zap?amount={msats}&nostr={eventJson}` - * returns LUD-06-style `{pr}` (or `{status:"ERROR"}` / `{success:false}`). - * - * This is what makes every catalog track payable even when the artist - * exposes no Lightning Address anywhere reachable. - */ -object WavlakeZap { - const val FEED_PUBKEY = - "7759fb24cec56fc57550754ca8f6d2c60183da2537c8f38108fdf283b20a0e58" - const val TRACK_KIND = 32123 - const val RELAY = "wss://relay.wavlake.com/" - const val ZAP_ENDPOINT = "https://www.wavlake.com/api/zap" - const val ZAP_HOST = "www.wavlake.com" - - /** UUID namespace from the official client (podcast feed GUIDs). */ - val FEED_NAMESPACE: UUID = UUID.fromString("ead4c236-bf58-58c6-a2c6-a6b28d128cb6") - - fun feedUrl(albumId: String): String = "wavlake.com/feed/music/$albumId" - - /** RFC 4122 §4.3 UUIDv5 (SHA-1). Java only ships v3/v4. */ - fun uuidV5(namespace: UUID, name: String): UUID { - val md = MessageDigest.getInstance("SHA-1") - val ns = ByteArray(16).also { - val bb = java.nio.ByteBuffer.wrap(it) - bb.putLong(namespace.mostSignificantBits) - bb.putLong(namespace.leastSignificantBits) - } - val hash = md.digest(ns + name.toByteArray(Charsets.UTF_8)) - hash[6] = ((hash[6].toInt() and 0x0f) or 0x50).toByte() // version 5 - hash[8] = ((hash[8].toInt() and 0x3f) or 0x80).toByte() // variant 10 - val bb = java.nio.ByteBuffer.wrap(hash) - return UUID(bb.long, bb.long) - } - - fun trackAddressPointer(trackId: String): String = "$TRACK_KIND:$FEED_PUBKEY:$trackId" - - fun itemTags(trackId: String, albumId: String): List> { - val feedGuid = uuidV5(FEED_NAMESPACE, feedUrl(albumId)).toString() - return listOf( - listOf("i", "podcast:item:guid:$trackId"), - listOf("i", "podcast:guid:$feedGuid"), - listOf("i", "podcast:publisher:guid:$feedGuid"), - ) - } - - /** - * Unsigned NIP-57 zap request for a track. The caller signs (identity - * key preferred, ephemeral per NIP-57 otherwise) — see ZapService. - */ - fun buildZapRequest( - trackId: String, - albumId: String, - amountSats: Long, - comment: String, - relays: List, - signerPubkeyHex: String, - ): UnsignedEvent { - require(trackId.isNotBlank() && albumId.isNotBlank()) - require(amountSats > 0) - val tags = mutableListOf( - listOf("p", FEED_PUBKEY), - listOf("relays", RELAY) + relays.take(2), - listOf("amount", (amountSats * 1000).toString()), - listOf("a", trackAddressPointer(trackId), RELAY), - listOf("timestamp", (System.currentTimeMillis() / 1000).toString()), - ) - tags.addAll(itemTags(trackId, albumId)) - return NostrEvent.unsigned( - pubkey = signerPubkeyHex, - kind = MusicKinds.ZAP_REQUEST, - content = comment, - tags = tags, - ) - } - - fun invoiceUrl(amountSats: Long, zapRequestJson: String): String { - val encoded = java.net.URLEncoder.encode(zapRequestJson, "UTF-8") - return "$ZAP_ENDPOINT?amount=${amountSats * 1000}&nostr=$encoded" - } - - /** Strict invoice sanity: mainnet bolt11 only, from the fixed endpoint. */ - fun isPlausibleInvoice(bolt11: String): Boolean { - val v = bolt11.trim().lowercase() - return v.startsWith("lnbc") && v.length > 20 && v.none { it.isWhitespace() } - } -} diff --git a/app/src/main/java/com/rada/di/AppModule.kt b/app/src/main/java/com/rada/di/AppModule.kt index 5d961aa..7de48e5 100644 --- a/app/src/main/java/com/rada/di/AppModule.kt +++ b/app/src/main/java/com/rada/di/AppModule.kt @@ -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() } diff --git a/app/src/main/java/com/rada/di/PaymentsModule.kt b/app/src/main/java/com/rada/di/PaymentsModule.kt deleted file mode 100644 index 06b3c03..0000000 --- a/app/src/main/java/com/rada/di/PaymentsModule.kt +++ /dev/null @@ -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) }) -} diff --git a/app/src/main/java/com/rada/domain/model/WavlakeModels.kt b/app/src/main/java/com/rada/domain/model/WavlakeModels.kt deleted file mode 100644 index 21273bf..0000000 --- a/app/src/main/java/com/rada/domain/model/WavlakeModels.kt +++ /dev/null @@ -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 = emptyList(), - val imageUrl: String? = null, - val bio: String? = null, - val source: String = SOURCE_WAVLAKE, - val sourceUrl: String? = null, - /** Lightning Address ONLY (e.g. artist@example.com). Never LNURL/lud06/lud16/npub here. */ - val lightningAddress: String? = null, - /** Nostr pubkey hex, when the API exposes it (used for the NIP-57 zap flow). */ - val npubHex: String? = null, -) { - /** Derived ONLY from [lightningAddress]. See [com.rada.payments.hasValidLightningAddress]. */ - val canZap: Boolean - get() = com.rada.payments.hasValidLightningAddress(lightningAddress) -} - -data class WavlakeTrack( - val id: String, - val title: String, - val artistId: String, - val artistName: String, - val genre: String? = null, - val genres: List = emptyList(), - val artworkUrl: String? = null, - val durationMs: Long? = null, - val audioUrl: String? = null, - val source: String = SOURCE_WAVLAKE, - val sourceUrl: String? = null, - /** Nostr pubkey hex of the artist, when exposed (zap flow). */ - val artistNpubHex: String? = null, - /** Album GUID (official track-zap `i`-tags + parent content id). */ - val albumId: String? = null, -) { - /** Officially payable: the track-zap endpoint only needs track + album ids. */ - val isZapPayable: Boolean - get() = id.isNotBlank() && !albumId.isNullOrBlank() && !audioUrl.isNullOrBlank() -} diff --git a/app/src/main/java/com/rada/nostr/Bech32.kt b/app/src/main/java/com/rada/nostr/Bech32.kt deleted file mode 100644 index 470bafc..0000000 --- a/app/src/main/java/com/rada/nostr/Bech32.kt +++ /dev/null @@ -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? { - 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() - 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() - 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 { - val tlv = ArrayList() - 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 = emptyList(), kind: Int? = null): String { - val tlv = ArrayList() - 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 = emptyList()): String { - val tlv = ArrayList() - 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? { - 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() - } -} - diff --git a/app/src/main/java/com/rada/nostr/Nip04.kt b/app/src/main/java/com/rada/nostr/Nip04.kt deleted file mode 100644 index 9770edd..0000000 --- a/app/src/main/java/com/rada/nostr/Nip04.kt +++ /dev/null @@ -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() -} diff --git a/app/src/main/java/com/rada/nostr/NostrEvent.kt b/app/src/main/java/com/rada/nostr/NostrEvent.kt deleted file mode 100644 index 81ea317..0000000 --- a/app/src/main/java/com/rada/nostr/NostrEvent.kt +++ /dev/null @@ -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>, - 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 = 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>, - 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>, - 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 -} diff --git a/app/src/main/java/com/rada/nostr/NostrKeys.kt b/app/src/main/java/com/rada/nostr/NostrKeys.kt deleted file mode 100644 index 35409f1..0000000 --- a/app/src/main/java/com/rada/nostr/NostrKeys.kt +++ /dev/null @@ -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(null) - /** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */ - val pubkey: StateFlow = _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 entry. */ - fun isExternalSignerInstalled(): Boolean = runCatching { - val probe = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")) - if (context.packageManager.queryIntentActivities(probe, 0).isNotEmpty()) return true - // Fallback: direct Amber package check (needs the queries entry). - context.packageManager.getPackageInfo(AMBER_PACKAGE, 0) - true - }.getOrDefault(false) - - /** Build the NIP-55 `get_public_key` intent. Launch via rememberLauncherForActivityResult. */ - fun buildGetPubkeyIntent(): Intent = - Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")).apply { - putExtra("type", "get_public_key") - putExtra( - "permissions", - """[{"type":"sign_event"},{"type":"nip44_encrypt"},{"type":"nip44_decrypt"}]""", - ) - } - - /** Import a raw 64-char hex pubkey (NIP-55 results are hex, not npub). */ - fun importHexPubkey(hex: String): Boolean { - val clean = hex.trim().lowercase() - if (!clean.matches(Regex("[0-9a-f]{64}"))) return false - _pubkey.value = clean - prefs.edit().putString("imported_pubkey", clean).apply() - return true - } - - /** Persist the signer package from a `get_public_key` result for future sign_event calls. */ - fun saveSignerPackage(pkg: String) { - prefs.edit().putString("signer_package", pkg).apply() - } - - /** Create a fresh identity. Returns the npub. Refuses if one exists (no silent overwrite). */ - fun createIdentity(): String { - check(!hasIdentity) { "identity already exists" } - 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 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() - } - } -} diff --git a/app/src/main/java/com/rada/nostr/NostrService.kt b/app/src/main/java/com/rada/nostr/NostrService.kt deleted file mode 100644 index fcc6a68..0000000 --- a/app/src/main/java/com/rada/nostr/NostrService.kt +++ /dev/null @@ -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 - 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() - - private val _relayStates = MutableStateFlow>(emptyList()) - val relayStates: StateFlow> = _relayStates.asStateFlow() - - /** Merged verified events from all relays (deduped by id downstream). */ - private val merged = kotlinx.coroutines.flow.MutableSharedFlow( - 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 - - 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 = relayUrls - fun setRelays(list: List) { - 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>): 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() - 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 { - val out = kotlinx.coroutines.flow.MutableStateFlow(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 - } -} diff --git a/app/src/main/java/com/rada/nostr/RelayConnection.kt b/app/src/main/java/com/rada/nostr/RelayConnection.kt deleted file mode 100644 index 532bbd1..0000000 --- a/app/src/main/java/com/rada/nostr/RelayConnection.kt +++ /dev/null @@ -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(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) - val state: SharedFlow = _state.asSharedFlow() - - private val _events = MutableSharedFlow( - replay = 0, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST, - ) - /** Verified events matching any subscription on this relay. */ - val events: SharedFlow = _events.asSharedFlow() - - /** reqId -> original filter JSON, so we can CLOSE. */ - private val subs = ConcurrentHashMap() - private val reqCounter = AtomicLong(0) - @Volatile private var socket: WebSocket? = null - private val pendingOut = ArrayDeque() - - 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" - } -} diff --git a/app/src/main/java/com/rada/payments/LightningAddress.kt b/app/src/main/java/com/rada/payments/LightningAddress.kt deleted file mode 100644 index a1d50bd..0000000 --- a/app/src/main/java/com/rada/payments/LightningAddress.kt +++ /dev/null @@ -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()}" -} diff --git a/app/src/main/java/com/rada/payments/NwcProvider.kt b/app/src/main/java/com/rada/payments/NwcProvider.kt deleted file mode 100644 index 04b839d..0000000 --- a/app/src/main/java/com/rada/payments/NwcProvider.kt +++ /dev/null @@ -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://?relay=&secret= - */ -@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() - // 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() -} diff --git a/app/src/main/java/com/rada/payments/PaymentService.kt b/app/src/main/java/com/rada/payments/PaymentService.kt deleted file mode 100644 index 87d4ccc..0000000 --- a/app/src/main/java/com/rada/payments/PaymentService.kt +++ /dev/null @@ -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, -) { - private val _history = MutableStateFlow>(emptyList()) - val history: StateFlow> = _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?, -) diff --git a/app/src/main/java/com/rada/payments/ZapService.kt b/app/src/main/java/com/rada/payments/ZapService.kt deleted file mode 100644 index c5f19cf..0000000 --- a/app/src/main/java/com/rada/payments/ZapService.kt +++ /dev/null @@ -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, - ): 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.") - } - } -} diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt index d656776..30b2d35 100644 --- a/app/src/main/java/com/rada/player/PlaybackManager.kt +++ b/app/src/main/java/com/rada/player/PlaybackManager.kt @@ -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) { - updateBtRoute() - } - - override fun onAudioDevicesRemoved(devices: Array) { - val hadBt = wasBtA2dpConnected - updateBtRoute() - if (hadBt && !wasBtA2dpConnected && isPlaying) { - Log.d(TAG, "Bluetooth audio route lost — pausing") - try { - controller?.pause() - } catch (e: Exception) { - Log.w(TAG, "Pause on BT loss failed", e) - } - } - } - } - - private fun updateBtRoute() { - wasBtA2dpConnected = audioManager - ?.getDevices(AudioManager.GET_DEVICES_OUTPUTS) - .orEmpty() - .any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP } - } - /** Delayed ICY-reader-only restart (see startIcyReader listener). */ - private var icyRestartJob: Job? = null - /** - * True after an explicit user Stop. Guards the auto-reconnect path: a - * late player error must never resurrect a stream the user stopped. - * Cleared whenever new playback starts. - */ - private var userStopped = false var listener: PlaybackListener? = null @@ -139,71 +90,6 @@ class PlaybackManager( private val _lastStreamMetaFlow = MutableStateFlow(null) val lastStreamMetaFlow: StateFlow = _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 = _speedFlow.asStateFlow() - - private val _repeatModeFlow = MutableStateFlow(androidx.media3.common.Player.REPEAT_MODE_OFF) - val repeatModeFlow: StateFlow = _repeatModeFlow.asStateFlow() - - private val _shuffleEnabledFlow = MutableStateFlow(false) - val shuffleEnabledFlow: StateFlow = _shuffleEnabledFlow.asStateFlow() - - /** Milliseconds left on the sleep timer; 0 when off. */ - private val _sleepRemainingMsFlow = MutableStateFlow(0L) - val sleepRemainingMsFlow: StateFlow = _sleepRemainingMsFlow.asStateFlow() - - /** Library song id of the current queue item (null when not queue-playing). */ - private val _currentLocalTrackIdFlow = MutableStateFlow(null) - val currentLocalTrackIdFlow: StateFlow = _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 = _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, 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) - .setMediaMetadata( - MediaMetadata.Builder() - .setArtist(track.artist) - .setTitle(track.title) - .build() - ) - .build() - } - ctrl.setMediaItems(items, startIndex.coerceIn(0, items.lastIndex), 0L) - ctrl.shuffleModeEnabled = shuffle - _shuffleEnabledFlow.value = shuffle - ctrl.setPlaybackSpeed(_speedFlow.value) + val mediaItem = MediaItem.Builder() + .setUri(filePath) + .setMediaMetadata( + MediaMetadata.Builder() + .setArtist(artist) + .setTitle(title) + .build() + ) + .build() + + 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( - 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}", - ) - } + val result = saveSongUseCase.save( + buffer = buffer, + metadataTimeline = metadataTimeline, + currentMetadata = metadataTimeline.currentMetadata, + stationName = currentStationName, + ) 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,23 +388,9 @@ 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) { - 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 { + if (currentStationUrl == url) { reconnectJob = null + playStation(url, currentStationName) } } 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 } } diff --git a/app/src/main/java/com/rada/player/PlaybackService.kt b/app/src/main/java/com/rada/player/PlaybackService.kt index a3d193f..764509c 100644 --- a/app/src/main/java/com/rada/player/PlaybackService.kt +++ b/app/src/main/java/com/rada/player/PlaybackService.kt @@ -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() } } diff --git a/app/src/main/java/com/rada/recording/MetadataEmbedder.kt b/app/src/main/java/com/rada/recording/MetadataEmbedder.kt index a3c1aef..e0b6915 100644 --- a/app/src/main/java/com/rada/recording/MetadataEmbedder.kt +++ b/app/src/main/java/com/rada/recording/MetadataEmbedder.kt @@ -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" }) diff --git a/app/src/main/java/com/rada/recording/SaveSongUseCase.kt b/app/src/main/java/com/rada/recording/SaveSongUseCase.kt index f017327..9e76f5f 100644 --- a/app/src/main/java/com/rada/recording/SaveSongUseCase.kt +++ b/app/src/main/java/com/rada/recording/SaveSongUseCase.kt @@ -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) { diff --git a/app/src/main/java/com/rada/ui/MainActivity.kt b/app/src/main/java/com/rada/ui/MainActivity.kt index b4fd011..1260ba3 100644 --- a/app/src/main/java/com/rada/ui/MainActivity.kt +++ b/app/src/main/java/com/rada/ui/MainActivity.kt @@ -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" - } } diff --git a/app/src/main/java/com/rada/ui/components/EqualizerBars.kt b/app/src/main/java/com/rada/ui/components/EqualizerBars.kt deleted file mode 100644 index 0990484..0000000 --- a/app/src/main/java/com/rada/ui/components/EqualizerBars.kt +++ /dev/null @@ -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 - } - } -} diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt deleted file mode 100644 index 82227ef..0000000 --- a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt +++ /dev/null @@ -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, - ) diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt deleted file mode 100644 index a50adb8..0000000 --- a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt +++ /dev/null @@ -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 = - 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(null) - val busy = MutableStateFlow(false) - val isSignerInstalled: Boolean get() = keys.isExternalSignerInstalled() - val signerPackage: String? get() = keys.signerPackage - - private val _relays = MutableStateFlow(nostr.relaysConfig()) - val relays: StateFlow> = _relays.asStateFlow() - - 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) { - 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 } -} diff --git a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt deleted file mode 100644 index 116a0e8..0000000 --- a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt +++ /dev/null @@ -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, - ) - } - }, - ) -} diff --git a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt deleted file mode 100644 index 93e6176..0000000 --- a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt +++ /dev/null @@ -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(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> = _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) - } -} diff --git a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt deleted file mode 100644 index 2972df0..0000000 --- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt +++ /dev/null @@ -1,1149 +0,0 @@ -package com.rada.ui.discover - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.Bolt -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Tune -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Slider -import androidx.compose.material3.SnackbarHost -import androidx.compose.material3.SnackbarHostState -import androidx.compose.material3.Switch -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel -import com.rada.data.wavlake.MatchingMode -import com.rada.data.wavlake.WavlakeSearchHit -import com.rada.data.wavlake.WavlakeStationSettings -import com.rada.domain.model.WavlakeTrack -import com.rada.payments.hasValidLightningAddress -import com.rada.ui.components.AppHomeButton -import com.rada.ui.components.EqualizerBars -import com.rada.ui.theme.LocalTokens -import com.rada.ui.theme.SatsAmpGlass -import kotlinx.coroutines.flow.first - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun DiscoverScreen( - onBack: () -> Unit, - onHome: () -> Unit = {}, - viewModel: WavlakeViewModel = hiltViewModel(), -) { - val searchQuery by viewModel.searchQuery.collectAsState() - val searchState by viewModel.searchState.collectAsState() - val rankings by viewModel.rankings.collectAsState() - val rankingsGenre by viewModel.rankingsGenre.collectAsState() - val rankingsLoading by viewModel.rankingsLoading.collectAsState() - val rankingsError by viewModel.rankingsError.collectAsState() - val addresses by viewModel.addresses.collectAsState() - val selectedArtistId by viewModel.selectedArtistId.collectAsState() - val artistDetail by viewModel.artistDetail.collectAsState() - val artistLoading by viewModel.artistLoading.collectAsState() - val notice by viewModel.notice.collectAsState() - val snackbarHostState = remember { SnackbarHostState() } - var showSettings by remember { mutableStateOf(false) } - var zapTarget by remember { mutableStateOf(null) } - var trackZap by remember { mutableStateOf(null) } - var pendingZapArtistId by remember { mutableStateOf(null) } - - LaunchedEffect(pendingZapArtistId) { - if (pendingZapArtistId == null) return@LaunchedEffect - val addr = snapshotFlow { addresses[pendingZapArtistId!!] } - .first { it != null } ?: "" - zapTarget = ZapTarget( - pendingZapArtistId!!, - "", - null, - addr, - ) - pendingZapArtistId = null - } - - LaunchedEffect(notice) { - notice?.let { - snackbarHostState.showSnackbar(it) - viewModel.consumeNotice() - } - } - - if (showSettings) { - WavlakeSettingsDialog( - current = viewModel.settings(), - onDismiss = { showSettings = false }, - onSave = { - viewModel.saveSettings(it) - showSettings = false - }, - ) - } - - zapTarget?.let { target -> - com.rada.ui.decentralized.TipSheet( - artistName = target.artistName, - trackTitle = target.trackTitle, - initialAddress = target.address, - artistId = target.artistId, - onDismiss = { zapTarget = null }, - ) - } - - trackZap?.let { track -> - WavlakeZapSheet( - track = track, - onDismiss = { trackZap = null }, - ) - } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(if (selectedArtistId != null) "Artist" else "Discover") }, - navigationIcon = { - Row(horizontalArrangement = Arrangement.Start) { - AppHomeButton(onClick = onHome) - IconButton(onClick = { - if (selectedArtistId != null) viewModel.closeArtist() else onBack() - }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - } - }, - actions = { - if (selectedArtistId == null) { - IconButton(onClick = { showSettings = true }) { - Icon(Icons.Default.Tune, contentDescription = "Wavlake settings") - } - } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), - ) - }, - containerColor = Color.Transparent, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> - if (selectedArtistId != null) { - ArtistDetailView( - artistId = selectedArtistId!!, - modifier = Modifier.padding(padding), - viewModel = viewModel, - ) - } else { - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding), - ) { - OutlinedTextField( - value = searchQuery, - onValueChange = { viewModel.search(it) }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - placeholder = { Text("Search Wavlake artists, tracks…") }, - singleLine = true, - leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, - colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = LocalTokens.current.panel, - unfocusedContainerColor = LocalTokens.current.panel, - focusedBorderColor = LocalTokens.current.accentFill.copy(alpha = 0.5f), - unfocusedBorderColor = LocalTokens.current.border, - focusedTextColor = LocalTokens.current.text, - unfocusedTextColor = LocalTokens.current.text, - ), - shape = RoundedCornerShape(12.dp), - ) - - when (val s = searchState) { - WavlakeSearchState.Idle -> { - GenreChips( - genres = DISCOVER_GENRES, - selected = rankingsGenre, - onSelect = { viewModel.refreshRankings(it) }, - ) - RankingsList( - title = if (rankingsGenre != null) "Top in ${rankingsGenre}" else "Top on Wavlake", - tracks = rankings, - loading = rankingsLoading, - error = rankingsError, - onRetry = { viewModel.refreshRankings(rankingsGenre) }, - viewModel = viewModel, - onZap = { track, address -> - if (address != null) { - zapTarget = ZapTarget( - track.artistId, - track.artistName, - track.title, - address, - ) - } else { - trackZap = track - } - }, - ) - } - WavlakeSearchState.Loading -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(22.dp), - strokeWidth = 2.dp, - color = LocalTokens.current.accentText, - ) - } - } - is WavlakeSearchState.Results -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit -> - when (hit) { - is WavlakeSearchHit.Artist -> ArtistRow( - name = hit.artist.name, - genre = hit.artist.genre, - imageUrl = hit.artist.imageUrl, - artistId = hit.artist.id, - address = addresses[hit.artist.id], - onClick = { - viewModel.openArtist(hit.artist.id, hit.artist.name) - }, - onZap = { - viewModel.resolveArtistAddress(hit.artist.id) - pendingZapArtistId = hit.artist.id - }, - ) - is WavlakeSearchHit.Album -> AlbumRow( - title = hit.title, - artist = hit.artist, - artworkUrl = hit.artworkUrl, - onClick = { /* albums open via artist detail */ }, - ) - is WavlakeSearchHit.Track -> TrackRow( - track = com.rada.domain.model.WavlakeTrack( - id = hit.track.id, - title = hit.track.title, - artistId = hit.track.artistId, - artistName = hit.track.artistName, - genre = hit.track.genre, - genres = hit.track.genres, - artworkUrl = hit.track.artworkUrl, - durationMs = hit.track.durationMs, - audioUrl = null, - artistNpubHex = null, - ), - showZap = true, - onPlay = { - viewModel.playSearchTrack( - hit.track.id, - hit.track.artistId, - hit.track.title, - ) - }, - onSave = { - viewModel.saveSearchTrack( - hit.track.id, - hit.track.artistId, - hit.track.title, - ) - }, - onZap = { t -> - addresses[t.artistId]?.let { addr -> - zapTarget = ZapTarget( - t.artistId, - t.artistName, - t.title, - addr, - ) - } ?: viewModel.resolveForZap( - t.id, t.artistId, t.title, - ) { full -> - if (full != null) trackZap = full - } - }, - ) - } - } - } - } - is WavlakeSearchState.Error -> { - Text( - text = s.message, - style = MaterialTheme.typography.bodyMedium, - color = LocalTokens.current.textSecondary, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), - ) - } - } - } - } - } -} - -private data class ZapTarget( - val artistId: String, - val artistName: String, - val trackTitle: String?, - val address: String, -) - -@Composable -private fun GenreChips( - genres: List, - selected: String?, - onSelect: (String?) -> Unit, -) { - LazyRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues( - horizontal = 16.dp, - vertical = 4.dp, - ), - ) { - item(key = "chip-all") { - GenreChip(label = "Top", selected = selected == null, onClick = { onSelect(null) }) - } - items(genres, key = { "chip-$it" }) { genre -> - GenreChip(label = genre, selected = selected == genre, onClick = { onSelect(genre) }) - } - } -} - -@Composable -private fun GenreChip(label: String, selected: Boolean, onClick: () -> Unit) { - FilterChip( - selected = selected, - onClick = onClick, - label = { Text(label) }, - colors = FilterChipDefaults.filterChipColors( - containerColor = LocalTokens.current.panel, - labelColor = LocalTokens.current.textSecondary, - selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f), - selectedLabelColor = LocalTokens.current.accentText, - ), - border = FilterChipDefaults.filterChipBorder( - borderColor = LocalTokens.current.border, - selectedBorderColor = LocalTokens.current.accentFill, - enabled = true, - selected = selected, - ), - ) -} - -@Composable -private fun RankingsList( - title: String, - tracks: List, - loading: Boolean, - error: String?, - onRetry: () -> Unit, - viewModel: WavlakeViewModel, - onZap: (WavlakeTrack, String?) -> Unit, -) { - val addresses by viewModel.addresses.collectAsState() - Column(modifier = Modifier.fillMaxSize()) { - Text( - text = title.uppercase(), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = LocalTokens.current.accentText, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), - ) - when { - loading -> { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(22.dp), - strokeWidth = 2.dp, - color = LocalTokens.current.accentText, - ) - } - } - error != null -> { - Text( - text = error, - style = MaterialTheme.typography.bodyMedium, - color = LocalTokens.current.textSecondary, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), - ) - TextButton(onClick = onRetry) { Text("Retry") } - } - tracks.isEmpty() -> { - Text( - text = "Nothing here yet.", - style = MaterialTheme.typography.bodyMedium, - color = LocalTokens.current.textSecondary, - modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), - ) - } - else -> { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(tracks, key = { it.id }, contentType = { "track" }) { track -> - TrackRow( - track = track, - showZap = addresses[track.artistId] != null || track.isZapPayable, - onPlay = { viewModel.playTrack(track) }, - onSave = { viewModel.saveTrack(track) }, - onZap = { t -> onZap(t, addresses[t.artistId]) }, - ) - } - } - } - } - } -} - -/** Track row: glyph tile + title/artist + play + save + bolt (see callers). */ -@Composable -fun TrackRow( - track: WavlakeTrack, - showZap: Boolean, - onPlay: () -> Unit, - onSave: () -> Unit, - onZap: (WavlakeTrack) -> Unit, -) { - val tokens = LocalTokens.current - Card( - onClick = onPlay, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong), - shape = RoundedCornerShape(SatsAmpGlass.rInput), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(tokens.accentFill.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center, - ) { - Text( - text = track.artistName.firstOrNull()?.uppercase() ?: "♪", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = tokens.accentText, - ) - } - Spacer(modifier = Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = track.title, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = tokens.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = listOfNotNull( - track.artistName.takeIf { it.isNotBlank() }, - track.genre?.takeIf { it.isNotBlank() }, - ).joinToString(" · ").ifEmpty { "Wavlake" }, - style = MaterialTheme.typography.bodySmall, - color = tokens.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - // Bolt rule: address path when known, else the official - // track-zap path when the track is payable. Never rendered - // when neither holds. - if (showZap) { - IconButton(onClick = { onZap(track) }, modifier = Modifier.size(48.dp)) { - Icon( - Icons.Default.Bolt, - contentDescription = "Zap ${track.artistName}", - tint = tokens.accentFill, - modifier = Modifier.size(22.dp), - ) - } - } - IconButton(onClick = onPlay, modifier = Modifier.size(48.dp)) { - Icon( - Icons.Default.PlayArrow, - contentDescription = "Play ${track.title}", - tint = tokens.textSecondary, - modifier = Modifier.size(22.dp), - ) - } - IconButton(onClick = onSave, modifier = Modifier.size(48.dp)) { - Icon( - Icons.Default.Add, - contentDescription = "Save ${track.title} to Library", - tint = tokens.textSecondary, - modifier = Modifier.size(22.dp), - ) - } - } - } -} - -@Composable -fun ArtistRow( - name: String, - genre: String?, - imageUrl: String?, - artistId: String? = null, - address: String? = null, - onClick: () -> Unit = {}, - onZap: () -> Unit = {}, -) { - val tokens = LocalTokens.current - val hasAddress = hasValidLightningAddress(address) - Card( - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp), - colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong), - shape = RoundedCornerShape(SatsAmpGlass.rInput), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(40.dp) - .clip(CircleShape) - .background(tokens.accentFill.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center, - ) { - Text( - text = name.firstOrNull()?.uppercase() ?: "?", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = tokens.accentText, - ) - } - Spacer(modifier = Modifier.width(14.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = name, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.SemiBold, - color = tokens.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = genre?.takeIf { it.isNotBlank() } ?: "Wavlake artist", - style = MaterialTheme.typography.bodySmall, - color = tokens.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (hasAddress) { - IconButton(onClick = onZap, modifier = Modifier.size(48.dp)) { - Icon( - Icons.Default.Bolt, - contentDescription = "Zap $name", - tint = tokens.accentFill, - modifier = Modifier.size(22.dp), - ) - } - } - } - } -} - -@Composable -private fun AlbumRow( - title: String, - artist: String?, - artworkUrl: String?, - artistId: String? = null, - address: String? = null, - onClick: () -> Unit = {}, - onZap: () -> Unit = {}, -) { - ArtistRow(name = title, genre = artist, imageUrl = artworkUrl, artistId = artistId, address = address, onClick = onClick, onZap = onZap) -} - -@Composable -fun ArtistDetailView( - artistId: String, - modifier: Modifier = Modifier, - viewModel: WavlakeViewModel = hiltViewModel(), -) { - val detail by viewModel.artistDetail.collectAsState() - val loading by viewModel.artistLoading.collectAsState() - val addresses by viewModel.addresses.collectAsState() - val tokens = LocalTokens.current - // The single zap sheet, fed by whichever bolt was tapped (header or row). - var zapAddress by remember { mutableStateOf(null) } - zapAddress?.let { addr -> - com.rada.ui.decentralized.TipSheet( - artistName = detail?.artist?.name ?: "Artist", - trackTitle = null, - initialAddress = addr, - artistId = artistId, - onDismiss = { zapAddress = null }, - ) - } - - Column(modifier = modifier.fillMaxSize()) { - if (loading && detail == null) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(24.dp), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(22.dp), - strokeWidth = 2.dp, - color = tokens.accentText, - ) - } - return - } - val artist = detail?.artist ?: return - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(72.dp) - .clip(CircleShape) - .background(tokens.accentFill.copy(alpha = 0.15f)), - contentAlignment = Alignment.Center, - ) { - Text( - text = artist.name.firstOrNull()?.uppercase() ?: "?", - style = MaterialTheme.typography.headlineMedium, - fontWeight = FontWeight.Bold, - color = tokens.accentText, - ) - } - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = artist.name, - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - color = tokens.text, - ) - Text( - text = (artist.genres.ifEmpty { listOfNotNull(artist.genre) }) - .joinToString(" · ").ifEmpty { "Wavlake artist" }, - style = MaterialTheme.typography.bodySmall, - color = tokens.textSecondary, - ) - } - // STRICT gate: no address, no bolt — nothing rendered at all. - val headerAddress = addresses[artistId] - if (headerAddress != null) { - IconButton( - onClick = { zapAddress = headerAddress }, - modifier = Modifier.size(48.dp), - ) { - Icon( - Icons.Default.Bolt, - contentDescription = "Zap ${artist.name}", - tint = tokens.accentFill, - modifier = Modifier.size(24.dp), - ) - } - } - } - if (!artist.bio.isNullOrBlank()) { - Text( - text = artist.bio, - style = MaterialTheme.typography.bodyMedium, - color = tokens.textSecondary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), - ) - } - val tracks = detail?.tracks.orEmpty() - // Official Wavlake page (track URL is an official API field): the - // one-tap path to zap artists whose Lightning Address the catalog - // doesn't expose. External browser — no address invented here. - val wavlakeUrl = tracks.firstNotNullOfOrNull { it.sourceUrl } - if (wavlakeUrl != null) { - val context = androidx.compose.ui.platform.LocalContext.current - TextButton(onClick = { - runCatching { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse(wavlakeUrl), - ), - ) - } - }) { - Text( - "View on wavlake.com", - modifier = Modifier.padding(horizontal = 8.dp), - ) - } - } - if (tracks.isEmpty() && !loading) { - Text( - text = "No tracks found for this artist.", - style = MaterialTheme.typography.bodyMedium, - color = tokens.textSecondary, - modifier = Modifier.padding(horizontal = 16.dp), - ) - } else { - LazyColumn( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - items(tracks, key = { it.id }, contentType = { "track" }) { track -> - var detailTrackZap by remember(track.id) { - mutableStateOf(null) - } - TrackRow( - track = track, - showZap = addresses[track.artistId] != null || track.isZapPayable, - onPlay = { viewModel.playTrack(track) }, - onSave = { viewModel.saveTrack(track) }, - onZap = { t -> - addresses[t.artistId]?.let { addr -> - zapAddress = addr - } ?: run { - if (t.isZapPayable) detailTrackZap = t - } - }, - ) - detailTrackZap?.let { zt -> - WavlakeZapSheet( - track = zt, - onDismiss = { detailTrackZap = null }, - ) - } - } - } - } - } -} - -@Composable -private fun WavlakeSettingsDialog( - current: WavlakeStationSettings, - onDismiss: () -> Unit, - onSave: (WavlakeStationSettings) -> Unit, -) { - var enabled by remember { mutableStateOf(current.enabled) } - var probability by remember { mutableStateOf(current.featureProbability.toFloat()) } - var mode by remember { mutableStateOf(current.matchingMode) } - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Wavlake featuring") }, - text = { - Column { - Text( - "Genre-matched Wavlake picks appear on the Player for compatible stations. Tapping plays — stations are never interrupted.", - style = MaterialTheme.typography.bodySmall, - color = LocalTokens.current.textSecondary, - ) - Spacer(modifier = Modifier.height(12.dp)) - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - "Enabled", - modifier = Modifier.weight(1f), - color = LocalTokens.current.text, - ) - Switch(checked = enabled, onCheckedChange = { enabled = it }) - } - Spacer(modifier = Modifier.height(8.dp)) - Text( - "Refresh chance: ${(probability * 100).toInt()}%", - color = LocalTokens.current.text, - ) - Slider( - value = probability, - onValueChange = { probability = it }, - valueRange = 0f..1f, - ) - Spacer(modifier = Modifier.height(4.dp)) - Text("Matching", color = LocalTokens.current.text) - Spacer(modifier = Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - MatchingMode.entries.forEach { m -> - TextButton(onClick = { mode = m }) { - Text( - m.name.lowercase().replace('_', '-'), - color = if (mode == m) LocalTokens.current.accentText - else LocalTokens.current.textSecondary, - fontWeight = if (mode == m) FontWeight.Bold else FontWeight.Normal, - ) - } - } - } - } - }, - confirmButton = { - TextButton( - onClick = { - onSave( - current.copy( - enabled = enabled, - featureProbability = probability.toDouble(), - matchingMode = mode, - ), - ) - }, - ) { Text("Save") } - }, - dismissButton = { - TextButton(onClick = onDismiss) { Text("Cancel") } - }, - ) -} - -/** - * Opt-in genre-matched picks for the current station. Shown on the Player; - * tapping a pick plays it (explicit choice), nothing auto-inserts. - */ -@Composable -fun WavlakeFeaturedRow( - stationId: String, - stationGenre: String, - viewModel: WavlakeViewModel = hiltViewModel(), -) { - val tokens = LocalTokens.current - var picks by remember(stationId) { mutableStateOf>(emptyList()) } - var loading by remember(stationId) { mutableStateOf(true) } - - LaunchedEffect(stationId) { - loading = true - picks = viewModel.featuredFor(stationId, stationGenre) - loading = false - } - - if (!loading && picks.isEmpty()) return - - Column(modifier = Modifier.fillMaxWidth()) { - Text( - text = "FEATURING WAVLAKE", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = tokens.accentText, - modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), - ) - if (loading) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(20.dp), - strokeWidth = 2.dp, - color = tokens.accentText, - ) - } - } else { - androidx.compose.foundation.lazy.LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp), - ) { - items(picks, key = { it.id }) { track -> - FeaturedChip( - track = track, - onPlay = { viewModel.playTrack(track) }, - onSave = { viewModel.saveTrack(track) }, - ) - } - } - } - } -} - -/** - * Official Wavlake track zap (no artist address needed): amount → invoice - * via wavlake.com, then pay in-app (wallet) or copy the invoice out to any - * wallet. Success is shown only after confirmation. - */ -@Composable -fun WavlakeZapSheet( - track: WavlakeTrack, - onDismiss: () -> Unit, - viewModel: WavlakeZapViewModel = hiltViewModel(), -) { - val tokens = LocalTokens.current - val state by viewModel.state.collectAsState() - var amount by remember(track.id) { mutableStateOf(100L) } - var customAmount by remember(track.id) { mutableStateOf("") } - var comment by remember(track.id) { mutableStateOf("") } - val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount - - LaunchedEffect(track.id) { viewModel.reset() } - - AlertDialog( - onDismissRequest = onDismiss, - containerColor = tokens.panel, - titleContentColor = tokens.text, - shape = RoundedCornerShape(20.dp), - title = { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText) - Spacer(Modifier.size(8.dp)) - Text( - "Zap ${track.title}", - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - }, - text = { - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - Text( - "by ${track.artistName} · via Wavlake", - style = MaterialTheme.typography.bodySmall, - color = tokens.textMuted, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - when (val s = state) { - TrackZapState.Editing -> { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - listOf(21L, 100L, 500L, 1000L).forEach { preset -> - FilterChip( - selected = customAmount.isBlank() && amount == preset, - onClick = { amount = preset; customAmount = "" }, - label = { Text("$preset") }, - colors = FilterChipDefaults.filterChipColors( - selectedContainerColor = tokens.accentSoft, - selectedLabelColor = tokens.accentText, - labelColor = tokens.textSecondary, - ), - ) - } - } - OutlinedTextField( - value = customAmount, - onValueChange = { customAmount = it.filter(Char::isDigit) }, - placeholder = { Text("Custom amount in sats") }, - singleLine = true, - suffix = { Text("sats", color = tokens.textMuted) }, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = tokens.accentText, - unfocusedBorderColor = tokens.borderInteractive, - focusedTextColor = tokens.text, - unfocusedTextColor = tokens.text, - ), - modifier = Modifier.fillMaxWidth(), - ) - OutlinedTextField( - value = comment, - onValueChange = { comment = it }, - placeholder = { Text("Message (optional)") }, - singleLine = true, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = tokens.accentText, - unfocusedBorderColor = tokens.borderInteractive, - focusedTextColor = tokens.text, - unfocusedTextColor = tokens.text, - ), - modifier = Modifier.fillMaxWidth(), - ) - } - TrackZapState.Sending -> { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - strokeWidth = 2.dp, - color = tokens.accentText, - ) - } - } - is TrackZapState.InvoiceReady -> { - Text( - "Invoice for ${s.sats} sats — copy it into any wallet app, no connected wallet needed.", - style = MaterialTheme.typography.bodyMedium, - color = tokens.text, - ) - Text( - s.bolt11.take(42) + "…", - style = MaterialTheme.typography.bodySmall, - color = tokens.textMuted, - ) - } - TrackZapState.Sent -> { - Text( - "⚡ Zap confirmed. The artist gets paid via Wavlake.", - style = MaterialTheme.typography.bodyMedium, - color = tokens.accentText, - ) - } - is TrackZapState.Failed -> { - Text( - s.reason, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - } - } - }, - confirmButton = { - when (val s = state) { - TrackZapState.Editing -> { - TextButton( - onClick = { viewModel.send(track, finalSats, comment) }, - enabled = finalSats > 0, - ) { Text("Send $finalSats sats") } - } - is TrackZapState.InvoiceReady -> { - val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current - TextButton(onClick = { - clipboard.setText(androidx.compose.ui.text.AnnotatedString(s.bolt11)) - }) { Text("Copy invoice") } - } - is TrackZapState.Failed -> { - TextButton(onClick = { viewModel.reset() }) { Text("Back") } - } - else -> {} - } - }, - dismissButton = { - when (state) { - TrackZapState.Editing -> { - TextButton( - onClick = { viewModel.fetchInvoice(track, finalSats, comment) }, - enabled = finalSats > 0, - ) { Text("Invoice only") } - } - else -> { - TextButton(onClick = onDismiss) { - Text(if (state == TrackZapState.Sent) "Done" else "Close") - } - } - } - }, - ) -} - -@Composable -private fun FeaturedChip( track: WavlakeTrack, - onPlay: () -> Unit, - onSave: () -> Unit, -) { - val tokens = LocalTokens.current - Card( - onClick = onPlay, - colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong), - shape = RoundedCornerShape(SatsAmpGlass.rInput), - ) { - Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - EqualizerBars(active = false, color = tokens.accentText) - Spacer(modifier = Modifier.width(8.dp)) - Column { - Text( - text = track.title, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - color = tokens.text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = track.artistName, - style = MaterialTheme.typography.bodySmall, - color = tokens.textSecondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Spacer(modifier = Modifier.width(8.dp)) - IconButton(onClick = onSave, modifier = Modifier.size(40.dp)) { - Icon( - Icons.Default.Add, - contentDescription = "Save ${track.title} to Library", - tint = tokens.textSecondary, - modifier = Modifier.size(18.dp), - ) - } - } - } -} diff --git a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt deleted file mode 100644 index c76862e..0000000 --- a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt +++ /dev/null @@ -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) : WavlakeSearchState - data class Error(val message: String) : WavlakeSearchState -} - -@OptIn(FlowPreview::class) -@HiltViewModel -class WavlakeViewModel @Inject constructor( - private val repository: WavlakeRepository, - private val playbackManager: PlaybackManager, - private val playlists: PlaylistRepository, -) : ViewModel() { - - private val _searchQuery = MutableStateFlow("") - val searchQuery: StateFlow = _searchQuery.asStateFlow() - - private val _searchState = MutableStateFlow(WavlakeSearchState.Idle) - val searchState: StateFlow = _searchState.asStateFlow() - - private val _rankings = MutableStateFlow>(emptyList()) - val rankings: StateFlow> = _rankings.asStateFlow() - - private val _rankingsGenre = MutableStateFlow(null) - val rankingsGenre: StateFlow = _rankingsGenre.asStateFlow() - - private val _rankingsLoading = MutableStateFlow(false) - val rankingsLoading: StateFlow = _rankingsLoading.asStateFlow() - - private val _rankingsError = MutableStateFlow(null) - val rankingsError: StateFlow = _rankingsError.asStateFlow() - - /** Resolved Lightning Addresses by artist id — populated best-effort. */ - private val _addresses = MutableStateFlow>(emptyMap()) - val addresses: StateFlow> = _addresses.asStateFlow() - - private val _selectedArtistId = MutableStateFlow(null) - val selectedArtistId: StateFlow = _selectedArtistId.asStateFlow() - - private val _artistDetail = MutableStateFlow(null) - val artistDetail: StateFlow = - _artistDetail.asStateFlow() - - private val _artistLoading = MutableStateFlow(false) - val artistLoading: StateFlow = _artistLoading.asStateFlow() - - private val _notice = MutableStateFlow(null) - val notice: StateFlow = _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) { - // 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) { - 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 = - 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 - } -} diff --git a/app/src/main/java/com/rada/ui/discover/WavlakeZapViewModel.kt b/app/src/main/java/com/rada/ui/discover/WavlakeZapViewModel.kt deleted file mode 100644 index 55a671a..0000000 --- a/app/src/main/java/com/rada/ui/discover/WavlakeZapViewModel.kt +++ /dev/null @@ -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.Editing) - val state: StateFlow = _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") - } - } - } -} diff --git a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt index 0d749f0..edf25e3 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -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(null) } - var songForPlaylist by remember { mutableStateOf(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, - onDismiss: () -> Unit, - onPlaylistsConfirmed: (List) -> Unit, - onCreatePlaylist: (String) -> Unit, -) { - var showNewInput by remember { mutableStateOf(false) } - var newPlaylistName by remember { mutableStateOf("") } - var selectedIds by remember { mutableStateOf(setOf()) } - - 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, - modifier = Modifier.size(24.dp), - ) - } + Icon( + Icons.Default.MusicNote, + contentDescription = null, + 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, diff --git a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt index 01ac2d1..1e186fc 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt @@ -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(null) - private val multiSelect = MutableStateFlow(false) - private val multiSelectedIds = MutableStateFlow>(emptySet()) val allPlaylists: StateFlow> = 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, 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, 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) { - 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 = 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 = 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, -) - -/** Songs + the view flags they were collected under. */ -private data class SongListView( - val songs: List, - val sort: SortOrder, - val ascending: Boolean, - val multi: Boolean, - val ids: Set, -) diff --git a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt index 17fa58c..aa0f547 100644 --- a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt @@ -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( diff --git a/app/src/main/java/com/rada/ui/navigation/Screen.kt b/app/src/main/java/com/rada/ui/navigation/Screen.kt index 43f3001..8a1d877 100644 --- a/app/src/main/java/com/rada/ui/navigation/Screen.kt +++ b/app/src/main/java/com/rada/ui/navigation/Screen.kt @@ -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" } ?: "") - } } diff --git a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt deleted file mode 100644 index 50e359c..0000000 --- a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt +++ /dev/null @@ -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) } - }, - ) - } - } - } -} diff --git a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt index 27f95fa..b2a9d7a 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -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(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,30 +105,12 @@ 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", - tint = LocalTokens.current.accentOnFill, - modifier = Modifier.size(24.dp), - ) - } + Icon( + if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio, + 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( @@ -185,86 +122,15 @@ fun NowPlayingBar( onClick = onNavigateToPlayer, ), ) { - Column { - AnimatedContent( - targetState = title, - transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) }, - label = "npTitle", - ) { t -> - Text( - text = t, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - 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), + Text( + text = stationName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = LocalTokens.current.text, ) } - 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), diff --git a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt index 2a2f4af..cdc996b 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt @@ -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 = playbackManager.currentStationNameFlow @@ -26,42 +16,8 @@ class NowPlayingViewModel @Inject constructor( val isPlaying: StateFlow = playbackManager.isPlayingFlow val bufferState: StateFlow = playbackManager.bufferStateFlow val isLocal: StateFlow = playbackManager.isLocalFlow - val streamMeta: StateFlow = playbackManager.lastStreamMetaFlow - val localMedia: StateFlow?> = 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?> = - 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) - } } diff --git a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt index 7db8ff5..2b5c713 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -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", - modifier = Modifier.size(36.dp), - ) - else -> Icon( - Icons.Default.PlayArrow, - contentDescription = "Play", - modifier = Modifier.size(36.dp), - ) - } + ) { playing -> + Icon( + if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (playing) "Pause" else "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, newPlaylistName: String, onNewPlaylistNameChange: (String) -> Unit, - onPlaylistsConfirmed: (List) -> 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()) } 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), ) { - Box( + Row( modifier = Modifier .fillMaxWidth() - .aspectRatio(16f / 10f) - .background(SatsAmpGradients.accentBrush()), + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, ) { - Icon( - Icons.Default.Radio, - contentDescription = null, - tint = tokens.accentOnFill.copy(alpha = 0.30f), + Box( 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), + .size(48.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, ) { - if (isPlaying) { - EqualizerBars( - active = true, - color = tokens.accentOnFill, - modifier = Modifier.padding(bottom = 6.dp), - ) - } + Icon( + Icons.Default.Radio, + contentDescription = null, + 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), + ) + } +} diff --git a/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt b/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt index b49ecd2..48e197d 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt @@ -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, ) } } @@ -135,7 +131,7 @@ class PlayerViewModel @Inject constructor( } } - init { + init { playbackManager.listener = playbackListener _uiState.update { it.copy( @@ -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) { + 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,41 +287,14 @@ 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 - } + playbackManager.listener = 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, ) diff --git a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt index 9382387..6160f92 100644 --- a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt +++ b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt @@ -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(null) - } - val snackbarHostState = remember { SnackbarHostState() } var showAddDialog by remember { mutableStateOf(false) } var renameTarget by remember { mutableStateOf(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(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(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 = { @@ -438,110 +288,9 @@ 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,49 +475,40 @@ 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, - contentDescription = null, - tint = tokens.accentText, - modifier = Modifier.size(22.dp), - ) - } + Icon( + if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio, + contentDescription = null, + tint = tokens.accentText, + modifier = Modifier.size(20.dp), + ) } Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { @@ -854,85 +531,42 @@ 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.Edit, + contentDescription = "Rename", + tint = tokens.textSecondary, + modifier = Modifier.size(20.dp), + ) + } + if (isCustom) { + IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) { Icon( - Icons.Default.MoreVert, - contentDescription = "More actions for ${station.name}", + Icons.Default.Delete, + contentDescription = "Remove", 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 = { - Icon( - if (wavlakeEnabled) Icons.Default.Check - else Icons.Default.Add, - contentDescription = null, - ) - }, - onClick = { - showMenu = false - onToggleWavlake() - }, - ) - } - } } } } diff --git a/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt b/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt index 775d663..241af48 100644 --- a/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt +++ b/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt @@ -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) : 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,46 +43,36 @@ class StationBrowserViewModel @Inject constructor( val favorites: StateFlow> = stationRepository.favorites - val hiddenIds: StateFlow> = stationRepository.hiddenIds - - /** One-shot delete notice, consumed by the Undo snackbar. */ - private val _notice = MutableStateFlow(null) - val notice: StateFlow = _notice.asStateFlow() - private var lastDeleted: Station? = null - /** URL of whatever is playing now, so the list can mark the active row. */ val playingUrl: StateFlow = playbackManager.currentStationUrlFlow - val stationsByGenre: StateFlow>> = - combine(_allStations, _searchQuery, favorites) { list, query, favs -> - // Starred stations move to the pinned top section, so they leave - // their genre group instead of appearing twice. - val favIds = favs.map { it.id }.toSet() - val base = if (query.isBlank()) { - list - } else { - list.filter { - it.name.contains(query, ignoreCase = true) || - it.genre.contains(query, ignoreCase = true) || - it.url.contains(query, ignoreCase = true) - } - } - // Curated order: DefaultStations order is the display order (not - // alphabetical), and groups appear in first-seen order so the - // playlist reads Lofi → … → NCS Chill, Media Monarchy first. - base.filter { it.id !in favIds } - .groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } } - }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) + val stations: StateFlow> = 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>> = combine(_allStations, _searchQuery) { list, query -> + val filtered = if (query.isBlank()) list + else list.filter { + it.name.contains(query, ignoreCase = true) || + it.genre.contains(query, ignoreCase = true) || + it.url.contains(query, ignoreCase = true) + } + filtered.sortedBy { it.name.lowercase() } + .groupBy { it.genre.split(" / ").first().ifEmpty { "Other" } } + .toSortedMap(compareBy { it.lowercase() }) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyMap()) // ── Directory search (radio-browser.info, ~58k stations) ─────────── private val _directoryState = MutableStateFlow(DirectoryState.Idle) val directoryState: StateFlow = _directoryState.asStateFlow() - private val _wavlakeArtists = - MutableStateFlow(WavlakeArtistSearch.Idle) - val wavlakeArtists: StateFlow = _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() - .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 = _wavlakeVersion.asStateFlow() - - fun isWavlakeFeaturing(id: String): Boolean = wavlakeRepository.isStationEnabled(id) - - fun toggleWavlakeFeaturing(id: String) { - wavlakeRepository.setStationEnabled(id, !wavlakeRepository.isStationEnabled(id)) - _wavlakeVersion.value += 1 - } } diff --git a/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt b/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt index 728a2f7..c9112f0 100644 --- a/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt +++ b/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt @@ -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,20 +415,12 @@ 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, - contentDescription = null, - tint = LocalTokens.current.accentText, - modifier = Modifier.size(22.dp), - ) - } + Icon( + if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, + contentDescription = null, + 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)) { @@ -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), ) diff --git a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt index c987284..6de7aa1 100644 --- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt +++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt @@ -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,15 +141,8 @@ 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) - } + TextButton(onClick = onDismiss) { + Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold) } }, ) diff --git a/app/src/main/java/com/rada/ui/theme/Motion.kt b/app/src/main/java/com/rada/ui/theme/Motion.kt index 709aec3..27e2fe3 100644 --- a/app/src/main/java/com/rada/ui/theme/Motion.kt +++ b/app/src/main/java/com/rada/ui/theme/Motion.kt @@ -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) - } - }, - ) - } -} diff --git a/app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt b/app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt deleted file mode 100644 index b36d7a4..0000000 --- a/app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt +++ /dev/null @@ -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" }) - } -} diff --git a/app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt b/app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt deleted file mode 100644 index ddecef8..0000000 --- a/app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt +++ /dev/null @@ -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")) - } -} diff --git a/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt b/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt deleted file mode 100644 index 587bab5..0000000 --- a/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt +++ /dev/null @@ -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) -} diff --git a/app/src/test/java/com/rada/payments/LightningAddressTest.kt b/app/src/test/java/com/rada/payments/LightningAddressTest.kt deleted file mode 100644 index 56be945..0000000 --- a/app/src/test/java/com/rada/payments/LightningAddressTest.kt +++ /dev/null @@ -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")) - } -}