diff --git a/Satsamp Icon.png b/Satsamp Icon.png new file mode 100644 index 0000000..4237cb2 Binary files /dev/null and b/Satsamp Icon.png differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ab2502d..957223e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,6 +54,7 @@ android { buildFeatures { compose = true + buildConfig = true } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b61c366..b5fe611 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,8 @@ + + @@ -48,6 +50,17 @@ + + + + + diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt index d656776..1bacbd6 100644 --- a/app/src/main/java/com/rada/player/PlaybackManager.kt +++ b/app/src/main/java/com/rada/player/PlaybackManager.kt @@ -465,11 +465,18 @@ class PlaybackManager( } private fun startIcyReader(url: String) { + // Never leave a previous reader's socket open: an unclosed stream + // keeps its OkHttp connection (and its own thread) streaming forever, + // and every ghost feeds the same now-playing field — that is how four + // different songs flickered over one another. + stopIcyReader() val reader = IcyStreamReader() icyReader = reader reader.listener = object : IcyStreamReader.Listener { override fun onMetadata(metadata: StreamMetadata, audioBytesAtMetadata: Long) { + // Drop callbacks from a reader we already replaced or stopped. + if (icyReader !== reader) return Log.d(TAG, "ICY metadata: ${metadata.artist} - ${metadata.title} @ $audioBytesAtMetadata") // Record at the reader-reported ABSOLUTE audio position (the same // quantity as audioBuffer.totalBytes). The previous code read @@ -486,6 +493,7 @@ class PlaybackManager( } override fun onAudioBytes(data: ByteArray, offset: Int, length: Long) { + if (icyReader !== reader) return audioBuffer?.write(data, offset, length.toInt()) listener?.onBufferBytesWritten(audioBuffer?.totalBytes ?: 0) @@ -495,6 +503,7 @@ class PlaybackManager( } override fun onError(error: String) { + if (icyReader !== reader) return 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 diff --git a/app/src/main/java/com/rada/recording/IcyStreamReader.kt b/app/src/main/java/com/rada/recording/IcyStreamReader.kt index 989e393..5c3e899 100644 --- a/app/src/main/java/com/rada/recording/IcyStreamReader.kt +++ b/app/src/main/java/com/rada/recording/IcyStreamReader.kt @@ -18,6 +18,12 @@ class IcyStreamReader( var isRunning: Boolean = false private set + /** True once stop() was called by request. A close() racing a blocked + * stream.read() throws IOException, NOT CancellationException — without + * this flag the generic catch reports an intentional stop as an error, + * and the manager's error-restart resurrects a ghost reader. */ + @Volatile private var requestedStop = false + private var metaInterval: Int = 0 private var bytesInInterval: Int = 0 private var pendingMetaBytes: Int = 0 @@ -52,6 +58,7 @@ class IcyStreamReader( fun start(streamUrl: String) { if (isRunning) stop() + requestedStop = false scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope?.launch { try { @@ -59,8 +66,10 @@ class IcyStreamReader( } catch (e: CancellationException) { // Normal stop } catch (e: Exception) { - Log.e(TAG, "Error reading stream", e) - listener?.onError(e.message ?: "Connection error") + if (!requestedStop) { + Log.e(TAG, "Error reading stream", e) + listener?.onError(e.message ?: "Connection error") + } } finally { isRunning = false } @@ -68,6 +77,7 @@ class IcyStreamReader( } fun stop() { + requestedStop = true scope?.cancel() scope = null closeConnection() diff --git a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt index a137c94..6a5d856 100644 --- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt +++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt @@ -23,6 +23,7 @@ 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.Radio import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.Tune import androidx.compose.material3.AlertDialog @@ -61,6 +62,7 @@ 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.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.rada.data.wavlake.MatchingMode import com.rada.data.wavlake.WavlakeSearchHit @@ -204,6 +206,25 @@ fun DiscoverScreen( shape = RoundedCornerShape(12.dp), ) + // What to search: toggle Artists / Tracks / Genres / Stations. + val selectedTypes by viewModel.selectedTypes.collectAsState() + LazyRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = androidx.compose.foundation.layout.PaddingValues( + horizontal = 16.dp, + ), + ) { + items(DiscoverType.entries.toList(), key = { "type-${it.name}" }) { type -> + GenreChip( + label = type.label, + selected = type in selectedTypes, + onClick = { viewModel.toggleType(type) }, + ) + } + } + Spacer(modifier = Modifier.height(6.dp)) + when (val s = searchState) { WavlakeSearchState.Idle -> { GenreChips( @@ -247,33 +268,142 @@ fun DiscoverScreen( } } is WavlakeSearchState.Results -> { + val r = s.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) - }, + if (r.stations.isNotEmpty()) { + item(key = "sec-stations", contentType = "header") { + SectionHeader("Stations") + } + items(r.stations, key = { "st-${it.id}" }, contentType = { "station" }) { st -> + Card( + onClick = { viewModel.playFoundStation(st) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + colors = CardDefaults.cardColors( + containerColor = LocalTokens.current.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( + LocalTokens.current.accentFill.copy(alpha = 0.15f), + ), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Default.Radio, + contentDescription = null, + tint = LocalTokens.current.accentText, + modifier = Modifier.size(20.dp), + ) + } + Spacer(modifier = Modifier.width(14.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = st.name, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = LocalTokens.current.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = st.genre.ifBlank { "Radio" }, + style = MaterialTheme.typography.bodySmall, + color = LocalTokens.current.textSecondary, + maxLines = 1, + ) + } + IconButton( + onClick = { viewModel.playFoundStation(st) }, + modifier = Modifier.size(48.dp), + ) { + Icon( + Icons.Default.PlayArrow, + contentDescription = "Play ${st.name}", + tint = LocalTokens.current.textSecondary, + modifier = Modifier.size(22.dp), + ) + } + } + } + } + } + if (r.genres.isNotEmpty()) { + item(key = "sec-genres", contentType = "header") { + SectionHeader("Genres") + } + items(r.genres, key = { "gen-$it" }, contentType = { "genre" }) { genre -> + Card( + onClick = { viewModel.openGenre(genre) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp), + colors = CardDefaults.cardColors( + containerColor = LocalTokens.current.glassFillStrong, + ), + shape = RoundedCornerShape(SatsAmpGlass.rInput), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = genre, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = LocalTokens.current.text, + modifier = Modifier.weight(1f), + ) + Text( + text = "Browse", + style = MaterialTheme.typography.labelLarge, + color = LocalTokens.current.accentText, + ) + } + } + } + } + if (r.artists.isNotEmpty()) { + item(key = "sec-artists", contentType = "header") { + SectionHeader("Artists") + } + items(r.artists, key = { "artist-${it.id}" }, contentType = { "artist" }) { artist -> + ArtistRow( + name = artist.name, + genre = artist.genre, + imageUrl = artist.imageUrl, + artistId = artist.id, + address = addresses[artist.id], + onClick = { viewModel.openArtist(artist.id, artist.name) }, onZap = { - viewModel.resolveArtistAddress(hit.artist.id) - pendingZapArtistId = hit.artist.id + viewModel.resolveArtistAddress(artist.id) + pendingZapArtistId = 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( + } + } + if (r.tracks.isNotEmpty() || r.albums.isNotEmpty()) { + item(key = "sec-tracks", contentType = "header") { + SectionHeader("Tracks") + } + items(r.tracks, key = { "track-${it.track.id}" }, contentType = { "track" }) { hit -> + TrackRow( track = com.rada.domain.model.WavlakeTrack( id = hit.track.id, title = hit.track.title, @@ -317,6 +447,14 @@ fun DiscoverScreen( }, ) } + items(r.albums, key = { "album-${it.id}" }, contentType = { "album" }) { album -> + AlbumRow( + title = album.title, + artist = album.artist, + artworkUrl = album.artworkUrl, + onClick = { /* albums open via artist detail */ }, + ) + } } } } @@ -334,6 +472,18 @@ fun DiscoverScreen( } } +@Composable +private fun SectionHeader(title: String) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + letterSpacing = 0.6.sp, + color = LocalTokens.current.accentText, + modifier = Modifier.padding(start = 20.dp, top = 14.dp, bottom = 2.dp), + ) +} + private data class ZapTarget( val artistId: String, val artistName: String, diff --git a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt index c76862e..819afad 100644 --- a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt +++ b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt @@ -26,10 +26,27 @@ import javax.inject.Inject /** Curated genre shortcuts — exact Wavlake catalog names only. */ val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock") +/** What the Discover search looks for; user-toggled chips. */ +enum class DiscoverType(val label: String) { + ARTISTS("Artists"), TRACKS("Tracks"), GENRES("Genres"), STATIONS("Stations"), +} + +/** Search results bucketed by type — only selected types are populated. */ +data class DiscoverResults( + val artists: List = emptyList(), + val tracks: List = emptyList(), + val albums: List = emptyList(), + val genres: List = emptyList(), + val stations: List = emptyList(), +) { + fun isEmpty() = artists.isEmpty() && tracks.isEmpty() && albums.isEmpty() && + genres.isEmpty() && stations.isEmpty() +} + sealed interface WavlakeSearchState { data object Idle : WavlakeSearchState data object Loading : WavlakeSearchState - data class Results(val hits: List) : WavlakeSearchState + data class Results(val results: DiscoverResults) : WavlakeSearchState data class Error(val message: String) : WavlakeSearchState } @@ -39,11 +56,39 @@ class WavlakeViewModel @Inject constructor( private val repository: WavlakeRepository, private val playbackManager: PlaybackManager, private val playlists: PlaylistRepository, + private val stations: com.rada.data.repository.StationRepository, + @dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context, ) : ViewModel() { private val _searchQuery = MutableStateFlow("") val searchQuery: StateFlow = _searchQuery.asStateFlow() + /** Which result types to search/show; persisted so the choice sticks. */ + private val _selectedTypes = MutableStateFlow(loadTypes()) + val selectedTypes: StateFlow> = _selectedTypes.asStateFlow() + + fun toggleType(type: DiscoverType) { + val current = _selectedTypes.value.toMutableSet() + if (type in current) { + // Never allow an empty selection — re-tapping the last one keeps it. + if (current.size == 1) return + current -= type + } else current += type + _selectedTypes.value = current + appContext.getSharedPreferences("discover_prefs", android.content.Context.MODE_PRIVATE) + .edit().putStringSet("types", current.map { it.name }.toSet()).apply() + // Re-run the current query so results reflect the new selection now. + val q = _searchQuery.value + if (q.trim().length >= 2) _searchQuery.value = "$q " + } + + private fun loadTypes(): Set { + val saved = appContext.getSharedPreferences("discover_prefs", android.content.Context.MODE_PRIVATE) + .getStringSet("types", null) + val parsed = saved?.mapNotNull { runCatching { DiscoverType.valueOf(it) }.getOrNull() }?.toSet() + return parsed?.takeIf { it.isNotEmpty() } ?: DiscoverType.entries.toSet() + } + private val _searchState = MutableStateFlow(WavlakeSearchState.Idle) val searchState: StateFlow = _searchState.asStateFlow() @@ -89,17 +134,60 @@ class WavlakeViewModel @Inject constructor( 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) + val types = _selectedTypes.value + // Local stations need no network; Wavlake hit types share one call. + val needRemote = types.any { + it != DiscoverType.GENRES && it != DiscoverType.STATIONS } + val hits = if (needRemote) { + when (val r = repository.search(query)) { + is WavlakeResult.Ok -> r.value + is WavlakeResult.Failed -> { + // Stations alone can still answer; otherwise surface the error. + if (types.contains(DiscoverType.STATIONS)) emptyList() + else { + _searchState.value = WavlakeSearchState.Error(r.message) + return@onEach + } + } + } + } else emptyList() + + val matchQ = query.lowercase() + val bucket = DiscoverResults( + artists = hits.filterIsInstance() + .filter { DiscoverType.ARTISTS in types }.map { it.artist }, + tracks = hits.filterIsInstance() + .filter { DiscoverType.TRACKS in types }, + albums = hits.filterIsInstance() + .filter { DiscoverType.TRACKS in types }, + genres = (if (DiscoverType.GENRES in types) genreMatches(query) else emptyList()), + stations = if (DiscoverType.STATIONS in types) + stations.getAll().filter { + it.name.lowercase().contains(matchQ) || + it.genre.lowercase().contains(matchQ) + } + else emptyList(), + ) + _searchState.value = + if (bucket.isEmpty()) WavlakeSearchState.Error("No results for “$query”.") + else WavlakeSearchState.Results(bucket) } .launchIn(viewModelScope) refreshRankings(null) } + /** Genres matching the query: curated discover genres + every station genre. */ + private fun genreMatches(query: String): List { + val q = query.lowercase() + return (DISCOVER_GENRES + stations.getAll().map { it.genre }.filter { it.isNotBlank() }) + .map { it to it.lowercase() } + .filter { (_, low) -> low.contains(q) || q.contains(low.substringBefore('/')) } + .map { it.first } + .distinct() + .take(8) + } + fun search(query: String) { _searchQuery.value = query } @@ -220,6 +308,31 @@ class WavlakeViewModel @Inject constructor( ) } + /** Start a local/added radio station found through Discover search. */ + fun playFoundStation(station: com.rada.domain.model.Station) { + playbackManager.playStation(station.url, station.name) + } + + /** Jump the Stations tab filtered to this genre (Discover genre hit). */ + fun openGenre(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 saveTrack(track: WavlakeTrack) { viewModelScope.launch { _notice.value = when (val r = repository.importTrack(track)) { 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 55ccca1..04db2c6 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -1,5 +1,6 @@ package com.rada.ui.library +import androidx.compose.animation.Crossfade import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background @@ -28,6 +29,7 @@ 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.MoreVert import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.filled.MusicNote @@ -107,6 +109,7 @@ fun LibraryScreen( var showSearch by remember { mutableStateOf(false) } var searchQuery by remember { mutableStateOf("") } var showImportMenu by remember { mutableStateOf(false) } + var showMoreMenu by remember { mutableStateOf(false) } var showAppearance by remember { mutableStateOf(false) } var playlistToDelete by remember { mutableStateOf(null) } var songForPlaylist by remember { mutableStateOf(null) } @@ -154,7 +157,14 @@ fun LibraryScreen( Scaffold( topBar = { TopAppBar( - title = { Text("Library") }, + title = { + Text( + "Library", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Bold, + ) + }, navigationIcon = { Row(horizontalArrangement = Arrangement.Start) { AppHomeButton(onClick = onHome) @@ -197,45 +207,75 @@ fun LibraryScreen( Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play") } } - IconButton( - onClick = { viewModel.refreshImportedFolders() }, - enabled = !uiState.isImporting, - ) { - Icon( - Icons.Default.Refresh, - contentDescription = "Refresh imported folders", - tint = if (uiState.isImporting) { - LocalTokens.current.textSecondary.copy(alpha = 0.4f) - } else { - LocalTokens.current.textSecondary - }, - ) - } - // Reliable delete path for the selected folder (besides long-press). - if (uiState.selectedPlaylistId != null) { - allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId }?.let { selected -> - IconButton(onClick = { playlistToDelete = selected }) { - Icon( - Icons.Default.Delete, - contentDescription = "Delete folder", - tint = LocalTokens.current.textSecondary, + // Refresh / delete-folder / multi-select live in one + // overflow menu so the title keeps its full width instead + // of wrapping mid-word ("Libr|ary"). All still reachable. + Box { + IconButton(onClick = { showMoreMenu = true }) { + Icon( + Icons.Default.MoreVert, + contentDescription = "More library actions", + tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill + else LocalTokens.current.textSecondary, + ) + } + DropdownMenu( + expanded = showMoreMenu, + onDismissRequest = { showMoreMenu = false }, + ) { + DropdownMenuItem( + text = { Text("Refresh imported folders") }, + enabled = !uiState.isImporting, + onClick = { + showMoreMenu = false + viewModel.refreshImportedFolders() + }, + leadingIcon = { Icon(Icons.Default.Refresh, contentDescription = null) }, + ) + if (uiState.selectedPlaylistId != null) { + allPlaylists.firstOrNull { it.id == uiState.selectedPlaylistId } + ?.let { selected -> + DropdownMenuItem( + text = { Text("Delete “${selected.name}”") }, + onClick = { + showMoreMenu = false + playlistToDelete = selected + }, + leadingIcon = { + Icon( + Icons.Default.Delete, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + ) + } + } + if (tabLabels.size > 1) { + DropdownMenuItem( + text = { + Text( + if (uiState.isMultiSelect) "Exit multi-select" + else "Select multiple folders", + ) + }, + onClick = { + showMoreMenu = false + if (uiState.isMultiSelect) viewModel.exitMultiSelect() + else viewModel.enterMultiSelect() + }, + leadingIcon = { + Icon( + Icons.Default.Checklist, + contentDescription = null, + tint = if (uiState.isMultiSelect) LocalTokens.current.accentFill + else LocalTokens.current.textSecondary, + ) + }, ) } } } - // 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, @@ -284,16 +324,20 @@ fun LibraryScreen( .padding(horizontal = 16.dp) .padding(top = 4.dp, bottom = 2.dp), ) { + // Crossfade the header when the selected folder changes — a + // hard text swap reads as a glitch when tabs are tapped. + Crossfade(targetState = headerTitle, label = "libraryHeader") { t -> + Text( + text = t, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + color = LocalTokens.current.text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } 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", + text = "${uiState.songs.size} ${if (uiState.songs.size == 1) "song" else "songs"}", style = MaterialTheme.typography.bodySmall, color = LocalTokens.current.textSecondary, ) @@ -477,8 +521,12 @@ fun LibraryScreen( modifier = Modifier.fillMaxSize(), ) { items(uiState.songs, key = { it.id }) { song -> + // Keyed rows animate in/out when the folder tab or + // search filter changes instead of popping. + val rowMod = if (LocalReduceMotion.current) Modifier else Modifier.animateItem() SongListItem( song = song, + modifier = rowMod, isPlaying = song.id == uiState.currentPlayingId, onClick = { viewModel.playSong(song) }, onToggleFavorite = { viewModel.toggleFavorite(song.id) }, @@ -768,10 +816,11 @@ private fun SongListItem( onToggleFavorite: () -> Unit, onAddToPlaylist: () -> Unit, onDelete: () -> Unit, + modifier: Modifier = Modifier, ) { val interaction = remember { MutableInteractionSource() } Row( - modifier = Modifier + modifier = modifier .fillMaxWidth() .pressDip(interaction, LocalReduceMotion.current) .clickable(interactionSource = interaction, indication = androidx.compose.foundation.LocalIndication.current, onClick = onClick) 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 feb5d55..3f7414a 100644 --- a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt +++ b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt @@ -30,6 +30,7 @@ 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.LocalReduceMotion import com.rada.ui.theme.LocalTokens import kotlinx.coroutines.launch @@ -49,14 +50,26 @@ fun TabsScreen( ) { val pagerState = rememberPagerState(initialPage = initialIndex.coerceIn(0, 4)) { 5 } val scope = rememberCoroutineScope() + val reduceMotion = LocalReduceMotion.current // Deep-link target for Library (playlist picker → open that playlist). var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) } - // Tab taps land immediately. Swiping remains animated, but animating a - // tap composes and disposes heavy pages while the gesture is in flight. + // Adjacent tabs glide (neighbors stay composed, so it's smooth); far + // taps jump instantly — animating a multi-page hop composes heavy pages + // mid-flight and reintroduces the jank the jump was there to avoid. fun goToPage(page: Int) { if (pagerState.currentPage == page) return scope.launch { - pagerState.scrollToPage(page) + if (kotlin.math.abs(pagerState.currentPage - page) == 1 && !reduceMotion) { + pagerState.animateScrollToPage( + page, + animationSpec = androidx.compose.animation.core.tween( + durationMillis = 260, + easing = com.rada.ui.theme.MotionArrival, + ), + ) + } else { + pagerState.scrollToPage(page) + } } } 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 ea0ff8f..9db2807 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -84,6 +84,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -440,41 +441,49 @@ private fun WaveGraphic(modifier: Modifier = Modifier) { val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd) val accentGlow = LocalTokens.current.accentFill - Canvas(modifier = modifier) { + // Build each wave path + its gradient ONCE per size/palette, not per draw + // pass: this canvas sits on the hub, and rebuilding ~1k lineTo segments + // per layer was the heavy frame behind the hub-transition jank (99th-p + // ~200ms). 4px steps are indistinguishable at this frequency and cut + // segment count by 4x. + var canvasSize by remember { mutableStateOf(androidx.compose.ui.unit.IntSize.Zero) } + val layers = remember(canvasSize, gradientColors) { + val w = canvasSize.width.toFloat() + val h = canvasSize.height.toFloat() + if (w <= 0f || h <= 0f) emptyList() + else (0..2).map { layer -> + val amplitude = (30f - layer * 8f) * (w / 400f) + val frequency = 0.008f + layer * 0.002f + val phaseShift = layer * 0.5f + val path = Path().apply { + moveTo(0f, h / 2 + amplitude * kotlin.math.sin(phaseShift.toDouble()).toFloat()) + var x = 0f + while (x <= w) { + val y = h / 2 + amplitude * kotlin.math.sin( + (x * frequency * Math.PI * 2).toDouble() + phaseShift + ).toFloat() + lineTo(x, y) + x += 4f + } + } + val strokeBrush = Brush.horizontalGradient( + colors = gradientColors.map { it.copy(alpha = 0.6f - layer * 0.15f) }, + startX = 0f, + endX = w, + ) + Triple(path, strokeBrush, (3f - layer * 0.5f) * (w / 400f)) + } + } + + Canvas( + modifier = modifier.onSizeChanged { canvasSize = it }, + ) { val width = size.width val height = size.height val centerY = height / 2 - // Draw multiple wave layers - for (layer in 0..2) { - val path = Path() - val amplitude = (30f - layer * 8f) * (width / 400f) - val frequency = 0.008f + layer * 0.002f - val phaseShift = layer * 0.5f - - path.moveTo(0f, centerY) - - for (x in 0..width.toInt()) { - val xFloat = x.toFloat() - val y = centerY + amplitude * kotlin.math.sin( - (xFloat * frequency * Math.PI * 2).toDouble() + phaseShift - ).toFloat() - if (x == 0) { - path.moveTo(xFloat, y) - } else { - path.lineTo(xFloat, y) - } - } - - drawPath( - path = path, - brush = Brush.horizontalGradient( - colors = gradientColors.map { it.copy(alpha = 0.6f - layer * 0.15f) }, - startX = 0f, - endX = width, - ), - style = Stroke(width = (3f - layer * 0.5f) * (width / 400f)), - ) + for ((path, strokeBrush, strokeWidth) in layers) { + drawPath(path = path, brush = strokeBrush, style = Stroke(width = strokeWidth)) } // Ambient glow behind the waves — wide, faint, no visible disc edge. diff --git a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt index c987284..7d44d55 100644 --- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt +++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt @@ -1,5 +1,8 @@ package com.rada.ui.theme +import android.content.Intent +import android.net.Uri +import android.provider.Settings import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -18,10 +21,17 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -30,6 +40,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.rada.BuildConfig +import com.rada.update.UpdateChecker +import kotlinx.coroutines.launch /** Static swatch preview colors for the picker (independent of active theme). */ internal val AccentSwatches = mapOf( @@ -52,6 +65,46 @@ fun AppearanceDialog( val context = LocalContext.current val tokens = LocalTokens.current val lightMode = !MaterialTheme.colorScheme.background.luminanceIsDark() + val scope = rememberCoroutineScope() + + // Update flow state: idle | checking | downloading(progress) | ready | error | upToDate + var updateStatus by remember { mutableStateOf(null) } + var updateProgress by remember { mutableFloatStateOf(-1f) } + var updateInfo by remember { mutableStateOf(null) } + + fun startUpdate() { + val pm = context.packageManager + if (!pm.canRequestPackageInstalls()) { + updateStatus = "needs_permission" + val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply { + data = Uri.parse("package:${context.packageName}") + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + runCatching { context.startActivity(intent) } + .onFailure { updateStatus = "Can't open install settings on this device." } + return + } + updateStatus = "checking" + scope.launch { + try { + val info = UpdateChecker.check(BuildConfig.VERSION_NAME) + if (info == null) { + updateStatus = "up_to_date" + } else { + updateInfo = info + updateStatus = "downloading" + updateProgress = -1f + val apk = UpdateChecker.download(context, info.downloadUrl) { f -> + updateProgress = f + } + updateStatus = "ready" + UpdateChecker.launchInstall(context, apk) + } + } catch (e: Exception) { + updateStatus = "error: ${e.message ?: "download failed"}" + } + } + } AlertDialog( onDismissRequest = onDismiss, @@ -141,6 +194,52 @@ fun AppearanceDialog( fontSize = 12.sp, color = tokens.textMuted, ) + Spacer(Modifier.height(22.dp)) + Text( + "UPDATE", + fontSize = 12.sp, + letterSpacing = 0.4.sp, + fontWeight = FontWeight.SemiBold, + color = tokens.textMuted, + ) + Spacer(Modifier.height(10.dp)) + val busy = updateStatus == "checking" || updateStatus == "downloading" + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(SatsAmpGlass.rInput)) + .background(tokens.accentSoft) + .border(1.dp, tokens.borderInteractive, RoundedCornerShape(SatsAmpGlass.rInput)) + .clickable(enabled = !busy) { startUpdate() } + .padding(vertical = 12.dp, horizontal = 14.dp), + ) { + Column { + Text( + when (updateStatus) { + "checking" -> "Checking for updates…" + "downloading" -> if (updateProgress >= 0f) + "Downloading v${updateInfo?.version}… ${(updateProgress * 100).toInt()}%" + else + "Downloading v${updateInfo?.version}…" + "ready" -> "Update downloaded — install again if the dialog was dismissed" + "up_to_date" -> "You're up to date (v${BuildConfig.VERSION_NAME})" + "needs_permission" -> "Allow \"Install unknown apps\" for SatsAmp, then tap again" + null -> "Check for updates (current: v${BuildConfig.VERSION_NAME})" + else -> updateStatus ?: "" + }, + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = tokens.text, + ) + if (updateStatus == "downloading" && updateProgress >= 0f) { + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { updateProgress }, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } } }, confirmButton = { diff --git a/app/src/main/java/com/rada/ui/theme/Motion.kt b/app/src/main/java/com/rada/ui/theme/Motion.kt index 709aec3..28df9ed 100644 --- a/app/src/main/java/com/rada/ui/theme/Motion.kt +++ b/app/src/main/java/com/rada/ui/theme/Motion.kt @@ -1,6 +1,7 @@ package com.rada.ui.theme import androidx.compose.ui.unit.dp +import androidx.compose.runtime.Composable import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState @@ -130,12 +131,15 @@ fun feedbackDuration(reduce: Boolean): Int = if (reduce) 0 else FeedbackMs /** * 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. + * (A plain @Composable extension — the old composed{} form spun up an extra + * subcomposition per row, wasted recomposition churn in long lists.) */ +@Composable fun Modifier.pressDip( interactionSource: MutableInteractionSource, reduce: Boolean, -): Modifier = composed { - if (reduce) return@composed this +): Modifier { + if (reduce) return this val pressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( targetValue = if (pressed) 0.97f else 1f, @@ -145,7 +149,7 @@ fun Modifier.pressDip( ), label = "pressDip", ) - this.graphicsLayer { scaleX = scale; scaleY = scale } + return this.graphicsLayer { scaleX = scale; scaleY = scale } } // ── Dismiss-by-drag ─────────────────────────────────────────────────────── diff --git a/app/src/main/java/com/rada/update/UpdateChecker.kt b/app/src/main/java/com/rada/update/UpdateChecker.kt new file mode 100644 index 0000000..d62094b --- /dev/null +++ b/app/src/main/java/com/rada/update/UpdateChecker.kt @@ -0,0 +1,121 @@ +package com.rada.update + +import android.content.Context +import android.content.Intent +import androidx.core.content.FileProvider +import java.io.File +import java.util.concurrent.TimeUnit +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Self-update over the Forgejo release API (git.atitlan.io/avi/SatsAmp). + * Reads the latest public release anonymously, downloads the APK asset into + * the app cache, then hands it to the system installer via FileProvider — + * the user only needs to confirm the install dialog (and allow + * "install unknown apps" once). + */ +object UpdateChecker { + const val RELEASE_PAGE_URL = "https://git.atitlan.io/avi/SatsAmp/releases" + private const val LATEST_API = + "https://git.atitlan.io/api/v1/repos/avi/SatsAmp/releases/latest" + + private val json = Json { ignoreUnknownKeys = true } + private val client = OkHttpClient.Builder() + .connectTimeout(15, TimeUnit.SECONDS) + .readTimeout(120, TimeUnit.SECONDS) + .build() + + @Serializable + private data class ReleaseJson( + val tag_name: String = "", + val assets: List = emptyList(), + ) + + @Serializable + private data class AssetJson( + val name: String = "", + val size: Long = 0, + val browser_download_url: String = "", + ) + + data class UpdateInfo(val version: String, val downloadUrl: String, val sizeBytes: Long) + + /** Returns UpdateInfo when the latest release is newer than this build, else null. */ + suspend fun check(currentVersion: String): UpdateInfo? = withContext(Dispatchers.IO) { + val req = Request.Builder().url(LATEST_API) + .header("Accept", "application/json").build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) return@withContext null // no releases yet / offline + val body = resp.body?.string() ?: return@withContext null + val rel = json.decodeFromString(ReleaseJson.serializer(), body) + val apk = rel.assets.firstOrNull { it.name.endsWith(".apk", ignoreCase = true) } + ?: return@withContext null + val v = normalize(rel.tag_name) + if (v.isNotEmpty() && compareVersions(v, normalize(currentVersion)) > 0) + UpdateInfo(v, apk.browser_download_url, apk.size) + else null + } + } + + /** Downloads the APK; [onProgress] gets fraction 0..1 (or -1 if size unknown). */ + suspend fun download( + context: Context, + url: String, + onProgress: (Float) -> Unit, + ): File = withContext(Dispatchers.IO) { + val dir = File(context.cacheDir, "apks").apply { mkdirs() } + // Clear stale downloads so a partial/old file can't shadow the new one. + dir.listFiles()?.forEach { it.delete() } + val out = File(dir, "update.apk") + val req = Request.Builder().url(url).build() + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) throw java.io.IOException("Download failed: HTTP ${resp.code}") + val stream = resp.body?.byteStream() ?: throw java.io.IOException("Empty body") + val total = resp.body?.contentLength() ?: -1L + stream.use { input -> + out.outputStream().use { os -> + val buf = ByteArray(64 * 1024) + var done = 0L + while (true) { + val n = input.read(buf) + if (n < 0) break + os.write(buf, 0, n) + done += n + onProgress(if (total > 0) done.toFloat() / total else -1f) + } + } + } + } + out + } + + /** Launches the system package installer for the downloaded APK. */ + fun launchInstall(context: Context, apk: File) { + val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", apk) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(uri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } + + /** "v0.1.2" / "0.1.2" -> "0.1.2" */ + private fun normalize(v: String): String = + v.trim().removePrefix("v").removePrefix("V").substringBefore('-') + + /** Component-wise numeric compare; missing components count as 0. */ + internal fun compareVersions(a: String, b: String): Int { + val pa = a.split('.').map { it.toIntOrNull() ?: 0 } + val pb = b.split('.').map { it.toIntOrNull() ?: 0 } + for (i in 0 until maxOf(pa.size, pb.size)) { + val d = (pa.getOrNull(i) ?: 0) - (pb.getOrNull(i) ?: 0) + if (d != 0) return if (d > 0) 1 else -1 + } + return 0 + } +} diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png index 1c0f03c..7f63d63 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..7f63d63 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index 1c3771c..5d2061f 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..5d2061f Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 930a353..5b9375b 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..5b9375b Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 07bd17b..dd56295 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..dd56295 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 21ea8c2..98c0ee5 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..98c0ee5 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..caa7643 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/java/com/rada/update/UpdateCheckerTest.kt b/app/src/test/java/com/rada/update/UpdateCheckerTest.kt new file mode 100644 index 0000000..0295e72 --- /dev/null +++ b/app/src/test/java/com/rada/update/UpdateCheckerTest.kt @@ -0,0 +1,23 @@ +package com.rada.update + +import org.junit.Assert.assertEquals +import org.junit.Test + +class UpdateCheckerTest { + @Test fun `newer release detected`() { + assertEquals(1, UpdateChecker.compareVersions("0.1.1", "0.1.0")) + assertEquals(1, UpdateChecker.compareVersions("0.2.0", "0.1.9")) + assertEquals(1, UpdateChecker.compareVersions("1.0.0", "0.9.9")) + } + + @Test fun `same and older are not updates`() { + assertEquals(0, UpdateChecker.compareVersions("0.1.0", "0.1.0")) + assertEquals(-1, UpdateChecker.compareVersions("0.1.0", "0.1.1")) + assertEquals(-1, UpdateChecker.compareVersions("0.9", "0.10")) + } + + @Test fun `missing components count as zero`() { + assertEquals(0, UpdateChecker.compareVersions("0.1", "0.1.0")) + assertEquals(1, UpdateChecker.compareVersions("0.1.1", "0.1")) + } +}