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 {
|
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"
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,34 +36,54 @@ 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()
|
||||||
.toHttpUrl()
|
val byName = fetch(mapOf("name" to q), limit)
|
||||||
.newBuilder()
|
val byTag = runCatching { fetch(mapOf("tag" to q), limit) }.getOrDefault(emptyList())
|
||||||
.addPathSegments("json/stations/search")
|
mergeResults(byName, byTag, limit)
|
||||||
.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<List<ApiStation>>(body).map { it.toStation() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 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("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<List<ApiStation>>(body).map { it.toStation() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun ApiStation.toStation(): Station {
|
private fun ApiStation.toStation(): Station {
|
||||||
val firstTag = tags.split(",").firstOrNull { it.isNotBlank() }?.trim().orEmpty()
|
val firstTag = tags.split(",").firstOrNull { it.isNotBlank() }?.trim().orEmpty()
|
||||||
val quality = listOfNotNull(
|
val quality = listOfNotNull(
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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