diff --git a/Satsamp Icon.png b/Satsamp Icon.png deleted file mode 100644 index 4237cb2..0000000 Binary files a/Satsamp Icon.png and /dev/null differ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 957223e..ab2502d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -54,7 +54,6 @@ android { buildFeatures { compose = true - buildConfig = true } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b5fe611..b61c366 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,8 +8,6 @@ - - @@ -50,17 +48,6 @@ - - - - - diff --git a/app/src/main/java/com/rada/player/PlaybackManager.kt b/app/src/main/java/com/rada/player/PlaybackManager.kt index 1bacbd6..d656776 100644 --- a/app/src/main/java/com/rada/player/PlaybackManager.kt +++ b/app/src/main/java/com/rada/player/PlaybackManager.kt @@ -465,18 +465,11 @@ 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 @@ -493,7 +486,6 @@ 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) @@ -503,7 +495,6 @@ 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 5c3e899..989e393 100644 --- a/app/src/main/java/com/rada/recording/IcyStreamReader.kt +++ b/app/src/main/java/com/rada/recording/IcyStreamReader.kt @@ -18,12 +18,6 @@ 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 @@ -58,7 +52,6 @@ class IcyStreamReader( fun start(streamUrl: String) { if (isRunning) stop() - requestedStop = false scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) scope?.launch { try { @@ -66,10 +59,8 @@ class IcyStreamReader( } catch (e: CancellationException) { // Normal stop } catch (e: Exception) { - if (!requestedStop) { - Log.e(TAG, "Error reading stream", e) - listener?.onError(e.message ?: "Connection error") - } + Log.e(TAG, "Error reading stream", e) + listener?.onError(e.message ?: "Connection error") } finally { isRunning = false } @@ -77,7 +68,6 @@ 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 6a5d856..a137c94 100644 --- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt +++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt @@ -23,7 +23,6 @@ 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 @@ -62,7 +61,6 @@ 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 @@ -206,25 +204,6 @@ 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( @@ -268,142 +247,33 @@ fun DiscoverScreen( } } is WavlakeSearchState.Results -> { - val r = s.results LazyColumn( modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(4.dp), ) { - 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) }, + 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(artist.id) - pendingZapArtistId = artist.id + viewModel.resolveArtistAddress(hit.artist.id) + pendingZapArtistId = hit.artist.id }, ) - } - } - 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( + 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, @@ -447,14 +317,6 @@ 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 */ }, - ) - } } } } @@ -472,18 +334,6 @@ 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 819afad..c76862e 100644 --- a/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt +++ b/app/src/main/java/com/rada/ui/discover/WavlakeViewModel.kt @@ -26,27 +26,10 @@ 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 results: DiscoverResults) : WavlakeSearchState + data class Results(val hits: List) : WavlakeSearchState data class Error(val message: String) : WavlakeSearchState } @@ -56,39 +39,11 @@ 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() @@ -134,60 +89,17 @@ class WavlakeViewModel @Inject constructor( return@onEach } _searchState.value = WavlakeSearchState.Loading - 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 + _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 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 } @@ -308,31 +220,6 @@ 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 04db2c6..55ccca1 100644 --- a/app/src/main/java/com/rada/ui/library/LibraryScreen.kt +++ b/app/src/main/java/com/rada/ui/library/LibraryScreen.kt @@ -1,6 +1,5 @@ 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 @@ -29,7 +28,6 @@ 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 @@ -109,7 +107,6 @@ 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) } @@ -157,14 +154,7 @@ fun LibraryScreen( Scaffold( topBar = { TopAppBar( - title = { - Text( - "Library", - maxLines = 1, - overflow = TextOverflow.Ellipsis, - fontWeight = FontWeight.Bold, - ) - }, + title = { Text("Library") }, navigationIcon = { Row(horizontalArrangement = Arrangement.Start) { AppHomeButton(onClick = onHome) @@ -207,75 +197,45 @@ fun LibraryScreen( Icon(Icons.Default.Shuffle, contentDescription = "Shuffle play") } } - // 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, - ) - }, + 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, ) } } } + // 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, @@ -324,20 +284,16 @@ 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 = "${uiState.songs.size} ${if (uiState.songs.size == 1) "song" else "songs"}", + 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, ) @@ -521,12 +477,8 @@ 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) }, @@ -816,11 +768,10 @@ 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 3f7414a..feb5d55 100644 --- a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt +++ b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt @@ -30,7 +30,6 @@ 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 @@ -50,26 +49,14 @@ 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) } - // 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. + // Tab taps land immediately. Swiping remains animated, but animating a + // tap composes and disposes heavy pages while the gesture is in flight. fun goToPage(page: Int) { if (pagerState.currentPage == page) return scope.launch { - 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) - } + 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 9db2807..ea0ff8f 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -84,7 +84,6 @@ 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 @@ -441,49 +440,41 @@ private fun WaveGraphic(modifier: Modifier = Modifier) { val gradientColors = listOf(LocalTokens.current.accentFill, GradientMid, GradientEnd) val accentGlow = LocalTokens.current.accentFill - // 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 }, - ) { + Canvas(modifier = modifier) { val width = size.width val height = size.height val centerY = height / 2 - for ((path, strokeBrush, strokeWidth) in layers) { - drawPath(path = path, brush = strokeBrush, style = Stroke(width = strokeWidth)) + // 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)), + ) } // 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 7d44d55..c987284 100644 --- a/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt +++ b/app/src/main/java/com/rada/ui/theme/AppearanceDialog.kt @@ -1,8 +1,5 @@ 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 @@ -21,17 +18,10 @@ 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 @@ -40,9 +30,6 @@ 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( @@ -65,46 +52,6 @@ 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, @@ -194,52 +141,6 @@ 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 28df9ed..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,7 +1,6 @@ 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 @@ -131,15 +130,12 @@ 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 { - if (reduce) return this +): Modifier = composed { + if (reduce) return@composed this val pressed by interactionSource.collectIsPressedAsState() val scale by animateFloatAsState( targetValue = if (pressed) 0.97f else 1f, @@ -149,7 +145,7 @@ fun Modifier.pressDip( ), label = "pressDip", ) - return this.graphicsLayer { scaleX = scale; scaleY = scale } + 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 deleted file mode 100644 index d62094b..0000000 --- a/app/src/main/java/com/rada/update/UpdateChecker.kt +++ /dev/null @@ -1,121 +0,0 @@ -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 7f63d63..1c0f03c 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 deleted file mode 100644 index 7f63d63..0000000 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png index 5d2061f..1c3771c 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 deleted file mode 100644 index 5d2061f..0000000 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 5b9375b..930a353 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 deleted file mode 100644 index 5b9375b..0000000 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index dd56295..07bd17b 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 deleted file mode 100644 index dd56295..0000000 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 98c0ee5..21ea8c2 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 deleted file mode 100644 index 98c0ee5..0000000 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml deleted file mode 100644 index caa7643..0000000 --- a/app/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/test/java/com/rada/update/UpdateCheckerTest.kt b/app/src/test/java/com/rada/update/UpdateCheckerTest.kt deleted file mode 100644 index 0295e72..0000000 --- a/app/src/test/java/com/rada/update/UpdateCheckerTest.kt +++ /dev/null @@ -1,23 +0,0 @@ -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")) - } -}