diff --git a/PRODUCT.md b/PRODUCT.md index e7d25d2..a545b51 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -26,7 +26,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live ## Operating Context - Single-user personal device use; headphone and speaker listening. -- Streams are live and can drop; playback is a foreground service with notification controls. +- Streams are live and can drop; playback is a foreground service with notification controls. Swiping the task away stops playback and clears the notification (closed means stopped); screen-off listening with the task alive keeps playing. - Library is local (Room DB, `radamp.db`); saved songs, playlists, and imported files live on-device. - Stations are built-in defaults plus user-added streams (direct URL or M3U playlist import). @@ -45,7 +45,7 @@ Most radio apps play streams but discard what's on them. SatsAmp treats the live ## Evidence on Hand -- 43 built-in stations, all verified live 2026-09-07 (HTTP 200 + audio payload; probe results were ephemeral in /tmp, not committed). +- 11 built-in stations (cliamp.stream music channels in playlist order: Lofi → … → NCS Chill), all verified live 2026-09-09 (HTTP 200 + audio payload; probe results were ephemeral, not committed). Media Monarchy ships as a built-in stream in the Streams tab instead (talk, not music). - Working recorder pipeline (`recording/`: IcyStreamReader, SongBoundaryDetector, RollingAudioBuffer, MetadataEmbedder). - Design scratch work in `design/` (glass theme CSS + demo). - Absences that must not be fabricated: no testimonials, no user base, no press, no pricing, no privacy policy text. diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 040a549..bb952aa 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,2 +1,13 @@ -dontwarn javax.annotation.** -keep class com.rada.data.local.entity.** { *; } + +# jaudiotagger maps tag frames via reflection; without keeps the release +# minifier breaks its writer (silent NPE, files saved untagged). +-keep class org.jaudiotagger.** { *; } +-keepattributes Signature, InnerClasses, EnclosingMethod +# jaudiotagger also references desktop-only image/test APIs that Android +# never executes (artwork scaling, test helpers). +-dontwarn org.jaudiotagger.** +-dontwarn java.awt.** +-dontwarn javax.imageio.** +-dontwarn javax.swing.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3285b91..5df91ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,16 @@ + + + + + + + + + + > + /** + * Union of songs across several playlists. DISTINCT because a song can + * sit in more than one selected list. + */ + @Query( + """ + SELECT DISTINCT s.* FROM saved_songs s + INNER JOIN playlist_songs ps ON s.id = ps.songId + WHERE ps.playlistId IN (:playlistIds) + """ + ) + fun getSongsInPlaylists(playlistIds: List): Flow> + @Query("SELECT COUNT(*) FROM playlist_songs WHERE playlistId = :playlistId") fun getPlaylistSongCount(playlistId: Long): Flow diff --git a/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt b/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt index be6e767..cafbe77 100644 --- a/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt +++ b/app/src/main/java/com/rada/data/local/entity/SavedSongEntity.kt @@ -18,4 +18,14 @@ data class SavedSongEntity( val format: String = "mp3", val dateSaved: Long = System.currentTimeMillis(), val isFavorite: Boolean = false, + /** Track origin: "local" (default) or "wavlake" (streamable catalog import). */ + val source: String = "local", + /** Canonical source URL (Wavlake track page). Empty for local files. */ + val sourceUrl: String = "", + /** Artwork URL (Wavlake imports). Empty for local files. */ + val artworkUrl: String = "", + /** Wavlake artist id (imports only) — links plays back to zap addresses. */ + val wavlakeArtistId: String = "", + /** Wavlake artist npub (imports only) — enables Nostr address resolution. */ + val wavlakeArtistNpub: String = "", ) diff --git a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt index 24f8f28..456c193 100644 --- a/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt +++ b/app/src/main/java/com/rada/data/repository/PlaylistRepository.kt @@ -45,6 +45,11 @@ class PlaylistRepository @Inject constructor( return playlistDao.getSongsInPlaylist(playlistId) } + /** Union of songs across several playlists (for multi-list shuffle). */ + fun getSongsInPlaylists(playlistIds: List): Flow> { + return playlistDao.getSongsInPlaylists(playlistIds) + } + fun getPlaylistSongCount(playlistId: Long): Flow { return playlistDao.getPlaylistSongCount(playlistId) } diff --git a/app/src/main/java/com/rada/data/repository/StationRepository.kt b/app/src/main/java/com/rada/data/repository/StationRepository.kt index efee1bf..5421fbd 100644 --- a/app/src/main/java/com/rada/data/repository/StationRepository.kt +++ b/app/src/main/java/com/rada/data/repository/StationRepository.kt @@ -24,6 +24,10 @@ class StationRepository @Inject constructor( private val stations = mutableListOf() private val nameOverrides = mutableMapOf() + /** Built-in station ids the user deleted. Persisted; survives restarts. */ + private val _hiddenIds = MutableStateFlow(loadHiddenIds()) + val hiddenIds: StateFlow> = _hiddenIds.asStateFlow() + init { nameOverrides.putAll(loadNameOverrides()) rebuild() @@ -38,10 +42,42 @@ class StationRepository @Inject constructor( saveCustomStations() } + /** + * Deletes any station. Custom stations are removed; built-ins are + * hidden (persisted) so they stay gone across restarts. See + * [unhideStation]/[restoreHiddenStations] for the way back. + */ fun removeStation(id: String) { + if (DefaultStations.getById(id) != null) { + if (id !in _hiddenIds.value) { + _hiddenIds.value = _hiddenIds.value + id + saveHiddenIds(_hiddenIds.value) + rebuild() + } + return + } stations.removeAll { it.id == id } if (nameOverrides.remove(id) != null) saveNameOverrides() saveCustomStations() + rebuild() + } + + /** Un-hides one previously hidden built-in station. No-op otherwise. */ + fun unhideStation(id: String) { + if (id in _hiddenIds.value) { + _hiddenIds.value = _hiddenIds.value - id + saveHiddenIds(_hiddenIds.value) + rebuild() + } + } + + /** Restores all hidden built-in stations. */ + fun restoreHiddenStations() { + if (_hiddenIds.value.isNotEmpty()) { + _hiddenIds.value = emptySet() + saveHiddenIds(emptySet()) + rebuild() + } } fun isCustomStation(id: String): Boolean { @@ -125,7 +161,7 @@ class StationRepository @Inject constructor( private fun rebuild() { stations.clear() - stations.addAll(DefaultStations.getAll()) + stations.addAll(DefaultStations.getAll().filter { it.id !in _hiddenIds.value }) stations.addAll(loadCustomStations()) for (i in stations.indices) { val station = stations[i] @@ -135,8 +171,7 @@ class StationRepository @Inject constructor( } } - private fun loadNameOverrides(): Map { - val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap() + private fun loadNameOverrides(): Map { val json = prefs.getString(KEY_NAME_OVERRIDES, null) ?: return emptyMap() return try { val obj = JSONObject(json) obj.keys().asSequence().associateWith { key -> obj.getString(key) } @@ -169,8 +204,7 @@ class StationRepository @Inject constructor( } } - private fun saveCustomStations() { - val custom = stations.filter { isCustomStation(it.id) } + private fun saveCustomStations() { val custom = stations.filter { isCustomStation(it.id) } val arr = JSONArray() custom.forEach { s -> arr.put(JSONObject().apply { @@ -183,9 +217,26 @@ class StationRepository @Inject constructor( prefs.edit().putString(KEY_CUSTOM_STATIONS, arr.toString()).apply() } + private fun loadHiddenIds(): Set { + val json = prefs.getString(KEY_HIDDEN_STATIONS, null) ?: return emptySet() + return try { + val arr = JSONArray(json) + (0 until arr.length()).mapTo(mutableSetOf()) { i -> arr.getString(i) } + } catch (_: Exception) { + emptySet() + } + } + + private fun saveHiddenIds(ids: Set) { + val arr = JSONArray() + ids.forEach { arr.put(it) } + prefs.edit().putString(KEY_HIDDEN_STATIONS, arr.toString()).apply() + } + companion object { private const val KEY_CUSTOM_STATIONS = "custom_stations" private const val KEY_NAME_OVERRIDES = "station_name_overrides" private const val KEY_FAVORITE_STATIONS = "favorite_stations" + private const val KEY_HIDDEN_STATIONS = "hidden_stations" } } diff --git a/app/src/main/java/com/rada/data/repository/StreamRepository.kt b/app/src/main/java/com/rada/data/repository/StreamRepository.kt index 8e53b42..da80e12 100644 --- a/app/src/main/java/com/rada/data/repository/StreamRepository.kt +++ b/app/src/main/java/com/rada/data/repository/StreamRepository.kt @@ -21,6 +21,34 @@ class StreamRepository @Inject constructor( private var cache: List? = null + init { + ensureBuiltins() + } + + /** + * Media Monarchy ships as a built-in stream (talk, not music — it lives + * here instead of the Stations tab). Seeded exactly once per install so + * a user delete is respected and never resurrected. + */ + private fun ensureBuiltins() { + if (prefs.getBoolean(KEY_BUILTINS_SEEDED, false)) return + val current = getAll().toMutableList() + if (current.none { it.url == BUILTIN_MONARCHY_URL }) { + current.add( + 0, + StreamEntry( + id = "media-monarchy", + name = "Media Monarchy", + url = BUILTIN_MONARCHY_URL, + source = "Built-in", + group = "Talk", + ), + ) + save(current) + } + prefs.edit().putBoolean(KEY_BUILTINS_SEEDED, true).apply() + } + fun getAll(): List { cache?.let { return it } val raw = prefs.getString(key, "[]") ?: "[]" @@ -61,4 +89,9 @@ class StreamRepository @Inject constructor( cache = list prefs.edit().putString(key, json.encodeToString(streamEntriesSerializer, list)).apply() } + + companion object { + private const val KEY_BUILTINS_SEEDED = "builtins_seeded" + private const val BUILTIN_MONARCHY_URL = "http://216.158.245.68:8000/listen" + } } diff --git a/app/src/main/java/com/rada/data/wavlake/StationFeature.kt b/app/src/main/java/com/rada/data/wavlake/StationFeature.kt new file mode 100644 index 0000000..d6a6b42 --- /dev/null +++ b/app/src/main/java/com/rada/data/wavlake/StationFeature.kt @@ -0,0 +1,112 @@ +package com.rada.data.wavlake + +import com.rada.domain.model.WavlakeTrack +import kotlinx.serialization.Serializable +import kotlin.random.Random + +/** + * Optional, configurable featuring of Wavlake tracks alongside (never + * inside) live radio stations. Stations are live streams, so featuring is + * an opt-in shelf the user taps — it never replaces station content, + * never interrupts playback, and any API failure degrades to "no picks". + */ +@Serializable +data class WavlakeStationSettings( + val enabled: Boolean = true, + /** Probability a station view refreshes its picks (0.0–1.0). */ + val featureProbability: Double = 0.1, + val maxFeaturedTracksPerHour: Int = 3, + val maxFeaturedTracksPerQueue: Int = 1, + val matchingMode: MatchingMode = MatchingMode.EXACT_OR_RELATED, +) + +enum class MatchingMode { EXACT, RELATED, EXACT_OR_RELATED } + +val defaultWavlakeStationSettings = WavlakeStationSettings() + +/** + * Station genre -> exact Wavlake catalog genre names + * (https://github.com/wavlake/genre-list). Only names from that list are + * ever sent to the API — unknown names return HTTP 400 there. + */ +object WavlakeGenreMatcher { + private val related: Map> = mapOf( + "Lofi" to listOf("Downtempo", "Ambient", "Electronic"), + "Synthwave" to listOf("Electro", "Electronica", "Electronic", "Electronic Rock"), + "EDM" to listOf( + "Dance/EDM", "House", "Jackin House", "Dubstep", "Techno", "Trance", + "Breakbeat", "Garage", "Hard Dance", "Jungle/Drum'n'bass", "Hi-NRG / Eurodance", + ), + "NCS" to listOf( + "Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "House", "Dubstep", + "Jungle/Drum'n'bass", "Trance", "Techno", + ), + "Electronic" to listOf("Electronic", "Electronica", "Downtempo", "Ambient"), + "Hip-Hop" to listOf("Hip-Hop/Rap", "Hip-Hop", "Rap"), + "Pop" to listOf("Pop", "Indie Pop"), + "Rock" to listOf("Rock", "Alternative Rock", "Indie Rock"), + ) + + /** Station "A / B" strings use the same split convention as the app. */ + fun splitStationGenres(genre: String): Pair> { + val parts = genre.split(" / ").map { it.trim() }.filter { it.isNotEmpty() } + return (parts.firstOrNull() ?: "") to parts.drop(1) + } + + /** Exact Wavlake genre names compatible with a station, per [MatchingMode]. */ + fun compatibleGenres( + primary: String, + secondary: List = emptyList(), + mode: MatchingMode = MatchingMode.EXACT_OR_RELATED, + ): List { + val out = LinkedHashSet() + val keys = (listOf(primary) + secondary).map { it.trim() }.filter { it.isNotEmpty() } + if (mode == MatchingMode.EXACT || mode == MatchingMode.EXACT_OR_RELATED) { + keys.filter { isWavlakeGenreName(it) }.forEach { out.add(it) } + } + if (mode == MatchingMode.RELATED || mode == MatchingMode.EXACT_OR_RELATED) { + keys.forEach { g -> related[g]?.let { out.addAll(it) } } + } + return out.toList() + } + + private fun isWavlakeGenreName(name: String): Boolean = + related.values.any { it.contains(name) } +} + +/** Inputs for one featuring decision. All IDs are Wavlake track/artist IDs. */ +data class FeatureSelection( + val candidates: List, + val settings: WavlakeStationSettings = defaultWavlakeStationSettings, + /** Track IDs already queued or playing — never duplicated. */ + val excludedTrackIds: Set = emptySet(), + /** Artist IDs blocked by the user. */ + val blockedArtistIds: Set = emptySet(), + /** Artist IDs featured recently — deprioritized to keep rotation fresh. */ + val recentArtistIds: Set = emptySet(), + /** How many tracks were featured in the last hour (cap gate). */ + val featuredThisHour: Int = 0, + val random: Random = Random.Default, +) + +object FeatureSelector { + fun select(input: FeatureSelection): List { + val s = input.settings + if (!s.enabled) return emptyList() + if (input.featuredThisHour >= s.maxFeaturedTracksPerHour) return emptyList() + if (s.maxFeaturedTracksPerQueue <= 0) return emptyList() + if (input.random.nextDouble() >= s.featureProbability.coerceIn(0.0, 1.0)) return emptyList() + + val pool = input.candidates + .filter { it.audioUrl?.isNotBlank() == true } + .filterNot { it.id in input.excludedTrackIds } + .filterNot { it.artistId in input.blockedArtistIds } + .distinctBy { it.id } + if (pool.isEmpty()) return emptyList() + + // Fresh artists first, stable order otherwise; then take the cap. + val fresh = pool.filterNot { it.artistId in input.recentArtistIds } + val ordered = (fresh.ifEmpty { pool }).shuffled(input.random) + return ordered.take(s.maxFeaturedTracksPerQueue) + } +} diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt new file mode 100644 index 0000000..c4cc385 --- /dev/null +++ b/app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt @@ -0,0 +1,227 @@ +package com.rada.data.wavlake + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.util.concurrent.TimeUnit + +/** + * Raw DTOs mirror wavlake/developer-docs swagger.yaml exactly (all fields + * optional-tolerant; unknown keys ignored for forward compatibility). + * Base https://wavlake.com/api/v1. No authentication (the spec's + * securityDefinitions are commented out; /lnurl needs a Wavlake-issued + * appId and is intentionally NOT used — zaps go through the app's + * existing NIP-57 wallet flow). + */ + +// ── DTOs ───────────────────────────────────────────────────────────────── + +@Serializable +data class SearchResultDto( + val id: String? = null, + val name: String? = null, + val type: String? = null, // artist | album | track + val albumArtUrl: String? = null, + val artistArtUrl: String? = null, + val albumId: String? = null, + val albumTitle: String? = null, + val artistId: String? = null, + val artist: String? = null, + val duration: Int? = null, +) + +@Serializable +data class TrackDto( + val id: String? = null, + val title: String? = null, + val albumTitle: String? = null, + val artist: String? = null, + val artistId: String? = null, + val albumId: String? = null, + val artistArtUrl: String? = null, + val albumArtUrl: String? = null, + val mediaUrl: String? = null, + val duration: Int? = null, + val releaseDate: String? = null, + val msatTotal: String? = null, + val artistNpub: String? = null, + val genre: String? = null, + val url: String? = null, +) + +@Serializable +data class CatalogDto( + val tracks: List = emptyList(), + val nextCursor: String? = null, + val total: Int? = null, + val limit: Int? = null, +) + +@Serializable +data class AlbumSummaryDto( + val id: String? = null, + val title: String? = null, + val albumArtUrl: String? = null, + val releaseDate: String? = null, +) + +@Serializable +data class ArtistDto( + val id: String? = null, + val name: String? = null, + val bio: String? = null, + val artistArtUrl: String? = null, + val albums: List = emptyList(), +) + +@Serializable +data class AlbumDto( + val id: String? = null, + val title: String? = null, + val artist: String? = null, + val artistUrl: String? = null, + val artistArtUrl: String? = null, + val albumArtUrl: String? = null, + val releaseDate: String? = null, + val tracks: List = emptyList(), +) + +@Serializable +data class ErrorDto(val error: String? = null) + +// ── Result ─────────────────────────────────────────────────────────────── + +sealed interface WavlakeResult { + data class Ok(val value: T) : WavlakeResult + /** API failure never throws — local radio keeps playing regardless. */ + data class Failed(val kind: FailureKind, val message: String) : WavlakeResult +} + +enum class FailureKind { NETWORK, HTTP, RATE_LIMITED, PARSE, EMPTY, AUTH } + +// ── Client ─────────────────────────────────────────────────────────────── + +class WavlakeApi( + private val baseUrl: String = "https://wavlake.com/api/v1", + private val client: OkHttpClient = defaultClient(), +) { + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + suspend fun search(term: String): WavlakeResult> = + get("content/search", mapOf("term" to term)) { body -> + json.decodeFromString( + kotlinx.serialization.builtins.ListSerializer(SearchResultDto.serializer()), + body, + ) + } + + suspend fun rankings( + days: Int = 7, + genre: String? = null, + limit: Int = 25, + ): WavlakeResult> { + val params = mutableMapOf("sort" to "sats", "days" to days.toString(), "limit" to limit.toString()) + if (!genre.isNullOrBlank()) params["genre"] = genre + return get("content/rankings", params) { body -> + json.decodeFromString( + kotlinx.serialization.builtins.ListSerializer(TrackDto.serializer()), + body, + ) + } + } + + suspend fun catalog( + limit: Int = 50, + cursor: String? = null, + artistId: String? = null, + genre: String? = null, + ): WavlakeResult { + val params = mutableMapOf("limit" to limit.coerceIn(1, 100).toString()) + if (!cursor.isNullOrBlank()) params["cursor"] = cursor + if (!artistId.isNullOrBlank()) params["artistId"] = artistId + if (!genre.isNullOrBlank()) params["genre"] = genre + return get("content/tracks", params) { body -> + json.decodeFromString(CatalogDto.serializer(), body) + } + } + + suspend fun track(trackId: String): WavlakeResult = + get("content/track/$trackId", emptyMap()) { body -> + json.decodeFromString(TrackDto.serializer(), body) + } + + suspend fun artist(artistId: String): WavlakeResult = + get("content/artist/$artistId", emptyMap()) { body -> + json.decodeFromString(ArtistDto.serializer(), body) + } + + suspend fun album(albumId: String): WavlakeResult = + get("content/album/$albumId", emptyMap()) { body -> + json.decodeFromString(AlbumDto.serializer(), body) + } + + private suspend fun get( + path: String, + params: Map, + parse: (String) -> T, + ): WavlakeResult = kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { + val url = try { + (baseUrl.trimEnd('/') + "/" + path).toHttpUrl().newBuilder() + .also { b -> params.forEach { (k, v) -> b.addQueryParameter(k, v) } } + .build() + } catch (e: Exception) { + return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Bad request URL.") + } + val response = try { + client.newCall( + Request.Builder() + .url(url) + .header("Accept", "application/json") + .header("User-Agent", "SatsAmp/0.1 (wavlake catalog client)") + .build(), + ).execute() + } catch (e: Exception) { + // Offline, DNS, TLS, timeouts — all surface here, never thrown. + return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Network unavailable.") + } + response.use { r -> + if (r.code == 429) { + val retryAfter = r.header("Retry-After")?.toLongOrNull() + return@withContext WavlakeResult.Failed( + FailureKind.RATE_LIMITED, + "Rate limited" + (retryAfter?.let { " — retry in ${it}s" } ?: "."), + ) + } + if (!r.isSuccessful) { + val serverMsg = runCatching { + json.decodeFromString(ErrorDto.serializer(), r.body?.string().orEmpty()).error + }.getOrNull() + val kind = if (r.code == 401 || r.code == 403) FailureKind.AUTH else FailureKind.HTTP + return@withContext WavlakeResult.Failed(kind, serverMsg ?: "Request failed (${r.code}).") + } + val body = try { + r.body?.string().orEmpty() + } catch (_: Exception) { + return@withContext WavlakeResult.Failed(FailureKind.NETWORK, "Empty response.") + } + try { + WavlakeResult.Ok(parse(body)) + } catch (_: Exception) { + WavlakeResult.Failed(FailureKind.PARSE, "Couldn't read the catalog response.") + } + } + } + + companion object { + fun defaultClient(): OkHttpClient = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(20, TimeUnit.SECONDS) + .build() + } +} diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt new file mode 100644 index 0000000..da4e33b --- /dev/null +++ b/app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt @@ -0,0 +1,441 @@ +package com.rada.data.wavlake + +import android.content.Context +import com.rada.data.local.entity.SavedSongEntity +import com.rada.data.repository.LibraryRepository +import com.rada.domain.model.SOURCE_WAVLAKE +import com.rada.domain.model.WavlakeArtist +import com.rada.domain.model.WavlakeTrack +import com.rada.nostr.Nip19 +import com.rada.nostr.NostrService +import com.rada.payments.hasValidLightningAddress +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.MapSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.json.Json +import org.json.JSONObject +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Wavlake catalog gateway (official public API, no auth — see [WavlakeApi]). + * + * Hard guarantees: + * - Every function returns normally; API trouble surfaces as + * [WavlakeResult.Failed] or empty lists — local radio never stops. + * - No secrets: nothing here needs credentials, so no .env is required. + * - The catalog exposes NO Lightning Address (only artist npub). Addresses + * resolve via the artist's Nostr kind-0 profile (lud16) and are validated + * with [hasValidLightningAddress] — the ONLY zap gate. + */ +@Singleton +class WavlakeRepository @Inject constructor( + @ApplicationContext private val context: Context, + private val api: WavlakeApi, + private val nostr: NostrService, + private val library: LibraryRepository, +) { + private val prefs by lazy { + context.getSharedPreferences("wavlake", Context.MODE_PRIVATE) + } + private val json = Json { ignoreUnknownKeys = true } + + // ── Normalization (pure top-level fns, JVM-testable) ───────────────── + + suspend fun search(term: String): WavlakeResult> { + if (term.isBlank()) return WavlakeResult.Ok(emptyList()) + return when (val r = api.search(term.trim())) { + is WavlakeResult.Ok -> WavlakeResult.Ok( + r.value.mapNotNull { dto -> + val id = dto.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + val name = dto.name?.takeIf { it.isNotBlank() } ?: return@mapNotNull null + when (dto.type) { + "artist" -> WavlakeSearchHit.Artist( + WavlakeArtist( + id = id, name = name, + imageUrl = dto.artistArtUrl ?: dto.albumArtUrl, + ), + ) + "album" -> WavlakeSearchHit.Album( + id = id, title = name, + artist = dto.artist, + artworkUrl = dto.albumArtUrl, + ) + else -> WavlakeSearchHit.Track( + WavlakeTrack( + id = id, title = name, + artistId = dto.artistId.orEmpty(), + artistName = dto.artist?.takeIf { it.isNotBlank() } + ?: "Unknown Artist", + artworkUrl = dto.albumArtUrl ?: dto.artistArtUrl, + durationMs = dto.duration?.let { if (it > 0) it * 1000L else null }, + ), + ) + } + }.distinctBy { it.stableId() }, + ) + is WavlakeResult.Failed -> r + } + } + + suspend fun rankings( + days: Int = 7, + genre: String? = null, + limit: Int = 25, + ): WavlakeResult> = + when (val r = api.rankings(days.coerceIn(1, 90), genre, limit.coerceIn(1, 100))) { + is WavlakeResult.Ok -> WavlakeResult.Ok( + r.value.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id }, + ) + is WavlakeResult.Failed -> r + } + + suspend fun artistDetail(artistId: String): WavlakeResult { + val artist = when (val r = api.artist(artistId)) { + is WavlakeResult.Ok -> normalizeWavlakeArtist(r.value) + ?: return WavlakeResult.Failed(FailureKind.EMPTY, "Artist has no usable metadata.") + is WavlakeResult.Failed -> return r + } + // The artist endpoint carries no genre; derive it from the tracks. + val tracks = when (val r = api.catalog(limit = 100, artistId = artistId)) { + is WavlakeResult.Ok -> r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) } + is WavlakeResult.Failed -> emptyList() // tracks optional; artist still shown + }.distinctBy { it.id } + val genres = tracks.mapNotNull { it.genre }.distinct() + return WavlakeResult.Ok( + WavlakeArtistDetail( + artist = artist.copy( + genre = genres.firstOrNull(), + genres = genres, + imageUrl = artist.imageUrl + ?: tracks.firstNotNullOfOrNull { it.artworkUrl }, + ), + tracks = tracks, + ), + ) + } + + suspend fun albumTracks(albumId: String): WavlakeResult> = + when (val r = api.album(albumId)) { + is WavlakeResult.Ok -> WavlakeResult.Ok( + r.value.tracks.mapNotNull { normalizeWavlakeTrack(it) }.distinctBy { it.id }, + ) + is WavlakeResult.Failed -> r + } + + suspend fun trackDetail(trackId: String): WavlakeResult = + when (val r = api.track(trackId)) { + is WavlakeResult.Ok -> + normalizeWavlakeTrack(r.value)?.let { WavlakeResult.Ok(it) } + ?: WavlakeResult.Failed(FailureKind.EMPTY, "Track has no usable metadata.") + is WavlakeResult.Failed -> r + } + + /** + * Fallback for search-index IDs that no longer resolve (stale entries + * 400 on /content/track): match by exact title inside the artist's + * catalog page. Same artist + same title = the same song. + */ + suspend fun matchArtistTrack(artistId: String, title: String): WavlakeTrack? { + if (artistId.isBlank() || title.isBlank()) return null + return when (val r = api.catalog(limit = 100, artistId = artistId)) { + is WavlakeResult.Ok -> r.value.tracks + .mapNotNull { normalizeWavlakeTrack(it) } + .firstOrNull { + it.title.equals(title, ignoreCase = true) && + it.audioUrl?.isNotBlank() == true + } + is WavlakeResult.Failed -> null + } + } + + // ── Lightning Address (Nostr kind-0 lud16 — the ONLY zap source) ───── + + private val addressCache = java.util.concurrent.ConcurrentHashMap() + + /** + * Addresses the user entered themselves (stored on successful zap). + * These are real, validated addresses — remembering them is what lets + * the bolt appear for artists the catalog exposes no npub for. + */ + fun getKnownAddress(artistId: String): String? { + val raw = prefs.getString(KEY_KNOWN_PREFIX + artistId, null) ?: return null + return raw.takeIf { hasValidLightningAddress(it) } + } + + fun rememberAddress(artistId: String, address: String): Boolean { + if (artistId.isBlank()) return false + val clean = address.trim() + if (!hasValidLightningAddress(clean)) return false + prefs.edit().putString(KEY_KNOWN_PREFIX + artistId, clean).apply() + return true + } + + /** Best-effort lud16 for an artist npub (bech32 or hex). Null when unknown. */ + suspend fun resolveLightningAddress(artistId: String?, artistNpub: String?): String? { + if (!artistId.isNullOrBlank()) { + getKnownAddress(artistId)?.let { return it } + } + val npub = artistNpub?.takeIf { it.isNotBlank() } ?: return null + addressCache[npub]?.let { return it } + val hex = npubToHex(npub) ?: return null + // Mobile relays can be slow; one retry on miss. Misses only log — + // the strict gate simply shows no bolt. + var lud16: String? = null + repeat(2) { + lud16 = withTimeoutOrNull(12_000) { + nostr.getProfile(hex).firstOrNull { it != null }?.let { ev -> + runCatching { + JSONObject(ev!!.content).optString("lud16").takeIf { it.isNotBlank() } + }.getOrNull() + } + } + if (lud16 != null) return@repeat + } + val valid = lud16?.takeIf { hasValidLightningAddress(it) } + if (valid == null) { + android.util.Log.w("Wavlake", "No lud16 for artist npub ${hex.take(12)}…") + } else { + addressCache[npub] = valid + } + return valid + } + + private fun npubToHex(npub: String): String? { + val v = npub.trim() + if (v.startsWith("npub1")) { + return runCatching { + val (hrp, bytes) = Nip19.decode(v) ?: return null + if (hrp != "npub" || bytes.size != 32) return null + bytes.joinToString("") { "%02x".format(it) } + }.getOrNull() + } + if (v.length == 64 && v.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) return v.lowercase() + return null + } + + // ── Import (streamable library entries — no downloads) ─────────────── + + sealed interface ImportOutcome { + data class Imported(val songId: Long) : ImportOutcome + data class AlreadyInLibrary(val songId: Long) : ImportOutcome + data class Unplayable(val reason: String) : ImportOutcome + } + + /** Imports a Wavlake track as a streamable library entry (dedupe-safe). */ + suspend fun importTrack(track: WavlakeTrack): ImportOutcome { + val audioUrl = track.audioUrl?.takeIf { it.isNotBlank() } + ?: return ImportOutcome.Unplayable("No playable audio for this track.") + library.getByPath(audioUrl)?.let { return ImportOutcome.AlreadyInLibrary(it.id) } + library.getByArtistTitle(track.artistName, track.title)?.let { + return ImportOutcome.AlreadyInLibrary(it.id) + } + val id = library.insert( + SavedSongEntity( + fileName = "${track.artistName} - ${track.title}.mp3", + filePath = audioUrl, + artist = track.artistName, + title = track.title, + album = track.genre ?: "Wavlake", + stationName = "Wavlake", + durationMs = track.durationMs ?: 0, + fileSize = 0, + format = "mp3", + source = SOURCE_WAVLAKE, + sourceUrl = track.sourceUrl.orEmpty(), + artworkUrl = track.artworkUrl.orEmpty(), + wavlakeArtistId = track.artistId, + wavlakeArtistNpub = track.artistNpubHex.orEmpty(), + ), + ) + return ImportOutcome.Imported(id) + } + + // ── Featuring (genre-matched, opt-in shelf — never auto-inserted) ──── + + fun settings(): WavlakeStationSettings = runCatching { + prefs.getString(KEY_SETTINGS, null)?.let { + Json.decodeFromString(WavlakeStationSettings.serializer(), it) + } + }.getOrNull() ?: defaultWavlakeStationSettings + + fun saveSettings(settings: WavlakeStationSettings) { + prefs.edit() + .putString(KEY_SETTINGS, Json.encodeToString(WavlakeStationSettings.serializer(), settings)) + .apply() + } + + fun isStationEnabled(stationId: String): Boolean { + if (!settings().enabled) return false + val map = stationMap() + return map[stationId] ?: true + } + + fun setStationEnabled(stationId: String, enabled: Boolean) { + val map = stationMap().toMutableMap() + map[stationId] = enabled + prefs.edit().putString(KEY_STATION_MAP, Json.encodeToString(mapSerializer(), map)).apply() + } + + fun blockedArtistIds(): Set = runCatching { + prefs.getString(KEY_BLOCKED, null)?.let { + Json.decodeFromString(ListSerializer(String.serializer()), it) + }.orEmpty().toSet() + }.getOrDefault(emptySet()) + + fun setArtistBlocked(artistId: String, blocked: Boolean) { + val set = blockedArtistIds().toMutableSet() + if (blocked) set.add(artistId) else set.remove(artistId) + prefs.edit() + .putString(KEY_BLOCKED, Json.encodeToString(ListSerializer(String.serializer()), set.toList())) + .apply() + } + + private fun stationMap(): Map = runCatching { + prefs.getString(KEY_STATION_MAP, null)?.let { + Json.decodeFromString(mapSerializer(), it) + } + }.getOrNull().orEmpty() + + private fun mapSerializer() = + MapSerializer(String.serializer(), Boolean.serializer()) + + private data class FeatureEvent(val atMs: Long, val artistId: String) + + private fun recentEvents(): List { + val now = System.currentTimeMillis() + return runCatching { + prefs.getString(KEY_FEATURED, null)?.let { raw -> + Json.decodeFromString( + ListSerializer(FeatureEventSer.serializer()), + raw, + ).map { FeatureEvent(it.atMs, it.artistId) } + } + }.getOrNull().orEmpty().filter { now - it.atMs < 3_600_000L } + } + + @kotlinx.serialization.Serializable + private data class FeatureEventSer(val atMs: Long, val artistId: String) + + private fun recordFeatured(artistIds: List) { + val now = System.currentTimeMillis() + val updated = (recentEvents() + artistIds.map { FeatureEvent(now, it) }) + .filter { now - it.atMs < 3_600_000L } + prefs.edit().putString( + KEY_FEATURED, + Json.encodeToString( + ListSerializer(FeatureEventSer.serializer()), + updated.map { FeatureEventSer(it.atMs, it.artistId) }, + ), + ).apply() + } + + /** + * Genre-matched Wavlake picks for a station. Network-bound, failure-safe + * (empty on any trouble), and purely advisory — the caller decides + * whether to show them; nothing here touches playback or the queue. + */ + suspend fun featuredForStation( + stationId: String, + stationGenre: String, + excludedTrackIds: Set = emptySet(), + ): List = coroutineScope { + val settings = settings() + if (!isStationEnabled(stationId)) return@coroutineScope emptyList() + val (primary, secondary) = WavlakeGenreMatcher.splitStationGenres(stationGenre) + val genres = WavlakeGenreMatcher.compatibleGenres(primary, secondary, settings.matchingMode) + if (genres.isEmpty()) return@coroutineScope emptyList() + + val events = recentEvents() + val perGenre = genres.take(3).map { genre -> + async { + when (val r = api.rankings(days = 30, genre = genre, limit = 25)) { + is WavlakeResult.Ok -> r.value.mapNotNull { normalizeWavlakeTrack(it) } + is WavlakeResult.Failed -> emptyList() + } + } + }.awaitAll().flatten().distinctBy { it.id } + + val picked = FeatureSelector.select( + FeatureSelection( + candidates = perGenre, + settings = settings, + excludedTrackIds = excludedTrackIds, + blockedArtistIds = blockedArtistIds(), + recentArtistIds = events.map { it.artistId }.toSet(), + featuredThisHour = events.size, + ), + ) + if (picked.isNotEmpty()) recordFeatured(picked.map { it.artistId }) + picked + } + + companion object { + private const val KEY_SETTINGS = "wavlake_settings" + private const val KEY_STATION_MAP = "wavlake_station_enabled" + private const val KEY_BLOCKED = "wavlake_blocked_artists" + private const val KEY_FEATURED = "wavlake_featured_hour" + private const val KEY_KNOWN_PREFIX = "wavlake_addr_" + } +} + +sealed interface WavlakeSearchHit { fun stableId(): String + data class Artist(val artist: WavlakeArtist) : WavlakeSearchHit { + override fun stableId() = "artist:${artist.id}" + } + data class Album( + val id: String, + val title: String, + val artist: String?, + val artworkUrl: String?, + ) : WavlakeSearchHit { + override fun stableId() = "album:$id" + } + data class Track(val track: WavlakeTrack) : WavlakeSearchHit { + override fun stableId() = "track:${track.id}" + } +} + +data class WavlakeArtistDetail( + val artist: WavlakeArtist, + val tracks: List, +) + +/** Pure DTO normalization (null-tolerant, JVM-testable). */ +fun normalizeWavlakeTrack(dto: TrackDto): WavlakeTrack? { + val id = dto.id?.takeIf { it.isNotBlank() } ?: return null + val title = dto.title?.takeIf { it.isNotBlank() } ?: return null + val audioUrl = dto.mediaUrl?.takeIf { it.isNotBlank() } + return WavlakeTrack( + id = id, + title = title, + artistId = dto.artistId?.takeIf { it.isNotBlank() }.orEmpty(), + artistName = dto.artist?.takeIf { it.isNotBlank() } ?: "Unknown Artist", + genre = dto.genre?.takeIf { it.isNotBlank() }, + genres = listOfNotNull(dto.genre?.takeIf { it.isNotBlank() }), + artworkUrl = dto.albumArtUrl?.takeIf { it.isNotBlank() } + ?: dto.artistArtUrl?.takeIf { it.isNotBlank() }, + durationMs = dto.duration?.let { if (it > 0) it * 1000L else null }, + audioUrl = audioUrl, + sourceUrl = dto.url?.takeIf { it.isNotBlank() }, + artistNpubHex = dto.artistNpub?.takeIf { it.isNotBlank() }, + albumId = dto.albumId?.takeIf { it.isNotBlank() }, + ) +} + +/** Pure DTO normalization (null-tolerant, JVM-testable). */ +fun normalizeWavlakeArtist(dto: ArtistDto): WavlakeArtist? { + val id = dto.id?.takeIf { it.isNotBlank() } ?: return null + return WavlakeArtist( + id = id, + name = dto.name?.takeIf { it.isNotBlank() } ?: "Unknown Artist", + imageUrl = dto.artistArtUrl?.takeIf { it.isNotBlank() }, + bio = dto.bio?.takeIf { it.isNotBlank() }, + ) +} diff --git a/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt b/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt new file mode 100644 index 0000000..89e5578 --- /dev/null +++ b/app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt @@ -0,0 +1,104 @@ +package com.rada.data.wavlake + +import com.rada.nostr.MusicKinds +import com.rada.nostr.NostrEvent +import com.rada.nostr.UnsignedEvent +import java.security.MessageDigest +import java.util.UUID + +/** + * Wavlake's official track-zap protocol, decoded from their MIT-licensed + * mobile client (wavlake/mobile: useZapContent + utils/nostr.ts — patterns + * only, no copied code): + * + * - Recipient is the PLATFORM key ([WAVLAKE_FEED_PUBKEY]); Wavlake splits + * downstream. No artist address or npub is required. + * - The NIP-57 zap request carries an `a` tag with the track's Nostr + * address (`32123::`), a `timestamp` tag, and three + * podcast `i` tags whose GUIDs are UUIDv5 (RFC 4122) of the feed URLs. + * - `GET https://www.wavlake.com/api/zap?amount={msats}&nostr={eventJson}` + * returns LUD-06-style `{pr}` (or `{status:"ERROR"}` / `{success:false}`). + * + * This is what makes every catalog track payable even when the artist + * exposes no Lightning Address anywhere reachable. + */ +object WavlakeZap { + const val FEED_PUBKEY = + "7759fb24cec56fc57550754ca8f6d2c60183da2537c8f38108fdf283b20a0e58" + const val TRACK_KIND = 32123 + const val RELAY = "wss://relay.wavlake.com/" + const val ZAP_ENDPOINT = "https://www.wavlake.com/api/zap" + const val ZAP_HOST = "www.wavlake.com" + + /** UUID namespace from the official client (podcast feed GUIDs). */ + val FEED_NAMESPACE: UUID = UUID.fromString("ead4c236-bf58-58c6-a2c6-a6b28d128cb6") + + fun feedUrl(albumId: String): String = "wavlake.com/feed/music/$albumId" + + /** RFC 4122 §4.3 UUIDv5 (SHA-1). Java only ships v3/v4. */ + fun uuidV5(namespace: UUID, name: String): UUID { + val md = MessageDigest.getInstance("SHA-1") + val ns = ByteArray(16).also { + val bb = java.nio.ByteBuffer.wrap(it) + bb.putLong(namespace.mostSignificantBits) + bb.putLong(namespace.leastSignificantBits) + } + val hash = md.digest(ns + name.toByteArray(Charsets.UTF_8)) + hash[6] = ((hash[6].toInt() and 0x0f) or 0x50).toByte() // version 5 + hash[8] = ((hash[8].toInt() and 0x3f) or 0x80).toByte() // variant 10 + val bb = java.nio.ByteBuffer.wrap(hash) + return UUID(bb.long, bb.long) + } + + fun trackAddressPointer(trackId: String): String = "$TRACK_KIND:$FEED_PUBKEY:$trackId" + + fun itemTags(trackId: String, albumId: String): List> { + val feedGuid = uuidV5(FEED_NAMESPACE, feedUrl(albumId)).toString() + return listOf( + listOf("i", "podcast:item:guid:$trackId"), + listOf("i", "podcast:guid:$feedGuid"), + listOf("i", "podcast:publisher:guid:$feedGuid"), + ) + } + + /** + * Unsigned NIP-57 zap request for a track. The caller signs (identity + * key preferred, ephemeral per NIP-57 otherwise) — see ZapService. + */ + fun buildZapRequest( + trackId: String, + albumId: String, + amountSats: Long, + comment: String, + relays: List, + signerPubkeyHex: String, + ): UnsignedEvent { + require(trackId.isNotBlank() && albumId.isNotBlank()) + require(amountSats > 0) + val tags = mutableListOf( + listOf("p", FEED_PUBKEY), + listOf("relays", RELAY) + relays.take(2), + listOf("amount", (amountSats * 1000).toString()), + listOf("a", trackAddressPointer(trackId), RELAY), + listOf("timestamp", (System.currentTimeMillis() / 1000).toString()), + ) + tags.addAll(itemTags(trackId, albumId)) + return NostrEvent.unsigned( + pubkey = signerPubkeyHex, + kind = MusicKinds.ZAP_REQUEST, + content = comment, + tags = tags, + ) + } + + fun invoiceUrl(amountSats: Long, zapRequestJson: String): String { + val encoded = java.net.URLEncoder.encode(zapRequestJson, "UTF-8") + return "$ZAP_ENDPOINT?amount=${amountSats * 1000}&nostr=$encoded" + } + + /** Strict invoice sanity: mainnet bolt11 only, from the fixed endpoint. */ + fun isPlausibleInvoice(bolt11: String): Boolean { + val v = bolt11.trim().lowercase() + return v.startsWith("lnbc") && v.length > 20 && v.none { it.isWhitespace() } + } +} diff --git a/app/src/main/java/com/rada/di/AppModule.kt b/app/src/main/java/com/rada/di/AppModule.kt index 7de48e5..5d961aa 100644 --- a/app/src/main/java/com/rada/di/AppModule.kt +++ b/app/src/main/java/com/rada/di/AppModule.kt @@ -7,6 +7,7 @@ import com.rada.data.local.dao.SavedSongDao import com.rada.data.repository.LibraryRepository import com.rada.data.repository.PlaylistRepository import com.rada.data.repository.StationRepository +import com.rada.data.wavlake.WavlakeApi import com.rada.player.PlaybackManager import dagger.Module import dagger.Provides @@ -61,4 +62,8 @@ object AppModule { @ApplicationContext context: Context, libraryRepository: LibraryRepository, ): PlaybackManager = PlaybackManager(context, libraryRepository) + + @Provides + @Singleton + fun provideWavlakeApi(): WavlakeApi = WavlakeApi() } diff --git a/app/src/main/java/com/rada/domain/model/WavlakeModels.kt b/app/src/main/java/com/rada/domain/model/WavlakeModels.kt new file mode 100644 index 0000000..21273bf --- /dev/null +++ b/app/src/main/java/com/rada/domain/model/WavlakeModels.kt @@ -0,0 +1,52 @@ +package com.rada.domain.model + +/** + * Normalized Wavlake catalog models. Field names follow the official + * Wavlake Developer API (wavlake/developer-docs, swagger.yaml, MIT): + * search/rankings/tracks/track/artist/album responses. No invented fields. + */ + +const val SOURCE_WAVLAKE = "wavlake" +const val SOURCE_LOCAL = "local" +const val SOURCE_OTHER = "other" + +data class WavlakeArtist( + val id: String, + val name: String, + val genre: String? = null, + val genres: List = emptyList(), + val imageUrl: String? = null, + val bio: String? = null, + val source: String = SOURCE_WAVLAKE, + val sourceUrl: String? = null, + /** Lightning Address ONLY (e.g. artist@example.com). Never LNURL/lud06/lud16/npub here. */ + val lightningAddress: String? = null, + /** Nostr pubkey hex, when the API exposes it (used for the NIP-57 zap flow). */ + val npubHex: String? = null, +) { + /** Derived ONLY from [lightningAddress]. See [com.rada.payments.hasValidLightningAddress]. */ + val canZap: Boolean + get() = com.rada.payments.hasValidLightningAddress(lightningAddress) +} + +data class WavlakeTrack( + val id: String, + val title: String, + val artistId: String, + val artistName: String, + val genre: String? = null, + val genres: List = emptyList(), + val artworkUrl: String? = null, + val durationMs: Long? = null, + val audioUrl: String? = null, + val source: String = SOURCE_WAVLAKE, + val sourceUrl: String? = null, + /** Nostr pubkey hex of the artist, when exposed (zap flow). */ + val artistNpubHex: String? = null, + /** Album GUID (official track-zap `i`-tags + parent content id). */ + val albumId: String? = null, +) { + /** Officially payable: the track-zap endpoint only needs track + album ids. */ + val isZapPayable: Boolean + get() = id.isNotBlank() && !albumId.isNullOrBlank() && !audioUrl.isNullOrBlank() +} diff --git a/app/src/main/java/com/rada/nostr/NostrKeys.kt b/app/src/main/java/com/rada/nostr/NostrKeys.kt index c882b7c..35409f1 100644 --- a/app/src/main/java/com/rada/nostr/NostrKeys.kt +++ b/app/src/main/java/com/rada/nostr/NostrKeys.kt @@ -1,6 +1,8 @@ package com.rada.nostr import android.content.Context +import android.content.Intent +import android.net.Uri import android.security.keystore.KeyGenParameterSpec import android.security.keystore.KeyProperties import android.util.Base64 @@ -18,6 +20,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import org.json.JSONObject +/** Well-known Amber package, used only for store fallback text. NIP-55 login itself is signer-agnostic. */ +const val AMBER_PACKAGE = "com.greenart7c3.nostrsigner" + /** * Optional Nostr identity for the device (NIP-01 keys). * @@ -39,18 +44,71 @@ class NostrKeys @Inject constructor( /** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */ val pubkey: StateFlow = _pubkey.asStateFlow() - val hasIdentity: Boolean get() = prefs.contains("ct") + /** True when the user has a Keystore identity (encrypted private key) or a manually imported pubkey. */ + val hasIdentity: Boolean get() = prefs.contains("ct") || prefs.contains("imported_pubkey") + + /** True when the user has an identity of any kind (local or imported pubkey). */ + val hasPubkey: Boolean get() = _pubkey.value != null + + /** True only when the private key lives on this device (Keystore) and local + * signing works. An imported or external-signer (Amber) pubkey alone does + * NOT allow local signing — callers must fall back (e.g. ephemeral keys). */ + val canSignLocally: Boolean get() = prefs.contains("ct") init { if (hasIdentity) { - // Derive pubkey once at startup (needs the key; decrypt, derive, wipe). - runCatching { - val priv = decryptSecret() - if (priv != null) _pubkey.value = xOnlyPubkey(priv) + // If a Keystore identity exists, derive pubkey from it. + if (prefs.contains("ct")) { + runCatching { + val priv = decryptSecret() + if (priv != null) _pubkey.value = xOnlyPubkey(priv) + } + } + // If a manually imported pubkey exists, use it. + val imported = prefs.getString("imported_pubkey", null) + if (imported != null && _pubkey.value == null) { + _pubkey.value = imported } } } + /** Package name of the external signer (NIP-55), if the user logged in that way. */ + val signerPackage: String? + get() = prefs.getString("signer_package", null) + + /** True when any NIP-55 external signer (Amber, etc.) is installed. Requires the nostrsigner entry. */ + fun isExternalSignerInstalled(): Boolean = runCatching { + val probe = Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")) + if (context.packageManager.queryIntentActivities(probe, 0).isNotEmpty()) return true + // Fallback: direct Amber package check (needs the queries entry). + context.packageManager.getPackageInfo(AMBER_PACKAGE, 0) + true + }.getOrDefault(false) + + /** Build the NIP-55 `get_public_key` intent. Launch via rememberLauncherForActivityResult. */ + fun buildGetPubkeyIntent(): Intent = + Intent(Intent.ACTION_VIEW, Uri.parse("nostrsigner:")).apply { + putExtra("type", "get_public_key") + putExtra( + "permissions", + """[{"type":"sign_event"},{"type":"nip44_encrypt"},{"type":"nip44_decrypt"}]""", + ) + } + + /** Import a raw 64-char hex pubkey (NIP-55 results are hex, not npub). */ + fun importHexPubkey(hex: String): Boolean { + val clean = hex.trim().lowercase() + if (!clean.matches(Regex("[0-9a-f]{64}"))) return false + _pubkey.value = clean + prefs.edit().putString("imported_pubkey", clean).apply() + return true + } + + /** Persist the signer package from a `get_public_key` result for future sign_event calls. */ + fun saveSignerPackage(pkg: String) { + prefs.edit().putString("signer_package", pkg).apply() + } + /** Create a fresh identity. Returns the npub. Refuses if one exists (no silent overwrite). */ fun createIdentity(): String { check(!hasIdentity) { "identity already exists" } @@ -72,6 +130,17 @@ class NostrKeys @Inject constructor( return true } + /** Import just a pubkey (manual paste fallback). + * Allows relay connectivity for profile lookups without a Keystore identity. */ + fun importPubkey(npub: String): Boolean { + val hex = runCatching { Nip19.decode(npub.trim()) }.getOrNull()?.let { (hrp, bytes) -> + if (hrp == "npub" && bytes.size == 32) bytes.joinToString("") { "%02x".format(it) } else null + } ?: return false + _pubkey.value = hex + prefs.edit().putString("imported_pubkey", hex).apply() + return true + } + fun npub(): String? = _pubkey.value?.let { Nip19.npub(it) } /** diff --git a/app/src/main/java/com/rada/nostr/NostrService.kt b/app/src/main/java/com/rada/nostr/NostrService.kt index e8d09ec..fcc6a68 100644 --- a/app/src/main/java/com/rada/nostr/NostrService.kt +++ b/app/src/main/java/com/rada/nostr/NostrService.kt @@ -43,6 +43,7 @@ class NostrService @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val defaultRelays = listOf( + "wss://relay.wavlake.com", "wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol", @@ -52,10 +53,13 @@ class NostrService @Inject constructor( get() { val saved = prefs.getString("relays", null) ?.split("\n")?.filter { it.isNotBlank() } - return if (saved.isNullOrEmpty()) defaultRelays else saved + val base = if (saved.isNullOrEmpty()) defaultRelays else saved + // Wavlake's own relay always rides along (first): artist kind-0s + // live there even when users customize the rest. Deduped. + return (listOf("wss://relay.wavlake.com") + base).distinct() } - private val relays = mutableMapOf() + private val relays = java.util.concurrent.ConcurrentHashMap() private val _relayStates = MutableStateFlow>(emptyList()) val relayStates: StateFlow> = _relayStates.asStateFlow() @@ -212,6 +216,9 @@ class NostrService @Inject constructor( put("authors", JSONArray().put(pubkeyHex)) put("limit", 1) } + // The merged flow only carries what subscriptions request — without + // this REQ the lookup below could only catch someone else's traffic. + val subs = relays.values.map { conn -> conn to conn.subscribe(filter) } val job = scope.launch { events.first { it.kind == MusicKinds.METADATA && it.pubkey == pubkeyHex } .let { out.value = it } @@ -219,6 +226,7 @@ class NostrService @Inject constructor( scope.launch { kotlinx.coroutines.delay(timeoutMs) job.cancel() + subs.forEach { (conn, id) -> runCatching { conn.unsubscribe(id) } } } return out } diff --git a/app/src/main/java/com/rada/nostr/RelayConnection.kt b/app/src/main/java/com/rada/nostr/RelayConnection.kt index c137ff1..532bbd1 100644 --- a/app/src/main/java/com/rada/nostr/RelayConnection.kt +++ b/app/src/main/java/com/rada/nostr/RelayConnection.kt @@ -1,5 +1,6 @@ package com.rada.nostr +import android.util.Log import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicLong import kotlinx.coroutines.CoroutineScope @@ -59,6 +60,7 @@ class RelayConnection( Request.Builder().url(url).build(), object : WebSocketListener() { override fun onOpen(webSocket: WebSocket, response: Response) { + Log.d(TAG, "connected: $url") _state.tryEmit(State.Connected) // Re-send subscriptions and queued events after (re)connect. subs.values.forEach { w -> runCatching { webSocket.send(w.toString()) } } @@ -70,11 +72,13 @@ class RelayConnection( override fun onMessage(webSocket: WebSocket, text: String) = handleFrame(text) override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + Log.w(TAG, "connection failed: $url: ${t.message}") socket = null _state.tryEmit(State.Disconnected(t.message)) } override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + Log.d(TAG, "closed: $url ($code)") socket = null _state.tryEmit(State.Disconnected(reason)) } @@ -107,15 +111,18 @@ class RelayConnection( // NIP-01: ["REQ", sub_id, filter] subs[id] = JSONArray().put("REQ").put(id).put(filter) val ws = socket - if (ws != null) runCatching { ws.send(subs[id].toString()) } + // send() on a non-open socket fails silently — the pending REQ is + // re-sent from onOpen, so delivery is still guaranteed. + val sent = if (ws != null) runCatching { ws.send(subs[id].toString()) }.getOrDefault(false) else false + Log.d(TAG, "REQ $id -> $url (immediate=$sent)") return id } fun unsubscribe(id: String) { subs.remove(id) - runCatching { - socket?.send(JSONObject().apply { put("CLOSE", id) }.toString()) - } + // NIP-01 CLOSE is an array, not an object. + val close = JSONArray().put("CLOSE").put(id).toString() + runCatching { socket?.send(close) } } fun publish(event: NostrEvent) { @@ -134,4 +141,8 @@ class RelayConnection( runCatching { socket?.close(1000, null) } socket = null } + + companion object { + private const val TAG = "Relay" + } } diff --git a/app/src/main/java/com/rada/payments/LightningAddress.kt b/app/src/main/java/com/rada/payments/LightningAddress.kt new file mode 100644 index 0000000..a1d50bd --- /dev/null +++ b/app/src/main/java/com/rada/payments/LightningAddress.kt @@ -0,0 +1,27 @@ +package com.rada.payments + +/** + * Strict Lightning Address validation. A zap affordance may ONLY be driven + * by [hasValidLightningAddress]; never by Wavlake membership, Nostr keys, + * LNURL, lud06/lud16, payment URLs, or any other Lightning metadata. + */ +fun hasValidLightningAddress(lightningAddress: String?): Boolean { + if (lightningAddress == null) return false + val value = lightningAddress.trim() + if (value.isEmpty()) return false + val parts = value.split("@") + if (parts.size != 2) return false + val username = parts[0].trim() + val domain = parts[1].trim() + if (username.isEmpty() || domain.isEmpty()) return false + if (domain.contains(" ")) return false + if (!domain.contains(".")) return false + return true +} + +/** Normalized form of a valid address, or null. */ +fun normalizedLightningAddress(lightningAddress: String?): String? { + if (!hasValidLightningAddress(lightningAddress)) return null + val parts = lightningAddress!!.trim().split("@") + return "${parts[0].trim()}@${parts[1].trim()}" +} diff --git a/app/src/main/java/com/rada/payments/NwcProvider.kt b/app/src/main/java/com/rada/payments/NwcProvider.kt index 9f296fc..04b839d 100644 --- a/app/src/main/java/com/rada/payments/NwcProvider.kt +++ b/app/src/main/java/com/rada/payments/NwcProvider.kt @@ -164,10 +164,10 @@ class NwcProvider @Inject constructor( */ private suspend fun nwcRequest(method: String, params: JSONObject): PaymentResult { val pairing = parseStored() ?: return PaymentResult.Failed( - "not connected", "Connect your wallet in Settings → Decentralized.", + "not connected", "Connect your wallet in Settings → Nostr.", ) val ourPub = keys.pubkey.value ?: return PaymentResult.Failed( - "no identity", "Create a Nostr identity first (Settings → Decentralized → Nostr).", + "no identity", "Create a Nostr identity first (Settings → Nostr).", ) val deferred = CompletableDeferred() diff --git a/app/src/main/java/com/rada/payments/PaymentService.kt b/app/src/main/java/com/rada/payments/PaymentService.kt index ff966d4..87d4ccc 100644 --- a/app/src/main/java/com/rada/payments/PaymentService.kt +++ b/app/src/main/java/com/rada/payments/PaymentService.kt @@ -60,7 +60,7 @@ class PaymentService( val provider = providers().firstOrNull { it.isConfigured } ?: return PaymentResult.Failed( "no wallet", - "Connect a wallet first (Settings → Decentralized → Bitcoin).", + "Connect a wallet first (Settings → Nostr → Bitcoin).", ) val memo = listOfNotNull(trackTitle?.takeIf { it.isNotBlank() }, "via SatsAmp") .joinToString(" · ") diff --git a/app/src/main/java/com/rada/payments/ZapService.kt b/app/src/main/java/com/rada/payments/ZapService.kt index b7ec591..c5f19cf 100644 --- a/app/src/main/java/com/rada/payments/ZapService.kt +++ b/app/src/main/java/com/rada/payments/ZapService.kt @@ -1,6 +1,8 @@ package com.rada.payments import android.util.Base64 +import android.util.Log +import com.rada.data.wavlake.WavlakeZap import com.rada.nostr.MusicKinds import com.rada.nostr.NostrEvent import com.rada.nostr.NostrKeys @@ -40,6 +42,14 @@ class ZapService @Inject constructor( data class Failed(val reason: String) : ZapOutcome } + /** Official Wavlake track-zap outcome (no artist address involved). */ + sealed interface WavlakeZapOutcome { + data object Sent : WavlakeZapOutcome + /** Invoice minted — payable in-app, or copied to any wallet. */ + data class Invoice(val bolt11: String, val sats: Long, val requestId: String) : WavlakeZapOutcome + data class Failed(val reason: String) : WavlakeZapOutcome + } + private val http = OkHttpClient.Builder() .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) .build() @@ -166,4 +176,110 @@ class ZapService @Inject constructor( else -> ZapOutcome.Failed("Unexpected wallet response.") } } + + /** + * Official Wavlake track zap (wavlake/mobile protocol): mint an invoice + * for a catalog track. Needs no artist address — the platform key is the + * recipient and Wavlake splits downstream. Wallet-free: returns the + * invoice for copy-out when no wallet is connected. + */ + suspend fun wavlakeTrackInvoice( + trackId: String, + albumId: String, + sats: Long, + comment: String = "", + ): WavlakeZapOutcome = withContext(Dispatchers.IO) { + if (sats <= 0) return@withContext WavlakeZapOutcome.Failed("Enter an amount first.") + if (trackId.isBlank() || albumId.isBlank()) { + return@withContext WavlakeZapOutcome.Failed("This track can't be zapped yet.") + } + // Identity key preferred (official client behavior) when the private + // key is on this device; ephemeral per NIP-57 otherwise (including + // external-signer logins like Amber, which hold no local key). + // Either way a real schnorr signature. + val identityPub = keys.pubkey.value?.takeIf { it.isNotBlank() && keys.canSignLocally } + val ephemeral = if (identityPub == null) { + ByteArray(32).also { SecureRandom().nextBytes(it) } + .also { + if (!Secp256k1.get().secKeyVerify(it)) { + return@withContext WavlakeZapOutcome.Failed("rng busy, retry") + } + } + } else null + val signerPub = identityPub + ?: Secp256k1.get().pubkeyCreate(ephemeral!!).copyOfRange(1, 33).toHex() + val zapReq = WavlakeZap.buildZapRequest( + trackId = trackId, + albumId = albumId, + amountSats = sats, + comment = comment, + relays = nostr.relaysConfig(), + signerPubkeyHex = signerPub, + ).sign { digest -> + if (ephemeral != null) Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex() + else keys.signDigest(digest) + } ?: return@withContext WavlakeZapOutcome.Failed("Couldn't build the zap request.") + + // Best-effort publish so relays (and receipts) can see it. + runCatching { + nostr.ensureConnected() + nostr.publishRaw(zapReq) + } + + // Fixed endpoint only — never follow redirects off-host (SSRF guard). + val invoice = runCatching { + val url = WavlakeZap.invoiceUrl(sats, zapReq.toJson()) + val request = okhttp3.Request.Builder().url(url).build() + if (request.url.host != WavlakeZap.ZAP_HOST || request.url.scheme != "https") { + return@runCatching null + } + http.newCall(request).execute().use { r -> + if (!r.isSuccessful) return@runCatching null + val body = JSONObject(r.body?.string().orEmpty()) + if (body.optString("status") == "ERROR" || + body.optBoolean("success", true) == false + ) { + return@withContext WavlakeZapOutcome.Failed( + body.optString("reason", body.optString("error", "Zap rejected.")), + ) + } + body.optString("pr").takeIf { it.isNotBlank() } + } + }.getOrNull() ?: return@withContext WavlakeZapOutcome.Failed("Couldn't reach the Lightning endpoint.") + if (!WavlakeZap.isPlausibleInvoice(invoice)) { + Log.w("Zap", "Implausible invoice from zap endpoint") + return@withContext WavlakeZapOutcome.Failed("Invalid invoice returned.") + } + WavlakeZapOutcome.Invoice(invoice, sats, zapReq.id) + } + + /** + * Full official track zap: invoice, then pay through the connected + * wallet, then wait for the receipt — success is reported only on + * confirmation, like the address flow. + */ + suspend fun zapWavlakeTrack( + trackId: String, + albumId: String, + sats: Long, + comment: String = "", + ): WavlakeZapOutcome = withContext(Dispatchers.IO) { + val invoice = when (val r = wavlakeTrackInvoice(trackId, albumId, sats, comment)) { + is WavlakeZapOutcome.Invoice -> r + is WavlakeZapOutcome.Sent -> return@withContext r + is WavlakeZapOutcome.Failed -> return@withContext r + } + when (val pay = paymentService.payInvoiceDirect(invoice.bolt11, sats)) { + is PaymentResult.Paid -> { + nostr.waitForZapReceipt( + requestId = invoice.requestId, + targetPubkey = WavlakeZap.FEED_PUBKEY, + timeoutMs = 10_000, + ) + WavlakeZapOutcome.Sent + } + is PaymentResult.Failed -> WavlakeZapOutcome.Failed(pay.userFacing) + else -> WavlakeZapOutcome.Failed("Unexpected wallet response.") + } + } } diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt index 7ae19f8..d656776 100644 --- a/app/src/main/java/com/rada/player/PlaybackManager.kt +++ b/app/src/main/java/com/rada/player/PlaybackManager.kt @@ -2,6 +2,11 @@ package com.rada.player import android.content.ComponentName import android.content.Context +import android.media.AudioDeviceCallback +import android.media.AudioDeviceInfo +import android.media.AudioManager +import android.os.Handler +import android.os.Looper import android.os.SystemClock import android.util.Log import androidx.media3.common.MediaItem @@ -62,6 +67,50 @@ class PlaybackManager( private var reconnectJob: Job? = null private var reconnectAttempt = 0 private var readyAtMs = 0L + /** + * Bluetooth A2DP route tracking: car stereos often drop A2DP while + * keeping hands-free connected (or vanish without the noisy broadcast), + * so engine-off doesn't reliably pause via ExoPlayer alone. When the + * last BT audio output disappears mid-playback, pause like Spotify. + * Needs no Bluetooth permissions — output-device observation only. + */ + private val audioManager: AudioManager? = + context.getSystemService(AudioManager::class.java) + private var wasBtA2dpConnected = false + private var deviceCallbackRegistered = false + private val deviceCallback = object : AudioDeviceCallback() { + override fun onAudioDevicesAdded(devices: Array) { + updateBtRoute() + } + + override fun onAudioDevicesRemoved(devices: Array) { + val hadBt = wasBtA2dpConnected + updateBtRoute() + if (hadBt && !wasBtA2dpConnected && isPlaying) { + Log.d(TAG, "Bluetooth audio route lost — pausing") + try { + controller?.pause() + } catch (e: Exception) { + Log.w(TAG, "Pause on BT loss failed", e) + } + } + } + } + + private fun updateBtRoute() { + wasBtA2dpConnected = audioManager + ?.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + .orEmpty() + .any { it.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP } + } + /** Delayed ICY-reader-only restart (see startIcyReader listener). */ + private var icyRestartJob: Job? = null + /** + * True after an explicit user Stop. Guards the auto-reconnect path: a + * late player error must never resurrect a stream the user stopped. + * Cleared whenever new playback starts. + */ + private var userStopped = false var listener: PlaybackListener? = null @@ -117,8 +166,7 @@ class PlaybackManager( controller?.setPlaybackSpeed(clamped) } - fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) { - controller?.repeatMode = mode + fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) { controller?.repeatMode = mode } fun setShuffleEnabled(enabled: Boolean) { @@ -144,8 +192,8 @@ class PlaybackManager( _sleepRemainingMsFlow.value = 0L sleepJob = null // Pause rather than stop: the station stays loaded for when the - // user wakes up. - controller?.pause() + // user wakes up. Controller calls are main-thread confined. + withContext(Dispatchers.Main) { controller?.pause() } } } @@ -182,6 +230,19 @@ class PlaybackManager( scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) saveSongUseCase = SaveSongUseCase(context, libraryRepository) + if (!deviceCallbackRegistered) { + updateBtRoute() + try { + audioManager?.registerAudioDeviceCallback( + deviceCallback, + Handler(Looper.getMainLooper()), + ) + deviceCallbackRegistered = true + } catch (e: Exception) { + Log.w(TAG, "BT route tracking unavailable", e) + } + } + val sessionToken = SessionToken( context, ComponentName(context, PlaybackService::class.java) @@ -194,6 +255,13 @@ class PlaybackManager( } fun disconnect() { + if (deviceCallbackRegistered) { + try { + audioManager?.unregisterAudioDeviceCallback(deviceCallback) + } catch (_: Exception) { + } + deviceCallbackRegistered = false + } stopAll() scope.cancel() controller?.removeListener(playerListener) @@ -205,6 +273,7 @@ class PlaybackManager( fun playStation(url: String, name: String) { currentStationUrl = url currentStationName = name + userStopped = false _currentStationUrlFlow.value = url _currentStationNameFlow.value = name _isLocalFlow.value = false @@ -253,6 +322,7 @@ class PlaybackManager( */ fun playLocalQueue(tracks: List, startIndex: Int, shuffle: Boolean) { if (tracks.isEmpty()) return + userStopped = false currentStationUrl = null currentStationName = "" _currentStationUrlFlow.value = null @@ -297,10 +367,26 @@ class PlaybackManager( fun togglePlayPause() { val ctrl = controller ?: return - if (ctrl.isPlaying) ctrl.pause() else ctrl.play() + if (ctrl.isPlaying) { + ctrl.pause() + return + } + // Resume-from-stop restarts the station fully: stop() tears down the + // recorder (buffer IDLE, reader stopped), so a bare play() would + // bring back audio with a dead save button. A normal pause (buffer + // READY) resumes directly. + val url = currentStationUrl + if (!_isLocalFlow.value && url != null && + (bufferState == BufferState.IDLE || bufferState == BufferState.ERROR) + ) { + playStation(url, currentStationName) + return + } + ctrl.play() } fun stop() { + userStopped = true stopAll() controller?.stop() isPlaying = false @@ -320,12 +406,22 @@ class PlaybackManager( updateSaveState(SaveState.SAVING, null) scope.launch { - val result = saveSongUseCase.save( - buffer = buffer, - metadataTimeline = metadataTimeline, - currentMetadata = metadataTimeline.currentMetadata, - stationName = currentStationName, - ) + // A throw anywhere below must still reset saveState (and surface), + // otherwise every later tap hits the SAVING guard silently. + val result = try { + saveSongUseCase.save( + buffer = buffer, + metadataTimeline = metadataTimeline, + currentMetadata = metadataTimeline.currentMetadata, + stationName = currentStationName, + ) + } catch (e: Exception) { + Log.e(TAG, "Save failed", e) + SaveSongUseCase.SaveResult( + success = false, + error = "Save failed: ${e.message ?: e.javaClass.simpleName}", + ) + } withContext(Dispatchers.Main) { lastSaveResult = result @@ -400,8 +496,34 @@ class PlaybackManager( override fun onError(error: String) { Log.w(TAG, "ICY reader error: $error") + // ExoPlayer often rides out a transient drop on its own + // buffer without erroring; if it does, the recorder would + // otherwise go stale silently (frozen buffer, dead save). + // Restart just the reader while a station is still wanted. + // NOTE: `isPlaying` is the wrong signal here — it is false + // while stalled/buffering, exactly when a restart is needed. + // Real deaths go through the full reconnect path, and + // stopAll() cancels this job. + if (!userStopped && !_isLocalFlow.value && + icyRestartJob?.isActive != true + ) { + val url = currentStationUrl + if (url != null) { + icyRestartJob = runCatching { + scope.launch { + delay(ICY_RESTART_DELAY_MS) + if (!userStopped && !_isLocalFlow.value && + currentStationUrl == url + ) { + Log.d(TAG, "Restarting ICY reader") + startIcyReader(url) + } + icyRestartJob = null + } + }.getOrNull() + } + } } - override fun onConnected(metaInterval: Int) { Log.d(TAG, "ICY connected, meta interval: $metaInterval bytes") } @@ -411,6 +533,8 @@ class PlaybackManager( } private fun stopIcyReader() { + icyRestartJob?.cancel() + icyRestartJob = null icyReader?.stop() icyReader = null } @@ -462,11 +586,20 @@ class PlaybackManager( } Player.STATE_BUFFERING -> listener?.onPlaybackStateChanged(false) Player.STATE_IDLE -> listener?.onPlaybackStateChanged(false) - Player.STATE_ENDED -> listener?.onPlaybackStateChanged(false) + Player.STATE_ENDED -> { + // A finished track is not playing: without this the UI + // keeps a stale pause icon over silence. + isPlaying = false + _isPlayingFlow.value = false + listener?.onPlaybackStateChanged(false) + } } } override fun onPlayerError(error: PlaybackException) { + // A late error for playback the user already stopped is stale + // noise: the buffer is IDLE and no reconnect is wanted. + if (userStopped) return val description = describeError(error) updateBufferState(BufferState.ERROR) stopIcyReader() @@ -495,6 +628,9 @@ class PlaybackManager( private fun scheduleReconnect(): Long? { val url = currentStationUrl ?: return null + // Never resurrect playback the user explicitly stopped, or a queue + // they have since replaced with local tracks. + if (userStopped || _isLocalFlow.value) return null if (reconnectJob?.isActive == true) return null if (readyAtMs != 0L && SystemClock.elapsedRealtime() - readyAtMs > RECONNECT_STABLE_MS) { @@ -508,9 +644,23 @@ class PlaybackManager( reconnectJob = scope.launch { delay(delayMs) - if (currentStationUrl == url) { + // MediaController is application-thread confined: marshal the + // retry onto Main. Calling playStation() here on Dispatchers.IO + // used to crash the whole process with IllegalStateException on + // the first stream drop instead of recovering from it. + if (currentStationUrl == url && !userStopped && !_isLocalFlow.value) { + reconnectJob = null + try { + withContext(Dispatchers.Main) { + if (currentStationUrl == url && !userStopped && !_isLocalFlow.value) { + playStation(url, currentStationName) + } + } + } catch (e: Exception) { + Log.w(TAG, "Reconnect failed", e) + } + } else { reconnectJob = null - playStation(url, currentStationName) } } return delayMs @@ -521,5 +671,6 @@ class PlaybackManager( private const val RECONNECT_BASE_DELAY_MS = 1000L private const val RECONNECT_MAX_DELAY_MS = 30_000L private const val RECONNECT_STABLE_MS = 10_000L + private const val ICY_RESTART_DELAY_MS = 5_000L } } diff --git a/app/src/main/java/com/rada/player/PlaybackService.kt b/app/src/main/java/com/rada/player/PlaybackService.kt index 764509c..a3d193f 100644 --- a/app/src/main/java/com/rada/player/PlaybackService.kt +++ b/app/src/main/java/com/rada/player/PlaybackService.kt @@ -3,24 +3,40 @@ package com.rada.player import android.app.PendingIntent import android.content.Intent import androidx.annotation.OptIn +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.ExoPlayer import androidx.media3.session.MediaSession import androidx.media3.session.MediaSessionService -import com.rada.ui.MainActivity import dagger.hilt.android.AndroidEntryPoint +import com.rada.ui.MainActivity +import javax.inject.Inject @AndroidEntryPoint class PlaybackService : MediaSessionService() { private var mediaSession: MediaSession? = null + @Inject lateinit var playbackManager: PlaybackManager + @OptIn(UnstableApi::class) override fun onCreate() { super.onCreate() - val player = ExoPlayer.Builder(this).build() + // Spotify contract: the stream is classified as media and the player + // yields audio focus (pauses for calls, ducks for transient + // notifications) and resumes when focus returns. Without this, phone + // calls never pause the music and Bluetooth can't hand over to them. + val audioAttributes = AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build() + val player = ExoPlayer.Builder(this) + .setAudioAttributes(audioAttributes, /* handleAudioFocus= */ true) + .setHandleAudioBecomingNoisy(true) + .build() val intent = Intent(this, MainActivity::class.java) val pendingIntent = PendingIntent.getActivity( @@ -38,9 +54,21 @@ class PlaybackService : MediaSessionService() { return mediaSession } + /** + * Closed means stopped: swiping the task away is an explicit kill, so + * stop playback (audio + recorder) and drop the notification. Screen-off + * pocket listening is unaffected — the task still exists then. + */ override fun onTaskRemoved(rootIntent: Intent?) { - val player = mediaSession?.player - if (player == null || !player.playWhenReady || player.mediaItemCount == 0) { + try { + playbackManager.stop() + } catch (_: Exception) { + try { + mediaSession?.player?.pause() + } catch (_: Exception) { + // Nothing more we can do; still stop below. + } + } finally { stopSelf() } } diff --git a/app/src/main/java/com/rada/recording/MetadataEmbedder.kt b/app/src/main/java/com/rada/recording/MetadataEmbedder.kt index e0b6915..a3c1aef 100644 --- a/app/src/main/java/com/rada/recording/MetadataEmbedder.kt +++ b/app/src/main/java/com/rada/recording/MetadataEmbedder.kt @@ -38,8 +38,10 @@ class MetadataEmbedder(private val context: Context) { // Store the jaudiotagger AudioFile reference (Bug #6). audio = AudioFileIO.read(audioFile) - // Set metadata fields on the stored reference's tag. - val tag = audio.tag + // Extracted segments are raw MP3 frames with NO tag yet, and + // getTag() returns null for them — the old NullPointerException. + // getTagOrCreateAndSetDefault() creates an ID3v2 tag instead. + val tag = audio.tagOrCreateAndSetDefault tag.setField(FieldKey.ARTIST, artist.ifEmpty { "Unknown Artist" }) tag.setField(FieldKey.TITLE, title.ifEmpty { "Unknown Title" }) tag.setField(FieldKey.ALBUM, stationName.ifEmpty { "SatsAmp" }) diff --git a/app/src/main/java/com/rada/ui/MainActivity.kt b/app/src/main/java/com/rada/ui/MainActivity.kt index 1260ba3..b4fd011 100644 --- a/app/src/main/java/com/rada/ui/MainActivity.kt +++ b/app/src/main/java/com/rada/ui/MainActivity.kt @@ -1,14 +1,19 @@ package com.rada.ui +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat import androidx.navigation.compose.rememberNavController import com.rada.player.PlaybackManager import com.rada.ui.navigation.NavGraph @@ -22,10 +27,22 @@ class MainActivity : ComponentActivity() { @Inject lateinit var playbackManager: PlaybackManager + /** + * Playback controls + the way back into the app after swiping it away + * live in the media notification — which Android 13+ hides until the + * user grants POST_NOTIFICATIONS. Ask once per install on first launch; + * a denial just means no notification (playback still works). + */ + private val notificationPermission = + registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + getPreferences(MODE_PRIVATE).edit().putBoolean(PREF_NOTIF_ASKED, true).apply() + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() playbackManager.connect() + askNotificationPermissionOnce() setContent { SatsAmpTheme { @@ -45,4 +62,21 @@ class MainActivity : ComponentActivity() { playbackManager.disconnect() super.onDestroy() } + + private fun askNotificationPermissionOnce() { + if (Build.VERSION.SDK_INT < 33) return + val prefs = getPreferences(MODE_PRIVATE) + if (prefs.getBoolean(PREF_NOTIF_ASKED, false)) return + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + ) { + prefs.edit().putBoolean(PREF_NOTIF_ASKED, true).apply() + return + } + notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) + } + + companion object { + private const val PREF_NOTIF_ASKED = "notif_asked" + } } diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt index 9e93d7f..82227ef 100644 --- a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt +++ b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt @@ -36,6 +36,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import android.app.Activity import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -66,9 +69,26 @@ fun DecentralizedSheet( val busy by viewModel.busy.collectAsState() val clipboard = LocalClipboardManager.current + val signerLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { res -> + if (res.resultCode != Activity.RESULT_OK) { + viewModel.onSignerError() + } else { + val rejected = res.data?.getBooleanExtra("rejected", false) == true + viewModel.onSignerResult( + res.data?.getStringExtra("result"), + res.data?.getStringExtra("package"), + rejected, + ) + } + } + var showAdvanced by remember { mutableStateOf(false) } var showImport by remember { mutableStateOf(false) } + var showPubkeyImport by remember { mutableStateOf(false) } var nsecInput by remember { mutableStateOf("") } + var pubkeyInput by remember { mutableStateOf("") } var nwcInput by remember { mutableStateOf("") } var nameInput by remember { mutableStateOf("") } var aboutInput by remember { mutableStateOf("") } @@ -82,7 +102,7 @@ fun DecentralizedSheet( Row(verticalAlignment = Alignment.CenterVertically) { Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText) Spacer(Modifier.width(8.dp)) - Text("Decentralized", fontWeight = FontWeight.Bold) + Text("Nostr", fontWeight = FontWeight.Bold) } }, text = { @@ -101,6 +121,31 @@ fun DecentralizedSheet( style = MaterialTheme.typography.bodySmall, color = tokens.textSecondary, ) + // Always shown: if no signer is installed the launch fails + // and the user gets a clear message instead of a missing button. + Button( + onClick = { + runCatching { + signerLauncher.launch(viewModel.amberLoginIntent()) + }.onFailure { + if (viewModel.isSignerInstalled) viewModel.onSignerError() + else viewModel.onSignerNotFound() + } + }, + colors = ButtonDefaults.buttonColors( + containerColor = tokens.accentFill, + contentColor = tokens.accentOnFill, + ), + shape = RoundedCornerShape(SatsAmpGlass.rInput), + modifier = Modifier.fillMaxWidth(), + ) { Text("Login with Amber") } + if (!viewModel.isSignerInstalled) { + Text( + "No signer app detected — install Amber on this phone, or import your npub below.", + style = MaterialTheme.typography.bodySmall, + color = tokens.textSecondary, + ) + } Button( onClick = { viewModel.createIdentity() }, colors = ButtonDefaults.buttonColors( @@ -113,6 +158,9 @@ fun DecentralizedSheet( TextButton(onClick = { showImport = !showImport }) { Text("Import existing nsec", color = tokens.accentText) } + TextButton(onClick = { showPubkeyImport = !showPubkeyImport }) { + Text("Import pubkey (e.g. from Amber)", color = tokens.accentText) + } if (showImport) { OutlinedTextField( value = nsecInput, @@ -127,6 +175,25 @@ fun DecentralizedSheet( enabled = nsecInput.isNotBlank(), ) { Text("Import") } } + if (showPubkeyImport) { + OutlinedTextField( + value = pubkeyInput, + onValueChange = { pubkeyInput = it }, + placeholder = { Text("npub1… (from Amber or another wallet)") }, + singleLine = true, + colors = fieldColors(tokens), + modifier = Modifier.fillMaxWidth(), + ) + TextButton( + onClick = { + if (pubkeyInput.isNotBlank()) { + viewModel.importPubkey(pubkeyInput) + pubkeyInput = "" + } + }, + enabled = pubkeyInput.isNotBlank(), + ) { Text("Import pubkey") } + } } else { Row( verticalAlignment = Alignment.CenterVertically, @@ -157,6 +224,9 @@ fun DecentralizedSheet( style = MaterialTheme.typography.labelSmall, color = tokens.textMuted, ) + TextButton(onClick = { viewModel.signOut() }) { + Text("Sign out", color = tokens.textSecondary) + } } Spacer(Modifier.height(4.dp)) diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt index 0db3cb3..a50adb8 100644 --- a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt +++ b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt @@ -1,5 +1,6 @@ package com.rada.ui.decentralized +import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rada.nostr.NostrKeys @@ -35,6 +36,8 @@ class DecentralizedViewModel @Inject constructor( val walletConnected = MutableStateFlow(nwc.connected) val message = MutableStateFlow(null) val busy = MutableStateFlow(false) + val isSignerInstalled: Boolean get() = keys.isExternalSignerInstalled() + val signerPackage: String? get() = keys.signerPackage private val _relays = MutableStateFlow(nostr.relaysConfig()) val relays: StateFlow> = _relays.asStateFlow() @@ -59,6 +62,50 @@ class DecentralizedViewModel @Inject constructor( } } + fun importPubkey(npub: String) { + if (keys.importPubkey(npub)) { + nostr.setEnabled(true) + nostrEnabled.value = true + message.value = "Pubkey imported ✅ — relays connected for profile lookups" + } else { + message.value = "That doesn't look like a valid npub." + } + } + + fun amberLoginIntent(): Intent = keys.buildGetPubkeyIntent() + + /** Handle the NIP-55 `get_public_key` activity result. */ + fun onSignerResult(resultHex: String?, signerPkg: String?, rejected: Boolean): Boolean { + if (rejected) { + message.value = "Sign-in rejected in the signer app." + return false + } + if (resultHex == null || !keys.importHexPubkey(resultHex)) { + message.value = "Signer didn't return a valid pubkey — try again." + return false + } + if (signerPkg != null) keys.saveSignerPackage(signerPkg) + nostr.setEnabled(true) + nostrEnabled.value = true + message.value = "Connected via external signer ✅" + return true + } + + fun onSignerError() { + message.value = "Signer app failed to respond — try again." + } + + fun onSignerNotFound() { + message.value = "No signer app found — install Amber (F-Droid / Zapstore / Play), then try again." + } + + fun signOut() { + keys.clearIdentity() + nostr.setEnabled(false) + nostrEnabled.value = false + message.value = "Signed out — identity erased from this device." + } + fun publishProfile(name: String, about: String) { busy.value = true val ev = nostr.publishProfile(name, about, "") diff --git a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt index 49a9019..116a0e8 100644 --- a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt +++ b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -32,10 +33,13 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.rada.payments.hasValidLightningAddress import com.rada.ui.theme.LocalTokens /** @@ -48,6 +52,9 @@ fun TipSheet( artistName: String, trackTitle: String?, onDismiss: () -> Unit, + initialAddress: String = "", + /** Wavlake artist id — a successful zap remembers its address for next time. */ + artistId: String? = null, viewModel: TipViewModel = hiltViewModel(), ) { val tokens = LocalTokens.current @@ -58,7 +65,11 @@ fun TipSheet( var amount by remember { mutableStateOf(100L) } var customAmount by remember { mutableStateOf("") } - var address by remember { mutableStateOf(recents.firstOrNull() ?: "") } + var address by remember(initialAddress) { + mutableStateOf( + initialAddress.ifBlank { recents.firstOrNull() ?: "" }, + ) + } var comment by remember { mutableStateOf("") } val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount @@ -126,6 +137,31 @@ fun TipSheet( onValueChange = { address = it }, placeholder = { Text("name@domain (Lightning address)") }, singleLine = true, + // Copy path needs no wallet: a valid address can always + // be taken to any wallet app. Shown only for valid + // addresses — never a dead or decorative button. + trailingIcon = { + if (hasValidLightningAddress(address)) { + val clipboard = LocalClipboardManager.current + androidx.compose.material3.IconButton(onClick = { + clipboard.setText(AnnotatedString(address.trim())) + // User-attested + syntactically valid: remembering + // here closes the wallet-less loop (paste once + // from the official page → bolt forever). + if (!artistId.isNullOrBlank()) { + viewModel.rememberArtistAddress(artistId, address) + } + viewModel.status.value = + "Address copied — paste it in any wallet app." + }) { + Icon( + Icons.Default.ContentCopy, + contentDescription = "Copy Lightning address", + tint = tokens.accentText, + ) + } + } + }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = tokens.accentText, unfocusedBorderColor = tokens.borderInteractive, @@ -160,7 +196,7 @@ fun TipSheet( }, confirmButton = { Button( - onClick = { viewModel.tip(address, finalSats, artistName, trackTitle) }, + onClick = { viewModel.tip(address, finalSats, artistName, trackTitle, artistId) }, enabled = !sending && address.contains("@") && finalSats > 0, colors = ButtonDefaults.buttonColors( containerColor = tokens.accentFill, @@ -181,7 +217,7 @@ fun TipSheet( dismissButton = { if (!hasWallet) { Text( - "connect a wallet first\n(Settings ⚙ → Decentralized)", + "No wallet connected — copy the address\nabove and pay from any wallet app.", style = MaterialTheme.typography.labelSmall, color = tokens.textMuted, ) diff --git a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt index 3066316..93e6176 100644 --- a/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt +++ b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt @@ -3,6 +3,7 @@ package com.rada.ui.decentralized import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import android.content.Context +import com.rada.data.wavlake.WavlakeRepository import com.rada.payments.PaymentResult import com.rada.payments.PaymentService import dagger.hilt.android.qualifiers.ApplicationContext @@ -17,6 +18,7 @@ import kotlinx.coroutines.launch class TipViewModel @Inject constructor( @ApplicationContext private val context: Context, private val paymentService: PaymentService, + private val wavlake: WavlakeRepository, ) : ViewModel() { private val prefs = context.getSharedPreferences("tips", Context.MODE_PRIVATE) @@ -31,7 +33,13 @@ class TipViewModel @Inject constructor( ) val recentAddresses: StateFlow> = _recentAddresses.asStateFlow() - fun tip(address: String, sats: Long, artistName: String, trackTitle: String?) { + fun tip( + address: String, + sats: Long, + artistName: String, + trackTitle: String?, + artistId: String? = null, + ) { val clean = address.trim().removePrefix("lightning:") if (!clean.contains("@")) { status.value = "Enter a name@domain Lightning address." @@ -49,6 +57,11 @@ class TipViewModel @Inject constructor( prefs.edit() .putString("recent", _recentAddresses.value.joinToString("\n")) .apply() + // Remember validated addresses per artist: the bolt can + // appear (prefilled) from the next encounter on. + if (!artistId.isNullOrBlank()) { + wavlake.rememberAddress(artistId, clean) + } } is PaymentResult.Failed -> status.value = r.userFacing else -> status.value = "Invoice created." @@ -57,4 +70,9 @@ class TipViewModel @Inject constructor( hasWallet.value = paymentService.hasAnyWallet } } + + /** Stores a user-attested address so the bolt can appear next time. */ + fun rememberArtistAddress(artistId: String, address: String) { + wavlake.rememberAddress(artistId, address) + } } diff --git a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt new file mode 100644 index 0000000..2972df0 --- /dev/null +++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt @@ -0,0 +1,1149 @@ +package com.rada.ui.discover + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Tune +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.rada.data.wavlake.MatchingMode +import com.rada.data.wavlake.WavlakeSearchHit +import com.rada.data.wavlake.WavlakeStationSettings +import com.rada.domain.model.WavlakeTrack +import com.rada.payments.hasValidLightningAddress +import com.rada.ui.components.AppHomeButton +import com.rada.ui.components.EqualizerBars +import com.rada.ui.theme.LocalTokens +import com.rada.ui.theme.SatsAmpGlass +import kotlinx.coroutines.flow.first + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiscoverScreen( + onBack: () -> Unit, + onHome: () -> Unit = {}, + viewModel: WavlakeViewModel = hiltViewModel(), +) { + val searchQuery by viewModel.searchQuery.collectAsState() + val searchState by viewModel.searchState.collectAsState() + val rankings by viewModel.rankings.collectAsState() + val rankingsGenre by viewModel.rankingsGenre.collectAsState() + val rankingsLoading by viewModel.rankingsLoading.collectAsState() + val rankingsError by viewModel.rankingsError.collectAsState() + val addresses by viewModel.addresses.collectAsState() + val selectedArtistId by viewModel.selectedArtistId.collectAsState() + val artistDetail by viewModel.artistDetail.collectAsState() + val artistLoading by viewModel.artistLoading.collectAsState() + val notice by viewModel.notice.collectAsState() + val snackbarHostState = remember { SnackbarHostState() } + var showSettings by remember { mutableStateOf(false) } + var zapTarget by remember { mutableStateOf(null) } + var trackZap by remember { mutableStateOf(null) } + var pendingZapArtistId by remember { mutableStateOf(null) } + + LaunchedEffect(pendingZapArtistId) { + if (pendingZapArtistId == null) return@LaunchedEffect + val addr = snapshotFlow { addresses[pendingZapArtistId!!] } + .first { it != null } ?: "" + zapTarget = ZapTarget( + pendingZapArtistId!!, + "", + null, + addr, + ) + pendingZapArtistId = null + } + + LaunchedEffect(notice) { + notice?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeNotice() + } + } + + if (showSettings) { + WavlakeSettingsDialog( + current = viewModel.settings(), + onDismiss = { showSettings = false }, + onSave = { + viewModel.saveSettings(it) + showSettings = false + }, + ) + } + + zapTarget?.let { target -> + com.rada.ui.decentralized.TipSheet( + artistName = target.artistName, + trackTitle = target.trackTitle, + initialAddress = target.address, + artistId = target.artistId, + onDismiss = { zapTarget = null }, + ) + } + + trackZap?.let { track -> + WavlakeZapSheet( + track = track, + onDismiss = { trackZap = null }, + ) + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(if (selectedArtistId != null) "Artist" else "Discover") }, + navigationIcon = { + Row(horizontalArrangement = Arrangement.Start) { + AppHomeButton(onClick = onHome) + IconButton(onClick = { + if (selectedArtistId != null) viewModel.closeArtist() else onBack() + }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + } + }, + actions = { + if (selectedArtistId == null) { + IconButton(onClick = { showSettings = true }) { + Icon(Icons.Default.Tune, contentDescription = "Wavlake settings") + } + } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, + containerColor = Color.Transparent, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + if (selectedArtistId != null) { + ArtistDetailView( + artistId = selectedArtistId!!, + modifier = Modifier.padding(padding), + viewModel = viewModel, + ) + } else { + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + OutlinedTextField( + value = searchQuery, + onValueChange = { viewModel.search(it) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + placeholder = { Text("Search Wavlake artists, tracks…") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = LocalTokens.current.panel, + unfocusedContainerColor = LocalTokens.current.panel, + focusedBorderColor = LocalTokens.current.accentFill.copy(alpha = 0.5f), + unfocusedBorderColor = LocalTokens.current.border, + focusedTextColor = LocalTokens.current.text, + unfocusedTextColor = LocalTokens.current.text, + ), + shape = RoundedCornerShape(12.dp), + ) + + when (val s = searchState) { + WavlakeSearchState.Idle -> { + GenreChips( + genres = DISCOVER_GENRES, + selected = rankingsGenre, + onSelect = { viewModel.refreshRankings(it) }, + ) + RankingsList( + title = if (rankingsGenre != null) "Top in ${rankingsGenre}" else "Top on Wavlake", + tracks = rankings, + loading = rankingsLoading, + error = rankingsError, + onRetry = { viewModel.refreshRankings(rankingsGenre) }, + viewModel = viewModel, + onZap = { track, address -> + if (address != null) { + zapTarget = ZapTarget( + track.artistId, + track.artistName, + track.title, + address, + ) + } else { + trackZap = track + } + }, + ) + } + WavlakeSearchState.Loading -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.dp, + color = LocalTokens.current.accentText, + ) + } + } + is WavlakeSearchState.Results -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit -> + when (hit) { + is WavlakeSearchHit.Artist -> ArtistRow( + name = hit.artist.name, + genre = hit.artist.genre, + imageUrl = hit.artist.imageUrl, + artistId = hit.artist.id, + address = addresses[hit.artist.id], + onClick = { + viewModel.openArtist(hit.artist.id, hit.artist.name) + }, + onZap = { + viewModel.resolveArtistAddress(hit.artist.id) + pendingZapArtistId = hit.artist.id + }, + ) + is WavlakeSearchHit.Album -> AlbumRow( + title = hit.title, + artist = hit.artist, + artworkUrl = hit.artworkUrl, + onClick = { /* albums open via artist detail */ }, + ) + is WavlakeSearchHit.Track -> TrackRow( + track = com.rada.domain.model.WavlakeTrack( + id = hit.track.id, + title = hit.track.title, + artistId = hit.track.artistId, + artistName = hit.track.artistName, + genre = hit.track.genre, + genres = hit.track.genres, + artworkUrl = hit.track.artworkUrl, + durationMs = hit.track.durationMs, + audioUrl = null, + artistNpubHex = null, + ), + showZap = true, + onPlay = { + viewModel.playSearchTrack( + hit.track.id, + hit.track.artistId, + hit.track.title, + ) + }, + onSave = { + viewModel.saveSearchTrack( + hit.track.id, + hit.track.artistId, + hit.track.title, + ) + }, + onZap = { t -> + addresses[t.artistId]?.let { addr -> + zapTarget = ZapTarget( + t.artistId, + t.artistName, + t.title, + addr, + ) + } ?: viewModel.resolveForZap( + t.id, t.artistId, t.title, + ) { full -> + if (full != null) trackZap = full + } + }, + ) + } + } + } + } + is WavlakeSearchState.Error -> { + Text( + text = s.message, + style = MaterialTheme.typography.bodyMedium, + color = LocalTokens.current.textSecondary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + } + } + } + } + } +} + +private data class ZapTarget( + val artistId: String, + val artistName: String, + val trackTitle: String?, + val address: String, +) + +@Composable +private fun GenreChips( + genres: List, + selected: String?, + onSelect: (String?) -> Unit, +) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 4.dp, + ), + ) { + item(key = "chip-all") { + GenreChip(label = "Top", selected = selected == null, onClick = { onSelect(null) }) + } + items(genres, key = { "chip-$it" }) { genre -> + GenreChip(label = genre, selected = selected == genre, onClick = { onSelect(genre) }) + } + } +} + +@Composable +private fun GenreChip(label: String, selected: Boolean, onClick: () -> Unit) { + FilterChip( + selected = selected, + onClick = onClick, + label = { Text(label) }, + colors = FilterChipDefaults.filterChipColors( + containerColor = LocalTokens.current.panel, + labelColor = LocalTokens.current.textSecondary, + selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f), + selectedLabelColor = LocalTokens.current.accentText, + ), + border = FilterChipDefaults.filterChipBorder( + borderColor = LocalTokens.current.border, + selectedBorderColor = LocalTokens.current.accentFill, + enabled = true, + selected = selected, + ), + ) +} + +@Composable +private fun RankingsList( + title: String, + tracks: List, + loading: Boolean, + error: String?, + onRetry: () -> Unit, + viewModel: WavlakeViewModel, + onZap: (WavlakeTrack, String?) -> Unit, +) { + val addresses by viewModel.addresses.collectAsState() + Column(modifier = Modifier.fillMaxSize()) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = LocalTokens.current.accentText, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + when { + loading -> { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.dp, + color = LocalTokens.current.accentText, + ) + } + } + error != null -> { + Text( + text = error, + style = MaterialTheme.typography.bodyMedium, + color = LocalTokens.current.textSecondary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + TextButton(onClick = onRetry) { Text("Retry") } + } + tracks.isEmpty() -> { + Text( + text = "Nothing here yet.", + style = MaterialTheme.typography.bodyMedium, + color = LocalTokens.current.textSecondary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 4.dp), + ) + } + else -> { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(tracks, key = { it.id }, contentType = { "track" }) { track -> + TrackRow( + track = track, + showZap = addresses[track.artistId] != null || track.isZapPayable, + onPlay = { viewModel.playTrack(track) }, + onSave = { viewModel.saveTrack(track) }, + onZap = { t -> onZap(t, addresses[t.artistId]) }, + ) + } + } + } + } + } +} + +/** Track row: glyph tile + title/artist + play + save + bolt (see callers). */ +@Composable +fun TrackRow( + track: WavlakeTrack, + showZap: Boolean, + onPlay: () -> Unit, + onSave: () -> Unit, + onZap: (WavlakeTrack) -> Unit, +) { + val tokens = LocalTokens.current + Card( + onClick = onPlay, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong), + shape = RoundedCornerShape(SatsAmpGlass.rInput), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(tokens.accentFill.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = track.artistName.firstOrNull()?.uppercase() ?: "♪", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = tokens.accentText, + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = track.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = tokens.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = listOfNotNull( + track.artistName.takeIf { it.isNotBlank() }, + track.genre?.takeIf { it.isNotBlank() }, + ).joinToString(" · ").ifEmpty { "Wavlake" }, + style = MaterialTheme.typography.bodySmall, + color = tokens.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + // Bolt rule: address path when known, else the official + // track-zap path when the track is payable. Never rendered + // when neither holds. + if (showZap) { + IconButton(onClick = { onZap(track) }, modifier = Modifier.size(48.dp)) { + Icon( + Icons.Default.Bolt, + contentDescription = "Zap ${track.artistName}", + tint = tokens.accentFill, + modifier = Modifier.size(22.dp), + ) + } + } + IconButton(onClick = onPlay, modifier = Modifier.size(48.dp)) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Play ${track.title}", + tint = tokens.textSecondary, + modifier = Modifier.size(22.dp), + ) + } + IconButton(onClick = onSave, modifier = Modifier.size(48.dp)) { + Icon( + Icons.Default.Add, + contentDescription = "Save ${track.title} to Library", + tint = tokens.textSecondary, + modifier = Modifier.size(22.dp), + ) + } + } + } +} + +@Composable +fun ArtistRow( + name: String, + genre: String?, + imageUrl: String?, + artistId: String? = null, + address: String? = null, + onClick: () -> Unit = {}, + onZap: () -> Unit = {}, +) { + val tokens = LocalTokens.current + val hasAddress = hasValidLightningAddress(address) + Card( + onClick = onClick, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong), + shape = RoundedCornerShape(SatsAmpGlass.rInput), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(tokens.accentFill.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = name.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = tokens.accentText, + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = tokens.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = genre?.takeIf { it.isNotBlank() } ?: "Wavlake artist", + style = MaterialTheme.typography.bodySmall, + color = tokens.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (hasAddress) { + IconButton(onClick = onZap, modifier = Modifier.size(48.dp)) { + Icon( + Icons.Default.Bolt, + contentDescription = "Zap $name", + tint = tokens.accentFill, + modifier = Modifier.size(22.dp), + ) + } + } + } + } +} + +@Composable +private fun AlbumRow( + title: String, + artist: String?, + artworkUrl: String?, + artistId: String? = null, + address: String? = null, + onClick: () -> Unit = {}, + onZap: () -> Unit = {}, +) { + ArtistRow(name = title, genre = artist, imageUrl = artworkUrl, artistId = artistId, address = address, onClick = onClick, onZap = onZap) +} + +@Composable +fun ArtistDetailView( + artistId: String, + modifier: Modifier = Modifier, + viewModel: WavlakeViewModel = hiltViewModel(), +) { + val detail by viewModel.artistDetail.collectAsState() + val loading by viewModel.artistLoading.collectAsState() + val addresses by viewModel.addresses.collectAsState() + val tokens = LocalTokens.current + // The single zap sheet, fed by whichever bolt was tapped (header or row). + var zapAddress by remember { mutableStateOf(null) } + zapAddress?.let { addr -> + com.rada.ui.decentralized.TipSheet( + artistName = detail?.artist?.name ?: "Artist", + trackTitle = null, + initialAddress = addr, + artistId = artistId, + onDismiss = { zapAddress = null }, + ) + } + + Column(modifier = modifier.fillMaxSize()) { + if (loading && detail == null) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(24.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(22.dp), + strokeWidth = 2.dp, + color = tokens.accentText, + ) + } + return + } + val artist = detail?.artist ?: return + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(tokens.accentFill.copy(alpha = 0.15f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = artist.name.firstOrNull()?.uppercase() ?: "?", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = tokens.accentText, + ) + } + Spacer(modifier = Modifier.width(16.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = artist.name, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = tokens.text, + ) + Text( + text = (artist.genres.ifEmpty { listOfNotNull(artist.genre) }) + .joinToString(" · ").ifEmpty { "Wavlake artist" }, + style = MaterialTheme.typography.bodySmall, + color = tokens.textSecondary, + ) + } + // STRICT gate: no address, no bolt — nothing rendered at all. + val headerAddress = addresses[artistId] + if (headerAddress != null) { + IconButton( + onClick = { zapAddress = headerAddress }, + modifier = Modifier.size(48.dp), + ) { + Icon( + Icons.Default.Bolt, + contentDescription = "Zap ${artist.name}", + tint = tokens.accentFill, + modifier = Modifier.size(24.dp), + ) + } + } + } + if (!artist.bio.isNullOrBlank()) { + Text( + text = artist.bio, + style = MaterialTheme.typography.bodyMedium, + color = tokens.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + val tracks = detail?.tracks.orEmpty() + // Official Wavlake page (track URL is an official API field): the + // one-tap path to zap artists whose Lightning Address the catalog + // doesn't expose. External browser — no address invented here. + val wavlakeUrl = tracks.firstNotNullOfOrNull { it.sourceUrl } + if (wavlakeUrl != null) { + val context = androidx.compose.ui.platform.LocalContext.current + TextButton(onClick = { + runCatching { + context.startActivity( + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(wavlakeUrl), + ), + ) + } + }) { + Text( + "View on wavlake.com", + modifier = Modifier.padding(horizontal = 8.dp), + ) + } + } + if (tracks.isEmpty() && !loading) { + Text( + text = "No tracks found for this artist.", + style = MaterialTheme.typography.bodyMedium, + color = tokens.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + items(tracks, key = { it.id }, contentType = { "track" }) { track -> + var detailTrackZap by remember(track.id) { + mutableStateOf(null) + } + TrackRow( + track = track, + showZap = addresses[track.artistId] != null || track.isZapPayable, + onPlay = { viewModel.playTrack(track) }, + onSave = { viewModel.saveTrack(track) }, + onZap = { t -> + addresses[t.artistId]?.let { addr -> + zapAddress = addr + } ?: run { + if (t.isZapPayable) detailTrackZap = t + } + }, + ) + detailTrackZap?.let { zt -> + WavlakeZapSheet( + track = zt, + onDismiss = { detailTrackZap = null }, + ) + } + } + } + } + } +} + +@Composable +private fun WavlakeSettingsDialog( + current: WavlakeStationSettings, + onDismiss: () -> Unit, + onSave: (WavlakeStationSettings) -> Unit, +) { + var enabled by remember { mutableStateOf(current.enabled) } + var probability by remember { mutableStateOf(current.featureProbability.toFloat()) } + var mode by remember { mutableStateOf(current.matchingMode) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Wavlake featuring") }, + text = { + Column { + Text( + "Genre-matched Wavlake picks appear on the Player for compatible stations. Tapping plays — stations are never interrupted.", + style = MaterialTheme.typography.bodySmall, + color = LocalTokens.current.textSecondary, + ) + Spacer(modifier = Modifier.height(12.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + "Enabled", + modifier = Modifier.weight(1f), + color = LocalTokens.current.text, + ) + Switch(checked = enabled, onCheckedChange = { enabled = it }) + } + Spacer(modifier = Modifier.height(8.dp)) + Text( + "Refresh chance: ${(probability * 100).toInt()}%", + color = LocalTokens.current.text, + ) + Slider( + value = probability, + onValueChange = { probability = it }, + valueRange = 0f..1f, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text("Matching", color = LocalTokens.current.text) + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + MatchingMode.entries.forEach { m -> + TextButton(onClick = { mode = m }) { + Text( + m.name.lowercase().replace('_', '-'), + color = if (mode == m) LocalTokens.current.accentText + else LocalTokens.current.textSecondary, + fontWeight = if (mode == m) FontWeight.Bold else FontWeight.Normal, + ) + } + } + } + } + }, + confirmButton = { + TextButton( + onClick = { + onSave( + current.copy( + enabled = enabled, + featureProbability = probability.toDouble(), + matchingMode = mode, + ), + ) + }, + ) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + }, + ) +} + +/** + * Opt-in genre-matched picks for the current station. Shown on the Player; + * tapping a pick plays it (explicit choice), nothing auto-inserts. + */ +@Composable +fun WavlakeFeaturedRow( + stationId: String, + stationGenre: String, + viewModel: WavlakeViewModel = hiltViewModel(), +) { + val tokens = LocalTokens.current + var picks by remember(stationId) { mutableStateOf>(emptyList()) } + var loading by remember(stationId) { mutableStateOf(true) } + + LaunchedEffect(stationId) { + loading = true + picks = viewModel.featuredFor(stationId, stationGenre) + loading = false + } + + if (!loading && picks.isEmpty()) return + + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = "FEATURING WAVLAKE", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = tokens.accentText, + modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp), + ) + if (loading) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + color = tokens.accentText, + ) + } + } else { + androidx.compose.foundation.lazy.LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 24.dp), + ) { + items(picks, key = { it.id }) { track -> + FeaturedChip( + track = track, + onPlay = { viewModel.playTrack(track) }, + onSave = { viewModel.saveTrack(track) }, + ) + } + } + } + } +} + +/** + * Official Wavlake track zap (no artist address needed): amount → invoice + * via wavlake.com, then pay in-app (wallet) or copy the invoice out to any + * wallet. Success is shown only after confirmation. + */ +@Composable +fun WavlakeZapSheet( + track: WavlakeTrack, + onDismiss: () -> Unit, + viewModel: WavlakeZapViewModel = hiltViewModel(), +) { + val tokens = LocalTokens.current + val state by viewModel.state.collectAsState() + var amount by remember(track.id) { mutableStateOf(100L) } + var customAmount by remember(track.id) { mutableStateOf("") } + var comment by remember(track.id) { mutableStateOf("") } + val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount + + LaunchedEffect(track.id) { viewModel.reset() } + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = tokens.panel, + titleContentColor = tokens.text, + shape = RoundedCornerShape(20.dp), + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText) + Spacer(Modifier.size(8.dp)) + Text( + "Zap ${track.title}", + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + text = { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + "by ${track.artistName} · via Wavlake", + style = MaterialTheme.typography.bodySmall, + color = tokens.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + when (val s = state) { + TrackZapState.Editing -> { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(21L, 100L, 500L, 1000L).forEach { preset -> + FilterChip( + selected = customAmount.isBlank() && amount == preset, + onClick = { amount = preset; customAmount = "" }, + label = { Text("$preset") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = tokens.accentSoft, + selectedLabelColor = tokens.accentText, + labelColor = tokens.textSecondary, + ), + ) + } + } + OutlinedTextField( + value = customAmount, + onValueChange = { customAmount = it.filter(Char::isDigit) }, + placeholder = { Text("Custom amount in sats") }, + singleLine = true, + suffix = { Text("sats", color = tokens.textMuted) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = tokens.accentText, + unfocusedBorderColor = tokens.borderInteractive, + focusedTextColor = tokens.text, + unfocusedTextColor = tokens.text, + ), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + placeholder = { Text("Message (optional)") }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = tokens.accentText, + unfocusedBorderColor = tokens.borderInteractive, + focusedTextColor = tokens.text, + unfocusedTextColor = tokens.text, + ), + modifier = Modifier.fillMaxWidth(), + ) + } + TrackZapState.Sending -> { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = tokens.accentText, + ) + } + } + is TrackZapState.InvoiceReady -> { + Text( + "Invoice for ${s.sats} sats — copy it into any wallet app, no connected wallet needed.", + style = MaterialTheme.typography.bodyMedium, + color = tokens.text, + ) + Text( + s.bolt11.take(42) + "…", + style = MaterialTheme.typography.bodySmall, + color = tokens.textMuted, + ) + } + TrackZapState.Sent -> { + Text( + "⚡ Zap confirmed. The artist gets paid via Wavlake.", + style = MaterialTheme.typography.bodyMedium, + color = tokens.accentText, + ) + } + is TrackZapState.Failed -> { + Text( + s.reason, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + }, + confirmButton = { + when (val s = state) { + TrackZapState.Editing -> { + TextButton( + onClick = { viewModel.send(track, finalSats, comment) }, + enabled = finalSats > 0, + ) { Text("Send $finalSats sats") } + } + is TrackZapState.InvoiceReady -> { + val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current + TextButton(onClick = { + clipboard.setText(androidx.compose.ui.text.AnnotatedString(s.bolt11)) + }) { Text("Copy invoice") } + } + is TrackZapState.Failed -> { + TextButton(onClick = { viewModel.reset() }) { Text("Back") } + } + else -> {} + } + }, + dismissButton = { + when (state) { + TrackZapState.Editing -> { + TextButton( + onClick = { viewModel.fetchInvoice(track, finalSats, comment) }, + enabled = finalSats > 0, + ) { Text("Invoice only") } + } + else -> { + TextButton(onClick = onDismiss) { + Text(if (state == TrackZapState.Sent) "Done" else "Close") + } + } + } + }, + ) +} + +@Composable +private fun FeaturedChip( track: WavlakeTrack, + onPlay: () -> Unit, + onSave: () -> Unit, +) { + val tokens = LocalTokens.current + Card( + onClick = onPlay, + colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong), + shape = RoundedCornerShape(SatsAmpGlass.rInput), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + EqualizerBars(active = false, color = tokens.accentText) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text( + text = track.title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = tokens.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = track.artistName, + style = MaterialTheme.typography.bodySmall, + color = tokens.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = onSave, modifier = Modifier.size(40.dp)) { + Icon( + Icons.Default.Add, + contentDescription = "Save ${track.title} to Library", + tint = tokens.textSecondary, + modifier = Modifier.size(18.dp), + ) + } + } + } +} diff --git a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt new file mode 100644 index 0000000..c76862e --- /dev/null +++ b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt @@ -0,0 +1,304 @@ +package com.rada.ui.discover + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rada.data.repository.PlaylistRepository +import com.rada.data.wavlake.WavlakeRepository +import com.rada.data.wavlake.WavlakeResult +import com.rada.data.wavlake.WavlakeSearchHit +import com.rada.data.wavlake.WavlakeStationSettings +import com.rada.domain.model.WavlakeArtist +import com.rada.domain.model.WavlakeTrack +import com.rada.payments.hasValidLightningAddress +import com.rada.player.PlaybackManager +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** Curated genre shortcuts — exact Wavlake catalog names only. */ +val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock") + +sealed interface WavlakeSearchState { + data object Idle : WavlakeSearchState + data object Loading : WavlakeSearchState + data class Results(val hits: List) : WavlakeSearchState + data class Error(val message: String) : WavlakeSearchState +} + +@OptIn(FlowPreview::class) +@HiltViewModel +class WavlakeViewModel @Inject constructor( + private val repository: WavlakeRepository, + private val playbackManager: PlaybackManager, + private val playlists: PlaylistRepository, +) : ViewModel() { + + private val _searchQuery = MutableStateFlow("") + val searchQuery: StateFlow = _searchQuery.asStateFlow() + + private val _searchState = MutableStateFlow(WavlakeSearchState.Idle) + val searchState: StateFlow = _searchState.asStateFlow() + + private val _rankings = MutableStateFlow>(emptyList()) + val rankings: StateFlow> = _rankings.asStateFlow() + + private val _rankingsGenre = MutableStateFlow(null) + val rankingsGenre: StateFlow = _rankingsGenre.asStateFlow() + + private val _rankingsLoading = MutableStateFlow(false) + val rankingsLoading: StateFlow = _rankingsLoading.asStateFlow() + + private val _rankingsError = MutableStateFlow(null) + val rankingsError: StateFlow = _rankingsError.asStateFlow() + + /** Resolved Lightning Addresses by artist id — populated best-effort. */ + private val _addresses = MutableStateFlow>(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 new file mode 100644 index 0000000..55a671a --- /dev/null +++ b/app/src/main/java/com/rada/ui/discover/WavlakeZapViewModel.kt @@ -0,0 +1,76 @@ +package com.rada.ui.discover + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rada.domain.model.WavlakeTrack +import com.rada.payments.ZapService +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * Official Wavlake track-zap sheet state machine. Amount → invoice → + * pay (wallet) or copy-out (no wallet). Success surfaces only after the + * wallet confirms payment. + */ +sealed interface TrackZapState { + data object Editing : TrackZapState + data object Sending : TrackZapState + data class InvoiceReady(val bolt11: String, val sats: Long) : TrackZapState + data object Sent : TrackZapState + data class Failed(val reason: String) : TrackZapState +} + +@HiltViewModel +class WavlakeZapViewModel @Inject constructor( + private val zaps: ZapService, +) : ViewModel() { + + private val _state = MutableStateFlow(TrackZapState.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 0dcf76d..0d749f0 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -24,6 +24,9 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowDownward import androidx.compose.material.icons.filled.ArrowUpward +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Checklist +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder @@ -33,6 +36,8 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Shuffle import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -45,6 +50,7 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.ScrollableTabRow import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Surface import androidx.compose.material3.Tab import androidx.compose.material3.TabRowDefaults import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset @@ -101,6 +107,7 @@ fun LibraryScreen( var showImportMenu by remember { mutableStateOf(false) } var showAppearance by remember { mutableStateOf(false) } var playlistToDelete by remember { mutableStateOf(null) } + var songForPlaylist by remember { mutableStateOf(null) } val snackbarHostState = remember { SnackbarHostState() } @@ -200,6 +207,19 @@ fun LibraryScreen( } } } + // Multi-select: combine several folders, shuffle the union. + if (tabLabels.size > 1) { + IconButton(onClick = { + if (uiState.isMultiSelect) viewModel.exitMultiSelect() + else viewModel.enterMultiSelect() + }) { + Icon( + Icons.Default.Checklist, + contentDescription = if (uiState.isMultiSelect) "Exit playlist selection" else "Select multiple playlists", + tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill else LocalTokens.current.textSecondary, + ) + } + } IconButton(onClick = { showAppearance = true }) { Icon( Icons.Default.Settings, @@ -215,12 +235,54 @@ fun LibraryScreen( }, snackbarHost = { SnackbarHost(snackbarHostState) }, containerColor = Color.Transparent, + bottomBar = { + if (uiState.isMultiSelect) { + MultiShuffleBar( + songCount = uiState.songs.size, + listCount = uiState.multiSelectedIds.size, + onShuffle = { viewModel.shuffleMulti() }, + onExit = { viewModel.exitMultiSelect() }, + ) + } + }, ) { padding -> Column( modifier = Modifier .fillMaxSize() .padding(padding), ) { + // View header: what you're looking at + live count (Apple-style + // display type; doubles as orientation in multi-select/search). + val selectedPlaylist = + filteredPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId } + val headerTitle = when { + uiState.isMultiSelect -> + if (uiState.multiSelectedIds.isEmpty()) "Select lists" + else "${uiState.multiSelectedIds.size} lists" + selectedPlaylist != null -> selectedPlaylist.name + else -> "All Songs" + } + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .padding(top = 4.dp, bottom = 2.dp), + ) { + Text( + text = headerTitle, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = LocalTokens.current.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${uiState.songs.size} songs", + style = MaterialTheme.typography.bodySmall, + color = LocalTokens.current.textSecondary, + ) + } + if (tabLabels.size > 1) { ScrollableTabRow( selectedTabIndex = selectedTabIndex, @@ -246,6 +308,13 @@ fun LibraryScreen( onDispose { tabLpHandler.removeCallbacksAndMessages(null) } } tabLabels.forEachIndexed { index, label -> + val tabPlaylistId = + if (index == 0) null else filteredPlaylists[index - 1].id + val tabSelected = if (uiState.isMultiSelect) { + index > 0 && tabPlaylistId in uiState.multiSelectedIds + } else { + selectedTabIndex == index + } Tab( modifier = if (index == 0) Modifier else Modifier.pointerInput(index) { awaitEachGesture { @@ -270,20 +339,33 @@ fun LibraryScreen( tabLpHandler.removeCallbacks(timer) } }, - selected = selectedTabIndex == index, + selected = tabSelected, onClick = { - if (index == 0) { + if (uiState.isMultiSelect) { + if (index == 0) viewModel.exitMultiSelect() + else viewModel.togglePlaylistMulti(filteredPlaylists[index - 1].id) + } else if (index == 0) { viewModel.selectPlaylist(null) } else { viewModel.selectPlaylist(filteredPlaylists[index - 1].id) } }, + icon = if (tabSelected && uiState.isMultiSelect) { + { + Icon( + Icons.Default.Check, + contentDescription = null, + tint = LocalTokens.current.accentFill, + modifier = Modifier.size(16.dp), + ) + } + } else null, text = { Text( text = label, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (selectedTabIndex == index) LocalTokens.current.accentFill else LocalTokens.current.textSecondary, + color = if (tabSelected) LocalTokens.current.accentFill else LocalTokens.current.textSecondary, ) }, ) @@ -362,8 +444,11 @@ fun LibraryScreen( ) Spacer(modifier = Modifier.height(16.dp)) Text( - text = if (uiState.selectedPlaylistId != null) "Playlist is empty" - else "No saved songs yet — tap + to import songs from your device", + text = when { + uiState.isMultiSelect -> "Tap playlist tabs above to combine them" + uiState.selectedPlaylistId != null -> "Playlist is empty" + else -> "No saved songs yet — tap + to import songs from your device" + }, style = MaterialTheme.typography.bodyLarge, color = LocalTokens.current.textSecondary, ) @@ -379,6 +464,7 @@ fun LibraryScreen( isPlaying = song.id == uiState.currentPlayingId, onClick = { viewModel.playSong(song) }, onToggleFavorite = { viewModel.toggleFavorite(song.id) }, + onAddToPlaylist = { songForPlaylist = song }, onDelete = { viewModel.deleteSong(song) }, ) } @@ -394,8 +480,7 @@ fun LibraryScreen( } playlistToDelete?.let { playlist -> - AlertDialog( - onDismissRequest = { playlistToDelete = null }, + AlertDialog( onDismissRequest = { playlistToDelete = null }, title = { Text("Delete \"${playlist.name}\"?") }, text = { Text("You can delete just the folder group — the songs stay in All Songs — or delete the songs and their files too.") }, confirmButton = { @@ -421,6 +506,205 @@ fun LibraryScreen( }, ) } + + songForPlaylist?.let { song -> + AddSongToPlaylistDialog( + songTitle = song.title, + playlists = allPlaylists, + onDismiss = { songForPlaylist = null }, + onPlaylistsConfirmed = { playlistIds -> + viewModel.addSongToPlaylists(song.id, playlistIds) + songForPlaylist = null + }, + onCreatePlaylist = { name -> + viewModel.createPlaylistAndAdd(song.id, name) + songForPlaylist = null + }, + ) + } +} + +@Composable +private fun AddSongToPlaylistDialog( + songTitle: String, + playlists: List, + 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 @@ -464,6 +748,7 @@ private fun SongListItem( isPlaying: Boolean, onClick: () -> Unit, onToggleFavorite: () -> Unit, + onAddToPlaylist: () -> Unit, onDelete: () -> Unit, ) { val interaction = remember { MutableInteractionSource() } @@ -522,6 +807,13 @@ private fun SongListItem( tint = if (song.isFavorite) LocalTokens.current.accentFill else LocalTokens.current.textSecondary, ) } + IconButton(onClick = onAddToPlaylist) { + Icon( + Icons.Default.PlaylistAdd, + contentDescription = "Add “${song.title}” to playlist", + tint = LocalTokens.current.textSecondary, + ) + } IconButton(onClick = onDelete) { Icon( Icons.Default.Delete, 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 26683a8..01ac2d1 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -74,6 +75,8 @@ class LibraryViewModel @Inject constructor( private val sortOrder = MutableStateFlow(SortOrder.DATE_SAVED) private val sortAscending = MutableStateFlow(true) private val selectedPlaylistId = MutableStateFlow(null) + private val multiSelect = MutableStateFlow(false) + private val multiSelectedIds = MutableStateFlow>(emptySet()) val allPlaylists: StateFlow> = playlistRepository.getAllPlaylists() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList()) @@ -89,28 +92,38 @@ class LibraryViewModel @Inject constructor( // flatMapLatest cancels the previous songs flow when the selection // changes, so picking a playlist immediately switches to the // playlist-filtered query instead of staying on the full library. - val songsFlow = combine(searchQuery, sortOrder, selectedPlaylistId) { query, sort, playlistId -> - Triple(query, sort, playlistId) - }.flatMapLatest { (query, sort, playlistId) -> + val songsFlow = combine( + searchQuery, sortOrder, selectedPlaylistId, multiSelect, multiSelectedIds + ) { query, sort, playlistId, multi, ids -> + SongQuery(query, sort, playlistId, multi, ids) + }.flatMapLatest { q -> when { + // Multi-select wins over everything: union of picked lists + // (empty until the first list is tapped). + q.multi -> + if (q.ids.isNotEmpty()) playlistRepository.getSongsInPlaylists(q.ids.toList()) + else flowOf(emptyList()) // A playlist selection always wins: query strictly by playlist_id. - playlistId != null -> playlistRepository.getSongsInPlaylist(playlistId) - query.isEmpty() -> libraryRepository.getAll(sort) - else -> libraryRepository.search(query) + q.playlistId != null -> playlistRepository.getSongsInPlaylist(q.playlistId) + q.query.isEmpty() -> libraryRepository.getAll(q.sort) + else -> libraryRepository.search(q.query) } } // Sort direction and in-folder sorting are applied here so every view - // (All Songs, folder tabs, search results) obeys the visible sort chips. + // (All Songs, folder tabs, search results, multi-select union) obeys + // the visible sort chips. viewModelScope.launch { - combine(songsFlow, sortOrder, sortAscending) { songs, sort, ascending -> - Triple(sortSongs(songs, sort, ascending), sort, ascending) - }.collect { (sortedSongs, sort, ascending) -> + combine(songsFlow, sortOrder, sortAscending, multiSelect, multiSelectedIds) { songs, sort, ascending, multi, ids -> + SongListView(songs, sort, ascending, multi, ids) + }.collect { view -> _uiState.update { it.copy( - songs = sortedSongs, + songs = sortSongs(view.songs, view.sort, view.ascending), isLoading = false, - sortOrder = sort, - sortAscending = ascending, + sortOrder = view.sort, + sortAscending = view.ascending, + isMultiSelect = view.multi, + multiSelectedIds = view.ids, ) } } @@ -187,8 +200,7 @@ class LibraryViewModel @Inject constructor( } } - fun deleteSong(song: SavedSongEntity) { - viewModelScope.launch { + fun deleteSong(song: SavedSongEntity) { viewModelScope.launch { libraryRepository.delete(song) try { val file = java.io.File(song.filePath) @@ -197,6 +209,25 @@ class LibraryViewModel @Inject constructor( } } + fun addSongToPlaylists(songId: Long, playlistIds: List) { + if (playlistIds.isEmpty()) return + viewModelScope.launch { + playlistIds.forEach { playlistRepository.addSongToPlaylist(it, songId) } + _importMessage.value = if (playlistIds.size == 1) "Added to playlist" + else "Added to ${playlistIds.size} playlists" + } + } + + fun createPlaylistAndAdd(songId: Long, name: String) { + val trimmed = name.trim() + if (trimmed.isEmpty()) return + viewModelScope.launch { + val playlistId = playlistRepository.createPlaylist(trimmed) + playlistRepository.addSongToPlaylist(playlistId, songId) + _importMessage.value = "Created \"$trimmed\" and added song" + } + } + fun search(query: String) { searchQuery.value = query } @@ -224,6 +255,38 @@ class LibraryViewModel @Inject constructor( _uiState.update { it.copy(selectedPlaylistId = playlistId) } } + /** Enters multi-select (union view takes over; single selection clears). */ + fun enterMultiSelect() { + selectedPlaylistId.value = null + multiSelectedIds.value = emptySet() + multiSelect.value = true + _uiState.update { it.copy(selectedPlaylistId = null) } + } + + /** Leaves multi-select and returns to All Songs. */ + fun exitMultiSelect() { + multiSelect.value = false + multiSelectedIds.value = emptySet() + selectedPlaylistId.value = null + _uiState.update { it.copy(selectedPlaylistId = null) } + } + + /** Toggles one playlist's membership in the multi-select union. */ + fun togglePlaylistMulti(playlistId: Long) { + if (!multiSelect.value) return + multiSelectedIds.value = + if (playlistId in multiSelectedIds.value) multiSelectedIds.value - playlistId + else multiSelectedIds.value + playlistId + } + + /** Shuffles the current multi-select union, then leaves select mode. */ + fun shuffleMulti() { + val queue = _uiState.value.songs + if (queue.isEmpty()) return + playQueue(queue, startIndex = 0, shuffle = true) + exitMultiSelect() + } + fun deletePlaylist(playlist: PlaylistEntity, deleteSongs: Boolean = false) { viewModelScope.launch { if (deleteSongs) { @@ -256,5 +319,26 @@ data class LibraryUiState( val sortAscending: Boolean = true, val isImporting: Boolean = false, + /** Multi-select union mode: tabs toggle membership, list shows the union. */ + val isMultiSelect: Boolean = false, + val multiSelectedIds: Set = emptySet(), ) { } + +/** One snapshot of the inputs driving the visible song list. */ +private data class SongQuery( + val query: String, + val sort: SortOrder, + val playlistId: Long?, + val multi: Boolean, + val ids: Set, +) + +/** 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 8639975..17fa58c 100644 --- a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt @@ -66,9 +66,10 @@ fun NavGraph(navController: NavHostController) { ) { PlayerScreen( onNavigateToStations = { navController.navigate(Screen.Tabs.createRoute(0)) }, - onNavigateToLibrary = { navController.navigate(Screen.Tabs.createRoute(1)) }, - onNavigateToStreams = { navController.navigate(Screen.Tabs.createRoute(2)) }, - onNavigateToPlaylists = { navController.navigate(Screen.Tabs.createRoute(3)) }, + onNavigateToDiscover = { navController.navigate(Screen.Tabs.createRoute(1)) }, + onNavigateToLibrary = { navController.navigate(Screen.Tabs.createRoute(2)) }, + onNavigateToStreams = { navController.navigate(Screen.Tabs.createRoute(3)) }, + onNavigateToPlaylists = { navController.navigate(Screen.Tabs.createRoute(4)) }, ) } composable( diff --git a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt index cf39fdb..50e359c 100644 --- a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt +++ b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.Podcasts import androidx.compose.material.icons.filled.Radio import androidx.compose.material3.Icon @@ -15,15 +16,12 @@ import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -36,10 +34,10 @@ import com.rada.ui.theme.LocalTokens import kotlinx.coroutines.launch /** - * The four peer tabs as a swipeable pager (Spotify's horizontal structure): - * Stations · Library · Streams · Playlists. Synced with the bottom bar; - * swiping follows the finger, tab taps animate. The mini-player sits above - * the nav bar so playback controls are always reachable. + * The five peer tabs as a swipeable pager (Spotify's horizontal structure): + * Stations · Discover · Library · Streams · Playlists. Synced with the + * bottom bar; swiping follows the finger, tab taps animate. The mini-player + * sits above the nav bar so playback controls are always reachable. */ @Composable fun TabsScreen( @@ -48,20 +46,11 @@ fun TabsScreen( onBack: () -> Unit, onHome: () -> Unit, ) { - val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 3)) { 4 } + val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 } val scope = rememberCoroutineScope() - val haptics = LocalHapticFeedback.current // Deep-link target for Library (playlist picker → open that playlist). var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) } - // Light tick as a new page settles — tab changes are tactile, not silent. - // Skipped on first composition (nothing actually changed). - var firstSettle by remember { mutableStateOf(true) } - LaunchedEffect(pagerState.settledPage) { - if (firstSettle) firstSettle = false - else haptics.performHapticFeedback(HapticFeedbackType.LongPress) - } - Scaffold( containerColor = MaterialTheme.colorScheme.background, bottomBar = { @@ -74,9 +63,10 @@ fun TabsScreen( ) { val items = listOf( Triple("Stations", Icons.Default.Radio, 0), - Triple("Library", Icons.Default.LibraryMusic, 1), - Triple("Streams", Icons.Default.Podcasts, 2), - Triple("Playlists", Icons.AutoMirrored.Filled.PlaylistAdd, 3), + Triple("Discover", Icons.Default.Explore, 1), + Triple("Library", Icons.Default.LibraryMusic, 2), + Triple("Streams", Icons.Default.Podcasts, 3), + Triple("Playlists", Icons.AutoMirrored.Filled.PlaylistAdd, 4), ) items.forEach { (label, icon, page) -> NavigationBarItem( @@ -112,22 +102,26 @@ fun TabsScreen( onHome = onHome, onStationSelected = { /* stay on tab; bar shows the station */ }, ) - 1 -> LibraryScreen( + 1 -> com.rada.ui.discover.DiscoverScreen( + onBack = onBack, + onHome = onHome, + ) + 2 -> LibraryScreen( onBack = onBack, onHome = onHome, initialPlaylistId = libraryPlaylistId, ) - 2 -> StreamsScreen( + 3 -> StreamsScreen( onBack = onBack, onHome = onHome, onStreamSelected = { }, ) - 3 -> PlaylistManagerScreen( + 4 -> PlaylistManagerScreen( onBack = onBack, onHome = onHome, onPlaylistSelected = { playlistId -> libraryPlaylistId = playlistId - scope.launch { pagerState.animateScrollToPage(1) } + scope.launch { pagerState.animateScrollToPage(2) } }, ) } 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 230bd51..27f95fa 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -238,8 +238,12 @@ fun NowPlayingBar( } } } - // ⚡ Support — tip the artist/station from anywhere. + // ⚡ Support — tip the artist/station from anywhere. When the + // playing track maps to an artist with a known address, it + // arrives prefilled (works for every such artist, no wallet + // needed — copy it out if you like); otherwise manual entry. var showTip by remember { mutableStateOf(false) } + val zapTarget by viewModel.zapTarget.collectAsState() IconButton( onClick = { showTip = true }, modifier = Modifier.size(40.dp), @@ -255,6 +259,8 @@ fun NowPlayingBar( com.rada.ui.decentralized.TipSheet( artistName = stationName, trackTitle = title.takeIf { subtitle.isNotEmpty() }, + initialAddress = zapTarget?.second.orEmpty(), + artistId = zapTarget?.first, onDismiss = { showTip = false }, ) } 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 698b501..2a2f4af 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt @@ -1,15 +1,24 @@ package com.rada.ui.player import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rada.data.repository.LibraryRepository +import com.rada.data.wavlake.WavlakeRepository import com.rada.player.PlaybackManager import com.rada.recording.StreamMetadata import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class NowPlayingViewModel @Inject constructor( private val playbackManager: PlaybackManager, + private val library: LibraryRepository, + private val wavlake: WavlakeRepository, ) : ViewModel() { val currentStationName: StateFlow = playbackManager.currentStationNameFlow @@ -20,6 +29,32 @@ class NowPlayingViewModel @Inject constructor( 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() } 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 04a5e11..7db8ff5 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -15,11 +15,15 @@ import com.rada.ui.theme.metadataExit import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.statusBarsPadding @@ -37,11 +41,9 @@ import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.filled.RepeatOne import androidx.compose.material.icons.filled.Shuffle -import androidx.compose.material.icons.filled.Speed -import androidx.compose.material.icons.filled.Timer -import androidx.compose.material.icons.filled.TimerOff import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Explore import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Pause @@ -54,12 +56,12 @@ import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material.icons.filled.SkipPrevious -import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.LibraryMusic import androidx.compose.material.icons.automirrored.filled.PlaylistAdd import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FloatingActionButton @@ -84,12 +86,16 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import kotlinx.coroutines.launch import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -104,7 +110,11 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.rada.domain.model.Station import com.rada.player.PlaybackManager import com.rada.ui.components.AppHomeButton +import com.rada.ui.components.EqualizerBars import com.rada.ui.theme.LocalTokens +import com.rada.ui.theme.SatsAmpGlass +import com.rada.ui.theme.SatsAmpGradients +import com.rada.ui.theme.pressDip import com.rada.ui.theme.AppearanceDialog import com.rada.ui.theme.GradientEnd import com.rada.ui.theme.GradientMid @@ -113,6 +123,7 @@ import com.rada.ui.theme.PremiumCyan @Composable fun PlayerScreen( onNavigateToStations: () -> Unit, + onNavigateToDiscover: () -> Unit, onNavigateToLibrary: () -> Unit, onNavigateToStreams: () -> Unit, onNavigateToPlaylists: () -> Unit, @@ -122,6 +133,7 @@ fun PlayerScreen( val playlists by viewModel.playlists.collectAsState() val importMessage by viewModel.importMessage.collectAsState() val snackbarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() var showImportDialog by remember { mutableStateOf(false) } var showAppearance by remember { mutableStateOf(false) } var showDecentralized by remember { mutableStateOf(false) } @@ -195,7 +207,7 @@ fun PlayerScreen( playlists = playlists, newPlaylistName = uiState.newPlaylistName, onNewPlaylistNameChange = viewModel::updateNewPlaylistName, - onPlaylistSelected = viewModel::addToPlaylist, + onPlaylistsConfirmed = viewModel::addToPlaylists, onCreatePlaylist = { viewModel.createPlaylistAndAdd(uiState.newPlaylistName) }, onDismiss = viewModel::dismissPlaylistPicker, ) @@ -265,6 +277,17 @@ fun PlayerScreen( indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), ), ) + NavigationBarItem( + selected = false, + onClick = onNavigateToDiscover, + icon = { Icon(Icons.Default.Explore, contentDescription = "Discover") }, + label = { Text("Discover") }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + ), + ) NavigationBarItem( selected = false, onClick = onNavigateToLibrary, @@ -306,6 +329,9 @@ fun PlayerScreen( uiState = uiState, viewModel = viewModel, modifier = Modifier.padding(padding), + onSaveDisabled = { message -> + scope.launch { snackbarHostState.showSnackbar(message) } + }, ) } else { EmptyStateView( @@ -327,7 +353,8 @@ private fun EmptyStateView( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - .padding(horizontal = 32.dp), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 32.dp, vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { @@ -473,21 +500,24 @@ private fun NowPlayingView( uiState: PlayerUiState, viewModel: PlayerViewModel, modifier: Modifier = Modifier, + onSaveDisabled: (String) -> Unit = {}, ) { val reduce = LocalReduceMotion.current Column( modifier = modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - .padding(horizontal = 24.dp), + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 24.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { // Station card uiState.currentStation?.let { station -> - StationCard( + StationHero( station = station, bufferState = uiState.bufferState, + isPlaying = uiState.isPlaying, ) } // Local library playback: a card for the song instead of a station. @@ -587,38 +617,58 @@ private fun NowPlayingView( } } - Spacer(modifier = Modifier.height(48.dp)) + Spacer(modifier = Modifier.height(32.dp)) + + // Genre-matched Wavlake picks for this station (opt-in shelf: + // tapping plays, nothing auto-inserts, failures show nothing). + uiState.currentStation?.let { station -> + if (!uiState.isLocal && station.genre.isNotBlank()) { + com.rada.ui.discover.WavlakeFeaturedRow( + stationId = station.id, + stationGenre = station.genre, + ) + Spacer(modifier = Modifier.height(8.dp)) + } + } // Save button + val saveDisabledHint = when { + uiState.currentStation == null -> null + uiState.bufferState == PlaybackManager.BufferState.ERROR -> + "Couldn't buffer this station — try again" + else -> "Waiting for the audio buffer…" + } SaveSongButton( canSave = uiState.currentStation != null && uiState.bufferState == PlaybackManager.BufferState.READY, saveState = uiState.saveState, onClick = { viewModel.saveSong() }, + onDisabledTap = { saveDisabledHint?.let(onSaveDisabled) }, ) Spacer(modifier = Modifier.height(32.dp)) - // Playback controls — conventional transport order: stop · prev · play · next + // Playback controls: prev · play · next. + // Every tap answers instantly (haptic tick + press dip) so feedback + // never waits on the stream catching up. + val haptics = LocalHapticFeedback.current + fun tap(action: () -> Unit) { + haptics.performHapticFeedback(HapticFeedbackType.LongPress) + action() + } + val transportEnabled = uiState.currentStation != null || uiState.isLocal + // Tapped play but audio hasn't resumed yet (reconnecting, initial + // buffering): show it instead of a play icon that promises sound. + val waitingForAudio = transportEnabled && !uiState.isPlaying && + (uiState.bufferState == PlaybackManager.BufferState.BUFFERING || + uiState.bufferState == PlaybackManager.BufferState.INITIALIZING) + val playInteraction = remember { MutableInteractionSource() } Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, ) { IconButton( - onClick = { viewModel.stop() }, - enabled = uiState.currentStation != null || uiState.isLocal, - ) { - Icon( - Icons.Default.Stop, - contentDescription = "Stop", - modifier = Modifier.size(28.dp), - ) - } - - Spacer(modifier = Modifier.width(12.dp)) - - IconButton( - onClick = { viewModel.previous() }, - enabled = uiState.currentStation != null || uiState.isLocal, + onClick = { tap(viewModel::previous) }, + enabled = transportEnabled, ) { Icon( Icons.Default.SkipPrevious, @@ -630,16 +680,24 @@ private fun NowPlayingView( Spacer(modifier = Modifier.width(16.dp)) FilledIconButton( - onClick = { viewModel.togglePlayPause() }, - enabled = uiState.currentStation != null || uiState.isLocal, - modifier = Modifier.size(72.dp), + onClick = { tap(viewModel::togglePlayPause) }, + enabled = transportEnabled, + modifier = Modifier + .size(72.dp) + .pressDip(playInteraction, reduce), shape = CircleShape, colors = IconButtonDefaults.filledIconButtonColors( containerColor = MaterialTheme.colorScheme.primary, ), + interactionSource = playInteraction, ) { AnimatedContent( - targetState = uiState.isPlaying, + // 0 = waiting, 1 = playing, 2 = paused/stopped. + targetState = when { + waitingForAudio -> 0 + uiState.isPlaying -> 1 + else -> 2 + }, transitionSpec = { if (reduce) fadeIn(tween(0)) togetherWith fadeOut(tween(0)) @@ -650,20 +708,31 @@ private fun NowPlayingView( fadeOut(tween(90)) }, label = "playPause", - ) { playing -> - Icon( - if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, - contentDescription = if (playing) "Pause" else "Play", - modifier = Modifier.size(36.dp), - ) + ) { visual -> + when (visual) { + 0 -> CircularProgressIndicator( + modifier = Modifier.size(32.dp), + strokeWidth = 3.dp, + ) + 1 -> Icon( + Icons.Default.Pause, + contentDescription = "Pause", + modifier = Modifier.size(36.dp), + ) + else -> Icon( + Icons.Default.PlayArrow, + contentDescription = "Play", + modifier = Modifier.size(36.dp), + ) + } } } Spacer(modifier = Modifier.width(16.dp)) IconButton( - onClick = { viewModel.next() }, - enabled = uiState.currentStation != null || uiState.isLocal, + onClick = { tap(viewModel::next) }, + enabled = transportEnabled, ) { Icon( Icons.Default.SkipNext, @@ -675,52 +744,11 @@ private fun NowPlayingView( Spacer(modifier = Modifier.height(8.dp)) - // Playback options: sleep timer · speed · repeat · shuffle - var showSleepDialog by remember { mutableStateOf(false) } - var showSpeedDialog by remember { mutableStateOf(false) } + // Playback options: repeat · shuffle (local queue only). Row( horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { - val sleepActive = uiState.sleepRemainingMs > 0 - IconButton(onClick = { showSleepDialog = true }) { - Icon( - if (sleepActive) Icons.Default.Timer else Icons.Default.TimerOff, - contentDescription = "Sleep timer", - tint = if (sleepActive) - MaterialTheme.colorScheme.primary - else - LocalTokens.current.textSecondary, - ) - } - if (sleepActive) { - val mins = (uiState.sleepRemainingMs / 60_000L).toInt() - val secs = ((uiState.sleepRemainingMs % 60_000L) / 1_000L).toInt() - Text( - "%d:%02d".format(mins, secs), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - - IconButton(onClick = { showSpeedDialog = true }) { - Icon( - Icons.Default.Speed, - contentDescription = "Playback speed", - tint = if (uiState.speed != 1f) - MaterialTheme.colorScheme.primary - else - LocalTokens.current.textSecondary, - ) - } - if (uiState.speed != 1f) { - Text( - "%.2f×".format(uiState.speed).replace(".00×", "×"), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - ) - } - IconButton( onClick = { viewModel.cycleRepeat() }, enabled = uiState.isLocal, @@ -756,138 +784,21 @@ private fun NowPlayingView( ) } } - - if (showSleepDialog) { - SleepTimerDialog( - activeMs = uiState.sleepRemainingMs, - onSelect = { minutes -> - if (minutes <= 0) viewModel.cancelSleepTimer() - else viewModel.startSleepTimer(minutes) - showSleepDialog = false - }, - onDismiss = { showSleepDialog = false }, - ) - } - - if (showSpeedDialog) { - SpeedDialog( - current = uiState.speed, - onSelect = { viewModel.setSpeed(it); showSpeedDialog = false }, - onDismiss = { showSpeedDialog = false }, - ) - } } } -@Composable -private fun SleepTimerDialog( - activeMs: Long, - onSelect: (Int) -> Unit, - onDismiss: () -> Unit, -) { - val tokens = LocalTokens.current - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Sleep timer") }, - text = { - Column { - Text( - "Pause playback after…", - style = MaterialTheme.typography.bodyMedium, - color = tokens.textSecondary, - ) - Spacer(modifier = Modifier.height(12.dp)) - val options = listOf(15, 30, 45, 60, 90) - options.forEach { minutes -> - TextButton( - onClick = { onSelect(minutes) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text( - "$minutes min", - modifier = Modifier.fillMaxWidth(), - color = tokens.text, - ) - } - } - if (activeMs > 0) { - TextButton( - onClick = { onSelect(0) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text("Cancel timer", color = tokens.accentText, - modifier = Modifier.fillMaxWidth()) - } - } - } - }, - confirmButton = {}, - containerColor = tokens.panel, - ) -} - -@Composable -private fun SpeedDialog( - current: Float, - onSelect: (Float) -> Unit, - onDismiss: () -> Unit, -) { - val tokens = LocalTokens.current - AlertDialog( - onDismissRequest = onDismiss, - title = { Text("Playback speed") }, - text = { - Column { - val options = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f) - options.forEach { speed -> - val isCurrent = kotlin.math.abs(speed - current) < 0.01f - TextButton( - onClick = { onSelect(speed) }, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - if (speed == 1f) "Normal (1×)" - else "%.2f×".format(speed).replace(".00×", "×"), - modifier = Modifier.weight(1f), - color = if (isCurrent) tokens.accentText else tokens.text, - fontWeight = if (isCurrent) FontWeight.SemiBold else FontWeight.Normal, - ) - if (isCurrent) { - Icon(Icons.Default.Check, contentDescription = null, - tint = tokens.accentText, - modifier = Modifier.size(18.dp)) - } - } - } - } - Spacer(modifier = Modifier.height(4.dp)) - Text( - "Applies to stations and saved songs.", - style = MaterialTheme.typography.bodySmall, - color = tokens.textMuted, - ) - } - }, - confirmButton = {}, - containerColor = tokens.panel, - ) -} - @Composable private fun PlaylistPickerDialog( playlists: List, newPlaylistName: String, onNewPlaylistNameChange: (String) -> Unit, - onPlaylistSelected: (Long) -> Unit, + onPlaylistsConfirmed: (List) -> 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 @@ -903,7 +814,7 @@ private fun PlaylistPickerDialog( tint = MaterialTheme.colorScheme.primary, ) }, - title = { Text("Add to Playlist") }, + title = { Text("Add to playlists") }, text = { Column { if (playlists.isNotEmpty()) { @@ -934,18 +845,25 @@ private fun PlaylistPickerDialog( modifier = Modifier.heightIn(max = 200.dp), ) { items(filteredPlaylists) { playlist -> + val checked = playlist.id in selectedIds Row( modifier = Modifier .fillMaxWidth() - .clickable { onPlaylistSelected(playlist.id) } - .padding(vertical = 12.dp, horizontal = 8.dp), + .clickable { + selectedIds = + if (checked) selectedIds - playlist.id + else selectedIds + playlist.id + } + .padding(vertical = 6.dp, horizontal = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - Icons.AutoMirrored.Filled.PlaylistAdd, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(20.dp), + Checkbox( + checked = checked, + onCheckedChange = { isChecked -> + selectedIds = + if (isChecked) selectedIds + playlist.id + else selectedIds - playlist.id + }, ) Spacer(modifier = Modifier.width(12.dp)) Text( @@ -1016,7 +934,14 @@ private fun PlaylistPickerDialog( } } }, - confirmButton = {}, + confirmButton = { + TextButton( + onClick = { onPlaylistsConfirmed(selectedIds.toList()) }, + enabled = selectedIds.isNotEmpty(), + ) { + Text(if (selectedIds.size == 1) "Add" else "Add to ${selectedIds.size}") + } + }, dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") @@ -1030,8 +955,10 @@ private fun SaveSongButton( canSave: Boolean, saveState: PlaybackManager.SaveState, onClick: () -> Unit, + onDisabledTap: () -> Unit = {}, ) { val reduce = LocalReduceMotion.current + val enabled = canSave && saveState != PlaybackManager.SaveState.SAVING val containerColor = when { saveState == PlaybackManager.SaveState.SAVING -> MaterialTheme.colorScheme.secondary saveState == PlaybackManager.SaveState.SUCCESS -> Color(0xFF10B981) @@ -1049,17 +976,18 @@ private fun SaveSongButton( } FilledIconButton( - onClick = onClick, - enabled = canSave && saveState != PlaybackManager.SaveState.SAVING, + // Always enabled at the framework level so a tap always lands: when + // not ready we explain why instead of eating the press silently. + // Visual enabled/disabled state is driven manually via the colors. + onClick = { if (canSave && saveState != PlaybackManager.SaveState.SAVING) onClick() else onDisabledTap() }, + enabled = true, modifier = Modifier .size(80.dp) .clip(RoundedCornerShape(20.dp)), shape = RoundedCornerShape(20.dp), colors = IconButtonDefaults.filledIconButtonColors( - containerColor = containerColor, - contentColor = contentColor, - disabledContainerColor = containerColor.copy(alpha = 0.5f), - disabledContentColor = contentColor.copy(alpha = 0.5f), + containerColor = if (enabled) containerColor else containerColor.copy(alpha = 0.5f), + contentColor = if (enabled) contentColor else contentColor.copy(alpha = 0.5f), ), ) { AnimatedContent( @@ -1108,73 +1036,90 @@ private fun SaveSongButton( } @Composable -private fun StationCard( +private fun StationHero( station: Station, bufferState: PlaybackManager.BufferState, + isPlaying: Boolean, ) { + // Big-artwork hero: the accent gradient is the "album art" for a live + // station, with the buffer state as a status chip and the equalizer as + // the playing signature. All colors come from the active accent tokens. + val tokens = LocalTokens.current androidx.compose.material3.Card( modifier = Modifier.fillMaxWidth(), colors = androidx.compose.material3.CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, + containerColor = Color.Transparent, ), - shape = RoundedCornerShape(16.dp), + shape = RoundedCornerShape(SatsAmpGlass.rCard), ) { - Row( + Box( modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically, + .aspectRatio(16f / 10f) + .background(SatsAmpGradients.accentBrush()), ) { - Box( + Icon( + Icons.Default.Radio, + contentDescription = null, + tint = tokens.accentOnFill.copy(alpha = 0.30f), modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), - contentAlignment = Alignment.Center, - ) { - Icon( - Icons.Default.Radio, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp), - ) + .size(128.dp) + .align(Alignment.Center), + ) + val statusLabel = when (bufferState) { + PlaybackManager.BufferState.INITIALIZING, + PlaybackManager.BufferState.BUFFERING -> "Buffering…" + PlaybackManager.BufferState.READY -> "Ready" + PlaybackManager.BufferState.ERROR -> "Error" + PlaybackManager.BufferState.IDLE -> null } - Spacer(modifier = Modifier.width(16.dp)) - Column(modifier = Modifier.weight(1f)) { + statusLabel?.let { label -> + androidx.compose.material3.Surface( + modifier = Modifier + .align(Alignment.TopStart) + .padding(12.dp), + color = tokens.textSafe, + shape = RoundedCornerShape(SatsAmpGlass.rInput), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = tokens.text, + modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), + ) + } + } + Column( + modifier = Modifier + .align(Alignment.BottomStart) + .padding(16.dp), + ) { + if (isPlaying) { + EqualizerBars( + active = true, + color = tokens.accentOnFill, + modifier = Modifier.padding(bottom = 6.dp), + ) + } Text( text = station.name, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = tokens.accentOnFill, + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) if (station.genre.isNotEmpty()) { Text( text = station.genre, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + color = tokens.accentOnFill.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) } } - BufferIndicator(bufferState = bufferState) } } } - -@Composable -private fun BufferIndicator(bufferState: PlaybackManager.BufferState) { - val (text, color) = when (bufferState) { - PlaybackManager.BufferState.IDLE -> "" to MaterialTheme.colorScheme.onSurfaceVariant - PlaybackManager.BufferState.INITIALIZING -> "Init" to PremiumCyan - PlaybackManager.BufferState.BUFFERING -> "Buffering" to PremiumCyan - PlaybackManager.BufferState.READY -> "Ready" to MaterialTheme.colorScheme.primary - PlaybackManager.BufferState.ERROR -> "Error" to MaterialTheme.colorScheme.error - } - - if (text.isNotEmpty()) { - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - color = color, - modifier = Modifier.padding(start = 8.dp), - ) - } -} 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 81ab0e9..b49ecd2 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt @@ -107,10 +107,14 @@ class PlayerViewModel @Inject constructor( ) } if (state == PlaybackManager.SaveState.SUCCESS && result?.songId != null) { + val meta = playbackManager.lastStreamMetaFlow.value + val title = meta?.displayTitle?.takeIf { it.isNotEmpty() } + ?: result.fileName?.substringBeforeLast(".mp3").orEmpty() _uiState.update { it.copy( showPlaylistPicker = true, pickedSongId = result.songId, + pickedSongTitle = title, ) } } @@ -131,7 +135,7 @@ class PlayerViewModel @Inject constructor( } } - init { + init { playbackManager.listener = playbackListener _uiState.update { it.copy( @@ -143,6 +147,16 @@ class PlayerViewModel @Inject constructor( localTitle = playbackManager.localMediaFlow.value?.first.orEmpty(), localArtist = playbackManager.localMediaFlow.value?.second.orEmpty(), isPlaying = playbackManager.isPlaying, + // Seed live recorder state: no transition event fires for a + // state that is already READY, so a fresh screen must read it. + bufferState = playbackManager.bufferState, + saveState = playbackManager.saveState, + // A save that finished while this screen was away (event went + // to a detached listener) would otherwise lose its playlist + // picker: surface it now instead. + showPlaylistPicker = pendingPicker() != null, + pickedSongId = pendingPicker()?.songId, + pickedSongTitle = pendingPicker()?.title.orEmpty(), ) } restoreCachedStreamMeta() @@ -284,25 +298,42 @@ class PlayerViewModel @Inject constructor( } fun dismissPlaylistPicker() { + val pendingId = _uiState.value.pickedSongId + val title = _uiState.value.pickedSongTitle _uiState.update { it.copy( showPlaylistPicker = false, pickedSongId = null, + pickedSongTitle = "", newPlaylistName = "", ) } + // Dismissed without adding: the song IS saved, so say so — otherwise + // a successful save leaves no trace at all. + if (pendingId != null) { + _uiState.update { + it.copy( + playlistPickerMessage = if (title.isNotBlank()) "Saved “$title” to Library" + else "Saved to Library", + ) + } + } } - fun addToPlaylist(playlistId: Long) { + /** Adds the picked song to one or more playlists, then closes the picker. */ + fun addToPlaylists(playlistIds: List) { val songId = _uiState.value.pickedSongId ?: return + if (playlistIds.isEmpty()) return viewModelScope.launch { - playlistRepository.addSongToPlaylist(playlistId, songId) + playlistIds.forEach { playlistRepository.addSongToPlaylist(it, songId) } _uiState.update { it.copy( showPlaylistPicker = false, pickedSongId = null, + pickedSongTitle = "", newPlaylistName = "", - playlistPickerMessage = "Added to playlist", + playlistPickerMessage = if (playlistIds.size == 1) "Added to playlist" + else "Added to ${playlistIds.size} playlists", ) } } @@ -317,6 +348,7 @@ class PlayerViewModel @Inject constructor( it.copy( showPlaylistPicker = false, pickedSongId = null, + pickedSongTitle = "", newPlaylistName = "", playlistPickerMessage = "Created \"$name\" and added song", ) @@ -349,14 +381,41 @@ class PlayerViewModel @Inject constructor( isPlaying = playbackManager.isPlaying, artist = if (isLocal) local?.second.orEmpty() else playbackManager.currentStationName, title = if (isLocal) local?.first.orEmpty() else it.title, + // Same seeding as init: returning to this screen must reflect + // the recorder's current state, not IDLE — including a fresh + // save whose picker event went to a detached listener. + bufferState = playbackManager.bufferState, + saveState = playbackManager.saveState, + showPlaylistPicker = pendingPicker() != null || it.showPlaylistPicker, + pickedSongId = pendingPicker()?.songId ?: it.pickedSongId, + pickedSongTitle = pendingPicker()?.title ?: it.pickedSongTitle, ) } if (!isLocal) restoreCachedStreamMeta() } + /** A save awaiting its playlist picker, if the manager holds one. */ + private fun pendingPicker(): PendingPicker? { + val result = playbackManager.lastSaveResult ?: return null + if (playbackManager.saveState != PlaybackManager.SaveState.SUCCESS) return null + val id = result.songId ?: return null + val title = playbackManager.lastStreamMetaFlow.value?.displayTitle?.takeIf { it.isNotEmpty() } + ?: result.fileName?.substringBeforeLast(".mp3").orEmpty() + return PendingPicker(id, title) + } + + private data class PendingPicker(val songId: Long, val title: String) + override fun onCleared() { super.onCleared() - playbackManager.listener = null + // Only detach our own listener: during mini-player navigation the new + // screen attaches first and the old entry is cleared after — a blind + // null here killed the NEW screen's events, leaving its buttons + // (save spinner/check/dialog, pause icon) permanently dead while the + // manager kept working headlessly. + if (playbackManager.listener === playbackListener) { + playbackManager.listener = null + } } } @@ -382,6 +441,7 @@ data class PlayerUiState( val shuffleEnabled: Boolean = false, val showPlaylistPicker: Boolean = false, val pickedSongId: Long? = null, + val pickedSongTitle: String = "", val newPlaylistName: String = "", val playlistPickerMessage: String? = null, ) 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 abf34d7..9382387 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,36 +15,49 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.Radio +import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -61,24 +74,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.rada.domain.model.Station import com.rada.ui.components.AppHomeButton import com.rada.ui.components.EqualizerBars +import com.rada.ui.theme.LocalReduceMotion import com.rada.ui.theme.LocalTokens - -private val genreColors = mapOf( - "Lofi" to 0xFF7C3AED, - "Synthwave" to 0xFFEC4899, - "EDM" to 0xFF06B6D4, - "Phonk" to 0xFFEF4444, - "Classical" to 0xFFF59E0B, - "Strings" to 0xFF10B981, - "Ambient" to 0xFF3B82F6, - "Electronic" to 0xFF06B6D4, - "Jazz" to 0xFFF59E0B, - "Eclectic" to 0xFFEC4899, - "Oldies" to 0xFFF59E0B, - "Rock" to 0xFFEF4444, - "Hip-Hop" to 0xFFEC4899, - "Custom" to 0xFF7C3AED, -) +import com.rada.ui.theme.SatsAmpGlass +import com.rada.ui.theme.SatsAmpGradients @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -93,8 +92,46 @@ fun StationBrowserScreen( val favorites by viewModel.favorites.collectAsState() val directoryState by viewModel.directoryState.collectAsState() val playingUrl by viewModel.playingUrl.collectAsState() + val hiddenIds by viewModel.hiddenIds.collectAsState() + val notice by viewModel.notice.collectAsState() + val wavlakeVersion by viewModel.wavlakeVersion.collectAsState() + val wavlakeArtists by viewModel.wavlakeArtists.collectAsState() + // Entry-scoped Wavlake VM (powers the artist dialog; independent of Discover). + val wavlakeVm: com.rada.ui.discover.WavlakeViewModel = hiltViewModel() + var wavlakeArtistDialog by remember { + mutableStateOf(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( @@ -146,6 +183,7 @@ fun StationBrowserScreen( ) }, containerColor = Color.Transparent, + snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> Column( modifier = Modifier @@ -174,17 +212,93 @@ fun StationBrowserScreen( shape = RoundedCornerShape(12.dp), ) + // ── Browse chips: Apple-style genre filter above the list ── + val genres = stationsByGenre.keys.toList() + var activeGenre by remember { mutableStateOf(null) } + val effectiveGenre = activeGenre?.takeIf { it in genres } + val visibleGenres = if (searchQuery.isBlank() && effectiveGenre != null) { + stationsByGenre.filterKeys { it == effectiveGenre } + } else { + stationsByGenre + } + if (searchQuery.isBlank() && genres.size > 1) { + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + vertical = 4.dp, + ), + ) { + item(key = "chip-all") { + FilterChip( + selected = effectiveGenre == null, + onClick = { activeGenre = null }, + label = { Text("All") }, + colors = FilterChipDefaults.filterChipColors( + containerColor = LocalTokens.current.panel, + labelColor = LocalTokens.current.textSecondary, + selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f), + selectedLabelColor = LocalTokens.current.accentText, + ), + border = FilterChipDefaults.filterChipBorder( + borderColor = LocalTokens.current.border, + selectedBorderColor = LocalTokens.current.accentFill, + enabled = true, + selected = effectiveGenre == null, + ), + ) + } + items(genres, key = { "chip-$it" }) { genre -> + FilterChip( + selected = effectiveGenre == genre, + onClick = { + activeGenre = if (effectiveGenre == genre) null else genre + }, + label = { Text(genre) }, + colors = FilterChipDefaults.filterChipColors( + containerColor = LocalTokens.current.panel, + labelColor = LocalTokens.current.textSecondary, + selectedContainerColor = LocalTokens.current.accentFill.copy(alpha = 0.22f), + selectedLabelColor = LocalTokens.current.accentText, + ), + border = FilterChipDefaults.filterChipBorder( + borderColor = LocalTokens.current.border, + selectedBorderColor = LocalTokens.current.accentFill, + enabled = true, + selected = effectiveGenre == genre, + ), + ) + } + } + } + + // ── Now-playing hero: jump back into the Player ── + val nowPlayingStation = remember(stationsByGenre, favorites, playingUrl) { + playingUrl?.let { url -> + (favorites + stationsByGenre.values.flatten()).firstOrNull { it.url == url } + } + } + LazyColumn( + state = listState, modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { + if (searchQuery.isBlank() && nowPlayingStation != null) { + item(key = "now-playing") { + NowPlayingHero( + station = nowPlayingStation, + onOpenPlayer = onHome, + ) + } + } // ── Favorites row: pinned above everything ── if (favorites.isNotEmpty()) { item { SectionHeader("Starred", Icons.Default.Star) } - items(favorites, key = { "fav-${it.id}" }) { station -> + items(favorites, key = { "fav-${it.id}" }, contentType = { "station" }) { station -> StationCard( station = station, - isCustom = viewModel.isCustomStation(station.id), isFavorite = true, isPlaying = playingUrl == station.url, onClick = { @@ -193,20 +307,21 @@ fun StationBrowserScreen( }, onToggleFavorite = { viewModel.toggleFavorite(station) }, onRename = { renameTarget = station }, - onDelete = { viewModel.removeCustomStation(station.id) }, + onDelete = { viewModel.removeStation(station.id) }, + wavlakeEnabled = viewModel.isWavlakeFeaturing(station.id).also { wavlakeVersion }, + onToggleWavlake = { viewModel.toggleWavlakeFeaturing(station.id) }, ) } item { Spacer(modifier = Modifier.height(8.dp)) } } - stationsByGenre.forEach { (genre, stations) -> - item { + visibleGenres.forEach { (genre, stations) -> + item(key = "genre-$genre") { GenreHeader(genre = genre, count = stations.size) } - items(stations, key = { it.id }) { station -> + items(stations, key = { it.id }, contentType = { "station" }) { station -> StationCard( station = station, - isCustom = viewModel.isCustomStation(station.id), isFavorite = viewModel.isFavorite(station.id), isPlaying = playingUrl == station.url, onClick = { @@ -216,8 +331,10 @@ fun StationBrowserScreen( onToggleFavorite = { viewModel.toggleFavorite(station) }, onRename = { renameTarget = station }, onDelete = { - viewModel.removeCustomStation(station.id) + viewModel.removeStation(station.id) }, + wavlakeEnabled = viewModel.isWavlakeFeaturing(station.id).also { wavlakeVersion }, + onToggleWavlake = { viewModel.toggleWavlakeFeaturing(station.id) }, ) } item { @@ -225,6 +342,39 @@ fun StationBrowserScreen( } } + // ── Restore point for deleted built-ins ── + if (searchQuery.isBlank() && hiddenIds.isNotEmpty()) { + item(key = "restore-hidden") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.Center, + ) { + TextButton(onClick = { viewModel.restoreHiddenStations() }) { + Text( + if (hiddenIds.size == 1) "Restore 1 hidden station" + else "Restore ${hiddenIds.size} hidden stations", + ) + } + } + } + } + + // ── Empty filter state: no built-in matches ── + if (searchQuery.isNotBlank() && stationsByGenre.isEmpty() && + directoryState is DirectoryState.Idle + ) { + item(key = "no-match") { + Text( + text = "No stations match “$searchQuery”.", + style = MaterialTheme.typography.bodyMedium, + color = LocalTokens.current.textSecondary, + modifier = Modifier.padding(horizontal = 20.dp, vertical = 12.dp), + ) + } + } + // ── Directory results (radio-browser.info) ── when (val ds = directoryState) { DirectoryState.Idle -> Unit @@ -259,10 +409,9 @@ fun StationBrowserScreen( is DirectoryState.Results -> { if (ds.stations.isNotEmpty()) { item { DirectorySectionHeader("From the directory") } - items(ds.stations, key = { "rb-${it.id}" }) { station -> + items(ds.stations, key = { "rb-${it.id}" }, contentType = { "station" }) { station -> StationCard( station = station, - isCustom = false, isFavorite = viewModel.isFavorite(station.id), isPlaying = playingUrl == station.url, onClick = { @@ -289,9 +438,110 @@ fun StationBrowserScreen( } } } + } + + // ── Artists on Wavlake (same query, independent backend) ── + val waArtists = wavlakeArtists + if (waArtists is WavlakeArtistSearch.Results) { + item(key = "wl-header") { DirectorySectionHeader("Artists on Wavlake") } + items( + waArtists.artists, + key = { "wl-${it.id}" }, + contentType = { "artist" }, + ) { artist -> + com.rada.ui.discover.ArtistRow( + name = artist.name, + genre = artist.genre, + imageUrl = artist.imageUrl, + onClick = { wavlakeArtistDialog = artist }, + ) + } + } else if (waArtists is WavlakeArtistSearch.Loading) { + item(key = "wl-loading") { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = LocalTokens.current.accentText, + ) + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = "Searching Wavlake artists…", + style = MaterialTheme.typography.bodySmall, + color = LocalTokens.current.textSecondary, + ) + } + } + } } } } + + wavlakeArtistDialog?.let { artist -> + WavlakeArtistDialog( + artist = artist, + viewModel = wavlakeVm, + onDismiss = { + wavlakeVm.closeArtist() + wavlakeArtistDialog = null + }, + ) + } +} +@Composable +private fun WavlakeArtistDialog( + artist: com.rada.domain.model.WavlakeArtist, + viewModel: com.rada.ui.discover.WavlakeViewModel, + onDismiss: () -> Unit, +) { + androidx.compose.runtime.LaunchedEffect(artist.id) { + viewModel.openArtist(artist.id, artist.name) + } + androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { + Card( + colors = CardDefaults.cardColors(containerColor = LocalTokens.current.panel), + shape = RoundedCornerShape(SatsAmpGlass.rCard), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.85f), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 20.dp, end = 8.dp, top = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = artist.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = LocalTokens.current.text, + modifier = Modifier.weight(1f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + IconButton(onClick = onDismiss, modifier = Modifier.size(48.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Close", + tint = LocalTokens.current.textSecondary, + ) + } + } + com.rada.ui.discover.ArtistDetailView( + artistId = artist.id, + modifier = Modifier.weight(1f), + viewModel = viewModel, + ) + } + } } } @@ -431,11 +681,75 @@ private fun RenameStationDialog( } @Composable -private fun GenreHeader(genre: String, count: Int) { - val colorInt = genreColors.entries.find { (key, _) -> - genre.contains(key, ignoreCase = true) - }?.value ?: 0xFF7C3AED +private fun NowPlayingHero( + station: Station, + onOpenPlayer: () -> Unit, +) { + val tokens = LocalTokens.current + Card( + onClick = onOpenPlayer, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 4.dp), + colors = CardDefaults.cardColors(containerColor = Color.Transparent), + shape = RoundedCornerShape(SatsAmpGlass.rCard), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(SatsAmpGradients.accentBrush()) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(52.dp) + .clip(CircleShape) + .background(tokens.accentOnFill.copy(alpha = 0.22f)), + contentAlignment = Alignment.Center, + ) { + EqualizerBars(active = true, color = tokens.accentOnFill) + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "Now Playing", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = tokens.accentOnFill.copy(alpha = 0.85f), + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = station.name, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = tokens.accentOnFill, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (station.genre.isNotEmpty()) { + Text( + text = station.genre, + style = MaterialTheme.typography.bodySmall, + color = tokens.accentOnFill.copy(alpha = 0.85f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Icon( + Icons.AutoMirrored.Filled.ArrowForward, + contentDescription = "Open player", + tint = tokens.accentOnFill.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp), + ) + } + } +} +@Composable +private fun GenreHeader(genre: String, count: Int) { + val tokens = LocalTokens.current Row( modifier = Modifier .fillMaxWidth() @@ -446,21 +760,21 @@ private fun GenreHeader(genre: String, count: Int) { modifier = Modifier .size(8.dp) .clip(CircleShape) - .background(androidx.compose.ui.graphics.Color(colorInt)), + .background(tokens.accentFill), ) Spacer(modifier = Modifier.width(10.dp)) Text( text = genre.uppercase(), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, - color = androidx.compose.ui.graphics.Color(colorInt), + color = tokens.accentText, letterSpacing = androidx.compose.ui.unit.TextUnit.Unspecified, ) Spacer(modifier = Modifier.width(8.dp)) Text( text = "$count", style = MaterialTheme.typography.labelMedium, - color = LocalTokens.current.textSecondary, + color = tokens.textSecondary, ) } } @@ -468,7 +782,6 @@ private fun GenreHeader(genre: String, count: Int) { @Composable private fun StationCard( station: Station, - isCustom: Boolean, isFavorite: Boolean, isPlaying: Boolean, onClick: () -> Unit, @@ -476,42 +789,47 @@ private fun StationCard( onRename: () -> Unit, onDelete: () -> Unit, onAdd: (() -> Unit)? = null, + wavlakeEnabled: Boolean = false, + onToggleWavlake: (() -> Unit)? = null, ) { val tokens = LocalTokens.current Card( + onClick = onClick, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp) - .clickable(onClick = onClick), + .padding(horizontal = 12.dp), colors = CardDefaults.cardColors( containerColor = if (isPlaying) tokens.accentSoft else tokens.glassFillStrong, ), border = if (isPlaying) { BorderStroke(1.dp, tokens.accentFill.copy(alpha = 0.6f)) } else null, - shape = RoundedCornerShape(12.dp), + shape = RoundedCornerShape(SatsAmpGlass.rInput), ) { Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 10.dp), + .padding(horizontal = 16.dp, vertical = 6.dp), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier .size(40.dp) .clip(CircleShape) - .background(tokens.accentFill.copy(alpha = 0.15f)), + .background( + if (isPlaying) tokens.accentFill + else tokens.accentFill.copy(alpha = 0.15f), + ), contentAlignment = Alignment.Center, ) { if (isPlaying) { - EqualizerBars(active = true, color = tokens.accentText) + EqualizerBars(active = true, color = tokens.accentOnFill) } else { Icon( - Icons.Default.Radio, + Icons.Default.PlayArrow, contentDescription = null, tint = tokens.accentText, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(22.dp), ) } } @@ -536,42 +854,85 @@ private fun StationCard( ) } } - IconButton(onClick = onToggleFavorite, modifier = Modifier.size(40.dp)) { + IconButton( + onClick = onToggleFavorite, + modifier = Modifier.size(48.dp), + ) { Icon( imageVector = if (isFavorite) Icons.Default.Star else Icons.Default.StarBorder, - contentDescription = if (isFavorite) "Unstar" else "Star", + contentDescription = if (isFavorite) "Unstar ${station.name}" else "Star ${station.name}", tint = if (isFavorite) tokens.accentFill else tokens.textSecondary, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(22.dp), ) } // Directory stations: offer saving into the local list. if (onAdd != null) { - IconButton(onClick = onAdd, modifier = Modifier.size(40.dp)) { + IconButton( + onClick = onAdd, + modifier = Modifier.size(48.dp), + ) { Icon( Icons.Default.Add, - contentDescription = "Save to stations", + contentDescription = "Save ${station.name} to stations", tint = tokens.textSecondary, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(22.dp), ) } } else { - IconButton(onClick = onRename, modifier = Modifier.size(40.dp)) { - Icon( - Icons.Default.Edit, - contentDescription = "Rename", - tint = tokens.textSecondary, - modifier = Modifier.size(20.dp), - ) - } - if (isCustom) { - IconButton(onClick = onDelete, modifier = Modifier.size(40.dp)) { + var showMenu by remember { mutableStateOf(false) } + Box { + IconButton( + onClick = { showMenu = true }, + modifier = Modifier.size(48.dp), + ) { Icon( - Icons.Default.Delete, - contentDescription = "Remove", + Icons.Default.MoreVert, + contentDescription = "More actions for ${station.name}", tint = tokens.textSecondary, - modifier = Modifier.size(20.dp), + modifier = Modifier.size(22.dp), ) } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Rename") }, + leadingIcon = { + Icon(Icons.Default.Edit, contentDescription = null) + }, + onClick = { + showMenu = false + onRename() + }, + ) + DropdownMenuItem( + text = { Text("Delete") }, + leadingIcon = { + Icon(Icons.Default.Delete, contentDescription = null) + }, + onClick = { + showMenu = false + onDelete() + }, + ) + if (onToggleWavlake != null) { + DropdownMenuItem( + text = { Text("Wavlake picks") }, + leadingIcon = { + Icon( + if (wavlakeEnabled) Icons.Default.Check + else Icons.Default.Add, + contentDescription = null, + ) + }, + onClick = { + showMenu = false + onToggleWavlake() + }, + ) + } + } } } } 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 241af48..775d663 100644 --- a/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt +++ b/app/src/main/java/com/rada/ui/stations/StationBrowserViewModel.kt @@ -4,6 +4,9 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rada.data.repository.RadioBrowserRepository import com.rada.data.repository.StationRepository +import com.rada.data.wavlake.WavlakeRepository +import com.rada.data.wavlake.WavlakeResult +import com.rada.data.wavlake.WavlakeSearchHit import com.rada.domain.model.Station import com.rada.player.PlaybackManager import dagger.hilt.android.lifecycle.HiltViewModel @@ -20,6 +23,13 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn import javax.inject.Inject +/** Wavlake artist search state (Stations search also finds artists). */ +sealed interface WavlakeArtistSearch { + data object Idle : WavlakeArtistSearch + data object Loading : WavlakeArtistSearch + data class Results(val artists: List) : WavlakeArtistSearch + data class Error(val message: String) : WavlakeArtistSearch +} /** Directory (radio-browser.info) search state. */ sealed interface DirectoryState { data object Idle : DirectoryState @@ -34,6 +44,7 @@ class StationBrowserViewModel @Inject constructor( private val stationRepository: StationRepository, private val radioBrowserRepository: RadioBrowserRepository, private val playbackManager: PlaybackManager, + private val wavlakeRepository: WavlakeRepository, ) : ViewModel() { private val _allStations = MutableStateFlow(stationRepository.getAll()) @@ -43,36 +54,46 @@ class StationBrowserViewModel @Inject constructor( val favorites: StateFlow> = 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 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()) + 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()) // ── 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 @@ -93,6 +114,37 @@ class StationBrowserViewModel @Inject constructor( } } .launchIn(viewModelScope) + + // The same query also searches Wavlake artists on its own debounce, + // so one slow backend never blocks the other. Misses stay silent + // (Idle) — stations remain the primary results. + _searchQuery + .debounce(550) + .distinctUntilChanged() + .onEach { query -> + val q = query.trim() + if (q.length < 2) { + _wavlakeArtists.value = WavlakeArtistSearch.Idle + return@onEach + } + _wavlakeArtists.value = WavlakeArtistSearch.Loading + _wavlakeArtists.value = try { + when (val r = wavlakeRepository.search(q)) { + is WavlakeResult.Ok -> { + val artists = r.value + .filterIsInstance() + .map { it.artist } + .distinctBy { it.id } + if (artists.isEmpty()) WavlakeArtistSearch.Idle + else WavlakeArtistSearch.Results(artists) + } + is WavlakeResult.Failed -> WavlakeArtistSearch.Idle + } + } catch (_: Exception) { + WavlakeArtistSearch.Idle + } + } + .launchIn(viewModelScope) } fun search(query: String) { @@ -132,9 +184,32 @@ class StationBrowserViewModel @Inject constructor( _allStations.value = stationRepository.getAll() } - fun removeCustomStation(id: String) { + /** Deletes any station (built-ins are hidden, customs removed) with Undo. */ + fun removeStation(id: String) { + val station = stationRepository.getById(id) ?: return + lastDeleted = station stationRepository.removeStation(id) _allStations.value = stationRepository.getAll() + _notice.value = "Deleted “${station.name}”" + } + + fun undoDelete() { + val station = lastDeleted ?: return + lastDeleted = null + stationRepository.unhideStation(station.id) + if (stationRepository.getById(station.id) == null) { + stationRepository.addStation(station) + } + _allStations.value = stationRepository.getAll() + } + + fun consumeNotice() { + _notice.value = null + } + + fun restoreHiddenStations() { + stationRepository.restoreHiddenStations() + _allStations.value = stationRepository.getAll() } fun renameStation(id: String, newName: String) { @@ -155,4 +230,15 @@ class StationBrowserViewModel @Inject constructor( fun isCustomStation(id: String): Boolean { return stationRepository.isCustomStation(id) } + + /** Bumps to refresh per-station Wavlake featuring toggles in the UI. */ + private val _wavlakeVersion = MutableStateFlow(0) + val wavlakeVersion: StateFlow = _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 bf27ca1..728a2f7 100644 --- a/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt +++ b/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt @@ -1,7 +1,6 @@ package com.rada.ui.streams import androidx.compose.foundation.background -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -212,7 +211,7 @@ fun StreamsScreen( item(key = "header-$group") { GroupHeader(group = group, count = entries.size) } - items(entries, key = { it.id }) { entry -> + items(entries, key = { it.id }, contentType = { "stream" }) { entry -> StreamCard( entry = entry, isPlaying = currentStreamUrl == entry.url, @@ -394,10 +393,10 @@ private fun StreamCard( onDelete: () -> Unit, ) { Card( + onClick = onClick, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp) - .clickable(onClick = onClick), + .padding(horizontal = 12.dp), colors = CardDefaults.cardColors( containerColor = if (isPlaying) LocalTokens.current.accentFill.copy(alpha = 0.12f) else LocalTokens.current.glassFillStrong, ), @@ -450,7 +449,7 @@ private fun StreamCard( IconButton(onClick = onRename) { Icon( Icons.Default.Edit, - contentDescription = "Rename", + contentDescription = "Rename ${entry.name}", tint = LocalTokens.current.textSecondary, modifier = Modifier.size(20.dp), ) @@ -458,7 +457,7 @@ private fun StreamCard( IconButton(onClick = onDelete) { Icon( Icons.Default.Delete, - contentDescription = "Remove", + contentDescription = "Remove ${entry.name}", tint = LocalTokens.current.textSecondary, modifier = Modifier.size(20.dp), ) 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 fc1c1d6..c987284 100644 --- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt +++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt @@ -147,7 +147,7 @@ fun AppearanceDialog( Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { if (onOpenDecentralized != null) { TextButton(onClick = onOpenDecentralized) { - Text("⚡ Decentralized", color = tokens.accentText) + Text("⚡ Nostr", color = tokens.accentText) } } TextButton(onClick = onDismiss) { diff --git a/app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt b/app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt new file mode 100644 index 0000000..b36d7a4 --- /dev/null +++ b/app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt @@ -0,0 +1,164 @@ +package com.rada.data.wavlake + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +class WavlakeLogicTest { + + private fun track( + id: String, + artistId: String = "a1", + audio: String? = "https://cdn.example/t.mp3", + ) = com.rada.domain.model.WavlakeTrack( + id = id, title = "T $id", artistId = artistId, + artistName = "A $artistId", audioUrl = audio, + ) + + // ── Normalization ──────────────────────────────────────────────────── + + @Test + fun `track without id or title is dropped`() { + assertNull(normalizeWavlakeTrack(TrackDto(id = "", title = "x"))) + assertNull(normalizeWavlakeTrack(TrackDto(id = "1", title = " "))) + assertNull(normalizeWavlakeTrack(TrackDto())) + } + + @Test + fun `track tolerates missing artwork duration genre`() { + val t = normalizeWavlakeTrack(TrackDto(id = "1", title = "Song"))!! + assertEquals("Unknown Artist", t.artistName) + assertNull(t.artworkUrl) + assertNull(t.durationMs) + assertNull(t.genre) + assertNull(t.audioUrl) + assertEquals("wavlake", t.source) + } + + @Test + fun `track maps all swagger fields`() { + val t = normalizeWavlakeTrack( + TrackDto( + id = "t1", title = "S", artist = "A", artistId = "a", + albumArtUrl = "https://img/a.png", mediaUrl = "https://cdn/t.mp3", + duration = 180, genre = "Dance/EDM", url = "https://wavlake.com/t", + artistNpub = "npub1xyz", + ), + )!! + assertEquals(180_000L, t.durationMs) + assertEquals(listOf("Dance/EDM"), t.genres) + assertEquals("https://cdn/t.mp3", t.audioUrl) + assertEquals("npub1xyz", t.artistNpubHex) + } + + @Test + fun `artist without id is dropped, blank name defaulted`() { + assertNull(normalizeWavlakeArtist(ArtistDto())) + assertEquals("Unknown Artist", normalizeWavlakeArtist(ArtistDto(id = "a"))!!.name) + } + + // ── Genre matching ─────────────────────────────────────────────────── + + @Test + fun `station genre split convention`() { + val (primary, secondary) = WavlakeGenreMatcher.splitStationGenres("Lofi / Hip-Hop") + assertEquals("Lofi", primary) + assertEquals(listOf("Hip-Hop"), secondary) + } + + @Test + fun `lofi maps to related exact names`() { + val genres = WavlakeGenreMatcher.compatibleGenres("Lofi", emptyList()) + assertTrue(genres.contains("Downtempo")) + assertTrue(genres.none { it == "Lofi" }) // never invent names + } + + @Test + fun `edm keeps exact Dance-EDM plus subs`() { + val genres = WavlakeGenreMatcher.compatibleGenres("EDM", emptyList()) + assertTrue(genres.contains("Dance/EDM")) + assertTrue(genres.contains("House")) + } + + @Test + fun `talk matches nothing`() { + assertTrue(WavlakeGenreMatcher.compatibleGenres("Talk", emptyList()).isEmpty()) + } + + @Test + fun `exact mode only`() { + val genres = WavlakeGenreMatcher.compatibleGenres("Pop", emptyList(), MatchingMode.EXACT) + assertEquals(listOf("Pop"), genres) + assertTrue( + WavlakeGenreMatcher.compatibleGenres("Lofi", emptyList(), MatchingMode.EXACT).isEmpty(), + ) + } + + // ── Selector ───────────────────────────────────────────────────────── + + private val settings = defaultWavlakeStationSettings.copy( + featureProbability = 1.0, + maxFeaturedTracksPerHour = 3, + maxFeaturedTracksPerQueue = 1, + ) + + @Test + fun `disabled or capped selects nothing`() { + val pool = listOf(track("t1")) + assertTrue( + FeatureSelector.select( + FeatureSelection(pool, settings.copy(enabled = false), random = Random(1)), + ).isEmpty(), + ) + assertTrue( + FeatureSelector.select( + FeatureSelection(pool, settings, featuredThisHour = 3, random = Random(1)), + ).isEmpty(), + ) + assertTrue( + FeatureSelector.select( + FeatureSelection(pool, settings.copy(featureProbability = 0.0), random = Random(1)), + ).isEmpty(), + ) + } + + @Test + fun `dedupes blocked and unplayable`() { + val pool = listOf( + track("t1", artistId = "blocked"), + track("t2", audio = " "), + track("t3"), + track("t3"), + ) + val picked = FeatureSelector.select( + FeatureSelection( + pool, settings, + excludedTrackIds = setOf("t3"), + blockedArtistIds = setOf("blocked"), + random = Random(1), + ), + ) + assertTrue(picked.isEmpty()) + } + + @Test + fun `fresh artists win over recent, cap respected`() { + val pool = listOf( + track("old", artistId = "a1"), + track("new1", artistId = "a2"), + track("new2", artistId = "a3"), + ) + val picked = FeatureSelector.select( + FeatureSelection( + pool, + settings.copy(maxFeaturedTracksPerQueue = 2), + recentArtistIds = setOf("a1"), + random = Random(7), + ), + ) + assertEquals(2, picked.size) + assertTrue(picked.none { it.artistId == "a1" }) + } +} diff --git a/app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt b/app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt new file mode 100644 index 0000000..ddecef8 --- /dev/null +++ b/app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt @@ -0,0 +1,67 @@ +package com.rada.data.wavlake + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.UUID + +/** + * Official track-zap protocol conformance (patterns decoded from + * wavlake/mobile, MIT — values below are independently computed). + */ +class WavlakeZapTest { + + @Test + fun `uuid v5 matches RFC vectors`() { + // Well-known: v5(DNS, www.example.com). + assertEquals( + "2ed6657d-e927-568b-95e1-2665a8aea6a2", + WavlakeZap.uuidV5(UUID.fromString("6ba7b810-9dad-11d1-80b4-00c04fd430c8"), "www.example.com").toString(), + ) + } + + @Test + fun `uuid v5 feed guid matches workstation python`() { + // Cross-checked with Python's uuid.uuid5. + assertEquals( + "3cd075e1-b31f-57f4-a37a-da0996e20ae1", + WavlakeZap.uuidV5( + WavlakeZap.FEED_NAMESPACE, + "wavlake.com/feed/music/7acbd8ef-15b7-4deb-9802-8b30035b0ec7", + ).toString(), + ) + } + + @Test + fun `zap request carries official tags`() { + val unsigned = WavlakeZap.buildZapRequest( + trackId = "81c45017-5d13-45fd-a751-c9483b579682", + albumId = "7acbd8ef-15b7-4deb-9802-8b30035b0ec7", + amountSats = 100, + comment = "test", + relays = listOf("wss://relay.damus.io"), + signerPubkeyHex = "00".repeat(32), + ) + assertEquals(9734, unsigned.kind) + val tags = unsigned.tags + assertTrue(tags.contains(listOf("p", WavlakeZap.FEED_PUBKEY))) + assertTrue(tags.any { it.getOrNull(0) == "relays" && it.contains("wss://relay.wavlake.com/") }) + assertTrue(tags.contains(listOf("amount", "100000"))) + assertTrue( + tags.any { + it.getOrNull(0) == "a" && + it.getOrNull(1) == "32123:${WavlakeZap.FEED_PUBKEY}:81c45017-5d13-45fd-a751-c9483b579682" + }, + ) + assertTrue(tags.any { it.getOrNull(0) == "timestamp" }) + assertTrue(tags.count { it.getOrNull(0) == "i" } == 3) + } + + @Test + fun `invoice sanity is strict mainnet-only`() { + assertTrue(WavlakeZap.isPlausibleInvoice("lnbc100n1p" + "x".repeat(30))) + assertTrue(!WavlakeZap.isPlausibleInvoice("lntb100n1p" + "x".repeat(30))) + assertTrue(!WavlakeZap.isPlausibleInvoice("not-an-invoice")) + assertTrue(!WavlakeZap.isPlausibleInvoice("lnbc short")) + } +} diff --git a/app/src/test/java/com/rada/payments/LightningAddressTest.kt b/app/src/test/java/com/rada/payments/LightningAddressTest.kt new file mode 100644 index 0000000..56be945 --- /dev/null +++ b/app/src/test/java/com/rada/payments/LightningAddressTest.kt @@ -0,0 +1,58 @@ +package com.rada.payments + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Spec-case coverage for the strict zap-symbol rule: ONLY a well-formed + * Lightning Address enables [hasValidLightningAddress]. + */ +class LightningAddressTest { + + @Test + fun `valid address passes`() { + assertTrue(hasValidLightningAddress("artist@example.com")) + } + + @Test + fun `missing null and blank fail`() { + assertFalse(hasValidLightningAddress(null)) + assertFalse(hasValidLightningAddress("")) + assertFalse(hasValidLightningAddress(" ")) + } + + @Test + fun `wrong at-count fails`() { + assertFalse(hasValidLightningAddress("artistexample.com")) + assertFalse(hasValidLightningAddress("a@b@c.com")) + } + + @Test + fun `empty user or domain fails`() { + assertFalse(hasValidLightningAddress("@example.com")) + assertFalse(hasValidLightningAddress("artist@")) + assertFalse(hasValidLightningAddress("artist@ ")) + } + + @Test + fun `domain without dot or with space fails`() { + assertFalse(hasValidLightningAddress("artist@localhost")) + assertFalse(hasValidLightningAddress("artist@exam ple.com")) + } + + @Test + fun `lnurl and keys are not addresses`() { + assertFalse(hasValidLightningAddress("LNURL1DP68GURN8PHKCMN9W...")) + assertFalse(hasValidLightningAddress("7e7e9c42a91bfef19fa9295fda1b72e0ebc1a4c1141673e2794234d86addf4e")) + assertFalse(hasValidLightningAddress("npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg")) + } + + @Test + fun `trims and normalizes`() { + assertTrue(hasValidLightningAddress(" artist@example.com ")) + assertEquals("artist@example.com", normalizedLightningAddress(" artist@example.com ")) + assertEquals(null, normalizedLightningAddress("not-an-address")) + } +}