perf(app): fluid navigation + genre search

Directory search now merges name and tag(genre) hits so genre queries
find tagged stations. Tabs jump instantly (adjacent glide, swipe kept);
startup pre-builds disk-backed singletons off main thread; equalizer
animation runs only on the playing indicator; row placement animations;
missing list key. Debug-only StrictMode guard for main-thread I/O.
Release signed with debug key for direct installs.
This commit is contained in:
avi 2026-09-10 06:48:55 -05:00
commit b000544d15
11 changed files with 209 additions and 34 deletions

View file

@ -24,6 +24,9 @@ android {
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true 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( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"

View file

@ -1,6 +1,8 @@
package com.rada package com.rada
import android.app.Application import android.app.Application
import android.content.pm.ApplicationInfo
import android.os.StrictMode
import com.rada.ui.theme.UiPrefs import com.rada.ui.theme.UiPrefs
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
@ -8,6 +10,19 @@ import dagger.hilt.android.HiltAndroidApp
class SatsAmpApp : Application() { class SatsAmpApp : Application() {
override fun onCreate() { override fun onCreate() {
super.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) UiPrefs.init(this)
} }
} }

View file

@ -36,22 +36,42 @@ class RadioBrowserRepository @Inject constructor() {
private val client = OkHttpClient.Builder().build() private val client = OkHttpClient.Builder().build()
private val json = Json { ignoreUnknownKeys = true } 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<Station> = suspend fun search(query: String, limit: Int = 30): List<Station> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val url = BASE 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<Station>,
byTag: List<Station>,
limit: Int,
): List<Station> {
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<String, String>, limit: Int): List<Station> {
val builder = BASE
.toHttpUrl() .toHttpUrl()
.newBuilder() .newBuilder()
.addPathSegments("json/stations/search") .addPathSegments("json/stations/search")
.addQueryParameter("name", query.trim())
.addQueryParameter("hidebroken", "true") .addQueryParameter("hidebroken", "true")
.addQueryParameter("limit", limit.toString()) .addQueryParameter("limit", limit.toString())
.addQueryParameter("order", "clickcount") .addQueryParameter("order", "clickcount")
.addQueryParameter("reverse", "true") .addQueryParameter("reverse", "true")
.build() params.forEach { (k, v) -> builder.addQueryParameter(k, v) }
.toString()
val request = Request.Builder() val request = Request.Builder()
.url(url) .url(builder.build().toString())
.header("User-Agent", "SatsAmp/1.0 (personal radio player)") .header("User-Agent", "SatsAmp/1.0 (personal radio player)")
.build() .build()
@ -60,7 +80,7 @@ class RadioBrowserRepository @Inject constructor() {
throw IllegalStateException("Directory search failed: ${response.code}") throw IllegalStateException("Directory search failed: ${response.code}")
} }
val body = response.body?.string().orEmpty() val body = response.body?.string().orEmpty()
json.decodeFromString<List<ApiStation>>(body).map { it.toStation() } return json.decodeFromString<List<ApiStation>>(body).map { it.toStation() }
} }
} }

View file

@ -7,10 +7,13 @@ import com.rada.data.local.dao.SavedSongDao
import com.rada.data.repository.LibraryRepository import com.rada.data.repository.LibraryRepository
import com.rada.data.repository.PlaylistRepository import com.rada.data.repository.PlaylistRepository
import com.rada.data.repository.StationRepository 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.data.wavlake.WavlakeApi
import com.rada.player.PlaybackManager import com.rada.player.PlaybackManager
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
@ -67,3 +70,17 @@ object AppModule {
@Singleton @Singleton
fun provideWavlakeApi(): WavlakeApi = WavlakeApi() 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
}

View file

