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:
parent
1c9d07251a
commit
b000544d15
11 changed files with 209 additions and 34 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,22 +36,42 @@ 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<Station> =
|
||||
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()
|
||||
.newBuilder()
|
||||
.addPathSegments("json/stations/search")
|
||||
.addQueryParameter("name", query.trim())
|
||||
.addQueryParameter("hidebroken", "true")
|
||||
.addQueryParameter("limit", limit.toString())
|
||||
.addQueryParameter("order", "clickcount")
|
||||
.addQueryParameter("reverse", "true")
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
params.forEach { (k, v) -> builder.addQueryParameter(k, v) }
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.url(builder.build().toString())
|
||||
.header("User-Agent", "SatsAmp/1.0 (personal radio player)")
|
||||
.build()
|
||||
|
||||
|
|
@ -60,7 +80,7 @@ class RadioBrowserRepository @Inject constructor() {
|
|||
throw IllegalStateException("Directory search failed: ${response.code}")
|
||||
}
|
||||
val body = response.body?.string().orEmpty()
|
||||
json.decodeFromString<List<ApiStation>>(body).map { it.toStation() }
|
||||
return json.decodeFromString<List<ApiStation>>(body).map { it.toStation() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue