feat(discover): type-toggle chips (Artists/Tracks/Genres/Stations), bucketed results, local station + genre search
This commit is contained in:
parent
bd4f6bb874
commit
9e84906715
2 changed files with 289 additions and 26 deletions
|
|
@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Bolt
|
import androidx.compose.material.icons.filled.Bolt
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
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.Search
|
||||||
import androidx.compose.material.icons.filled.Tune
|
import androidx.compose.material.icons.filled.Tune
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
|
@ -61,6 +62,7 @@ import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
import com.rada.data.wavlake.MatchingMode
|
import com.rada.data.wavlake.MatchingMode
|
||||||
import com.rada.data.wavlake.WavlakeSearchHit
|
import com.rada.data.wavlake.WavlakeSearchHit
|
||||||
|
|
@ -204,6 +206,25 @@ fun DiscoverScreen(
|
||||||
shape = RoundedCornerShape(12.dp),
|
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) {
|
when (val s = searchState) {
|
||||||
WavlakeSearchState.Idle -> {
|
WavlakeSearchState.Idle -> {
|
||||||
GenreChips(
|
GenreChips(
|
||||||
|
|
@ -247,33 +268,142 @@ fun DiscoverScreen(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is WavlakeSearchState.Results -> {
|
is WavlakeSearchState.Results -> {
|
||||||
|
val r = s.results
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
) {
|
) {
|
||||||
items(s.hits, key = { it.stableId() }, contentType = { "hit" }) { hit ->
|
if (r.stations.isNotEmpty()) {
|
||||||
when (hit) {
|
item(key = "sec-stations", contentType = "header") {
|
||||||
is WavlakeSearchHit.Artist -> ArtistRow(
|
SectionHeader("Stations")
|
||||||
name = hit.artist.name,
|
}
|
||||||
genre = hit.artist.genre,
|
items(r.stations, key = { "st-${it.id}" }, contentType = { "station" }) { st ->
|
||||||
imageUrl = hit.artist.imageUrl,
|
Card(
|
||||||
artistId = hit.artist.id,
|
onClick = { viewModel.playFoundStation(st) },
|
||||||
address = addresses[hit.artist.id],
|
modifier = Modifier
|
||||||
onClick = {
|
.fillMaxWidth()
|
||||||
viewModel.openArtist(hit.artist.id, hit.artist.name)
|
.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) },
|
||||||
onZap = {
|
onZap = {
|
||||||
viewModel.resolveArtistAddress(hit.artist.id)
|
viewModel.resolveArtistAddress(artist.id)
|
||||||
pendingZapArtistId = hit.artist.id
|
pendingZapArtistId = artist.id
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
is WavlakeSearchHit.Album -> AlbumRow(
|
}
|
||||||
title = hit.title,
|
}
|
||||||
artist = hit.artist,
|
if (r.tracks.isNotEmpty() || r.albums.isNotEmpty()) {
|
||||||
artworkUrl = hit.artworkUrl,
|
item(key = "sec-tracks", contentType = "header") {
|
||||||
onClick = { /* albums open via artist detail */ },
|
SectionHeader("Tracks")
|
||||||
)
|
}
|
||||||
is WavlakeSearchHit.Track -> TrackRow(
|
items(r.tracks, key = { "track-${it.track.id}" }, contentType = { "track" }) { hit ->
|
||||||
|
TrackRow(
|
||||||
track = com.rada.domain.model.WavlakeTrack(
|
track = com.rada.domain.model.WavlakeTrack(
|
||||||
id = hit.track.id,
|
id = hit.track.id,
|
||||||
title = hit.track.title,
|
title = hit.track.title,
|
||||||
|
|
@ -317,6 +447,14 @@ 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 */ },
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -334,6 +472,18 @@ 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(
|
private data class ZapTarget(
|
||||||
val artistId: String,
|
val artistId: String,
|
||||||
val artistName: String,
|
val artistName: String,
|
||||||
|
|
|
||||||
|
|
@ -26,10 +26,27 @@ import javax.inject.Inject
|
||||||
/** Curated genre shortcuts — exact Wavlake catalog names only. */
|
/** Curated genre shortcuts — exact Wavlake catalog names only. */
|
||||||
val DISCOVER_GENRES = listOf("Dance/EDM", "Electronic", "Hip-Hop/Rap", "Pop", "Rock")
|
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<WavlakeArtist> = emptyList(),
|
||||||
|
val tracks: List<WavlakeSearchHit.Track> = emptyList(),
|
||||||
|
val albums: List<WavlakeSearchHit.Album> = emptyList(),
|
||||||
|
val genres: List<String> = emptyList(),
|
||||||
|
val stations: List<com.rada.domain.model.Station> = emptyList(),
|
||||||
|
) {
|
||||||
|
fun isEmpty() = artists.isEmpty() && tracks.isEmpty() && albums.isEmpty() &&
|
||||||
|
genres.isEmpty() && stations.isEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
sealed interface WavlakeSearchState {
|
sealed interface WavlakeSearchState {
|
||||||
data object Idle : WavlakeSearchState
|
data object Idle : WavlakeSearchState
|
||||||
data object Loading : WavlakeSearchState
|
data object Loading : WavlakeSearchState
|
||||||
data class Results(val hits: List<WavlakeSearchHit>) : WavlakeSearchState
|
data class Results(val results: DiscoverResults) : WavlakeSearchState
|
||||||
data class Error(val message: String) : WavlakeSearchState
|
data class Error(val message: String) : WavlakeSearchState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,11 +56,39 @@ class WavlakeViewModel @Inject constructor(
|
||||||
private val repository: WavlakeRepository,
|
private val repository: WavlakeRepository,
|
||||||
private val playbackManager: PlaybackManager,
|
private val playbackManager: PlaybackManager,
|
||||||
private val playlists: PlaylistRepository,
|
private val playlists: PlaylistRepository,
|
||||||
|
private val stations: com.rada.data.repository.StationRepository,
|
||||||
|
@dagger.hilt.android.qualifiers.ApplicationContext private val appContext: android.content.Context,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _searchQuery = MutableStateFlow("")
|
private val _searchQuery = MutableStateFlow("")
|
||||||
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
val searchQuery: StateFlow<String> = _searchQuery.asStateFlow()
|
||||||
|
|
||||||
|
/** Which result types to search/show; persisted so the choice sticks. */
|
||||||
|
private val _selectedTypes = MutableStateFlow(loadTypes())
|
||||||
|
val selectedTypes: StateFlow<Set<DiscoverType>> = _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<DiscoverType> {
|
||||||
|
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>(WavlakeSearchState.Idle)
|
private val _searchState = MutableStateFlow<WavlakeSearchState>(WavlakeSearchState.Idle)
|
||||||
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
|
val searchState: StateFlow<WavlakeSearchState> = _searchState.asStateFlow()
|
||||||
|
|
||||||
|
|
@ -89,17 +134,60 @@ class WavlakeViewModel @Inject constructor(
|
||||||
return@onEach
|
return@onEach
|
||||||
}
|
}
|
||||||
_searchState.value = WavlakeSearchState.Loading
|
_searchState.value = WavlakeSearchState.Loading
|
||||||
_searchState.value = when (val r = repository.search(query)) {
|
val types = _selectedTypes.value
|
||||||
is WavlakeResult.Ok ->
|
// Local stations need no network; Wavlake hit types share one call.
|
||||||
if (r.value.isEmpty()) WavlakeSearchState.Error("No Wavlake results.")
|
val needRemote = types.any {
|
||||||
else WavlakeSearchState.Results(r.value)
|
it != DiscoverType.GENRES && it != DiscoverType.STATIONS
|
||||||
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<WavlakeSearchHit.Artist>()
|
||||||
|
.filter { DiscoverType.ARTISTS in types }.map { it.artist },
|
||||||
|
tracks = hits.filterIsInstance<WavlakeSearchHit.Track>()
|
||||||
|
.filter { DiscoverType.TRACKS in types },
|
||||||
|
albums = hits.filterIsInstance<WavlakeSearchHit.Album>()
|
||||||
|
.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)
|
.launchIn(viewModelScope)
|
||||||
refreshRankings(null)
|
refreshRankings(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Genres matching the query: curated discover genres + every station genre. */
|
||||||
|
private fun genreMatches(query: String): List<String> {
|
||||||
|
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) {
|
fun search(query: String) {
|
||||||
_searchQuery.value = query
|
_searchQuery.value = query
|
||||||
}
|
}
|
||||||
|
|
@ -220,6 +308,31 @@ 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) {
|
fun saveTrack(track: WavlakeTrack) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_notice.value = when (val r = repository.importTrack(track)) {
|
_notice.value = when (val r = repository.importTrack(track)) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue