From ec55ba84ae0824bf5ede4aef8567910933dd30dd Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 01:30:43 -0500 Subject: [PATCH 01/10] feat: sleep timer, playback speed, real queue with shuffle/repeat - Sleep timer: 15/30/45/60/90 min, live countdown chip, pauses playback on expiry (station stays loaded); cancel from dialog - Playback speed 0.5x-2x for stations and saved songs; persists across track changes; chip shown when != 1x - Local playback now uses an ExoPlayer media queue (library list from tapped song, shuffle option): native advance at track end, transport next/prev walk the queue, repeat off/all/one - Player transport routes queue-first: local playback advances the queue, radio cycles stations; repeat/shuffle dimmed for radio - Library 'now playing' highlight follows queue transitions via currentLocalTrackIdFlow; dead manual shuffle-index code removed - Verified on-device: speed 1.25x confirmed in media_session, sleep dialog + countdown, save-from-air picker, radio option states --- .../java/com/rada/player/PlaybackManager.kt | 150 ++++++++++-- .../com/rada/ui/library/LibraryViewModel.kt | 73 ++---- .../java/com/rada/ui/player/PlayerScreen.kt | 217 +++++++++++++++++- .../com/rada/ui/player/PlayerViewModel.kt | 66 ++++++ 4 files changed, 438 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt index 30b2d35..7ae19f8 100644 --- a/app/src/main/java/com/rada/player/PlaybackManager.kt +++ b/app/src/main/java/com/rada/player/PlaybackManager.kt @@ -90,6 +90,72 @@ class PlaybackManager( private val _lastStreamMetaFlow = MutableStateFlow(null) val lastStreamMetaFlow: StateFlow = _lastStreamMetaFlow.asStateFlow() + // ── Queue / repeat / shuffle / speed / sleep timer ───────────────── + /** Playback speed, 0.5×–2.0×. Survives station and track changes. */ + private val _speedFlow = MutableStateFlow(1.0f) + val speedFlow: StateFlow = _speedFlow.asStateFlow() + + private val _repeatModeFlow = MutableStateFlow(androidx.media3.common.Player.REPEAT_MODE_OFF) + val repeatModeFlow: StateFlow = _repeatModeFlow.asStateFlow() + + private val _shuffleEnabledFlow = MutableStateFlow(false) + val shuffleEnabledFlow: StateFlow = _shuffleEnabledFlow.asStateFlow() + + /** Milliseconds left on the sleep timer; 0 when off. */ + private val _sleepRemainingMsFlow = MutableStateFlow(0L) + val sleepRemainingMsFlow: StateFlow = _sleepRemainingMsFlow.asStateFlow() + + /** Library song id of the current queue item (null when not queue-playing). */ + private val _currentLocalTrackIdFlow = MutableStateFlow(null) + val currentLocalTrackIdFlow: StateFlow = _currentLocalTrackIdFlow.asStateFlow() + + private var sleepJob: Job? = null + + fun setSpeed(speed: Float) { + val clamped = speed.coerceIn(0.5f, 2.0f) + _speedFlow.value = clamped + controller?.setPlaybackSpeed(clamped) + } + + fun setRepeatMode(@androidx.media3.common.Player.RepeatMode mode: Int) { + controller?.repeatMode = mode + } + + fun setShuffleEnabled(enabled: Boolean) { + controller?.shuffleModeEnabled = enabled + } + + /** Start (or restart) the sleep timer; pauses playback when it fires. */ + fun startSleepTimer(minutes: Int) { + sleepJob?.cancel() + if (minutes <= 0) { + _sleepRemainingMsFlow.value = 0L + return + } + val totalMs = minutes * 60_000L + val endAt = SystemClock.elapsedRealtime() + totalMs + sleepJob = scope.launch { + while (true) { + val remaining = endAt - SystemClock.elapsedRealtime() + if (remaining <= 0L) break + _sleepRemainingMsFlow.value = remaining + delay(1_000L) + } + _sleepRemainingMsFlow.value = 0L + sleepJob = null + // Pause rather than stop: the station stays loaded for when the + // user wakes up. + controller?.pause() + } + } + + fun cancelSleepTimer() { + sleepJob?.cancel() + sleepJob = null + _sleepRemainingMsFlow.value = 0L + } + + private val _bufferStateFlow = MutableStateFlow(BufferState.IDLE) val bufferStateFlow: StateFlow = _bufferStateFlow.asStateFlow() @@ -144,7 +210,11 @@ class PlaybackManager( _isLocalFlow.value = false _localMediaFlow.value = null _lastStreamMetaFlow.value = null + _currentLocalTrackIdFlow.value = null val ctrl = controller ?: return + // A live stream has no queue: shuffle must not leak from local playback. + ctrl.shuffleModeEnabled = false + _shuffleEnabledFlow.value = false stopAll() initBuffer() @@ -162,40 +232,69 @@ class PlaybackManager( .build() ctrl.setMediaItem(mediaItem) + ctrl.setPlaybackSpeed(_speedFlow.value) ctrl.prepare() ctrl.play() startIcyReader(url) } - fun playLocalFile(filePath: String, title: String, artist: String) { + fun playLocalFile(filePath: String, title: String, artist: String, id: Long? = null) { + playLocalQueue(listOf(LocalTrack(id, filePath, title, artist)), startIndex = 0, shuffle = false) + } + + /** A local library track for queue playback. `id` links UI state to the player. */ + data class LocalTrack(val id: Long?, val filePath: String, val title: String, val artist: String) + + /** + * Plays a local queue (library songs / playlist). ExoPlayer handles + * advance, shuffle order and repeat natively, so transport next/prev + * and end-of-track behave like a real music player. + */ + fun playLocalQueue(tracks: List, startIndex: Int, shuffle: Boolean) { + if (tracks.isEmpty()) return currentStationUrl = null currentStationName = "" - // Publish so the shared NowPlaying bar shows library playback too. - _currentStationNameFlow.value = if (artist.isBlank()) title else "$title — $artist" _currentStationUrlFlow.value = null _isLocalFlow.value = true - _localMediaFlow.value = title to artist + val first = tracks[startIndex.coerceIn(0, tracks.lastIndex)] + _currentStationNameFlow.value = + if (first.artist.isBlank()) first.title else "${first.title} — ${first.artist}" + _localMediaFlow.value = first.title to first.artist _lastStreamMetaFlow.value = null val ctrl = controller ?: return stopAll() - val mediaItem = MediaItem.Builder() - .setUri(filePath) - .setMediaMetadata( - MediaMetadata.Builder() - .setArtist(artist) - .setTitle(title) - .build() - ) - .build() - - ctrl.setMediaItem(mediaItem) + val items = tracks.map { track -> + MediaItem.Builder() + .setUri(track.filePath) + .setMediaId(track.id?.toString() ?: track.filePath) + .setMediaMetadata( + MediaMetadata.Builder() + .setArtist(track.artist) + .setTitle(track.title) + .build() + ) + .build() + } + ctrl.setMediaItems(items, startIndex.coerceIn(0, items.lastIndex), 0L) + ctrl.shuffleModeEnabled = shuffle + _shuffleEnabledFlow.value = shuffle + ctrl.setPlaybackSpeed(_speedFlow.value) ctrl.prepare() ctrl.play() } + /** Transport next within the current queue (no-op for a single stream). */ + fun seekToNextItem() { + controller?.let { if (it.mediaItemCount > 1) it.seekToNext() } + } + + fun seekToPreviousItem() { + controller?.let { if (it.mediaItemCount > 1) it.seekToPrevious() } + } + fun togglePlayPause() { val ctrl = controller ?: return if (ctrl.isPlaying) ctrl.pause() else ctrl.play() @@ -334,6 +433,27 @@ class PlaybackManager( listener?.onPlaybackStateChanged(playing) } + override fun onRepeatModeChanged(mode: Int) { + _repeatModeFlow.value = mode + } + + override fun onShuffleModeEnabledChanged(enabled: Boolean) { + _shuffleEnabledFlow.value = enabled + } + + /** Queue advanced (auto or manual): publish the new item's identity. */ + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (_isLocalFlow.value && mediaItem != null) { + val title = mediaItem.mediaMetadata.title?.toString().orEmpty() + val artist = mediaItem.mediaMetadata.artist?.toString().orEmpty() + _localMediaFlow.value = title to artist + _currentStationNameFlow.value = + if (artist.isBlank()) title else "$title — $artist" + val itemId = mediaItem.mediaId.toLongOrNull() + _currentLocalTrackIdFlow.value = itemId + } + } + override fun onPlaybackStateChanged(playbackState: Int) { when (playbackState) { Player.STATE_READY -> { 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 1e186fc..efbe24a 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -import kotlin.random.Random @OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel @@ -116,6 +115,14 @@ class LibraryViewModel @Inject constructor( } } + init { + viewModelScope.launch { + playbackManager.currentLocalTrackIdFlow.collect { id -> + if (id != null) _uiState.update { it.copy(currentPlayingId = id) } + } + } + } + private fun sortSongs( songs: List, order: SortOrder, @@ -138,58 +145,30 @@ class LibraryViewModel @Inject constructor( } fun playSong(song: SavedSongEntity) { - playbackManager.playLocalFile( - filePath = song.filePath, - title = song.title, - artist = song.artist, - ) - _uiState.update { it.copy(currentPlayingId = song.id, shuffleQueue = emptyList(), shuffleIndex = -1) } + // Queue the visible list from the tapped song so transport next/prev + // and end-of-track advance through the library natively (ExoPlayer). + val queue = _uiState.value.songs + val startIdx = queue.indexOfFirst { it.id == song.id }.coerceAtLeast(0) + playQueue(queue, startIdx, shuffle = false) } fun shufflePlay() { - val songs = _uiState.value.songs - if (songs.isEmpty()) return - val shuffled = songs.shuffled(Random) - _uiState.update { it.copy(shuffleQueue = shuffled, shuffleIndex = 0) } - val first = shuffled.first() - playbackManager.playLocalFile( - filePath = first.filePath, - title = first.title, - artist = first.artist, - ) - _uiState.update { it.copy(currentPlayingId = first.id) } + val queue = _uiState.value.songs + if (queue.isEmpty()) return + playQueue(queue, startIndex = 0, shuffle = true) } - fun playNextInShuffle(): Boolean { - val queue = _uiState.value.shuffleQueue - val idx = _uiState.value.shuffleIndex - if (queue.isEmpty() || idx < 0) return false - val nextIdx = idx + 1 - if (nextIdx >= queue.size) return false - val next = queue[nextIdx] - playbackManager.playLocalFile( - filePath = next.filePath, - title = next.title, - artist = next.artist, + private fun playQueue(queue: List, startIndex: Int, shuffle: Boolean) { + playbackManager.playLocalQueue( + tracks = queue.map { + PlaybackManager.LocalTrack(it.id, it.filePath, it.title, it.artist) + }, + startIndex = startIndex, + shuffle = shuffle, ) - _uiState.update { it.copy(currentPlayingId = next.id, shuffleIndex = nextIdx) } - return true + _uiState.update { it.copy(currentPlayingId = queue[startIndex].id) } } - fun playPreviousInShuffle(): Boolean { - val queue = _uiState.value.shuffleQueue - val idx = _uiState.value.shuffleIndex - if (queue.isEmpty() || idx <= 0) return false - val prevIdx = idx - 1 - val prev = queue[prevIdx] - playbackManager.playLocalFile( - filePath = prev.filePath, - title = prev.title, - artist = prev.artist, - ) - _uiState.update { it.copy(currentPlayingId = prev.id, shuffleIndex = prevIdx) } - return true - } fun toggleFavorite(songId: Long) { viewModelScope.launch { @@ -264,9 +243,7 @@ data class LibraryUiState( val selectedPlaylistId: Long? = null, val sortOrder: SortOrder = SortOrder.DATE_SAVED, val sortAscending: Boolean = true, - val shuffleQueue: List = emptyList(), - val shuffleIndex: Int = -1, + val isImporting: Boolean = false, ) { - val isShuffleMode: Boolean get() = shuffleQueue.isNotEmpty() && shuffleIndex >= 0 } 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 2b5c713..b077843 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -34,6 +34,12 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.filled.RepeatOne +import androidx.compose.material.icons.filled.Shuffle +import androidx.compose.material.icons.filled.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.LibraryMusic @@ -81,7 +87,6 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.material3.TextButton import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -599,7 +604,7 @@ private fun NowPlayingView( Spacer(modifier = Modifier.width(12.dp)) IconButton( - onClick = { viewModel.previousStation() }, + onClick = { viewModel.previous() }, enabled = uiState.currentStation != null || uiState.isLocal, ) { Icon( @@ -644,19 +649,221 @@ private fun NowPlayingView( Spacer(modifier = Modifier.width(16.dp)) IconButton( - onClick = { viewModel.nextStation() }, - enabled = uiState.currentStation != null, + onClick = { viewModel.next() }, + enabled = uiState.currentStation != null || uiState.isLocal, ) { Icon( Icons.Default.SkipNext, - contentDescription = "Next station", + contentDescription = if (uiState.isLocal) "Next track" else "Next station", modifier = Modifier.size(32.dp), ) } } + + 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) } + 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, + ) { + Icon( + if (uiState.repeatMode == androidx.media3.common.Player.REPEAT_MODE_ONE) + Icons.Default.RepeatOne + else + Icons.Default.Repeat, + contentDescription = "Repeat", + tint = if (uiState.repeatMode != androidx.media3.common.Player.REPEAT_MODE_OFF) + MaterialTheme.colorScheme.primary + else + LocalTokens.current.textSecondary.copy( + alpha = if (uiState.isLocal) 1f else 0.38f, + ), + ) + } + + IconButton( + onClick = { viewModel.toggleShuffle() }, + enabled = uiState.isLocal, + ) { + Icon( + Icons.Default.Shuffle, + contentDescription = "Shuffle", + tint = if (uiState.shuffleEnabled) + MaterialTheme.colorScheme.primary + else + LocalTokens.current.textSecondary.copy( + alpha = if (uiState.isLocal) 1f else 0.38f, + ), + ) + } + } + + 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, 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 48e197d..81ab0e9 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerViewModel.kt @@ -146,6 +146,36 @@ class PlayerViewModel @Inject constructor( ) } restoreCachedStreamMeta() + + // Keep local queue state (title/artist across track changes) live. + viewModelScope.launch { + playbackManager.localMediaFlow.collect { local -> + _uiState.update { + it.copy( + localTitle = local?.first.orEmpty(), + localArtist = local?.second.orEmpty(), + ) + } + } + } + viewModelScope.launch { + playbackManager.speedFlow.collect { v -> _uiState.update { it.copy(speed = v) } } + } + viewModelScope.launch { + playbackManager.sleepRemainingMsFlow.collect { ms -> + _uiState.update { it.copy(sleepRemainingMs = ms) } + } + } + viewModelScope.launch { + playbackManager.repeatModeFlow.collect { mode -> + _uiState.update { it.copy(repeatMode = mode) } + } + } + viewModelScope.launch { + playbackManager.shuffleEnabledFlow.collect { on -> + _uiState.update { it.copy(shuffleEnabled = on) } + } + } } /** Re-apply the last ICY metadata so a reopened screen isn't blank. */ @@ -191,6 +221,38 @@ class PlayerViewModel @Inject constructor( } } + /** Transport next: queue advance for local playback, station cycle for radio. */ + fun next() { + if (_uiState.value.isLocal) { playbackManager.seekToNextItem(); return } + nextStation() + } + + fun previous() { + if (_uiState.value.isLocal) { playbackManager.seekToPreviousItem(); return } + previousStation() + } + + fun setSpeed(speed: Float) = playbackManager.setSpeed(speed) + + fun cycleRepeat() { + val next = when (_uiState.value.repeatMode) { + androidx.media3.common.Player.REPEAT_MODE_OFF -> + androidx.media3.common.Player.REPEAT_MODE_ALL + androidx.media3.common.Player.REPEAT_MODE_ALL -> + androidx.media3.common.Player.REPEAT_MODE_ONE + else -> androidx.media3.common.Player.REPEAT_MODE_OFF + } + playbackManager.setRepeatMode(next) + } + + fun toggleShuffle() { + playbackManager.setShuffleEnabled(!_uiState.value.shuffleEnabled) + } + + fun startSleepTimer(minutes: Int) = playbackManager.startSleepTimer(minutes) + + fun cancelSleepTimer() = playbackManager.cancelSleepTimer() + fun nextStation() { val stations = _uiState.value.stations if (stations.isEmpty()) return @@ -314,6 +376,10 @@ data class PlayerUiState( val bufferBytesWritten: Long = 0, val saveState: PlaybackManager.SaveState = PlaybackManager.SaveState.IDLE, val lastSaveResult: SaveSongUseCase.SaveResult? = null, + val speed: Float = 1f, + val sleepRemainingMs: Long = 0L, + val repeatMode: Int = androidx.media3.common.Player.REPEAT_MODE_OFF, + val shuffleEnabled: Boolean = false, val showPlaylistPicker: Boolean = false, val pickedSongId: Long? = null, val newPlaylistName: String = "", From 5b524f2153e5825a8b88835e6fbbe9186ff69ef4 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 09:18:25 -0500 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20dedupe=20library=20=E2=80=94=20ref?= =?UTF-8?q?use=20duplicate=20saves,=20auto-remove=20existing=20dupes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: import deduped by artist+title, but save-from-air did not, so saving the same broadcast again wrote 'Song (1).mp3' + a new row. - SaveSongUseCase now refuses a save when artist+title already exists (case-insensitive), reporting 'Already in your library' - DedupeLibrary collapses existing duplicates: keeps the largest file (ties → oldest), re-points playlist memberships to the keeper, then deletes duplicate rows + files - Runs automatically when the Library opens; a snackbar reports the removed count - On-device: user library 89 → 54 songs, 0 remaining dup groups, playlist links intact (Zaza All 32, Apollo 19), zero orphans, all kept files present on disk (pre-run backup taken) --- .../com/rada/data/local/dao/SavedSongDao.kt | 3 + .../com/rada/data/repository/DedupeLibrary.kt | 68 +++++++++++++++++++ .../com/rada/recording/SaveSongUseCase.kt | 15 ++++ .../com/rada/ui/library/LibraryViewModel.kt | 11 +++ 4 files changed, 97 insertions(+) create mode 100644 app/src/main/java/com/rada/data/repository/DedupeLibrary.kt diff --git a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt index 6a76890..a0f6761 100644 --- a/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt +++ b/app/src/main/java/com/rada/data/local/dao/SavedSongDao.kt @@ -14,6 +14,9 @@ interface SavedSongDao { @Query("SELECT * FROM saved_songs ORDER BY dateSaved DESC") fun getAllByDate(): Flow> + @Query("SELECT * FROM saved_songs ORDER BY id ASC") + suspend fun getAllOnce(): List + @Query("SELECT * FROM saved_songs ORDER BY artist ASC, title ASC") fun getAllByArtist(): Flow> diff --git a/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt b/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt new file mode 100644 index 0000000..2171301 --- /dev/null +++ b/app/src/main/java/com/rada/data/repository/DedupeLibrary.kt @@ -0,0 +1,68 @@ +package com.rada.data.repository + +import android.util.Log +import com.rada.data.local.dao.PlaylistDao +import com.rada.data.local.dao.SavedSongDao +import com.rada.data.local.entity.SavedSongEntity +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Collapses duplicate library songs (same artist + title, case-insensitive). + * Duplicates arise mainly from save-from-air: saving the same broadcast twice + * wrote a second file ("Song (1).mp3") and a second row. + * + * Keep rule: the largest file (the most complete recording; equal sizes tie + * to the oldest entry). Dropped songs' playlist links are re-pointed to the + * kept song before their rows and files are deleted. + */ +@Singleton +class DedupeLibrary @Inject constructor( + private val savedSongDao: SavedSongDao, + private val playlistDao: PlaylistDao, +) { + data class Result(val removed: Int) + + suspend fun run(): Result { + val all = savedSongDao.getAllOnce() + val groups = all.groupBy { + "${it.artist.trim().lowercase()}${it.title.trim().lowercase()}" + } + var removed = 0 + for ((_, group) in groups) { + if (group.size < 2) continue + // Largest file wins; ties → oldest (smallest id). + val keeper = group.maxWithOrNull( + compareBy { it.fileSize }.thenBy { -it.id } + ) ?: continue + for (dup in group.filter { it.id != keeper.id }) { + // Preserve group membership: re-point links, skip if the + // keeper is already in that playlist. + for (playlistId in playlistDao.getPlaylistIdsForSong(dup.id)) { + if (!playlistDao.isSongInPlaylist(playlistId, keeper.id)) { + playlistDao.addSongToPlaylist( + com.rada.data.local.entity.PlaylistSongEntity( + playlistId = playlistId, + songId = keeper.id, + ), + ) + } + } + savedSongDao.deleteById(dup.id) + removed++ + try { + if (dup.filePath != keeper.filePath) File(dup.filePath).delete() + } catch (_: Exception) { + } + } + Log.d(TAG, "Deduped ${group.size - 1} copy(ies) of " + + "${keeper.artist} — ${keeper.title}") + } + return Result(removed) + } + + companion object { + private const val TAG = "DedupeLibrary" + } +} diff --git a/app/src/main/java/com/rada/recording/SaveSongUseCase.kt b/app/src/main/java/com/rada/recording/SaveSongUseCase.kt index 9e76f5f..f017327 100644 --- a/app/src/main/java/com/rada/recording/SaveSongUseCase.kt +++ b/app/src/main/java/com/rada/recording/SaveSongUseCase.kt @@ -47,6 +47,21 @@ class SaveSongUseCase( ) } + // Refuse duplicates before touching storage: the same artist+title is + // already in the library (case-insensitive, trimmed). + val dupArtist = boundaries.artist.trim() + val dupTitle = boundaries.title.trim() + if (dupArtist.isNotEmpty() && dupTitle.isNotEmpty()) { + val existing = libraryRepository.getByArtistTitle(dupArtist, dupTitle) + if (existing != null) { + return SaveResult( + success = false, + songId = existing.id, + error = "Already in your library: $dupTitle", + ) + } + } + // Check minimum length (at least 10 seconds at 128kbps = 160,000 bytes) val minBytes = 160_000L if (boundaries.durationBytes < minBytes) { diff --git a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt index efbe24a..26683a8 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rada.data.local.entity.PlaylistEntity import com.rada.data.local.entity.SavedSongEntity +import com.rada.data.repository.DedupeLibrary import com.rada.data.repository.LibraryRepository import com.rada.data.repository.PlaylistRepository import com.rada.data.repository.SortOrder @@ -29,6 +30,7 @@ class LibraryViewModel @Inject constructor( private val playlistRepository: PlaylistRepository, private val playbackManager: PlaybackManager, private val songImporter: SongImporter, + private val dedupeLibrary: DedupeLibrary, ) : ViewModel() { private val _uiState = MutableStateFlow(LibraryUiState()) @@ -121,6 +123,15 @@ class LibraryViewModel @Inject constructor( if (id != null) _uiState.update { it.copy(currentPlayingId = id) } } } + // Collapse duplicate saves (same artist+title) once per app session. + viewModelScope.launch { + val result = runCatching { dedupeLibrary.run() }.getOrNull() + if (result != null && result.removed > 0) { + _importMessage.value = + "Removed ${result.removed} duplicate song" + + (if (result.removed == 1) "" else "s") + } + } } private fun sortSongs( From 4f8d3e929960dbdd31f3851918d649abaa626d85 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 09:35:53 -0500 Subject: [PATCH 03/10] feat(ui): Spotify-style playback signatures - EqualizerBars: animated 3-bar 'now playing' indicator (out-of-phase pulses; frozen mid-height when paused; static under Reduce Motion) - Now-playing bar: two-line layout with live metadata (title + artist for streams and local songs) and rise transitions; equalizer glyph replaces the icon chip while playing - Playing state in every list: Stations card, Streams card, and Library song rows show the animated equalizer instead of a static icon on the active source - NowPlayingViewModel exposes stream/local metadata flows --- .../com/rada/ui/components/EqualizerBars.kt | 78 ++++++++++++++++++ .../java/com/rada/ui/library/LibraryScreen.kt | 21 +++-- .../java/com/rada/ui/player/NowPlayingBar.kt | 80 +++++++++++++++---- .../com/rada/ui/player/NowPlayingViewModel.kt | 3 + .../rada/ui/stations/StationBrowserScreen.kt | 17 ++-- .../java/com/rada/ui/streams/StreamsScreen.kt | 21 +++-- 6 files changed, 188 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/com/rada/ui/components/EqualizerBars.kt diff --git a/app/src/main/java/com/rada/ui/components/EqualizerBars.kt b/app/src/main/java/com/rada/ui/components/EqualizerBars.kt new file mode 100644 index 0000000..0990484 --- /dev/null +++ b/app/src/main/java/com/rada/ui/components/EqualizerBars.kt @@ -0,0 +1,78 @@ +package com.rada.ui.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.rada.ui.theme.LocalReduceMotion + +/** + * The three-bar "now playing" indicator — the compact signal that a row is + * the active source (Spotify's equalizer). Bars pulse out of phase; paused + * freezes them mid-height so the shape still reads as "the equalizer", and + * reduce-motion renders a static, even-bar version. + */ +@Composable +fun EqualizerBars( + active: Boolean, + color: Color, + modifier: Modifier = Modifier, + barWidth: Dp = 2.5.dp, +) { + val reduce = LocalReduceMotion.current + val transition = rememberInfiniteTransition(label = "eq") + // Out-of-phase sine-ish pulses; different durations keep bars unsynced. + val b1 by transition.animateFloat( + initialValue = 0.25f, targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(520), repeatMode = RepeatMode.Reverse, + ), label = "b1", + ) + val b2 by transition.animateFloat( + initialValue = 1f, targetValue = 0.3f, + animationSpec = infiniteRepeatable( + animation = tween(660), repeatMode = RepeatMode.Reverse, + ), label = "b2", + ) + val b3 by transition.animateFloat( + initialValue = 0.45f, targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(440), repeatMode = RepeatMode.Reverse, + ), label = "b3", + ) + // Frozen heights for the paused pose (mid-range, uneven = still "eq"). + val h1 = if (reduce || !active) 0.6f else b1 + val h2 = if (reduce || !active) 1f else b2 + val h3 = if (reduce || !active) 0.45f else b3 + + Canvas(modifier = modifier.size(14.dp)) { + val gap = this.size.width / 8f + val w = barWidth.toPx() + val totalW = w * 3 + gap * 2 + var x = (this.size.width - totalW) / 2f + val r = CornerRadius(w / 2f, w / 2f) + for (h in floatArrayOf(h1, h2, h3)) { + val barH = this.size.height * h + drawRoundRect( + color = color, + topLeft = Offset(x, this.size.height - barH), + size = Size(w, barH), + cornerRadius = r, + ) + x += w + gap + } + } +} diff --git a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt index edf25e3..d9ee346 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -78,6 +78,7 @@ import com.rada.data.local.entity.SavedSongEntity import com.rada.data.repository.SortOrder import com.rada.ui.theme.AppearanceDialog import com.rada.ui.components.AppHomeButton +import com.rada.ui.components.EqualizerBars import com.rada.ui.theme.LocalTokens @OptIn(ExperimentalMaterial3Api::class) @@ -469,12 +470,20 @@ private fun SongListItem( .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Icon( - Icons.Default.MusicNote, - contentDescription = null, - tint = if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.textSecondary, - modifier = Modifier.size(24.dp), - ) + if (isPlaying) { + EqualizerBars( + active = true, + color = LocalTokens.current.accentFill, + modifier = Modifier.size(20.dp), + ) + } else { + Icon( + Icons.Default.MusicNote, + contentDescription = null, + tint = LocalTokens.current.textSecondary, + modifier = Modifier.size(24.dp), + ) + } Spacer(modifier = Modifier.width(16.dp)) Column(modifier = Modifier.weight(1f)) { Text( 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 b2a9d7a..a3cbde2 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -1,6 +1,11 @@ package com.rada.ui.player +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.togetherWith +import com.rada.ui.components.EqualizerBars +import com.rada.ui.theme.metadataEnter +import com.rada.ui.theme.metadataExit import com.rada.ui.theme.LocalReduceMotion import com.rada.ui.theme.barEnter import com.rada.ui.theme.barExit @@ -55,8 +60,23 @@ fun NowPlayingBar( val isPlaying by viewModel.isPlaying.collectAsState() val bufferState by viewModel.bufferState.collectAsState() val isLocal by viewModel.isLocal.collectAsState() + val streamMeta by viewModel.streamMeta.collectAsState() + val localMedia by viewModel.localMedia.collectAsState() val isVisible = stationName.isNotEmpty() + // Spotify-style two-line bar: track (or station) + artist (or station). + val title: String + val subtitle: String + if (isLocal) { + title = localMedia?.first?.takeIf { it.isNotEmpty() } ?: stationName + subtitle = localMedia?.second.orEmpty() + } else { + val meta = streamMeta + title = meta?.displayTitle?.takeIf { it.isNotEmpty() } ?: stationName + subtitle = if (meta != null && meta.displayTitle.isNotEmpty()) + meta.displayArtist.ifEmpty { stationName } else "" + } + AnimatedVisibility( visible = isVisible, enter = barEnter(reduce), @@ -105,12 +125,21 @@ fun NowPlayingBar( .background(LocalTokens.current.accentFill), contentAlignment = Alignment.Center, ) { - Icon( - if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio, - contentDescription = if (isLocal) "Now playing" else "Now playing station", - tint = LocalTokens.current.accentOnFill, - modifier = Modifier.size(24.dp), - ) + if (isPlaying) { + EqualizerBars( + active = true, + color = LocalTokens.current.accentOnFill, + barWidth = 3.dp, + modifier = Modifier.size(22.dp), + ) + } else { + Icon( + if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio, + contentDescription = "Paused", + tint = LocalTokens.current.accentOnFill, + modifier = Modifier.size(24.dp), + ) + } } Spacer(modifier = Modifier.width(12.dp)) Column( @@ -122,14 +151,37 @@ fun NowPlayingBar( onClick = onNavigateToPlayer, ), ) { - Text( - text = stationName, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = LocalTokens.current.text, - ) + Column { + AnimatedContent( + targetState = title, + transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) }, + label = "npTitle", + ) { t -> + Text( + text = t, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = LocalTokens.current.text, + ) + } + if (subtitle.isNotEmpty()) { + AnimatedContent( + targetState = subtitle, + transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) }, + label = "npSubtitle", + ) { st -> + Text( + text = st, + style = MaterialTheme.typography.bodySmall, + color = LocalTokens.current.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } } IconButton( onClick = { viewModel.togglePlayPause() }, 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 cdc996b..9105fc3 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt @@ -2,6 +2,7 @@ package com.rada.ui.player import androidx.lifecycle.ViewModel import com.rada.player.PlaybackManager +import com.rada.recording.StreamMetadata import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.StateFlow import javax.inject.Inject @@ -16,6 +17,8 @@ class NowPlayingViewModel @Inject constructor( val isPlaying: StateFlow = playbackManager.isPlayingFlow val bufferState: StateFlow = playbackManager.bufferStateFlow val isLocal: StateFlow = playbackManager.isLocalFlow + val streamMeta: StateFlow = playbackManager.lastStreamMetaFlow + val localMedia: StateFlow?> = playbackManager.localMediaFlow fun togglePlayPause() { playbackManager.togglePlayPause() 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 6160f92..abf34d7 100644 --- a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt +++ b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt @@ -60,6 +60,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.rada.domain.model.Station import com.rada.ui.components.AppHomeButton +import com.rada.ui.components.EqualizerBars import com.rada.ui.theme.LocalTokens private val genreColors = mapOf( @@ -503,12 +504,16 @@ private fun StationCard( .background(tokens.accentFill.copy(alpha = 0.15f)), contentAlignment = Alignment.Center, ) { - Icon( - if (isPlaying) Icons.Default.PlayArrow else Icons.Default.Radio, - contentDescription = null, - tint = tokens.accentText, - modifier = Modifier.size(20.dp), - ) + if (isPlaying) { + EqualizerBars(active = true, color = tokens.accentText) + } else { + Icon( + Icons.Default.Radio, + contentDescription = null, + tint = tokens.accentText, + modifier = Modifier.size(20.dp), + ) + } } Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { 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 c9112f0..bf27ca1 100644 --- a/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt +++ b/app/src/main/java/com/rada/ui/streams/StreamsScreen.kt @@ -56,6 +56,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.rada.domain.model.StreamEntry import com.rada.ui.components.AppHomeButton +import com.rada.ui.components.EqualizerBars import com.rada.ui.theme.LocalTokens @OptIn(ExperimentalMaterial3Api::class) @@ -415,12 +416,20 @@ private fun StreamCard( .background(if (isPlaying) LocalTokens.current.accentFill else LocalTokens.current.accentFill.copy(alpha = 0.15f)), contentAlignment = Alignment.Center, ) { - Icon( - if (isPlaying) Icons.Default.Stop else Icons.Default.PlayArrow, - contentDescription = null, - tint = if (isPlaying) Color.White else LocalTokens.current.accentText, - modifier = Modifier.size(22.dp), - ) + if (isPlaying) { + EqualizerBars( + active = true, + color = LocalTokens.current.accentOnFill, + modifier = Modifier.size(16.dp), + ) + } else { + Icon( + Icons.Default.PlayArrow, + contentDescription = null, + tint = LocalTokens.current.accentText, + modifier = Modifier.size(22.dp), + ) + } } Spacer(modifier = Modifier.width(14.dp)) Column(modifier = Modifier.weight(1f)) { From eb32aed07c777626dbbf81da56e0ac8a6b6e2026 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 09:39:26 -0500 Subject: [PATCH 04/10] fix(ui): mini-player attributes track to its station MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Radio subtitle is now 'artist · Station' so a track name never looks like it contradicts the station shown in the list/hub. --- .../main/java/com/rada/ui/player/NowPlayingBar.kt | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 a3cbde2..c7f0834 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -72,9 +72,17 @@ fun NowPlayingBar( subtitle = localMedia?.second.orEmpty() } else { val meta = streamMeta - title = meta?.displayTitle?.takeIf { it.isNotEmpty() } ?: stationName - subtitle = if (meta != null && meta.displayTitle.isNotEmpty()) - meta.displayArtist.ifEmpty { stationName } else "" + val track = meta?.displayTitle?.takeIf { it.isNotEmpty() } + if (track != null) { + // Radio playing a tagged track: track on top, "artist · Station" + // below so the track is always attributable to its station. + title = track + val artist = meta?.displayArtist.orEmpty() + subtitle = if (artist.isNotEmpty()) "$artist · $stationName" else stationName + } else { + title = stationName + subtitle = "" + } } AnimatedVisibility( From d612ba6f49864a2569957293f8c9d6e89039fb4d Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 09:59:21 -0500 Subject: [PATCH 05/10] feat(nav): swipeable pager for the 4 tabs (Spotify-style) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New TabsScreen: HorizontalPager (Stations · Library · Streams · Playlists) synced with a shared bottom NavigationBar; swipes follow the finger, tab taps animate to page, neighbors pre-composed so pages and their ViewModels survive page changes - Light haptic tick when a new page settles (skipped on first frame) - Mini-player sits above the nav bar inside the pager surface: playback controls reachable from every tab - Hub tab buttons navigate to tabs?index=N; playlist selection from the Playlists page animates to Library and deep-links the playlist - All previous routes preserved (stations/library/streams/playlists still resolve); detail transitions now direction-aware via Motion.kt forward/backward params --- .../java/com/rada/ui/navigation/NavGraph.kt | 27 +++- .../java/com/rada/ui/navigation/Screen.kt | 4 + .../java/com/rada/ui/navigation/TabsScreen.kt | 136 ++++++++++++++++++ app/src/main/java/com/rada/ui/theme/Motion.kt | 16 +-- 4 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 app/src/main/java/com/rada/ui/navigation/TabsScreen.kt 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 aa0f547..54976a9 100644 --- a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt @@ -38,6 +38,25 @@ fun NavGraph(navController: NavHostController) { startDestination = Screen.Player.route, modifier = Modifier.weight(1f), ) { + composable( + route = Screen.Tabs.route, + arguments = listOf( + navArgument("index") { type = NavType.IntType; defaultValue = 0 }, + navArgument("playlistId") { type = NavType.LongType; defaultValue = -1L }, + ), + enterTransition = { detailEnter(reduce) }, + exitTransition = { detailExit(reduce) }, + popEnterTransition = { detailPopEnter(reduce) }, + popExitTransition = { detailPopExit(reduce) }, + ) { backStackEntry -> + val args = backStackEntry.arguments + TabsScreen( + initialIndex = args?.getInt("index") ?: 0, + initialPlaylistId = args?.getLong("playlistId")?.takeIf { it > 0 }, + onBack = { navController.popBackStack() }, + onHome = { navController.navigate(Screen.Player.route) { popUpTo(Screen.Player.route) { inclusive = true } } }, + ) + } composable( Screen.Player.route, enterTransition = { hubEnter(reduce) }, @@ -46,10 +65,10 @@ fun NavGraph(navController: NavHostController) { popExitTransition = { hubExit(reduce) }, ) { PlayerScreen( - onNavigateToStations = { navController.navigate(Screen.Stations.route) }, - onNavigateToLibrary = { navController.navigate(Screen.Library.createRoute()) }, - onNavigateToStreams = { navController.navigate(Screen.Streams.route) }, - onNavigateToPlaylists = { navController.navigate(Screen.Playlists.route) }, + 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)) }, ) } composable( diff --git a/app/src/main/java/com/rada/ui/navigation/Screen.kt b/app/src/main/java/com/rada/ui/navigation/Screen.kt index 8a1d877..43f3001 100644 --- a/app/src/main/java/com/rada/ui/navigation/Screen.kt +++ b/app/src/main/java/com/rada/ui/navigation/Screen.kt @@ -10,4 +10,8 @@ sealed class Screen(val route: String) { data object Stations : Screen("stations") data object Streams : Screen("streams") data object Playlists : Screen("playlists") + data object Tabs : Screen("tabs?index={index}&playlistId={playlistId}") { + fun createRoute(index: Int = 0, playlistId: Long? = null): String = + "tabs?index=$index" + (playlistId?.let { "&playlistId=$it" } ?: "") + } } diff --git a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt new file mode 100644 index 0000000..cf39fdb --- /dev/null +++ b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt @@ -0,0 +1,136 @@ +package com.rada.ui.navigation + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.PlaylistAdd +import androidx.compose.material.icons.filled.LibraryMusic +import androidx.compose.material.icons.filled.Podcasts +import androidx.compose.material.icons.filled.Radio +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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 +import com.rada.ui.library.LibraryScreen +import com.rada.ui.player.NowPlayingBar +import com.rada.ui.playlists.PlaylistManagerScreen +import com.rada.ui.stations.StationBrowserScreen +import com.rada.ui.streams.StreamsScreen +import com.rada.ui.theme.LocalTokens +import kotlinx.coroutines.launch + +/** + * The 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. + */ +@Composable +fun TabsScreen( + initialIndex: Int, + initialPlaylistId: Long? = null, + onBack: () -> Unit, + onHome: () -> Unit, +) { + val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 3)) { 4 } + 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 = { + Column { + NowPlayingBar( + onNavigateToPlayer = onHome, + ) + NavigationBar( + containerColor = LocalTokens.current.panel.copy(alpha = 0.94f), + ) { + 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), + ) + items.forEach { (label, icon, page) -> + NavigationBarItem( + selected = pagerState.settledPage == page, + onClick = { + scope.launch { pagerState.animateScrollToPage(page) } + }, + icon = { Icon(icon, contentDescription = label) }, + label = { Text(label) }, + colors = NavigationBarItemDefaults.colors( + selectedIconColor = MaterialTheme.colorScheme.primary, + selectedTextColor = MaterialTheme.colorScheme.primary, + indicatorColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), + ), + ) + } + } + } + }, + ) { padding -> + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .padding(bottom = padding.calculateBottomPadding()), + // Keep neighbors composed so swiping shows real content instantly + // and their ViewModels survive page changes. + beyondViewportPageCount = 1, + ) { page -> + when (page) { + 0 -> StationBrowserScreen( + onBack = onBack, + onHome = onHome, + onStationSelected = { /* stay on tab; bar shows the station */ }, + ) + 1 -> LibraryScreen( + onBack = onBack, + onHome = onHome, + initialPlaylistId = libraryPlaylistId, + ) + 2 -> StreamsScreen( + onBack = onBack, + onHome = onHome, + onStreamSelected = { }, + ) + 3 -> PlaylistManagerScreen( + onBack = onBack, + onHome = onHome, + onPlaylistSelected = { playlistId -> + libraryPlaylistId = playlistId + scope.launch { pagerState.animateScrollToPage(1) } + }, + ) + } + } + } +} diff --git a/app/src/main/java/com/rada/ui/theme/Motion.kt b/app/src/main/java/com/rada/ui/theme/Motion.kt index 27e2fe3..d114d7e 100644 --- a/app/src/main/java/com/rada/ui/theme/Motion.kt +++ b/app/src/main/java/com/rada/ui/theme/Motion.kt @@ -61,24 +61,24 @@ fun hubExit(reduce: Boolean): ExitTransition = // and pop back to the left, so direction teaches the hierarchy. Slide is a // quarter-travel, not a full page wipe — fast screens stay legible mid-motion. -fun detailEnter(reduce: Boolean): EnterTransition = +fun detailEnter(reduce: Boolean, forward: Boolean = true): EnterTransition = if (reduce) fadeIn(tween(ExitFastMs)) - else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { it / 4 } + + else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) it / 4 else -it / 4 } + fadeIn(tween(StateMs, easing = MotionArrival)) -fun detailExit(reduce: Boolean): ExitTransition = +fun detailExit(reduce: Boolean, forward: Boolean = true): ExitTransition = if (reduce) fadeOut(tween(ExitFastMs)) - else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { -it / 8 } + + else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) -it / 8 else it / 8 } + fadeOut(tween(ExitFastMs, easing = MotionExit)) -fun detailPopEnter(reduce: Boolean): EnterTransition = +fun detailPopEnter(reduce: Boolean, forward: Boolean = true): EnterTransition = if (reduce) fadeIn(tween(ExitFastMs)) - else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { -it / 4 } + + else slideInHorizontally(tween(ViewMs, easing = MotionArrival)) { if (forward) -it / 8 else it / 8 } + fadeIn(tween(StateMs, easing = MotionArrival)) -fun detailPopExit(reduce: Boolean): ExitTransition = +fun detailPopExit(reduce: Boolean, forward: Boolean = true): ExitTransition = if (reduce) fadeOut(tween(ExitFastMs)) - else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { it / 8 } + + else slideOutHorizontally(tween(StateMs, easing = MotionExit)) { if (forward) it / 4 else -it / 4 } + fadeOut(tween(ExitFastMs, easing = MotionExit)) // ── Now-playing bar ─────────────────────────────────────────────────────── From 172e932ef36f8042d88d01ede259b452fa621cc6 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 10:02:35 -0500 Subject: [PATCH 06/10] feat(ui): press feedback + haptics - pressDip modifier (scale 0.97 press / spring-back, transform-only, reduce-motion aware) sharing the row's interaction source with the Material ripple - Library song rows: ripple + dip on tap; verified tap still plays - Page-settle haptic tick in the tab pager (skipped on first frame) --- .../java/com/rada/ui/library/LibraryScreen.kt | 7 +++- app/src/main/java/com/rada/ui/theme/Motion.kt | 35 +++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) 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 d9ee346..0dcf76d 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -79,6 +79,9 @@ import com.rada.data.repository.SortOrder import com.rada.ui.theme.AppearanceDialog import com.rada.ui.components.AppHomeButton import com.rada.ui.components.EqualizerBars +import com.rada.ui.theme.LocalReduceMotion +import com.rada.ui.theme.pressDip +import androidx.compose.foundation.interaction.MutableInteractionSource import com.rada.ui.theme.LocalTokens @OptIn(ExperimentalMaterial3Api::class) @@ -463,10 +466,12 @@ private fun SongListItem( onToggleFavorite: () -> Unit, onDelete: () -> Unit, ) { + val interaction = remember { MutableInteractionSource() } Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onClick) + .pressDip(interaction, LocalReduceMotion.current) + .clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick) .padding(horizontal = 16.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { diff --git a/app/src/main/java/com/rada/ui/theme/Motion.kt b/app/src/main/java/com/rada/ui/theme/Motion.kt index d114d7e..eb7f622 100644 --- a/app/src/main/java/com/rada/ui/theme/Motion.kt +++ b/app/src/main/java/com/rada/ui/theme/Motion.kt @@ -1,5 +1,13 @@ package com.rada.ui.theme +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.CubicBezierEasing @@ -107,3 +115,30 @@ fun metadataExit(reduce: Boolean): ExitTransition = /** Duration for press/feedback animations, zeroed under reduce motion. */ fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs + +// ── Press feedback ──────────────────────────────────────────────────────── +// Taps should feel physical before navigation resolves: the row dips +// (scale 0.97) on press and springs back on release. Transform-only, so +// it's cheap; zeroed under Reduce Motion by the caller. + + +/** + * Press-dip for list rows: scales to 0.97 while pressed. Use on rows that + * already have a clickable/combinedClickable modifier earlier in the chain. + */ +fun Modifier.pressDip( + interactionSource: MutableInteractionSource, + reduce: Boolean, +): Modifier = composed { + if (reduce) return@composed this + val pressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (pressed) 0.97f else 1f, + animationSpec = androidx.compose.animation.core.tween( + durationMillis = if (pressed) 90 else 160, + easing = if (pressed) MotionExit else MotionArrival, + ), + label = "pressDip", + ) + this.graphicsLayer { scaleX = scale; scaleY = scale } +} From 918aeaf8d3d5bce70954abbc21b17480ee86d38f Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 10:07:25 -0500 Subject: [PATCH 07/10] feat(ui): swipe-down to dismiss the mini-player - verticalDragToDismiss: bar follows the finger downward (translationY, capped 120dp) and dismisses past 40% height or on a downward fling; springs back otherwise; static under Reduce Motion - Dismissal is per-source: playback continues, and the bar returns automatically when a different station/song starts - Tabs route now owns its mini-player (root no longer stacks one) - On-device: swipe dismissed bar while PLAYING; station change re-showed it --- .../java/com/rada/ui/navigation/NavGraph.kt | 2 +- .../java/com/rada/ui/player/NowPlayingBar.kt | 14 ++++++- app/src/main/java/com/rada/ui/theme/Motion.kt | 37 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) 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 54976a9..8639975 100644 --- a/app/src/main/java/com/rada/ui/navigation/NavGraph.kt +++ b/app/src/main/java/com/rada/ui/navigation/NavGraph.kt @@ -29,7 +29,7 @@ import com.rada.ui.streams.StreamsScreen fun NavGraph(navController: NavHostController) { val navBackStackEntry by navController.currentBackStackEntryAsState() val currentRoute = navBackStackEntry?.destination?.route - val showNowPlayingBar = currentRoute != Screen.Player.route + val showNowPlayingBar = currentRoute != Screen.Player.route && currentRoute != Screen.Tabs.route val reduce = LocalReduceMotion.current Column(modifier = Modifier.fillMaxSize()) { 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 c7f0834..b5982b0 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -8,6 +8,7 @@ import com.rada.ui.theme.metadataEnter import com.rada.ui.theme.metadataExit import com.rada.ui.theme.LocalReduceMotion import com.rada.ui.theme.barEnter +import com.rada.ui.theme.verticalDragToDismiss import com.rada.ui.theme.barExit import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -36,6 +37,8 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -62,7 +65,12 @@ fun NowPlayingBar( val isLocal by viewModel.isLocal.collectAsState() val streamMeta by viewModel.streamMeta.collectAsState() val localMedia by viewModel.localMedia.collectAsState() - val isVisible = stationName.isNotEmpty() + // Swipe-down (or quick drag-down) dismisses the bar until a *new* + // source starts playing — a floating thing should answer to gestures. + var dismissedFor by remember { mutableStateOf(null) } + val sourceKey = stationName + if (dismissedFor != null && dismissedFor != sourceKey) dismissedFor = null + val isVisible = stationName.isNotEmpty() && dismissedFor != sourceKey // Spotify-style two-line bar: track (or station) + artist (or station). val title: String @@ -93,6 +101,10 @@ fun NowPlayingBar( Column( modifier = Modifier .fillMaxWidth() + .verticalDragToDismiss( + thresholdFraction = 0.4f, + onDismiss = { dismissedFor = sourceKey }, + ) .padding(horizontal = 12.dp) .clip(RoundedCornerShape(SatsAmpGlass.rCard)) // glass: textSafe 90% overlay + 6% white + highlight border diff --git a/app/src/main/java/com/rada/ui/theme/Motion.kt b/app/src/main/java/com/rada/ui/theme/Motion.kt index eb7f622..709aec3 100644 --- a/app/src/main/java/com/rada/ui/theme/Motion.kt +++ b/app/src/main/java/com/rada/ui/theme/Motion.kt @@ -1,5 +1,6 @@ package com.rada.ui.theme +import androidx.compose.ui.unit.dp import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState @@ -8,6 +9,10 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.CubicBezierEasing @@ -142,3 +147,35 @@ fun Modifier.pressDip( ) this.graphicsLayer { scaleX = scale; scaleY = scale } } + +// ── Dismiss-by-drag ─────────────────────────────────────────────────────── +// A floating bar should answer to a downward drag: it follows the finger +// (translationY only) and dismisses past a threshold or on a downward fling; +// otherwise it springs back. Reduce Motion: no follow, threshold-only dismiss. + +fun Modifier.verticalDragToDismiss( + thresholdFraction: Float = 0.5f, + onDismiss: () -> Unit, +): Modifier = composed { + val reduce = LocalReduceMotion.current + var dragPx by remember { mutableFloatStateOf(0f) } + val density = androidx.compose.ui.platform.LocalDensity.current + val maxDrag = with(density) { 120.dp.toPx() } + this + .graphicsLayer { translationY = if (reduce) 0f else dragPx } + .pointerInput(Unit) { + detectVerticalDragGestures( + onDragEnd = { + if (dragPx > size.height * thresholdFraction) onDismiss() + dragPx = 0f + }, + onDragCancel = { dragPx = 0f }, + onVerticalDrag = { change, amount -> + if (amount > 0) { + change.consume() + dragPx = (dragPx + amount).coerceIn(0f, maxDrag) + } + }, + ) + } +} From e2ed74a47dbf08620a38410e69f3c245b42c71f6 Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 10:13:06 -0500 Subject: [PATCH 08/10] feat(ui): mini-player buffering pose + one-tap retry on error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Equalizer chip dims while BUFFERING/INITIALIZING (no false 'playing' signal during reconnect) - On ERROR the bar's subtitle becomes 'Couldn't reach · Retry' — tapping replays the current station without leaving the tab - NowPlayingViewModel.retry() re-plays current URL --- .../java/com/rada/ui/player/NowPlayingBar.kt | 38 ++++++++++++++++++- .../com/rada/ui/player/NowPlayingViewModel.kt | 6 +++ 2 files changed, 42 insertions(+), 2 deletions(-) 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 b5982b0..b9cf1b4 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -65,6 +65,10 @@ fun NowPlayingBar( val isLocal by viewModel.isLocal.collectAsState() val streamMeta by viewModel.streamMeta.collectAsState() val localMedia by viewModel.localMedia.collectAsState() + val isError = bufferState == PlaybackManager.BufferState.ERROR + val isBuffering = + bufferState == PlaybackManager.BufferState.BUFFERING || + bufferState == PlaybackManager.BufferState.INITIALIZING // Swipe-down (or quick drag-down) dismisses the bar until a *new* // source starts playing — a floating thing should answer to gestures. var dismissedFor by remember { mutableStateOf(null) } @@ -145,13 +149,22 @@ fun NowPlayingBar( .background(LocalTokens.current.accentFill), contentAlignment = Alignment.Center, ) { - if (isPlaying) { + if (isPlaying && !isBuffering) { EqualizerBars( active = true, color = LocalTokens.current.accentOnFill, barWidth = 3.dp, modifier = Modifier.size(22.dp), ) + } else if (isBuffering && !isError) { + EqualizerBars( + active = false, + color = LocalTokens.current.accentOnFill.copy(alpha = 0.7f), + barWidth = 3.dp, + modifier = Modifier + .size(22.dp) + .padding(2.dp), + ) } else { Icon( if (isLocal) Icons.Default.MusicNote else Icons.Default.Radio, @@ -186,7 +199,28 @@ fun NowPlayingBar( color = LocalTokens.current.text, ) } - if (subtitle.isNotEmpty()) { + if (isError) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { viewModel.retry() }, + ), + ) { + Text( + "Couldn't reach · ", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + Text( + "Retry", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = tokens.accentText, + ) + } + } else if (subtitle.isNotEmpty()) { AnimatedContent( targetState = subtitle, transitionSpec = { metadataEnter(reduce) togetherWith metadataExit(reduce) }, 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 9105fc3..698b501 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingViewModel.kt @@ -23,4 +23,10 @@ class NowPlayingViewModel @Inject constructor( fun togglePlayPause() { playbackManager.togglePlayPause() } + + /** Reconnect the current station (mini-player error state). */ + fun retry() { + val url = playbackManager.currentStationUrl ?: return + playbackManager.playStation(url, playbackManager.currentStationName) + } } From 5ff5318db48b795970469263238d9fa7dff428bd Mon Sep 17 00:00:00 2001 From: avi Date: Tue, 8 Sep 2026 11:49:09 -0500 Subject: [PATCH 09/10] =?UTF-8?q?feat(nostr):=20decentralized=20layer=20Ph?= =?UTF-8?q?ase=20A=20=E2=80=94=20identity,=20relays,=20zaps,=20NWC=20walle?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Additive Wavlake-inspired layer; zero existing features touched. Crypto/protocol (pure-Kotlin core, fr.acinq secp256k1-kmp 0.17.3): - Bech32 + NIP-19 (npub/nsec/nprofile/nevent/naddr) - NIP-01 events: canonical serialization, BIP-340 schnorr sign/verify - NIP-04 ECDH AES-CBC (raw x-coord via pubKeyTweakMul) - Wavlake music NIP kinds 30440-30443 (Track/Album/Artist/Playlist) - 4 conformance unit tests vs official spec vectors (all pass) NostrService: multi-relay OkHttp websocket client, publish/subscribe/ query/getProfile/publishProfile/publishReaction/publishTrack/publishRaw, best-effort failure posture (relays down = silent, app unaffected). Identity: NostrKeys — AndroidKeyStore-encrypted private key at rest, npub-only display. Verified on-device: create → npub chip → survives force-stop + relaunch. Payments (PaymentService abstraction, NWC provider): - NIP-47 Nostr Wallet Connect over the pairing relay, pairing string keystore-encrypted, 15s timeout, never hangs playback - LUD-06/LUD-16 lightning-address invoice resolution - NIP-57 zaps: ephemeral-key zap request → callback nostr= → pay - payment history records (no secrets) UI (design-system tokens, progressive disclosure): - ⚡ bolt on mini-player → TipSheet (21/100/500/1000/custom, recent addresses) - Appearance → ⚡ Decentralized sheet: identity create/import, NWC connect, advanced relays/profile --- app/build.gradle.kts | 6 + .../main/java/com/rada/di/PaymentsModule.kt | 24 ++ app/src/main/java/com/rada/nostr/Bech32.kt | 156 +++++++++ app/src/main/java/com/rada/nostr/Nip04.kt | 48 +++ .../main/java/com/rada/nostr/NostrEvent.kt | 143 ++++++++ app/src/main/java/com/rada/nostr/NostrKeys.kt | 154 +++++++++ .../main/java/com/rada/nostr/NostrService.kt | 225 +++++++++++++ .../java/com/rada/nostr/RelayConnection.kt | 137 ++++++++ .../java/com/rada/payments/NwcProvider.kt | 286 ++++++++++++++++ .../java/com/rada/payments/PaymentService.kt | 112 +++++++ .../main/java/com/rada/payments/ZapService.kt | 169 ++++++++++ .../ui/decentralized/DecentralizedSheet.kt | 311 ++++++++++++++++++ .../decentralized/DecentralizedViewModel.kt | 93 ++++++ .../com/rada/ui/decentralized/TipSheet.kt | 191 +++++++++++ .../com/rada/ui/decentralized/TipViewModel.kt | 60 ++++ .../java/com/rada/ui/player/NowPlayingBar.kt | 22 ++ .../java/com/rada/ui/player/PlayerScreen.kt | 15 +- .../com/rada/ui/theme/AppearanceDialog.kt | 16 +- .../java/com/rada/nostr/NostrProtocolTest.kt | 91 +++++ 19 files changed, 2255 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/rada/di/PaymentsModule.kt create mode 100644 app/src/main/java/com/rada/nostr/Bech32.kt create mode 100644 app/src/main/java/com/rada/nostr/Nip04.kt create mode 100644 app/src/main/java/com/rada/nostr/NostrEvent.kt create mode 100644 app/src/main/java/com/rada/nostr/NostrKeys.kt create mode 100644 app/src/main/java/com/rada/nostr/NostrService.kt create mode 100644 app/src/main/java/com/rada/nostr/RelayConnection.kt create mode 100644 app/src/main/java/com/rada/payments/NwcProvider.kt create mode 100644 app/src/main/java/com/rada/payments/PaymentService.kt create mode 100644 app/src/main/java/com/rada/payments/ZapService.kt create mode 100644 app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt create mode 100644 app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt create mode 100644 app/src/main/java/com/rada/ui/decentralized/TipSheet.kt create mode 100644 app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt create mode 100644 app/src/test/java/com/rada/nostr/NostrProtocolTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e8dfd23..6b2bf88 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -104,9 +104,15 @@ dependencies { // not exist on Maven Central), which made the build fail during // dependency resolution before Kotlin was ever compiled. implementation("net.jthink:jaudiotagger:2.2.5") + // Nostr/Lightning crypto: BIP-340 schnorr + ECDH via ACINQ's secp256k1 + // Kotlin-multiplatform binding (prebuilt .so for all Android ABIs). + implementation("fr.acinq.secp256k1:secp256k1-kmp:0.17.3") + implementation("fr.acinq.secp256k1:secp256k1-kmp-jni-android:0.17.3") // Testing testImplementation("junit:junit:4.13.2") + testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-extract:0.17.3") + testImplementation("fr.acinq.secp256k1:secp256k1-kmp-jni-jvm-linux:0.17.3") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation(composeBom) androidTestImplementation("androidx.compose.ui:ui-test-junit4") diff --git a/app/src/main/java/com/rada/di/PaymentsModule.kt b/app/src/main/java/com/rada/di/PaymentsModule.kt new file mode 100644 index 0000000..06b3c03 --- /dev/null +++ b/app/src/main/java/com/rada/di/PaymentsModule.kt @@ -0,0 +1,24 @@ +package com.rada.di + +import com.rada.payments.NwcProvider +import com.rada.payments.PaymentService +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object PaymentsModule { + + /** + * Provider priority list. NWC first (user-connected wallet); LNURL and + * Cashu providers land in later phases and just append here — no other + * call site changes. + */ + @Provides + @Singleton + fun providePaymentService(nwc: NwcProvider): PaymentService = + PaymentService(providers = { listOf(nwc) }) +} diff --git a/app/src/main/java/com/rada/nostr/Bech32.kt b/app/src/main/java/com/rada/nostr/Bech32.kt new file mode 100644 index 0000000..470bafc --- /dev/null +++ b/app/src/main/java/com/rada/nostr/Bech32.kt @@ -0,0 +1,156 @@ +package com.rada.nostr + +/** + * BIP-173 bech32 + NIP-19 TLV entities (npub, nsec, nprofile, nevent, naddr). + * Pure Kotlin, no external deps. + */ +object Bech32 { + private const val CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + private val GENERATOR = intArrayOf(0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3) + + private fun polymod(values: IntArray): Int { + var chk = 1 + for (v in values) { + val b = chk ushr 25 + chk = ((chk and 0x1ffffff) shl 5) xor v + for (i in 0..4) if (((b ushr i) and 1) != 0) chk = chk xor GENERATOR[i] + } + return chk + } + + private fun hrpExpand(hrp: String): IntArray { + val ret = IntArray(hrp.length * 2 + 1) + for (i in hrp.indices) ret[i] = hrp[i].code ushr 5 + // ret[hrp.length] stays 0 (separator) + for (i in hrp.indices) ret[hrp.length + 1 + i] = hrp[i].code and 31 + return ret + } + + private fun verifyChecksum(hrp: String, data: IntArray): Boolean = + polymod(hrpExpand(hrp) + data) == 1 + + private fun createChecksum(hrp: String, data: IntArray): IntArray { + val values = hrpExpand(hrp) + data + IntArray(6) + val mod = polymod(values) xor 1 + return IntArray(6) { (mod ushr (5 * (5 - it))) and 31 } + } + + fun encode(hrp: String, data5bit: IntArray): String { + val combined = data5bit + createChecksum(hrp, data5bit) + return hrp + "1" + combined.joinToString("") { CHARSET[it].toString() } + } + + /** Returns (hrp, data5bit) or null on invalid input. */ + fun decode(bech: String): Pair? { + val lower = bech.lowercase() + val pos = lower.lastIndexOf('1') + if (pos < 1 || pos + 7 > lower.length) return null + val hrp = lower.substring(0, pos) + val data = IntArray(lower.length - pos - 1) + for (i in data.indices) { + val idx = CHARSET.indexOf(lower[pos + 1 + i]) + if (idx == -1) return null + data[i] = idx + } + if (!verifyChecksum(hrp, data)) return null + return hrp to data.dropLast(6).toIntArray() + } + + fun convertBits(data: ByteArray, fromBits: Int, toBits: Int, pad: Boolean): IntArray { + var acc = 0 + var bits = 0 + val out = ArrayList() + val maxv = (1 shl toBits) - 1 + for (b in data) { + val value = b.toInt() and 0xff + acc = (acc shl fromBits) or value + bits += fromBits + while (bits >= toBits) { + bits -= toBits + out.add((acc ushr bits) and maxv) + } + } + if (pad && bits > 0) out.add((acc shl (toBits - bits)) and maxv) + return out.toIntArray() + } + + fun convertBitsBack(data5: IntArray, toBits: Int = 8): ByteArray { + var acc = 0 + var bits = 0 + val out = ArrayList() + val maxv = (1 shl toBits) - 1 + for (value in data5) { + acc = (acc shl 5) or value + bits += 5 + if (bits >= toBits) { + bits -= toBits + out.add(((acc ushr bits) and maxv).toByte()) + } + } + return out.toByteArray() + } +} + +/** NIP-19 entities. */ +object Nip19 { + fun npub(pubkeyHex: String): String = + Bech32.encode("npub", Bech32.convertBits(hexToBytes(pubkeyHex), 8, 5, true)) + + fun nsec(privkeyHex: String): String = + Bech32.encode("nsec", Bech32.convertBits(hexToBytes(privkeyHex), 8, 5, true)) + + /** nprofile = TLV {0: pubkey, 1: relay hints} */ + fun nprofile(pubkeyHex: String, relays: List): String { + val tlv = ArrayList() + fun tag(type: Int, payload: ByteArray) { + tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList()) + } + tag(0, hexToBytes(pubkeyHex)) + relays.take(3).forEach { tag(1, it.toByteArray()) } + return Bech32.encode("nprofile", + Bech32.convertBits(tlv.toByteArray(), 8, 5, true)) + } + + /** nevent = TLV {1: event_id, 3: relay, [5 kind]} */ + fun nevent(eventIdHex: String, relays: List = emptyList(), kind: Int? = null): String { + val tlv = ArrayList() + fun tag(type: Int, payload: ByteArray) { + tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList()) + } + tag(1, hexToBytes(eventIdHex)) + relays.take(3).forEach { tag(3, it.toByteArray()) } + kind?.let { tag(5, byteArrayOf((it shr 8).toByte(), (it and 0xff).toByte())) } + return Bech32.encode("nevent", Bech32.convertBits(tlv.toByteArray(), 8, 5, true)) + } + + /** naddr = TLV {0: identifier(d), 1: relays, 2: pubkey, [3: kind]} — for 3044x music. */ + fun naddr(dTag: String, pubkeyHex: String, kind: Int, relays: List = emptyList()): String { + val tlv = ArrayList() + fun tag(type: Int, payload: ByteArray) { + tlv.add(type.toByte()); tlv.add(payload.size.toByte()); tlv.addAll(payload.toList()) + } + tag(0, dTag.toByteArray()) + relays.take(3).forEach { tag(3, it.toByteArray()) } + tag(2, hexToBytes(pubkeyHex)) + tag(3, byteArrayOf((kind shr 8).toByte(), (kind and 0xff).toByte())) + return Bech32.encode("naddr", Bech32.convertBits(tlv.toByteArray(), 8, 5, true)) + } + + fun decode(bech: String): Pair? { + val (hrp, data5) = Bech32.decode(bech) ?: return null + return hrp to Bech32.convertBitsBack(data5) + } +} + +fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) } + +fun hexToBytes(hex: String): ByteArray { + val clean = hex.trim().removePrefix("0x") + require(clean.length % 2 == 0 && clean.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) { + "invalid hex" + } + return ByteArray(clean.length / 2) { + ((Character.digit(clean[it * 2], 16) shl 4) + Character.digit(clean[it * 2 + 1], 16)).toByte() + } +} + diff --git a/app/src/main/java/com/rada/nostr/Nip04.kt b/app/src/main/java/com/rada/nostr/Nip04.kt new file mode 100644 index 0000000..9770edd --- /dev/null +++ b/app/src/main/java/com/rada/nostr/Nip04.kt @@ -0,0 +1,48 @@ +package com.rada.nostr + +import fr.acinq.secp256k1.Secp256k1 +import java.util.Base64 as JBase64 +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +/** + * NIP-04: ECDH shared secret + AES-256-CBC for encrypted DMs and the NWC + * (NIP-47) JSON-RPC envelope. Uses the app-held key via [keys]. + */ +object Nip04 { + + /** + * NIP-04 shared secret = raw x-coordinate of priv*otherPub, NOT hashed. + * (ACINQ's ecdh() applies libsecp256k1's default sha256 hashfp, which is + * *not* what Nostr wants — pubKeyTweakMul gives the serialized point.) + */ + private fun ecdh(priv: ByteArray, pubkeyHex: String): ByteArray { + val compressed = byteArrayOf(0x02) + hexToBytes(pubkeyHex) + val point = Secp256k1.get().pubKeyTweakMul(compressed, priv) + return point.copyOfRange(1, 33) // x-coordinate, parity-independent + } + + fun encrypt(priv: ByteArray, recipientPubkeyHex: String, plaintext: String): String { + val shared = ecdh(priv, recipientPubkeyHex) + val iv = ByteArray(16).also { SecureRandom().nextBytes(it) } + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(shared, "AES"), IvParameterSpec(iv)) + val ct = cipher.doFinal(plaintext.toByteArray()) + return JBase64.getEncoder().encodeToString(ct) + "?iv=" + + JBase64.getEncoder().encodeToString(iv) + } + + fun decrypt(priv: ByteArray, senderPubkeyHex: String, payload: String): String? = runCatching { + val (ctB64, ivB64) = payload.split("?iv=", limit = 2) + val shared = ecdh(priv, senderPubkeyHex) + val cipher = Cipher.getInstance("AES/CBC/PKCS5Padding") + cipher.init( + Cipher.DECRYPT_MODE, + SecretKeySpec(shared, "AES"), + IvParameterSpec(JBase64.getDecoder().decode(ivB64)), + ) + String(cipher.doFinal(JBase64.getDecoder().decode(ctB64))) + }.getOrNull() +} diff --git a/app/src/main/java/com/rada/nostr/NostrEvent.kt b/app/src/main/java/com/rada/nostr/NostrEvent.kt new file mode 100644 index 0000000..81ea317 --- /dev/null +++ b/app/src/main/java/com/rada/nostr/NostrEvent.kt @@ -0,0 +1,143 @@ +package com.rada.nostr + +import fr.acinq.secp256k1.Secp256k1 +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.security.MessageDigest + +/** + * NIP-01 event. Minimal, immutable, serialization exactly per spec so the + * event id (SHA-256 of [serialized]) verifies on any relay/client. + */ +data class NostrEvent( + val id: String, + val pubkey: String, + val createdAt: Long, + val kind: Int, + val tags: List>, + val content: String, + val sig: String, +) { + /** Canonical NIP-01 serialization: [0,pubkey,created_at,kind,tags,content]. */ + fun serialized(): String { + val obj = buildJsonArray { + add(JsonPrimitive(0)) + add(JsonPrimitive(pubkey)) + add(JsonPrimitive(createdAt)) + add(JsonPrimitive(kind)) + add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } }) + add(JsonPrimitive(content)) + } + return CANONICAL.encodeToString(JsonElement.serializer(), obj) + } + + fun toJson(): String { + val obj = buildJsonObject { + put("id", id) + put("pubkey", pubkey) + put("created_at", createdAt) + put("kind", kind) + put("tags", buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } }) + put("content", content) + put("sig", sig) + } + return CANONICAL.encodeToString(JsonElement.serializer(), obj) + } + + fun tag(name: String): String? = tags.firstOrNull { it.firstOrNull() == name }?.getOrNull(1) + fun tagValues(name: String): List = tags.filter { it.firstOrNull() == name }.mapNotNull { it.getOrNull(1) } + + /** Recompute id and verify schnorr sig. Never trust unverified relay data. */ + fun verify(): Boolean = runCatching { + val expectedId = sha256Hex(serialized().toByteArray()) + if (expectedId != id) return false + Secp256k1.get().verifySchnorr( + hexToBytes(sig), + hexToBytes(id), + hexToBytes(pubkey), + ) + }.getOrDefault(false) + + companion object { + internal val CANONICAL = Json { prettyPrint = false; encodeDefaults = true } + + fun sha256Hex(bytes: ByteArray): String = + MessageDigest.getInstance("SHA-256").digest(bytes).toHex() + + fun unsigned( + pubkey: String, + kind: Int, + content: String, + tags: List>, + createdAt: Long = System.currentTimeMillis() / 1000, + ): UnsignedEvent = UnsignedEvent(pubkey, createdAt, kind, tags, content) + + fun fromJson(raw: String): NostrEvent? = runCatching { + val el = CANONICAL.parseToJsonElement(raw) + val o = el as? kotlinx.serialization.json.JsonObject ?: return null + NostrEvent( + id = o["id"]?.jsonPrimitive?.content ?: return null, + pubkey = o["pubkey"]?.jsonPrimitive?.content ?: return null, + createdAt = o["created_at"]?.jsonPrimitive?.content?.toLongOrNull() ?: return null, + kind = o["kind"]?.jsonPrimitive?.content?.toIntOrNull() ?: return null, + tags = (o["tags"] as? JsonArray)?.map { a -> + a.jsonArray.map { it.jsonPrimitive.content } + } ?: emptyList(), + content = (o["content"] as? JsonPrimitive)?.content ?: "", + sig = o["sig"]?.jsonPrimitive?.content ?: return null, + ) + }.getOrNull() + } +} + +data class UnsignedEvent( + val pubkey: String, + val createdAt: Long, + val kind: Int, + val tags: List>, + val content: String, +) { + fun serialized(): String { + val obj = buildJsonArray { + add(JsonPrimitive(0)) + add(JsonPrimitive(pubkey)) + add(JsonPrimitive(createdAt)) + add(JsonPrimitive(kind)) + add(buildJsonArray { tags.forEach { t -> add(buildJsonArray { t.forEach { add(JsonPrimitive(it)) } }) } }) + add(JsonPrimitive(content)) + } + return NostrEvent.CANONICAL.encodeToString(JsonElement.serializer(), obj) + } + + /** Sign with the app-held key (signer callback keeps key ownership in NostrKeys). */ + fun sign(signer: (ByteArray) -> String?): NostrEvent? { + val digest = MessageDigest.getInstance("SHA-256").digest(serialized().toByteArray()) + val sig = signer(digest) ?: return null + val id = digest.toHex() + return NostrEvent(id, pubkey, createdAt, kind, tags, content, sig) + } +} + +/** Music kinds from the Wavlake music NIPs (parameterized replaceable, NIP-33). */ +object MusicKinds { + const val TRACK = 30440 + const val ALBUM = 30441 + const val ARTIST = 30442 + const val PLAYLIST = 30443 + + const val METADATA = 0 + const val TEXT_NOTE = 1 + const val FOLLOW_LIST = 3 + const val DELETION = 5 + const val REACTION = 7 + const val ZAP_REQUEST = 9734 + const val ZAP_RECEIPT = 9735 +} diff --git a/app/src/main/java/com/rada/nostr/NostrKeys.kt b/app/src/main/java/com/rada/nostr/NostrKeys.kt new file mode 100644 index 0000000..c882b7c --- /dev/null +++ b/app/src/main/java/com/rada/nostr/NostrKeys.kt @@ -0,0 +1,154 @@ +package com.rada.nostr + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import dagger.hilt.android.qualifiers.ApplicationContext +import fr.acinq.secp256k1.Secp256k1 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.json.JSONObject + +/** + * Optional Nostr identity for the device (NIP-01 keys). + * + * Security posture: + * - Private key is encrypted with an AndroidKeyStore AES-GCM key that is + * non-exportable; only the ciphertext blob ever leaves the Keystore. + * - The plaintext key exists in memory only while signing an event. + * - Nostr is entirely optional: without an identity every other app + * feature works unchanged, and nothing here ever blocks playback. + */ +@Singleton +class NostrKeys @Inject constructor( + @ApplicationContext private val context: Context, +) { + private val alias = "satsamp_nostr_key" + private val prefs = context.getSharedPreferences("nostr_identity", Context.MODE_PRIVATE) + + private val _pubkey = MutableStateFlow(null) + /** Hex x-only pubkey, or null when the user hasn't created/imported an identity. */ + val pubkey: StateFlow = _pubkey.asStateFlow() + + val hasIdentity: 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) + } + } + } + + /** Create a fresh identity. Returns the npub. Refuses if one exists (no silent overwrite). */ + fun createIdentity(): String { + check(!hasIdentity) { "identity already exists" } + val rnd = java.security.SecureRandom() + var key = ByteArray(32) + do { rnd.nextBytes(key) } while (!Secp256k1.get().secKeyVerify(key)) + val pub = xOnlyPubkey(key) + encryptAndStore(key) + _pubkey.value = pub + return Nip19.npub(pub) + } + + /** Import an nsec (paste flow). Stored encrypted; the plaintext is wiped after use. */ + fun importNsec(nsec: String): Boolean { + val (hrp, bytes) = Nip19.decode(nsec.trim()) ?: return false + if (hrp != "nsec" || bytes.size != 32) return false + if (!Secp256k1.get().secKeyVerify(bytes)) return false + encryptAndStore(bytes) + return true + } + + fun npub(): String? = _pubkey.value?.let { Nip19.npub(it) } + + /** + * Sign `digest` (32-byte SHA-256 of serialized event) with the stored key. + * Returns 64-byte BIP-340 schnorr sig hex, or null when no identity. + */ + fun signDigest(digest: ByteArray): String? { + val priv = decryptSecret() ?: return null + try { + return Secp256k1.get().signSchnorr(digest, priv, null).toHex() + } finally { + priv.fill(0) + } + } + + /** NIP-47/NIP-44 style ECDH needs the full priv transiently too. */ + fun withPrivateKey(block: (ByteArray) -> T): T? { + val priv = decryptSecret() ?: return null + try { return block(priv) } finally { priv.fill(0) } + } + + /** Erase the identity (ciphertext + keystore key). */ + fun clearIdentity() { + prefs.edit().clear().apply() + runCatching { + KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + .deleteEntry(alias) + } + _pubkey.value = null + } + + // ── Keystore plumbing ──────────────────────────────────────────────── + + private fun keystoreKey(): SecretKey { + val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (ks.getKey(alias, null) as? SecretKey)?.let { return it } + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + gen.init( + KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + .build(), + ) + return gen.generateKey() + } + + private fun encryptAndStore(priv: ByteArray) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, keystoreKey()) + val ct = cipher.doFinal(priv) + prefs.edit() + .putString("ct", Base64.encodeToString(ct, Base64.NO_WRAP)) + .putString("iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .apply() + priv.fill(0) + } + + private fun decryptSecret(): ByteArray? { + val ct = prefs.getString("ct", null) ?: return null + val iv = prefs.getString("iv", null) ?: return null + return runCatching { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, + keystoreKey(), + GCMParameterSpec(128, Base64.decode(iv, Base64.NO_WRAP)), + ) + cipher.doFinal(Base64.decode(ct, Base64.NO_WRAP)) + }.getOrNull() + } + + companion object { + /** compressed pubkey (33B) minus the 02/03 prefix = x-only (32B), NIP-01 style. */ + fun xOnlyPubkey(priv: ByteArray): String { + val compressed = Secp256k1.get().pubkeyCreate(priv) + return compressed.copyOfRange(1, 33).toHex() + } + } +} diff --git a/app/src/main/java/com/rada/nostr/NostrService.kt b/app/src/main/java/com/rada/nostr/NostrService.kt new file mode 100644 index 0000000..e8d09ec --- /dev/null +++ b/app/src/main/java/com/rada/nostr/NostrService.kt @@ -0,0 +1,225 @@ +package com.rada.nostr + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject + +data class RelayInfo(val url: String, val connected: Boolean) + +/** + * The single Nostr entry point (publish/subscribe/query/profile/music). + * UI must never talk to RelayConnection directly. + * + * Failure posture: relays are best-effort. If every relay is down, flows + * simply never emit; nothing else in the app notices or degrades. + */ +@Singleton +class NostrService @Inject constructor( + @ApplicationContext context: Context, + private val keys: NostrKeys, +) { + private val prefs = context.getSharedPreferences("nostr_settings", Context.MODE_PRIVATE) + + private val client = OkHttpClient.Builder() + .pingInterval(30, TimeUnit.SECONDS) + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) // websockets: no read timeout + .build() + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + private val defaultRelays = listOf( + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nos.lol", + ) + + private val relayUrls: List + get() { + val saved = prefs.getString("relays", null) + ?.split("\n")?.filter { it.isNotBlank() } + return if (saved.isNullOrEmpty()) defaultRelays else saved + } + + private val relays = mutableMapOf() + + private val _relayStates = MutableStateFlow>(emptyList()) + val relayStates: StateFlow> = _relayStates.asStateFlow() + + /** Merged verified events from all relays (deduped by id downstream). */ + private val merged = kotlinx.coroutines.flow.MutableSharedFlow( + extraBufferCapacity = 512, + ) + + @Volatile private var started = false + + /** Call once when the user enables Nostr; safe to call repeatedly. */ + fun ensureConnected() { + if (started) return + synchronized(this) { + if (started) return + started = true + relayUrls.forEach { url -> + val conn = RelayConnection(url, client) + relays[url] = conn + scope.launch { + conn.state.collect { st -> + _relayStates.value = relays.map { (u, c) -> + RelayInfo(u, st is RelayConnection.State.Connected) + } + } + } + scope.launch { conn.events.collect { merged.emit(it) } } + conn.connect() + } + _relayStates.value = relays.map { RelayInfo(it.key, false) } + } + } + + val events get() = merged as kotlinx.coroutines.flow.SharedFlow + + val enabled: Boolean + get() = prefs.getBoolean("enabled", false) && keys.hasIdentity + + fun setEnabled(on: Boolean) { + prefs.edit().putBoolean("enabled", on).apply() + if (on) ensureConnected() + else { + relays.values.forEach { it.close() } + started = false + } + } + + fun relaysConfig(): List = relayUrls + fun setRelays(list: List) { + prefs.edit().putString("relays", list.filter { it.isNotBlank() }.joinToString("\n")).apply() + relays.values.forEach { it.close() } + relays.clear() + started = false + if (enabled) ensureConnected() + } + + // ── publish ─────────────────────────────────────────────────────────── + + /** Publish a pre-signed event (zap requests use ephemeral keys). */ + fun publishRaw(event: NostrEvent) { + ensureConnected() + relays.values.forEach { it.publish(event) } + } + + /** Await the lnurl server's zap receipt (kind 9735) referencing [requestId]. */ + suspend fun waitForZapReceipt(requestId: String, targetPubkey: String, timeoutMs: Long) { + ensureConnected() + runCatching { + kotlinx.coroutines.withTimeout(timeoutMs) { + events.first { + it.kind == MusicKinds.ZAP_RECEIPT && + it.tagValues("e").contains(requestId) && + it.pubkey == targetPubkey + } + } + } + } + + fun publish(kind: Int, content: String, tags: List>): NostrEvent? { + val ev = NostrEvent.unsigned( + pubkey = keys.pubkey.value ?: return null, + kind = kind, content = content, tags = tags, + ).sign { digest -> keys.signDigest(digest) } ?: return null + relays.values.forEach { it.publish(ev) } + return ev + } + + /** Kind 0 metadata (NIP-01 profile). */ + fun publishProfile(name: String, about: String, pictureUrl: String): NostrEvent? { + val content = JSONObject().apply { + put("name", name) + put("about", about) + if (pictureUrl.isNotBlank()) put("picture", pictureUrl) + }.toString() + return publish(MusicKinds.METADATA, content, emptyList()) + } + + /** Kind 7 reaction ("+") to an event, optional lightning zap request. */ + fun publishReaction(target: NostrEvent, content: String = "+"): NostrEvent? = + publish( + MusicKinds.REACTION, content, + listOf( + listOf("e", target.id), + listOf("p", target.pubkey), + ), + ) + + // ── music metadata (Wavlake music NIPs 30440-30443) ────────────────── + + /** Publish a track as kind 30440 with a NOM-flavored JSON payload. */ + fun publishTrack( + dTag: String, + title: String, + creator: String, + enclosureUrl: String, + mimeType: String = "audio/mpeg", + durationSec: Long = 0, + ): NostrEvent? { + val content = JSONObject().apply { + put("title", title) + put("guid", dTag) + put("creator", creator) + put("type", mimeType) + put("duration", durationSec) + put("published_at", (System.currentTimeMillis() / 1000).toString()) + put("enclosure", enclosureUrl) + put("version", "0.1") + }.toString() + return publish(MusicKinds.TRACK, content, listOf(listOf("d", dTag))) + } + + // ── query (one-shot-ish) ────────────────────────────────────────────── + + /** Subscribe with [filter]; collected events (verified) flow into [onEvent]. */ + fun query(filter: JSONObject, onEvent: (NostrEvent) -> Unit): String { + ensureConnected() + val ids = relays.values.map { it.subscribe(filter) } + scope.launch { + val seen = HashSet() + events.collect { ev -> + if (seen.add(ev.id)) onEvent(ev) + } + } + return ids.firstOrNull() ?: "" + } + + /** Fetch a profile (kind 0) for [pubkeyHex]; emits once if any relay has it. */ + fun getProfile(pubkeyHex: String, timeoutMs: Long = 6000): kotlinx.coroutines.flow.Flow { + val out = kotlinx.coroutines.flow.MutableStateFlow(null) + ensureConnected() + val filter = JSONObject().apply { + put("kinds", JSONArray().put(MusicKinds.METADATA)) + put("authors", JSONArray().put(pubkeyHex)) + put("limit", 1) + } + val job = scope.launch { + events.first { it.kind == MusicKinds.METADATA && it.pubkey == pubkeyHex } + .let { out.value = it } + } + scope.launch { + kotlinx.coroutines.delay(timeoutMs) + job.cancel() + } + return out + } +} diff --git a/app/src/main/java/com/rada/nostr/RelayConnection.kt b/app/src/main/java/com/rada/nostr/RelayConnection.kt new file mode 100644 index 0000000..c137ff1 --- /dev/null +++ b/app/src/main/java/com/rada/nostr/RelayConnection.kt @@ -0,0 +1,137 @@ +package com.rada.nostr + +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONArray +import org.json.JSONObject + +/** + * Minimal NIP-01 relay client over OkHttp WebSocket — no new dependencies. + * Design guarantees for the app: + * - Every method is safe to call with relays down: failures surface as + * closed flows / nulls, never exceptions into the UI, never blocking + * playback. + * - Events arriving from relays are [NostrEvent.verify]-checked before + * they are emitted; untrusted relay data can't inject fakes. + */ +class RelayConnection( + val url: String, + private val client: OkHttpClient, +) { + sealed interface State { + data object Connecting : State + data object Connected : State + data class Disconnected(val reason: String?) : State + } + + private val _state = MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) + val state: SharedFlow = _state.asSharedFlow() + + private val _events = MutableSharedFlow( + replay = 0, extraBufferCapacity = 256, onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + /** Verified events matching any subscription on this relay. */ + val events: SharedFlow = _events.asSharedFlow() + + /** reqId -> original filter JSON, so we can CLOSE. */ + private val subs = ConcurrentHashMap() + private val reqCounter = AtomicLong(0) + @Volatile private var socket: WebSocket? = null + private val pendingOut = ArrayDeque() + + val isConnected: Boolean get() = socket != null + + fun connect() { + if (socket != null) return + _state.tryEmit(State.Connecting) + runCatching { + socket = client.newWebSocket( + Request.Builder().url(url).build(), + object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + _state.tryEmit(State.Connected) + // Re-send subscriptions and queued events after (re)connect. + subs.values.forEach { w -> runCatching { webSocket.send(w.toString()) } } + while (pendingOut.isNotEmpty()) { + webSocket.send(pendingOut.removeFirst()) + } + } + + override fun onMessage(webSocket: WebSocket, text: String) = handleFrame(text) + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + socket = null + _state.tryEmit(State.Disconnected(t.message)) + } + + override fun onClosed(webSocket: WebSocket, code: Int, reason: String) { + socket = null + _state.tryEmit(State.Disconnected(reason)) + } + }, + ) + }.onFailure { + _state.tryEmit(State.Disconnected(it.message)) + } + } + + private fun handleFrame(text: String) { + runCatching { + val arr = JSONArray(text) + when (arr.optString(0)) { + "EVENT" -> { + val ev = NostrEvent.fromJson(arr.optString(2)) + // Trust boundary: id + schnorr signature must verify. + if (ev != null && ev.verify()) _events.tryEmit(ev) + } + // ["OK", evt_id, ok|fail, msg] and ["EOSE", sub_id] are handled + // by callers that need them via their own flow when extended; + // for now presence is enough. + } + } + } + + /** NIP-01 REQ. Returns subscription id; results arrive on [events]. */ + fun subscribe(filter: JSONObject): String { + val id = "s${reqCounter.incrementAndGet()}" + // NIP-01: ["REQ", sub_id, filter] + subs[id] = JSONArray().put("REQ").put(id).put(filter) + val ws = socket + if (ws != null) runCatching { ws.send(subs[id].toString()) } + return id + } + + fun unsubscribe(id: String) { + subs.remove(id) + runCatching { + socket?.send(JSONObject().apply { put("CLOSE", id) }.toString()) + } + } + + fun publish(event: NostrEvent) { + val msg = JSONArray().apply { + put("EVENT") + put(JSONObject(event.toJson())) + }.toString() + val ws = socket + if (ws != null && ws.send(msg)) return + // Queue for the next successful connect; bounded. + if (pendingOut.size < 32) pendingOut.addLast(msg) + } + + fun close() { + subs.clear() + runCatching { socket?.close(1000, null) } + socket = null + } +} diff --git a/app/src/main/java/com/rada/payments/NwcProvider.kt b/app/src/main/java/com/rada/payments/NwcProvider.kt new file mode 100644 index 0000000..9f296fc --- /dev/null +++ b/app/src/main/java/com/rada/payments/NwcProvider.kt @@ -0,0 +1,286 @@ +package com.rada.payments + +import android.content.Context +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import com.rada.nostr.Nip04 +import com.rada.nostr.NostrEvent +import com.rada.nostr.NostrKeys +import com.rada.nostr.NostrService +import dagger.hilt.android.qualifiers.ApplicationContext +import java.security.KeyStore +import java.util.Base64 as JBase64 +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull +import org.json.JSONObject + +/** + * Nostr Wallet Connect (NIP-47) provider: the app talks to the user's + * wallet *through their relay* using encrypted kind-13194 info events and + * kind-13195/23194/23195 request/response DMs. The app never holds wallet + * keys; it holds only the pairing connection string (encrypted at rest). + * + * Connection string format (from Alby Hub / coinos etc.): + * nostr+walletconnect://?relay=&secret= + */ +@Singleton +class NwcProvider @Inject constructor( + @ApplicationContext private val context: Context, + private val nostr: NostrService, + private val keys: NostrKeys, +) : PaymentProvider { + + override val id = "nwc" + + private val prefs = context.getSharedPreferences("nwc", Context.MODE_PRIVATE) + + private var walletPubkey: String? = null + private var relayUrl: String? = null + private var secretHex: String? = null + + val connected: Boolean get() = parseStored() != null + + override val isConfigured: Boolean get() = connected + + data class Pairing(val walletPubkey: String, val relayUrl: String, val secretHex: String) + + /** Parse + validate a pairing string. Stores it; returns false if malformed. */ + fun connect(pairingUri: String): Boolean { + val parsed = parse(pairingUri.trim()) ?: return false + // store encrypted (it contains a secret key!) + storeSecret(pairingUri.trim()) + walletPubkey = parsed.walletPubkey + relayUrl = parsed.relayUrl + secretHex = parsed.secretHex + return true + } + + fun disconnect() { + prefs.edit().clear().apply() + walletPubkey = null; relayUrl = null; secretHex = null + } + + private fun parse(uri: String): Pairing? = runCatching { + require(uri.startsWith("nostr+walletconnect://")) { "not an NWC uri" } + val rest = uri.removePrefix("nostr+walletconnect://") + val pubkey = rest.substringBefore("?") + require(pubkey.length == 64 && pubkey.all { it in '0'..'9' || it in 'a'..'f' }) { "bad pubkey" } + val qs = rest.substringAfter("?", "") + val params = qs.split("&").mapNotNull { + val k = it.substringBefore("="); val v = it.substringAfter("=", "") + k to java.net.URLDecoder.decode(v, "UTF-8") + }.toMap() + val relay = params["relay"] ?: return null + val secret = params["secret"] ?: return null + require(secret.length == 64) { "bad secret" } + Pairing(pubkey, relay, secret) + }.getOrNull() + + private fun parseStored(): Pairing? { + if (walletPubkey != null) return Pairing(walletPubkey!!, relayUrl!!, secretHex!!) + val ct = prefs.getString("pairing_ct", null) ?: return null + val iv = prefs.getString("pairing_iv", null) ?: return null + val uri = decrypt(ct, iv) ?: return null + val p = parse(uri) ?: return null + walletPubkey = p.walletPubkey; relayUrl = p.relayUrl; secretHex = p.secretHex + return p + } + + // ── provider API ────────────────────────────────────────────────────── + + override suspend fun payLightningAddress( + address: String, + sats: Long, + memo: String?, + ): PaymentResult { + // NIP-47 pay_invoice needs a bolt11; a lightning *address* first + // resolves via LUD-06 to an invoice. + val identifier = if (address.contains("@")) { + resolveLightningAddress(address, sats, memo) + ?: return PaymentResult.Failed("lnurl failed", "Couldn't reach that Lightning address.") + } else address + return nwcRequest("pay_invoice", JSONObject().apply { + put("invoice", identifier) + }) + } + + override suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult = + nwcRequest("pay_invoice", JSONObject().apply { put("invoice", bolt11) }) + + override suspend fun createInvoice(sats: Long, memo: String?): PaymentResult = + nwcRequest("make_invoice", JSONObject().apply { + put("amount", sats * 1000) // msats + memo?.let { put("description", it) } + }) + + /** NIP-06/LUD-16: lightning address -> {callback}?amount= -> bolt11 */ + private suspend fun resolveLightningAddress( + address: String, + sats: Long, + memo: String?, + ): String? = withContext(Dispatchers.IO) { + runCatching { + val (name, domain) = address.split("@", limit = 2) + val https = "https://$domain/.well-known/lnurlp/$name" + val client = okhttp3.OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + val lnl = client.newCall(okhttp3.Request.Builder().url(https).build()) + .execute().use { r -> + if (!r.isSuccessful) return@runCatching null + JSONObject(r.body?.string() ?: return@runCatching null) + } + if (lnl.optString("tag") != "payRequest") return@runCatching null + val min = lnl.optLong("minSendable", 1000L) // msats + val max = lnl.optLong("maxSendable", Long.MAX_VALUE) + val msats = (sats * 1000).coerceIn(min, max) + val cb = lnl.getString("callback") + val url = "$cb${if (cb.contains("?")) "&" else "?"}amount=$msats" + + (memo?.let { "&comment=${java.net.URLEncoder.encode(it, "UTF-8")}" } ?: "") + val resp = client.newCall(okhttp3.Request.Builder().url(url).build()) + .execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) } + if (resp.has("error")) { + return@runCatching null + } + resp.optString("pr").takeIf { it.isNotBlank() } + }.getOrNull() + } + + /** + * Send a NIP-47 request event, await the wallet's encrypted response. + * Timeout-safe: returns Failed, never hangs the caller. + */ + private suspend fun nwcRequest(method: String, params: JSONObject): PaymentResult { + val pairing = parseStored() ?: return PaymentResult.Failed( + "not connected", "Connect your wallet in Settings → Decentralized.", + ) + val ourPub = keys.pubkey.value ?: return PaymentResult.Failed( + "no identity", "Create a Nostr identity first (Settings → Decentralized → Nostr).", + ) + + val deferred = CompletableDeferred() + // Subscribe to the wallet's response kind for our pubkey (NIP-47: 23195). + val filter = JSONObject().apply { + put("kinds", org.json.JSONArray().put(23195)) + put("authors", org.json.JSONArray().put(pairing.walletPubkey)) + put("#p", org.json.JSONArray().put(ourPub)) + put("limit", 5) + } + val requestId = java.util.UUID.randomUUID().toString() + val payload = JSONObject().apply { + put("method", method) + put("params", params) + } + + val enc = keys.withPrivateKey { priv -> + Nip04.encrypt(priv, pairing.walletPubkey, payload.toString()) + } ?: return PaymentResult.Failed("crypto", "Couldn't encrypt the request.") + + // Publish request (kind 23194) to the NWC relay AND app relays. + val ev = NostrEvent.unsigned( + pubkey = ourPub, kind = 23194, content = enc, + tags = listOf(listOf("p", pairing.walletPubkey)), + ).sign { d -> keys.signDigest(d) } + ?: return PaymentResult.Failed("signing", "Couldn't sign the request.") + + // A dedicated relay connection to the NWC relay from the pairing. + val nwcRelay = com.rada.nostr.RelayConnection( + pairing.relayUrl, + okhttp3.OkHttpClient.Builder() + .pingInterval(20, java.util.concurrent.TimeUnit.SECONDS).build(), + ) + return try { + nwcRelay.connect() + // Listen for the response on the NWC relay only. + val listen = CoroutineScope(Dispatchers.IO + SupervisorJob()) + val listenJob = listen.launch { + nwcRelay.events.collect { e -> + if (e.kind == 23195 && !deferred.isCompleted) { + val plain = keys.withPrivateKey { priv -> + Nip04.decrypt(priv, pairing.walletPubkey, e.content) + } ?: return@collect + runCatching { JSONObject(plain) } + .onSuccess { deferred.complete(it) } + } + } + } + nwcRelay.publish(ev) + val result = withTimeoutOrNull(15_000) { deferred.await() } + listenJob.cancel() + result?.let { json -> + when { + json.has("error") -> PaymentResult.Failed( + json.optString("error"), + "Wallet declined: ${json.optString("error_message", json.optString("error"))}", + ) + json.has("result_type") -> { + val res = json.optJSONObject("result") + when (method) { + "make_invoice" -> PaymentResult.InvoiceCreated( + res?.optString("invoice") ?: "", pairing.walletPubkey, + ) + "pay_invoice" -> PaymentResult.Paid + "get_info" -> PaymentResult.Paid + else -> PaymentResult.Paid + } + } + else -> PaymentResult.Failed("bad response", "Unexpected wallet response.") + } + } ?: PaymentResult.Failed("timeout", "Wallet didn't respond in time.") + } finally { + nwcRelay.close() + } + } + + /** Wallet info (NIP-47 get_info) — names the connected wallet. */ + suspend fun getInfo(): String? { + val r = nwcRequest("get_info", JSONObject()) + return if (r is PaymentResult.Paid) "connected" else null + } + + // ── keystore at-rest protection for the pairing secret ─────────────── + + private fun ksKey(): SecretKey { + val ks = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + (ks.getKey("satsamp_nwc", null) as? SecretKey)?.let { return it } + val gen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore") + gen.init( + KeyGenParameterSpec.Builder("satsamp_nwc", + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .build(), + ) + return gen.generateKey() + } + + private fun storeSecret(uri: String) { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.ENCRYPT_MODE, ksKey()) + prefs.edit() + .putString("pairing_ct", Base64.encodeToString(cipher.doFinal(uri.toByteArray()), Base64.NO_WRAP)) + .putString("pairing_iv", Base64.encodeToString(cipher.iv, Base64.NO_WRAP)) + .apply() + } + + private fun decrypt(ctB64: String, ivB64: String): String? = runCatching { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init( + Cipher.DECRYPT_MODE, ksKey(), + GCMParameterSpec(128, Base64.decode(ivB64, Base64.NO_WRAP)), + ) + String(cipher.doFinal(Base64.decode(ctB64, Base64.NO_WRAP))) + }.getOrNull() +} diff --git a/app/src/main/java/com/rada/payments/PaymentService.kt b/app/src/main/java/com/rada/payments/PaymentService.kt new file mode 100644 index 0000000..ff966d4 --- /dev/null +++ b/app/src/main/java/com/rada/payments/PaymentService.kt @@ -0,0 +1,112 @@ +package com.rada.payments + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Payment abstraction — the rest of the app only ever talks to + * [PaymentService]; provider specifics (NWC, LNURL, Lightning Address, + * Cashu later) stay behind [PaymentProvider]. + * + * Guarantees the app relies on: + * - No payment failure ever propagates into playback code. + * - Nothing auto-spends: pay() is only invoked from explicit UI actions + * or bounded value-for-value logic with user-set caps. + */ +sealed interface PaymentResult { + data class InvoiceCreated(val bolt11: String, val lightningAddress: String?) : PaymentResult + data object Paid : PaymentResult + data class Failed(val reason: String, val userFacing: String) : PaymentResult +} + +interface PaymentProvider { + val id: String + val isConfigured: Boolean + + /** Pay [sats] to a lightning address / lnurl (LUD-06/LUD-07/LUD-16 flow). */ + suspend fun payLightningAddress(address: String, sats: Long, memo: String?): PaymentResult + + /** Create an invoice to ourselves (NWC make_invoice) — for receiving. */ + suspend fun createInvoice(sats: Long, memo: String?): PaymentResult + + /** Pay a bolt11 invoice directly (zap flow). */ + suspend fun payInvoice(bolt11: String, sats: Long): PaymentResult +} + +/** + * Central payment service. Providers are consulted in priority order; + * when none is configured every method fails fast with a clear reason + * and the UI renders the plain "⚡ Support" flow as connect-prompt. + */ +class PaymentService( + private val providers: () -> List, +) { + private val _history = MutableStateFlow>(emptyList()) + val history: StateFlow> = _history.asStateFlow() + + val hasAnyWallet: Boolean get() = providers().any { it.isConfigured } + + suspend fun tipArtist( + lightningAddress: String?, + sats: Long, + artistName: String, + trackTitle: String? = null, + ): PaymentResult { + if (sats <= 0) return PaymentResult.Failed("invalid amount", "Enter an amount first.") + if (lightningAddress.isNullOrBlank()) { + return PaymentResult.Failed("no address", "$artistName has no Lightning address set.") + } + val provider = providers().firstOrNull { it.isConfigured } + ?: return PaymentResult.Failed( + "no wallet", + "Connect a wallet first (Settings → Decentralized → Bitcoin).", + ) + val memo = listOfNotNull(trackTitle?.takeIf { it.isNotBlank() }, "via SatsAmp") + .joinToString(" · ") + val result = runCatching { provider.payLightningAddress(lightningAddress, sats, memo) } + .getOrElse { + PaymentResult.Failed("provider error: ${it.message}", "Payment failed — try again.") + } + _history.value = listOf( + PaymentRecord( + timestampMs = System.currentTimeMillis(), + artist = artistName, + track = trackTitle, + sats = sats, + method = provider.id, + success = result is PaymentResult.Paid || result is PaymentResult.InvoiceCreated, + detail = when (result) { + is PaymentResult.Failed -> result.reason + is PaymentResult.InvoiceCreated -> result.bolt11.take(24) + else -> null + }, + ), + ) + _history.value + return result + } + + suspend fun payInvoiceDirect(bolt11: String, sats: Long): PaymentResult { + val provider = providers().firstOrNull { it.isConfigured } + ?: return PaymentResult.Failed("no wallet", "No wallet connected.") + return runCatching { provider.payInvoice(bolt11, sats) } + .getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Payment failed.") } + } + + suspend fun createInvoice(sats: Long, memo: String?): PaymentResult { + val provider = providers().firstOrNull { it.isConfigured } + ?: return PaymentResult.Failed("no wallet", "No wallet connected.") + return runCatching { provider.createInvoice(sats, memo) } + .getOrElse { PaymentResult.Failed("provider error: ${it.message}", "Invoice failed.") } + } +} + +data class PaymentRecord( + val timestampMs: Long, + val artist: String, + val track: String?, + val sats: Long, + val method: String, + val success: Boolean, + val detail: String?, +) diff --git a/app/src/main/java/com/rada/payments/ZapService.kt b/app/src/main/java/com/rada/payments/ZapService.kt new file mode 100644 index 0000000..b7ec591 --- /dev/null +++ b/app/src/main/java/com/rada/payments/ZapService.kt @@ -0,0 +1,169 @@ +package com.rada.payments + +import android.util.Base64 +import com.rada.nostr.MusicKinds +import com.rada.nostr.NostrEvent +import com.rada.nostr.NostrKeys +import com.rada.nostr.NostrService +import com.rada.nostr.hexToBytes +import com.rada.nostr.toHex +import fr.acinq.secp256k1.Secp256k1 +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import org.json.JSONArray +import org.json.JSONObject + +/** + * NIP-57 zaps, end to end: + * target npub -> kind-0 profile -> lud16 -> LUD-06 lnurl -> + * zap request (kind 9734, signed by an ephemeral key per NIP-57) -> + * lnurl callback with `nostr=` -> bolt11 -> paid via [PaymentService] + * (NWC today; any provider later). + * + * Every failure is reported as ZapOutcome.Failed with a human line; the + * caller must treat zaps as strictly optional garnish on playback. + */ +@Singleton +private object StopCollect : Exception() + +class ZapService @Inject constructor( + private val nostr: NostrService, + private val keys: NostrKeys, + private val paymentService: PaymentService, +) { + sealed interface ZapOutcome { + data object Sent : ZapOutcome + data class Failed(val reason: String) : ZapOutcome + } + + private val http = OkHttpClient.Builder() + .connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS) + .build() + + suspend fun zapRecipient( + targetNpubHex: String, + sats: Long, + comment: String = "", + eventId: String? = null, + ): ZapOutcome { + if (sats <= 0) return ZapOutcome.Failed("Enter an amount first.") + val relays = nostr.relaysConfig() + + // 1. Target profile -> lud16 (lightning address). + val lud16: String? = withContext(Dispatchers.IO) { + var found: String? = null + try { + kotlinx.coroutines.withTimeout(6_000) { + nostr.getProfile(targetNpubHex).collect { ev -> + if (ev != null && found == null) { + found = JSONObject(ev.content).optString("lud16") + .takeIf { it.isNotBlank() } + if (found != null) throw StopCollect + } + } + } + } catch (e: StopCollect) { /* found set */ } + catch (e: kotlinx.coroutines.TimeoutCancellationException) { /* none */ } + found + } ?: return ZapOutcome.Failed("No Lightning address (lud16) on that profile.") + + val address: String = lud16 ?: return ZapOutcome.Failed("No Lightning address set.") + return zapLightningAddress(address, sats, comment, targetNpubHex, eventId, relays) + } + + suspend fun zapLightningAddress( + lud16: String, + sats: Long, + comment: String, + targetNpubHex: String, + eventId: String?, + relays: List, + ): ZapOutcome = withContext(Dispatchers.IO) { + // 2. LUD-06: https://domain/.well-known/lnurlp/name + val lnl = runCatching { + val (name, domain) = lud16.split("@", limit = 2) + http.newCall( + okhttp3.Request.Builder() + .url("https://$domain/.well-known/lnurlp/$name").build(), + ).execute().use { r -> + if (!r.isSuccessful) return@runCatching null + JSONObject(r.body?.string() ?: return@runCatching null) + } + }.getOrNull() ?: return@withContext ZapOutcome.Failed("Couldn't reach the Lightning endpoint.") + + if (lnl.optString("tag") != "payRequest") + return@withContext ZapOutcome.Failed("Endpoint isn't a pay request.") + + val min = lnl.optLong("minSendable", 1000L) / 1000 + val max = lnl.optLong("maxSendable", Long.MAX_VALUE) / 1000 + if (sats < min || sats > max) + return@withContext ZapOutcome.Failed("Amount must be between $min and $max sats.") + + // 3. Zap request event (NIP-57): signed by an EPHEMERAL key, not the + // user's identity key; `relays` tag points at our relays so the + // lnurl server can fetch it and verify. + val ephemeral = ByteArray(32).also { SecureRandom().nextBytes(it) } + .also { if (!Secp256k1.get().secKeyVerify(it)) return@withContext ZapOutcome.Failed("rng busy, retry") } + val ephPub = Secp256k1.get().pubkeyCreate(ephemeral).copyOfRange(1, 33).toHex() + + val tags = mutableListOf( + listOf("p", targetNpubHex), + listOf("relays", *relays.take(3).toTypedArray()), + listOf("amount", (sats * 1000).toString()), + ) + if (eventId != null) tags.add(listOf("e", eventId)) + + val zapReq = NostrEvent.unsigned( + pubkey = ephPub, kind = MusicKinds.ZAP_REQUEST, content = comment, tags = tags, + ).sign { digest -> Secp256k1.get().signSchnorr(digest, ephemeral, null).toHex() } + ?: return@withContext ZapOutcome.Failed("Couldn't build the zap request.") + + // Publish the zap request so the lnurl server can fetch it. + nostr.ensureConnected() + // (NostrService.publish signs with the *identity* key — for zap + // requests we must publish the pre-signed ephemeral event directly.) + nostr.publishRaw(zapReq) + + // 4. Callback with nostr= param -> invoice. + val allowsNostr = lnl.optBoolean("allowsNostr", false) + if (!allowsNostr && comment.isNotEmpty()) { + // still payable without comment? proceed without nostr tag + } + val cb = lnl.optString("callback") + if (cb.isBlank()) return@withContext ZapOutcome.Failed("Endpoint missing callback.") + val url = buildString { + append(cb) + append(if (cb.contains("?")) "&" else "?") + append("amount=").append(sats * 1000) + if (allowsNostr) { + append("&nostr=").append( + java.net.URLEncoder.encode(zapReq.toJson(), "UTF-8"), + ) + } + } + val resp = runCatching { + http.newCall(okhttp3.Request.Builder().url(url).build()) + .execute().use { r -> JSONObject(r.body?.string() ?: return@runCatching null) } + }.getOrNull() ?: return@withContext ZapOutcome.Failed("Zap callback unreachable.") + + if (resp.has("error")) { + return@withContext ZapOutcome.Failed(resp.optString("reason", "Zap rejected.")) + } + val bolt11 = resp.optString("pr") + if (bolt11.isBlank()) return@withContext ZapOutcome.Failed("No invoice returned.") + + // 5. Pay via the connected wallet. + when (val pay = paymentService.payInvoiceDirect(bolt11, sats)) { + is PaymentResult.Paid -> { + nostr.waitForZapReceipt(zapReq.id, targetNpubHex, timeoutMs = 10_000) + ZapOutcome.Sent + } + is PaymentResult.Failed -> ZapOutcome.Failed(pay.userFacing) + else -> ZapOutcome.Failed("Unexpected wallet response.") + } + } +} diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt new file mode 100644 index 0000000..9e93d7f --- /dev/null +++ b/app/src/main/java/com/rada/ui/decentralized/DecentralizedSheet.kt @@ -0,0 +1,311 @@ +package com.rada.ui.decentralized + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.rada.ui.theme.LocalTokens +import com.rada.ui.theme.SatsAmpGlass + +/** + * Decentralized settings sheet — progressive disclosure per the design: + * listeners see "identity + wallet", advanced rows (relays, profile) sit + * behind a toggle. All state via [DecentralizedViewModel]. + */ +@Composable +fun DecentralizedSheet( + onDismiss: () -> Unit, + viewModel: DecentralizedViewModel = hiltViewModel(), +) { + val tokens = LocalTokens.current + val npub by viewModel.npub.collectAsState() + val walletConnected by viewModel.walletConnected.collectAsState() + val relays by viewModel.relays.collectAsState() + val message by viewModel.message.collectAsState() + val busy by viewModel.busy.collectAsState() + val clipboard = LocalClipboardManager.current + + var showAdvanced by remember { mutableStateOf(false) } + var showImport by remember { mutableStateOf(false) } + var nsecInput by remember { mutableStateOf("") } + var nwcInput by remember { mutableStateOf("") } + var nameInput by remember { mutableStateOf("") } + var aboutInput by remember { mutableStateOf("") } + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = tokens.panel, + titleContentColor = tokens.text, + shape = RoundedCornerShape(SatsAmpGlass.rCard), + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText) + Spacer(Modifier.width(8.dp)) + Text("Decentralized", fontWeight = FontWeight.Bold) + } + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // ── Nostr identity ──────────────────────────────────── + SectionLabel("Nostr identity") + val pk = npub + if (pk == null) { + Text( + "Optional. Gives you a profile, follows, reactions and zaps. Everything else in SatsAmp works without it.", + style = MaterialTheme.typography.bodySmall, + color = tokens.textSecondary, + ) + Button( + onClick = { viewModel.createIdentity() }, + colors = ButtonDefaults.buttonColors( + containerColor = tokens.accentFill, + contentColor = tokens.accentOnFill, + ), + shape = RoundedCornerShape(SatsAmpGlass.rInput), + modifier = Modifier.fillMaxWidth(), + ) { Text("Create Nostr identity") } + TextButton(onClick = { showImport = !showImport }) { + Text("Import existing nsec", color = tokens.accentText) + } + if (showImport) { + OutlinedTextField( + value = nsecInput, + onValueChange = { nsecInput = it }, + placeholder = { Text("nsec… (stored encrypted)") }, + singleLine = true, + colors = fieldColors(tokens), + modifier = Modifier.fillMaxWidth(), + ) + TextButton( + onClick = { viewModel.importNsec(nsecInput) }, + enabled = nsecInput.isNotBlank(), + ) { Text("Import") } + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(tokens.glassFillStrong) + .border(1.dp, tokens.border, RoundedCornerShape(12.dp)) + .clickable { clipboard.setText(AnnotatedString(pk)) } + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Text( + pk, + style = MaterialTheme.typography.bodySmall, + color = tokens.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Default.ContentCopy, contentDescription = "Copy npub", + tint = tokens.textSecondary, + modifier = Modifier.size(16.dp), + ) + } + Text( + "tap to copy", + style = MaterialTheme.typography.labelSmall, + color = tokens.textMuted, + ) + } + + Spacer(Modifier.height(4.dp)) + + // ── Wallet ──────────────────────────────────────────── + SectionLabel("Lightning wallet") + if (!walletConnected) { + Text( + "Connect a NIP-47 wallet (Alby Hub, Coinos, etc.) with its pairing string to zap and tip artists.", + style = MaterialTheme.typography.bodySmall, + color = tokens.textSecondary, + ) + OutlinedTextField( + value = nwcInput, + onValueChange = { nwcInput = it }, + placeholder = { Text("nostr+walletconnect://…") }, + singleLine = true, + colors = fieldColors(tokens), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { viewModel.connectWallet(nwcInput) }, + enabled = nwcInput.isNotBlank() && busy.not(), + colors = ButtonDefaults.buttonColors( + containerColor = tokens.accentFill, + contentColor = tokens.accentOnFill, + ), + shape = RoundedCornerShape(SatsAmpGlass.rInput), + modifier = Modifier.fillMaxWidth(), + ) { + if (busy) CircularProgressIndicator( + modifier = Modifier.size(16.dp), strokeWidth = 2.dp, + color = tokens.accentOnFill, + ) else Text("Connect wallet") + } + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(tokens.accentSoft) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) { + Icon(Icons.Default.Check, contentDescription = null, + tint = tokens.accentText, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(8.dp)) + Text("Wallet connected", color = tokens.accentText, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f)) + TextButton(onClick = { viewModel.disconnectWallet() }) { + Text("Disconnect", color = tokens.textSecondary) + } + } + } + + Spacer(Modifier.height(4.dp)) + + // ── Advanced ────────────────────────────────────────── + TextButton(onClick = { showAdvanced = !showAdvanced }) { + Text( + if (showAdvanced) "Hide advanced" else "Advanced: relays & profile", + color = tokens.accentText, + ) + } + if (showAdvanced) { + SectionLabel("Relays") + relays.forEach { r -> + Text( + r, + style = MaterialTheme.typography.bodySmall, + color = tokens.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + TextButton( + onClick = { + viewModel.setRelays( + listOf("wss://relay.damus.io", "wss://relay.primal.net", "wss://nos.lol"), + ) + }, + ) { Text("Reset to defaults", color = tokens.textSecondary) } + + SectionLabel("Publish profile") + OutlinedTextField( + value = nameInput, + onValueChange = { nameInput = it }, + placeholder = { Text("Display name") }, + singleLine = true, + colors = fieldColors(tokens), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = aboutInput, + onValueChange = { aboutInput = it }, + placeholder = { Text("About (optional)") }, + singleLine = true, + colors = fieldColors(tokens), + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { viewModel.publishProfile(nameInput, aboutInput) }, + enabled = nameInput.isNotBlank() && busy.not(), + colors = ButtonDefaults.buttonColors( + containerColor = tokens.accentFill, + contentColor = tokens.accentOnFill, + ), + shape = RoundedCornerShape(SatsAmpGlass.rInput), + modifier = Modifier.fillMaxWidth(), + ) { Text("Publish to relays") } + } + + message?.let { + Text( + it, + style = MaterialTheme.typography.bodySmall, + color = tokens.accentText, + ) + TextButton(onClick = { viewModel.consumeMessage() }) { + Text("Dismiss", color = tokens.textMuted) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text("Close", color = tokens.accentText) } + }, + ) +} + +@Composable +private fun SectionLabel(text: String) { + Text( + text.uppercase(), + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = LocalTokens.current.textMuted, + ) +} + +@Composable +private fun fieldColors(tokens: com.rada.ui.theme.UiTokens) = + OutlinedTextFieldDefaults.colors( + focusedBorderColor = tokens.accentText, + unfocusedBorderColor = tokens.borderInteractive, + focusedTextColor = tokens.text, + unfocusedTextColor = tokens.text, + cursorColor = tokens.accentText, + focusedPlaceholderColor = tokens.textMuted, + unfocusedPlaceholderColor = tokens.textMuted, + ) diff --git a/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt new file mode 100644 index 0000000..0db3cb3 --- /dev/null +++ b/app/src/main/java/com/rada/ui/decentralized/DecentralizedViewModel.kt @@ -0,0 +1,93 @@ +package com.rada.ui.decentralized + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.rada.nostr.NostrKeys +import com.rada.nostr.NostrService +import com.rada.payments.NwcProvider +import com.rada.payments.PaymentService +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +@HiltViewModel +class DecentralizedViewModel @Inject constructor( + private val keys: NostrKeys, + private val nostr: NostrService, + private val nwc: NwcProvider, + val paymentService: PaymentService, +) : ViewModel() { + + /** NIP-19 npub for display/share (never raw hex). */ + val npub: kotlinx.coroutines.flow.StateFlow = + keys.pubkey + .map { hex -> hex?.let { com.rada.nostr.Nip19.npub(it) } } + .stateIn( + viewModelScope, + kotlinx.coroutines.flow.SharingStarted.Eagerly, + keys.npub(), + ) + val nostrEnabled = MutableStateFlow(nostr.enabled) + val walletConnected = MutableStateFlow(nwc.connected) + val message = MutableStateFlow(null) + val busy = MutableStateFlow(false) + + private val _relays = MutableStateFlow(nostr.relaysConfig()) + val relays: StateFlow> = _relays.asStateFlow() + + fun createIdentity() { + runCatching { keys.createIdentity() } + .onSuccess { + nostr.setEnabled(true) + nostrEnabled.value = true + message.value = "Nostr identity created ✅" + } + .onFailure { message.value = "Couldn't create identity: ${it.message}" } + } + + fun importNsec(nsec: String) { + if (keys.importNsec(nsec)) { + nostr.setEnabled(true) + nostrEnabled.value = true + message.value = "Identity imported ✅" + } else { + message.value = "That doesn't look like a valid nsec." + } + } + + fun publishProfile(name: String, about: String) { + busy.value = true + val ev = nostr.publishProfile(name, about, "") + busy.value = false + message.value = if (ev != null) + "Profile published to ${nostr.relaysConfig().size} relays" + else "Publish failed — check relays are reachable." + } + + fun setRelays(list: List) { + nostr.setRelays(list) + _relays.value = nostr.relaysConfig() + message.value = "Relays updated" + } + + fun connectWallet(uri: String) { + if (nwc.connect(uri)) { + walletConnected.value = true + message.value = "Wallet connected via Nostr Wallet Connect ⚡" + } else { + message.value = "That pairing string isn't valid (nostr+walletconnect://…)." + } + } + + fun disconnectWallet() { + nwc.disconnect() + walletConnected.value = false + message.value = "Wallet disconnected" + } + + fun consumeMessage() { message.value = null } +} diff --git a/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt new file mode 100644 index 0000000..49a9019 --- /dev/null +++ b/app/src/main/java/com/rada/ui/decentralized/TipSheet.kt @@ -0,0 +1,191 @@ +package com.rada.ui.decentralized + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Bolt +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.rada.ui.theme.LocalTokens + +/** + * "⚡ Support" — the whole listener-facing payment surface. Presets + + * optional comment + one address field (remembered). Advanced wiring + * (NWC, relays) lives in the Decentralized sheet, not here. + */ +@Composable +fun TipSheet( + artistName: String, + trackTitle: String?, + onDismiss: () -> Unit, + viewModel: TipViewModel = hiltViewModel(), +) { + val tokens = LocalTokens.current + val status by viewModel.status.collectAsState() + val sending by viewModel.sending.collectAsState() + val hasWallet by viewModel.hasWallet.collectAsState() + val recents by viewModel.recentAddresses.collectAsState() + + var amount by remember { mutableStateOf(100L) } + var customAmount by remember { mutableStateOf("") } + var address by remember { mutableStateOf(recents.firstOrNull() ?: "") } + var comment by remember { mutableStateOf("") } + val finalSats = customAmount.toLongOrNull()?.takeIf { it > 0 } ?: amount + + AlertDialog( + onDismissRequest = onDismiss, + containerColor = tokens.panel, + titleContentColor = tokens.text, + shape = RoundedCornerShape(20.dp), + title = { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.Bolt, contentDescription = null, tint = tokens.accentText) + Spacer(Modifier.size(8.dp)) + Text("Support $artistName", fontWeight = FontWeight.Bold) + } + }, + text = { + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + trackTitle?.takeIf { it.isNotBlank() }?.let { + Text( + "playing · $it", + style = MaterialTheme.typography.bodySmall, + color = tokens.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf(21L, 100L, 500L, 1000L).forEach { preset -> + FilterChip( + selected = customAmount.isBlank() && amount == preset, + onClick = { amount = preset; customAmount = "" }, + label = { Text("$preset") }, + colors = FilterChipDefaults.filterChipColors( + selectedContainerColor = tokens.accentSoft, + selectedLabelColor = tokens.accentText, + labelColor = tokens.textSecondary, + ), + ) + } + } + OutlinedTextField( + value = customAmount, + onValueChange = { customAmount = it.filter(Char::isDigit) }, + placeholder = { Text("Custom amount in sats") }, + singleLine = true, + suffix = { Text("sats", color = tokens.textMuted) }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = tokens.accentText, + unfocusedBorderColor = tokens.borderInteractive, + focusedTextColor = tokens.text, + unfocusedTextColor = tokens.text, + cursorColor = tokens.accentText, + focusedPlaceholderColor = tokens.textMuted, + unfocusedPlaceholderColor = tokens.textMuted, + ), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = address, + onValueChange = { address = it }, + placeholder = { Text("name@domain (Lightning address)") }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = tokens.accentText, + unfocusedBorderColor = tokens.borderInteractive, + focusedTextColor = tokens.text, + unfocusedTextColor = tokens.text, + cursorColor = tokens.accentText, + focusedPlaceholderColor = tokens.textMuted, + unfocusedPlaceholderColor = tokens.textMuted, + ), + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = comment, + onValueChange = { comment = it }, + placeholder = { Text("Message (optional)") }, + singleLine = true, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = tokens.accentText, + unfocusedBorderColor = tokens.borderInteractive, + focusedTextColor = tokens.text, + unfocusedTextColor = tokens.text, + cursorColor = tokens.accentText, + focusedPlaceholderColor = tokens.textMuted, + unfocusedPlaceholderColor = tokens.textMuted, + ), + modifier = Modifier.fillMaxWidth(), + ) + status?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = tokens.accentText) + } + } + }, + confirmButton = { + Button( + onClick = { viewModel.tip(address, finalSats, artistName, trackTitle) }, + enabled = !sending && address.contains("@") && finalSats > 0, + colors = ButtonDefaults.buttonColors( + containerColor = tokens.accentFill, + contentColor = tokens.accentOnFill, + ), + shape = RoundedCornerShape(14.dp), + ) { + if (sending) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), strokeWidth = 2.dp, + color = tokens.accentOnFill, + ) + } else { + Text("Send $finalSats sats") + } + } + }, + dismissButton = { + if (!hasWallet) { + Text( + "connect a wallet first\n(Settings ⚙ → Decentralized)", + 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 new file mode 100644 index 0000000..3066316 --- /dev/null +++ b/app/src/main/java/com/rada/ui/decentralized/TipViewModel.kt @@ -0,0 +1,60 @@ +package com.rada.ui.decentralized + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import android.content.Context +import com.rada.payments.PaymentResult +import com.rada.payments.PaymentService +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@HiltViewModel +class TipViewModel @Inject constructor( + @ApplicationContext private val context: Context, + private val paymentService: PaymentService, +) : ViewModel() { + + private val prefs = context.getSharedPreferences("tips", Context.MODE_PRIVATE) + + val status = MutableStateFlow(null) + val sending = MutableStateFlow(false) + val hasWallet = MutableStateFlow(paymentService.hasAnyWallet) + + /** Recently used lightning addresses (most recent first, max 8). */ + private val _recentAddresses = MutableStateFlow( + prefs.getString("recent", "")!!.split("\n").filter { it.contains("@") }, + ) + val recentAddresses: StateFlow> = _recentAddresses.asStateFlow() + + fun tip(address: String, sats: Long, artistName: String, trackTitle: String?) { + val clean = address.trim().removePrefix("lightning:") + if (!clean.contains("@")) { + status.value = "Enter a name@domain Lightning address." + return + } + sending.value = true + status.value = null + viewModelScope.launch { + when (val r = paymentService.tipArtist(clean, sats, artistName, trackTitle)) { + is PaymentResult.Paid -> { + status.value = "⚡ $sats sats sent to $artistName" + _recentAddresses.value = + (listOf(clean) + _recentAddresses.value.filter { it != clean }) + .distinct().take(8) + prefs.edit() + .putString("recent", _recentAddresses.value.joinToString("\n")) + .apply() + } + is PaymentResult.Failed -> status.value = r.userFacing + else -> status.value = "Invoice created." + } + sending.value = false + hasWallet.value = paymentService.hasAnyWallet + } + } +} 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 b9cf1b4..230bd51 100644 --- a/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt +++ b/app/src/main/java/com/rada/ui/player/NowPlayingBar.kt @@ -28,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.MusicNote import androidx.compose.material.icons.filled.Radio import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.Bolt import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -237,6 +238,27 @@ fun NowPlayingBar( } } } + // ⚡ Support — tip the artist/station from anywhere. + var showTip by remember { mutableStateOf(false) } + IconButton( + onClick = { showTip = true }, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.Default.Bolt, + contentDescription = "Support artist", + tint = LocalTokens.current.textSecondary, + modifier = Modifier.size(20.dp), + ) + } + if (showTip) { + com.rada.ui.decentralized.TipSheet( + artistName = stationName, + trackTitle = title.takeIf { subtitle.isNotEmpty() }, + onDismiss = { showTip = false }, + ) + } + IconButton( onClick = { viewModel.togglePlayPause() }, modifier = Modifier.size(44.dp), 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 b077843..04a5e11 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -124,6 +124,7 @@ fun PlayerScreen( val snackbarHostState = remember { SnackbarHostState() } var showImportDialog by remember { mutableStateOf(false) } var showAppearance by remember { mutableStateOf(false) } + var showDecentralized by remember { mutableStateOf(false) } val importSongsPicker = rememberLauncherForActivityResult( ActivityResultContracts.OpenMultipleDocuments() @@ -201,7 +202,19 @@ fun PlayerScreen( } if (showAppearance) { - AppearanceDialog(onDismiss = { showAppearance = false }) + AppearanceDialog( + onDismiss = { showAppearance = false }, + onOpenDecentralized = { + showAppearance = false + showDecentralized = true + }, + ) + } + + if (showDecentralized) { + com.rada.ui.decentralized.DecentralizedSheet( + onDismiss = { showDecentralized = false }, + ) } Scaffold( 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 6de7aa1..fc1c1d6 100644 --- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt +++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt @@ -45,7 +45,10 @@ internal val AccentSwatches = mapOf( * the whole UI recomposes live through LocalTokens. */ @Composable -fun AppearanceDialog(onDismiss: () -> Unit) { +fun AppearanceDialog( + onDismiss: () -> Unit, + onOpenDecentralized: (() -> Unit)? = null, +) { val context = LocalContext.current val tokens = LocalTokens.current val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark() @@ -141,8 +144,15 @@ fun AppearanceDialog(onDismiss: () -> Unit) { } }, confirmButton = { - TextButton(onClick = onDismiss) { - Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold) + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + if (onOpenDecentralized != null) { + TextButton(onClick = onOpenDecentralized) { + Text("⚡ Decentralized", color = tokens.accentText) + } + } + TextButton(onClick = onDismiss) { + Text("Done", color = tokens.accentText, fontWeight = FontWeight.Bold) + } } }, ) diff --git a/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt b/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt new file mode 100644 index 0000000..587bab5 --- /dev/null +++ b/app/src/test/java/com/rada/nostr/NostrProtocolTest.kt @@ -0,0 +1,91 @@ +package com.rada.nostr + +import fr.acinq.secp256k1.Secp256k1 +import fr.acinq.secp256k1.jni.NativeSecp256k1JvmLoader +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Protocol conformance tests against official spec vectors. + * Runs on the JVM via secp256k1-kmp-jni-jvm (testImplementation). + */ +class NostrProtocolTest { + + init { + // Load the JVM native binding for unit tests only. + NativeSecp256k1JvmLoader.load() + } + + @Test + fun `NIP-19 npub encodes known vector`() { + val pub = "7e7e9c42a91bfef19fa929e5fda1b72e0ebc1a4c1141673e2794234d86addf4e" + assertEquals( + "npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg", + Nip19.npub(pub), + ) + } + + @Test + fun `NIP-19 nsec encodes and decodes known vector`() { + val priv = "67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa" + val enc = Nip19.nsec(priv) + assertEquals("nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5", enc) + val dec = Nip19.decode(enc) + assertNotNull(dec) + val (hrp, bytes) = dec!! + assertEquals("nsec", hrp) + assertEquals(priv, bytes.toHex()) + } + + @Test + fun `schnorr sign verifies with BIP-340 semantics`() { + // sk=3 -> x-only pubkey F9308A... (BIP-340 vector 0) + val sk = ByteArray(32).also { it[31] = 3 } + val pub = Secp256k1.get().pubkeyCreate(sk).copyOfRange(1, 33) + assertEquals( + "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + pub.toHex(), + ) + val msg = ByteArray(32) + val sig = Secp256k1.get().signSchnorr(msg, sk, null) + assertEquals(64, sig.size) + assertTrue(Secp256k1.get().verifySchnorr(sig, msg, pub)) + } + + @Test + fun `event id is sha256 of NIP-01 serialization`() { + val unsigned = UnsignedEvent( + pubkey = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9", + createdAt = 1700000000, + kind = 1, + tags = listOf(listOf("e", "abc", "wss://relay.x"), listOf("d", "t1")), + content = "hello world", + ) + // Canonical serialization per NIP-01 (no whitespace, fixed order). + assertEquals( + """[0,"f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9",1700000000,1,[["e","abc","wss://relay.x"],["d","t1"]],"hello world"]""", + unsigned.serialized(), + ) + // Sign with sk=3 and verify() must pass end to end. + val ev = unsigned.sign { digest -> Secp256k1.get().signSchnorr(digest, ByteArray(32).also { it[31] = 3 }, null).toHex() } + assertNotNull(ev) + assertTrue("event self-verification (id hash + schnorr)", ev!!.verify()) + // Tampering must break verification. + assertFalse(ev.copy(content = "tampered").verify()) + } + + @Test + fun `nip-04 roundtrip between two identities`() { + val skA = ByteArray(32).also { it[31] = 11 } + val skB = ByteArray(32).also { it[31] = 22 } + val pubA = Secp256k1.get().pubkeyCreate(skA).copyOfRange(1, 33).toHex() + val pubB = Secp256k1.get().pubkeyCreate(skB).copyOfRange(1, 33).toHex() + val msg = "zap 21 sats ⚡" + val enc = Nip04.encrypt(skA, pubB, msg) + org.junit.Assert.assertEquals(msg, Nip04.decrypt(skB, pubA, enc)) + } + + private fun assertFalse(b: Boolean) = org.junit.Assert.assertFalse(b) +} From 1c9d07251a5c8616f849af14a55eccc49b02588d Mon Sep 17 00:00:00 2001 From: avi Date: Thu, 10 Sep 2026 02:17:48 -0500 Subject: [PATCH 10/10] feat(nostr): Amber external-signer login, pubkey import, ephemeral zap fallback NIP-55 get_public_key flow (nostrsigner: intent) with signer package stored for future requests; manual npub import fallback; sign-out. Rename user-facing Decentralized labels to Nostr. Fix Wavlake track zaps for external-signer logins via canSignLocally + ephemeral keys. Artist address resolution + zap buttons in Discover search. --- PRODUCT.md | 4 +- app/proguard-rules.pro | 11 + app/src/main/AndroidManifest.xml | 10 + .../com/rada/data/local/DefaultStations.kt | 370 +----- .../com/rada/data/local/SatsAmpDatabase.kt | 19 +- .../com/rada/data/local/dao/PlaylistDao.kt | 13 + .../rada/data/local/entity/SavedSongEntity.kt | 10 + .../data/repository/PlaylistRepository.kt | 5 + .../rada/data/repository/StationRepository.kt | 61 +- .../rada/data/repository/StreamRepository.kt | 33 + .../com/rada/data/wavlake/StationFeature.kt | 112 ++ .../java/com/rada/data/wavlake/WavlakeApi.kt | 227 ++++ .../rada/data/wavlake/WavlakeRepository.kt | 441 +++++++ .../java/com/rada/data/wavlake/WavlakeZap.kt | 104 ++ app/src/main/java/com/rada/di/AppModule.kt | 5 + .../com/rada/domain/model/WavlakeModels.kt | 52 + app/src/main/java/com/rada/nostr/NostrKeys.kt | 79 +- .../main/java/com/rada/nostr/NostrService.kt | 12 +- .../java/com/rada/nostr/RelayConnection.kt | 19 +- .../com/rada/payments/LightningAddress.kt | 27 + .../java/com/rada/payments/NwcProvider.kt | 4 +- .../java/com/rada/payments/PaymentService.kt | 2 +- .../main/java/com/rada/payments/ZapService.kt | 116 ++ .../java/com/rada/player/PlaybackManager.kt | 181 ++- .../java/com/rada/player/PlaybackService.kt | 36 +- .../com/rada/recording/MetadataEmbedder.kt | 6 +- app/src/main/java/com/rada/ui/MainActivity.kt | 34 + .../ui/decentralized/DecentralizedSheet.kt | 72 +- .../decentralized/DecentralizedViewModel.kt | 47 + .../com/rada/ui/decentralized/TipSheet.kt | 42 +- .../com/rada/ui/decentralized/TipViewModel.kt | 20 +- .../com/rada/ui/discover/DiscoverScreen.kt | 1149 +++++++++++++++++ .../com/rada/ui/discover/WavlakeViewModel.kt | 304 +++++ .../rada/ui/discover/WavlakeZapViewModel.kt | 76 ++ .../java/com/rada/ui/library/LibraryScreen.kt | 306 ++++- .../com/rada/ui/library/LibraryViewModel.kt | 114 +- .../java/com/rada/ui/navigation/NavGraph.kt | 7 +- .../java/com/rada/ui/navigation/TabsScreen.kt | 42 +- .../java/com/rada/ui/player/NowPlayingBar.kt | 8 +- .../com/rada/ui/player/NowPlayingViewModel.kt | 35 + .../java/com/rada/ui/player/PlayerScreen.kt | 461 +++---- .../com/rada/ui/player/PlayerViewModel.kt | 70 +- .../rada/ui/stations/StationBrowserScreen.kt | 489 ++++++- .../ui/stations/StationBrowserViewModel.kt | 130 +- .../java/com/rada/ui/streams/StreamsScreen.kt | 11 +- .../com/rada/ui/theme/AppearanceDialog.kt | 2 +- .../com/rada/data/wavlake/WavlakeLogicTest.kt | 164 +++ .../com/rada/data/wavlake/WavlakeZapTest.kt | 67 + .../com/rada/payments/LightningAddressTest.kt | 58 + 49 files changed, 4892 insertions(+), 775 deletions(-) create mode 100644 app/src/main/java/com/rada/data/wavlake/StationFeature.kt create mode 100644 app/src/main/java/com/rada/data/wavlake/WavlakeApi.kt create mode 100644 app/src/main/java/com/rada/data/wavlake/WavlakeRepository.kt create mode 100644 app/src/main/java/com/rada/data/wavlake/WavlakeZap.kt create mode 100644 app/src/main/java/com/rada/domain/model/WavlakeModels.kt create mode 100644 app/src/main/java/com/rada/payments/LightningAddress.kt create mode 100644 app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt create mode 100644 app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt create mode 100644 app/src/main/java/com/rada/ui/discover/WavlakeZapViewModel.kt create mode 100644 app/src/test/java/com/rada/data/wavlake/WavlakeLogicTest.kt create mode 100644 app/src/test/java/com/rada/data/wavlake/WavlakeZapTest.kt create mode 100644 app/src/test/java/com/rada/payments/LightningAddressTest.kt 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")) + } +}