diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6b2bf88..76a51bf 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,6 +24,9 @@ android { buildTypes { release { isMinifyEnabled = true + // Personal test builds: sign release with the debug key so it can + // be installed directly. Production releases must use a real key. + signingConfig = signingConfigs.getByName("debug") proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" diff --git a/app/src/main/java/com/rada/SatsAmpApp.kt b/app/src/main/java/com/rada/SatsAmpApp.kt index 965c9ec..d27a0eb 100644 --- a/app/src/main/java/com/rada/SatsAmpApp.kt +++ b/app/src/main/java/com/rada/SatsAmpApp.kt @@ -1,6 +1,8 @@ package com.rada import android.app.Application +import android.content.pm.ApplicationInfo +import android.os.StrictMode import com.rada.ui.theme.UiPrefs import dagger.hilt.android.HiltAndroidApp @@ -8,6 +10,19 @@ import dagger.hilt.android.HiltAndroidApp class SatsAmpApp : Application() { override fun onCreate() { super.onCreate() + if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0) { + // Log (never crash) main-thread disk/network hits so jank + // sources show up in logcat with a stack trace. + StrictMode.setThreadPolicy( + StrictMode.ThreadPolicy.Builder() + .detectDiskReads() + .detectDiskWrites() + .detectNetwork() + .detectCustomSlowCalls() + .penaltyLog() + .build(), + ) + } UiPrefs.init(this) } } diff --git a/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt b/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt index 24990bb..fd701ae 100644 --- a/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt +++ b/app/src/main/java/com/rada/data/repository/RadioBrowserRepository.kt @@ -36,34 +36,54 @@ class RadioBrowserRepository @Inject constructor() { private val client = OkHttpClient.Builder().build() private val json = Json { ignoreUnknownKeys = true } + /** + * Name matches first, then tag (genre) matches — one query rarely covers + * both ("jazz" as a name vs. as a tag). Each leg fetches up to [limit] + * and the merged list is capped at [limit]. + */ suspend fun search(query: String, limit: Int = 30): List = withContext(Dispatchers.IO) { - val url = BASE - .toHttpUrl() - .newBuilder() - .addPathSegments("json/stations/search") - .addQueryParameter("name", query.trim()) - .addQueryParameter("hidebroken", "true") - .addQueryParameter("limit", limit.toString()) - .addQueryParameter("order", "clickcount") - .addQueryParameter("reverse", "true") - .build() - .toString() - - val request = Request.Builder() - .url(url) - .header("User-Agent", "SatsAmp/1.0 (personal radio player)") - .build() - - client.newCall(request).execute().use { response -> - if (!response.isSuccessful) { - throw IllegalStateException("Directory search failed: ${response.code}") - } - val body = response.body?.string().orEmpty() - json.decodeFromString>(body).map { it.toStation() } - } + val q = query.trim() + val byName = fetch(mapOf("name" to q), limit) + val byTag = runCatching { fetch(mapOf("tag" to q), limit) }.getOrDefault(emptyList()) + mergeResults(byName, byTag, limit) } + /** Name hits keep their order; tag hits append, de-duplicated by id. */ + internal fun mergeResults( + byName: List, + byTag: List, + limit: Int, + ): List { + if (byTag.isEmpty()) return byName.take(limit) + val seen = byName.map { it.id }.toHashSet() + return (byName + byTag.filter { seen.add(it.id) }).take(limit) + } + + private fun fetch(params: Map, limit: Int): List { + val builder = BASE + .toHttpUrl() + .newBuilder() + .addPathSegments("json/stations/search") + .addQueryParameter("hidebroken", "true") + .addQueryParameter("limit", limit.toString()) + .addQueryParameter("order", "clickcount") + .addQueryParameter("reverse", "true") + params.forEach { (k, v) -> builder.addQueryParameter(k, v) } + val request = Request.Builder() + .url(builder.build().toString()) + .header("User-Agent", "SatsAmp/1.0 (personal radio player)") + .build() + + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw IllegalStateException("Directory search failed: ${response.code}") + } + val body = response.body?.string().orEmpty() + return json.decodeFromString>(body).map { it.toStation() } + } + } + private fun ApiStation.toStation(): Station { val firstTag = tags.split(",").firstOrNull { it.isNotBlank() }?.trim().orEmpty() val quality = listOfNotNull( diff --git a/app/src/main/java/com/rada/di/AppModule.kt b/app/src/main/java/com/rada/di/AppModule.kt index 5d961aa..a39405d 100644 --- a/app/src/main/java/com/rada/di/AppModule.kt +++ b/app/src/main/java/com/rada/di/AppModule.kt @@ -7,10 +7,13 @@ 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.repository.StreamRepository +import com.rada.data.wavlake.WavlakeRepository import com.rada.data.wavlake.WavlakeApi import com.rada.player.PlaybackManager import dagger.Module import dagger.Provides +import dagger.hilt.EntryPoint import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent @@ -67,3 +70,17 @@ object AppModule { @Singleton fun provideWavlakeApi(): WavlakeApi = WavlakeApi() } + +/** + * Entry point for warming disk-backed singletons off the main thread at + * startup (see MainActivity). Their constructors do SharedPreferences/JSON + * I/O (~130–290ms); Hilt builds them lazily, so without warming that cost + * lands inside first page composition and stalls tab switches. + */ +@EntryPoint +@InstallIn(SingletonComponent::class) +interface WarmEntryPoint { + fun stationRepository(): StationRepository + fun streamRepository(): StreamRepository + fun wavlakeRepository(): WavlakeRepository +} diff --git a/app/src/main/java/com/rada/ui/MainActivity.kt b/app/src/main/java/com/rada/ui/MainActivity.kt index b4fd011..d7653d1 100644 --- a/app/src/main/java/com/rada/ui/MainActivity.kt +++ b/app/src/main/java/com/rada/ui/MainActivity.kt @@ -14,13 +14,18 @@ import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import androidx.navigation.compose.rememberNavController +import com.rada.di.WarmEntryPoint import com.rada.player.PlaybackManager import com.rada.ui.navigation.NavGraph import com.rada.ui.theme.GlassBackground import com.rada.ui.theme.SatsAmpTheme import dagger.hilt.android.AndroidEntryPoint +import dagger.hilt.android.EntryPointAccessors import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -43,6 +48,7 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() playbackManager.connect() askNotificationPermissionOnce() + warmRepositories() setContent { SatsAmpTheme { @@ -63,6 +69,26 @@ class MainActivity : ComponentActivity() { super.onDestroy() } + /** + * Pre-build the disk-backed singletons on a background thread. Without + * this, Hilt constructs them lazily on first injection — inside page + * composition — and their SharedPreferences/JSON reads (~130–290ms) + * stall tab switches. Hilt caches singletons thread-safely, so later + * main-thread access is free. + */ + private fun warmRepositories() { + lifecycleScope.launch(Dispatchers.IO) { + runCatching { + val entry = EntryPointAccessors.fromApplication( + applicationContext, WarmEntryPoint::class.java, + ) + entry.stationRepository().getAll() + entry.streamRepository().getAll() + entry.wavlakeRepository().isStationEnabled("") + } + } + } + private fun askNotificationPermissionOnce() { if (Build.VERSION.SDK_INT < 33) return val prefs = getPreferences(MODE_PRIVATE) diff --git a/app/src/main/java/com/rada/ui/components/EqualizerBars.kt b/app/src/main/java/com/rada/ui/components/EqualizerBars.kt index 0990484..a37b387 100644 --- a/app/src/main/java/com/rada/ui/components/EqualizerBars.kt +++ b/app/src/main/java/com/rada/ui/components/EqualizerBars.kt @@ -24,6 +24,10 @@ import com.rada.ui.theme.LocalReduceMotion * 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. + * + * The infinite transition runs ONLY while actively playing: every mounted + * instance otherwise spins at display refresh rate and invalidates its row + * every frame, which is pure jank in long lists. */ @Composable fun EqualizerBars( @@ -33,6 +37,11 @@ fun EqualizerBars( barWidth: Dp = 2.5.dp, ) { val reduce = LocalReduceMotion.current + if (!active || reduce) { + // Frozen heights (mid-range, uneven = still "eq"). No animation running. + EqCanvas(h1 = 0.6f, h2 = 1f, h3 = 0.45f, color = color, modifier = modifier, barWidth = barWidth) + return + } val transition = rememberInfiniteTransition(label = "eq") // Out-of-phase sine-ish pulses; different durations keep bars unsynced. val b1 by transition.animateFloat( @@ -53,11 +62,18 @@ fun EqualizerBars( 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 + EqCanvas(h1 = b1, h2 = b2, h3 = b3, color = color, modifier = modifier, barWidth = barWidth) +} +@Composable +private fun EqCanvas( + h1: Float, + h2: Float, + h3: Float, + color: Color, + modifier: Modifier, + barWidth: Dp, +) { Canvas(modifier = modifier.size(14.dp)) { val gap = this.size.width / 8f val w = barWidth.toPx() 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 2972df0..a137c94 100644 --- a/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt +++ b/app/src/main/java/com/rada/ui/discover/DiscoverScreen.kt @@ -448,6 +448,7 @@ private fun RankingsList( onPlay = { viewModel.playTrack(track) }, onSave = { viewModel.saveTrack(track) }, onZap = { t -> onZap(t, addresses[t.artistId]) }, + modifier = Modifier.animateItem(), ) } } @@ -464,11 +465,12 @@ fun TrackRow( onPlay: () -> Unit, onSave: () -> Unit, onZap: (WavlakeTrack) -> Unit, + modifier: Modifier = Modifier, ) { val tokens = LocalTokens.current Card( onClick = onPlay, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 12.dp), colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong), @@ -782,6 +784,7 @@ fun ArtistDetailView( if (t.isZapPayable) detailTrackZap = t } }, + modifier = Modifier.animateItem(), ) detailTrackZap?.let { zt -> WavlakeZapSheet( 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 50e359c..6381be3 100644 --- a/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt +++ b/app/src/main/java/com/rada/ui/navigation/TabsScreen.kt @@ -36,7 +36,8 @@ import kotlinx.coroutines.launch /** * The five peer tabs as a swipeable pager (Spotify's horizontal structure): * Stations · Discover · Library · Streams · Playlists. Synced with the - * bottom bar; swiping follows the finger, tab taps animate. The mini-player + * bottom bar; swiping follows the finger, tab taps jump (adjacent tabs + * glide). The mini-player * sits above the nav bar so playback controls are always reachable. */ @Composable @@ -50,6 +51,18 @@ fun TabsScreen( val scope = rememberCoroutineScope() // Deep-link target for Library (playlist picker → open that playlist). var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) } + // Tab taps jump: swiping is the animated path, but animating a tap from + // Stations → Playlists scrolls through (and composes) every page in + // between — that mid-flight compose/dispose churn is the jumpiness. + // Adjacent taps still glide; distant ones land instantly like Spotify. + fun goToPage(page: Int) { + val from = pagerState.currentPage + if (from == page) return + scope.launch { + if (kotlin.math.abs(from - page) == 1) pagerState.animateScrollToPage(page) + else pagerState.scrollToPage(page) + } + } Scaffold( containerColor = MaterialTheme.colorScheme.background, @@ -70,10 +83,11 @@ fun TabsScreen( ) items.forEach { (label, icon, page) -> NavigationBarItem( - selected = pagerState.settledPage == page, - onClick = { - scope.launch { pagerState.animateScrollToPage(page) } - }, + // currentPage tracks the finger/target live; settledPage + // lags until the animation ends and the highlight + // feels glued to the old tab. + selected = pagerState.currentPage == page, + onClick = { goToPage(page) }, icon = { Icon(icon, contentDescription = label) }, label = { Text(label) }, colors = NavigationBarItemDefaults.colors( @@ -121,7 +135,7 @@ fun TabsScreen( onHome = onHome, onPlaylistSelected = { playlistId -> libraryPlaylistId = playlistId - scope.launch { pagerState.animateScrollToPage(2) } + goToPage(2) }, ) } diff --git a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt index 7db8ff5..1a3306d 100644 --- a/app/src/main/java/com/rada/ui/player/PlayerScreen.kt +++ b/app/src/main/java/com/rada/ui/player/PlayerScreen.kt @@ -844,7 +844,7 @@ private fun PlaylistPickerDialog( LazyColumn( modifier = Modifier.heightIn(max = 200.dp), ) { - items(filteredPlaylists) { playlist -> + items(filteredPlaylists, key = { it.id }) { playlist -> val checked = playlist.id in selectedIds Row( modifier = Modifier diff --git a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt index 9382387..e2a1344 100644 --- a/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt +++ b/app/src/main/java/com/rada/ui/stations/StationBrowserScreen.kt @@ -298,6 +298,7 @@ fun StationBrowserScreen( item { SectionHeader("Starred", Icons.Default.Star) } items(favorites, key = { "fav-${it.id}" }, contentType = { "station" }) { station -> StationCard( + modifier = Modifier.animateItem(), station = station, isFavorite = true, isPlaying = playingUrl == station.url, @@ -321,6 +322,7 @@ fun StationBrowserScreen( } items(stations, key = { it.id }, contentType = { "station" }) { station -> StationCard( + modifier = Modifier.animateItem(), station = station, isFavorite = viewModel.isFavorite(station.id), isPlaying = playingUrl == station.url, @@ -411,6 +413,7 @@ fun StationBrowserScreen( item { DirectorySectionHeader("From the directory") } items(ds.stations, key = { "rb-${it.id}" }, contentType = { "station" }) { station -> StationCard( + modifier = Modifier.animateItem(), station = station, isFavorite = viewModel.isFavorite(station.id), isPlaying = playingUrl == station.url, @@ -791,11 +794,12 @@ private fun StationCard( onAdd: (() -> Unit)? = null, wavlakeEnabled: Boolean = false, onToggleWavlake: (() -> Unit)? = null, + modifier: Modifier = Modifier, ) { val tokens = LocalTokens.current Card( onClick = onClick, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 12.dp), colors = CardDefaults.cardColors( diff --git a/app/src/test/java/com/rada/data/repository/RadioBrowserSearchTest.kt b/app/src/test/java/com/rada/data/repository/RadioBrowserSearchTest.kt new file mode 100644 index 0000000..1003dcb --- /dev/null +++ b/app/src/test/java/com/rada/data/repository/RadioBrowserSearchTest.kt @@ -0,0 +1,57 @@ +package com.rada.data.repository + +import com.rada.domain.model.Station +import org.junit.Assert.assertEquals +import org.junit.Test + +/** + * Directory search merges name hits with tag (genre) hits so a query like + * "jazz" finds stations merely tagged jazz, not just named jazz. + */ +class RadioBrowserSearchTest { + + private val repo = RadioBrowserRepository() + + private fun station(id: String, name: String) = + Station(id = id, name = name, url = "https://example.com/$id") + + @Test + fun `name hits come first, tag hits append`() { + val merged = repo.mergeResults( + byName = listOf(station("rb-1", "Jazz FM")), + byTag = listOf(station("rb-2", "Smooth Grooves")), + limit = 30, + ) + assertEquals(listOf("rb-1", "rb-2"), merged.map { it.id }) + } + + @Test + fun `duplicates are removed, name copy wins`() { + val merged = repo.mergeResults( + byName = listOf(station("rb-1", "Jazz FM")), + byTag = listOf(station("rb-1", "Jazz FM"), station("rb-2", "Blue Note")), + limit = 30, + ) + assertEquals(listOf("rb-1", "rb-2"), merged.map { it.id }) + } + + @Test + fun `merged list is capped at limit`() { + val merged = repo.mergeResults( + byName = listOf(station("rb-1", "A"), station("rb-2", "B")), + byTag = listOf(station("rb-3", "C")), + limit = 2, + ) + assertEquals(listOf("rb-1", "rb-2"), merged.map { it.id }) + } + + @Test + fun `empty tag leg returns name hits capped`() { + val merged = repo.mergeResults( + byName = listOf(station("rb-1", "A"), station("rb-2", "B")), + byTag = emptyList(), + limit = 1, + ) + assertEquals(listOf("rb-1"), merged.map { it.id }) + } +}