@ -14,13 +14,18 @@ import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.rada.di.WarmEntryPoint
import com.rada.player.PlaybackManager import com.rada.player.PlaybackManager
import com.rada.ui.navigation.NavGraph import com.rada.ui.navigation.NavGraph
import com.rada.ui.theme.GlassBackground import com.rada.ui.theme.GlassBackground
import com.rada.ui.theme.SatsAmpTheme import com.rada.ui.theme.SatsAmpTheme
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.EntryPointAccessors
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@AndroidEntryPoint @AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@ -43,6 +48,7 @@ class MainActivity : ComponentActivity() {
enableEdgeToEdge() enableEdgeToEdge()
playbackManager.connect() playbackManager.connect()
askNotificationPermissionOnce() askNotificationPermissionOnce()
warmRepositories()
setContent { setContent {
SatsAmpTheme { SatsAmpTheme {
@ -63,6 +69,26 @@ class MainActivity : ComponentActivity() {
super.onDestroy() 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() { private fun askNotificationPermissionOnce() {
if (Build.VERSION.SDK_INT < 33) return if (Build.VERSION.SDK_INT < 33) return
val prefs = getPreferences(MODE_PRIVATE) val prefs = getPreferences(MODE_PRIVATE)

View file

@ -24,6 +24,10 @@ import com.rada.ui.theme.LocalReduceMotion
* the active source (Spotify's equalizer). Bars pulse out of phase; paused * 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 * freezes them mid-height so the shape still reads as "the equalizer", and
* reduce-motion renders a static, even-bar version. * 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 @Composable
fun EqualizerBars( fun EqualizerBars(
@ -33,6 +37,11 @@ fun EqualizerBars(
barWidth: Dp = 2.5.dp, barWidth: Dp = 2.5.dp,
) { ) {
val reduce = LocalReduceMotion.current 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") val transition = rememberInfiniteTransition(label = "eq")
// Out-of-phase sine-ish pulses; different durations keep bars unsynced. // Out-of-phase sine-ish pulses; different durations keep bars unsynced.
val b1 by transition.animateFloat( val b1 by transition.animateFloat(
@ -53,11 +62,18 @@ fun EqualizerBars(
animation = tween(440), repeatMode = RepeatMode.Reverse, animation = tween(440), repeatMode = RepeatMode.Reverse,
), label = "b3", ), label = "b3",
) )
// Frozen heights for the paused pose (mid-range, uneven = still "eq"). EqCanvas(h1 = b1, h2 = b2, h3 = b3, color = color, modifier = modifier, barWidth = barWidth)
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
@Composable
private fun EqCanvas(
h1: Float,
h2: Float,
h3: Float,
color: Color,
modifier: Modifier,
barWidth: Dp,
) {
Canvas(modifier = modifier.size(14.dp)) { Canvas(modifier = modifier.size(14.dp)) {
val gap = this.size.width / 8f val gap = this.size.width / 8f
val w = barWidth.toPx() val w = barWidth.toPx()

View file

@ -448,6 +448,7 @@ private fun RankingsList(
onPlay = { viewModel.playTrack(track) }, onPlay = { viewModel.playTrack(track) },
onSave = { viewModel.saveTrack(track) }, onSave = { viewModel.saveTrack(track) },
onZap = { t -> onZap(t, addresses[t.artistId]) }, onZap = { t -> onZap(t, addresses[t.artistId]) },
modifier = Modifier.animateItem(),
) )
} }
} }
@ -464,11 +465,12 @@ fun TrackRow(
onPlay: () -> Unit, onPlay: () -> Unit,
onSave: () -> Unit, onSave: () -> Unit,
onZap: (WavlakeTrack) -> Unit, onZap: (WavlakeTrack) -> Unit,
modifier: Modifier = Modifier,
) { ) {
val tokens = LocalTokens.current val tokens = LocalTokens.current
Card( Card(
onClick = onPlay, onClick = onPlay,
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = 12.dp),
colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong), colors = CardDefaults.cardColors(containerColor = tokens.glassFillStrong),
@ -782,6 +784,7 @@ fun ArtistDetailView(
if (t.isZapPayable) detailTrackZap = t if (t.isZapPayable) detailTrackZap = t
} }
}, },
modifier = Modifier.animateItem(),
) )
detailTrackZap?.let { zt -> detailTrackZap?.let { zt ->
WavlakeZapSheet( WavlakeZapSheet(

View file

@ -36,7 +36,8 @@ import kotlinx.coroutines.launch
/** /**
* The five peer tabs as a swipeable pager (Spotify's horizontal structure): * The five peer tabs as a swipeable pager (Spotify's horizontal structure):
* Stations · Discover · Library · Streams · Playlists. Synced with the * 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. * sits above the nav bar so playback controls are always reachable.
*/ */
@Composable @Composable
@ -50,6 +51,18 @@ fun TabsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
// Deep-link target for Library (playlist picker → open that playlist). // Deep-link target for Library (playlist picker → open that playlist).
var libraryPlaylistId by remember { mutableStateOf(initialPlaylistId) } 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( Scaffold(
containerColor = MaterialTheme.colorScheme.background, containerColor = MaterialTheme.colorScheme.background,
@ -70,10 +83,11 @@ fun TabsScreen(
) )
items.forEach { (label, icon, page) -> items.forEach { (label, icon, page) ->
NavigationBarItem( NavigationBarItem(
selected = pagerState.settledPage == page, // currentPage tracks the finger/target live; settledPage
onClick = { // lags until the animation ends and the highlight
scope.launch { pagerState.animateScrollToPage(page) } // feels glued to the old tab.
}, selected = pagerState.currentPage == page,
onClick = { goToPage(page) },
icon = { Icon(icon, contentDescription = label) }, icon = { Icon(icon, contentDescription = label) },
label = { Text(label) }, label = { Text(label) },
colors = NavigationBarItemDefaults.colors( colors = NavigationBarItemDefaults.colors(
@ -121,7 +135,7 @@ fun TabsScreen(
onHome = onHome, onHome = onHome,
onPlaylistSelected = { playlistId -> onPlaylistSelected = { playlistId ->
libraryPlaylistId = playlistId libraryPlaylistId = playlistId
scope.launch { pagerState.animateScrollToPage(2) } goToPage(2)
}, },
) )
} }

View file

@ -844,7 +844,7 @@ private fun PlaylistPickerDialog(
LazyColumn( LazyColumn(
modifier = Modifier.heightIn(max = 200.dp), modifier = Modifier.heightIn(max = 200.dp),
) { ) {
items(filteredPlaylists) { playlist -> items(filteredPlaylists, key = { it.id }) { playlist ->
val checked = playlist.id in selectedIds val checked = playlist.id in selectedIds
Row( Row(
modifier = Modifier modifier = Modifier

View file

@ -298,6 +298,7 @@ fun StationBrowserScreen(
item { SectionHeader("Starred", Icons.Default.Star) } item { SectionHeader("Starred", Icons.Default.Star) }
items(favorites, key = { "fav-${it.id}" }, contentType = { "station" }) { station -> items(favorites, key = { "fav-${it.id}" }, contentType = { "station" }) { station ->
StationCard( StationCard(
modifier = Modifier.animateItem(),
station = station, station = station,
isFavorite = true, isFavorite = true,
isPlaying = playingUrl == station.url, isPlaying = playingUrl == station.url,
@ -321,6 +322,7 @@ fun StationBrowserScreen(
} }
items(stations, key = { it.id }, contentType = { "station" }) { station -> items(stations, key = { it.id }, contentType = { "station" }) { station ->
StationCard( StationCard(
modifier = Modifier.animateItem(),
station = station, station = station,
isFavorite = viewModel.isFavorite(station.id), isFavorite = viewModel.isFavorite(station.id),
isPlaying = playingUrl == station.url, isPlaying = playingUrl == station.url,
@ -411,6 +413,7 @@ fun StationBrowserScreen(
item { DirectorySectionHeader("From the directory") } item { DirectorySectionHeader("From the directory") }
items(ds.stations, key = { "rb-${it.id}" }, contentType = { "station" }) { station -> items(ds.stations, key = { "rb-${it.id}" }, contentType = { "station" }) { station ->
StationCard( StationCard(
modifier = Modifier.animateItem(),
station = station, station = station,
isFavorite = viewModel.isFavorite(station.id), isFavorite = viewModel.isFavorite(station.id),
isPlaying = playingUrl == station.url, isPlaying = playingUrl == station.url,
@ -791,11 +794,12 @@ private fun StationCard(
onAdd: (() -> Unit)? = null, onAdd: (() -> Unit)? = null,
wavlakeEnabled: Boolean = false, wavlakeEnabled: Boolean = false,
onToggleWavlake: (() -> Unit)? = null, onToggleWavlake: (() -> Unit)? = null,
modifier: Modifier = Modifier,
) { ) {
val tokens = LocalTokens.current val tokens = LocalTokens.current
Card( Card(
onClick = onClick, onClick = onClick,
modifier = Modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp), .padding(horizontal = 12.dp),
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(

View file

@ -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 })
}
}