feat: fans on local Navidrome artists (android) + harmonize web fan layout
- Android local artist page (ArtistDetailScreen/ArtistViewModel) now resolves the Deezer artist (fan-sorted, so same-name collisions pick the popular one) and shows "<n> Fans" under the name — parity with the web local artist page. - Also migrated ArtistViewModel's preview to the app-level PreviewController: the local artist page had its OWN uncontrollable MediaPlayer preview, which had the same "keeps playing after navigating away" bug. Now every preview uses the global bar. - Web: the artist-hero fan line was laid out in a row next to the name (flex without column direction) and used an odd tinted color — now stacked cleanly under the name in a muted white. SW ns-v54. - CHANGELOG: documented the above + added a Known Bug (Android synced-lyrics auto-scroll still doesn't follow playback). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
9167bfaf0e
commit
5528947a93
5 files changed
+42
-51
No files matched your search
@@ -43,6 +43,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.Song
|
||||
import com.novasonic.app.api.model.fmtFanCount
|
||||
import com.novasonic.app.player.PlayerState
|
||||
import com.novasonic.app.ui.component.AlbumCard
|
||||
import com.novasonic.app.ui.component.ConcertsSheet
|
||||
@@ -241,13 +242,21 @@ fun ArtistDetailScreen(
|
||||
)
|
||||
)
|
||||
)
|
||||
Text(
|
||||
artist.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
|
||||
letterSpacing = (-0.5).sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.align(Alignment.BottomStart).padding(20.dp),
|
||||
)
|
||||
Column(modifier = Modifier.align(Alignment.BottomStart).padding(20.dp)) {
|
||||
Text(
|
||||
artist.name,
|
||||
fontFamily = Outfit, fontWeight = FontWeight.ExtraBold, fontSize = 30.sp,
|
||||
letterSpacing = (-0.5).sp, color = Color.White,
|
||||
maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
state.fanCount?.takeIf { it > 0 }?.let {
|
||||
Text(
|
||||
"${fmtFanCount(it)} Fans",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 13.sp,
|
||||
color = Color.White.copy(0.58f), modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import android.media.MediaPlayer
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.viewModelScope
|
||||
@@ -42,6 +41,8 @@ data class ArtistUiState(
|
||||
// Concerts
|
||||
val events: List<ConcertEvent> = emptyList(),
|
||||
val showEventsSheet: Boolean = false,
|
||||
// Deezer fan count of the matched artist (shown under the name), null if unknown
|
||||
val fanCount: Long? = null,
|
||||
// Preview + download
|
||||
val previewingId: Long? = null,
|
||||
val downloadSheetItem: ItunesItem? = null,
|
||||
@@ -51,22 +52,22 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
|
||||
private val repo = app.app.repository
|
||||
private val player = app.app.playerController
|
||||
private val preview = app.app.previewController
|
||||
private val artistId: String? = savedState["artistId"]
|
||||
|
||||
private val _state = MutableStateFlow(ArtistUiState())
|
||||
val state: StateFlow<ArtistUiState> = _state
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
|
||||
init {
|
||||
if (artistId.isNullOrBlank()) {
|
||||
_state.value = ArtistUiState(isLoading = false, error = "Keine Künstler-ID übergeben")
|
||||
} else {
|
||||
load()
|
||||
}
|
||||
// Mirror the app-level preview so track rows show which clip plays (bar lives in AppNavigation).
|
||||
viewModelScope.launch {
|
||||
player.state.collect { ps ->
|
||||
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
|
||||
preview.state.collect { ps ->
|
||||
_state.value = _state.value.copy(previewingId = if (ps.isPlaying) ps.trackId else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,14 +110,18 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
}.getOrNull()
|
||||
|
||||
val artists = searchResp?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||
val iArtistId = (
|
||||
// artists are sorted by fan count (ItunesClient), so the first name match is the most popular
|
||||
// one — disambiguates same-name artists (5x "Joji").
|
||||
val iArtist = (
|
||||
artists.find { it.artistName?.equals(artistName, ignoreCase = true) == true }
|
||||
?: artists.find {
|
||||
val n = it.artistName ?: return@find false
|
||||
n.contains(artistName, ignoreCase = true) || artistName.contains(n, ignoreCase = true)
|
||||
}
|
||||
?: artists.firstOrNull() // iTunes sorts by relevance — first hit is almost always right
|
||||
)?.artistId
|
||||
?: artists.firstOrNull()
|
||||
)
|
||||
val iArtistId = iArtist?.artistId
|
||||
if (iArtist?.fanCount != null) _state.value = _state.value.copy(fanCount = iArtist.fanCount)
|
||||
|
||||
if (iArtistId != null) {
|
||||
coroutineScope {
|
||||
@@ -254,41 +259,17 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
||||
}
|
||||
}
|
||||
|
||||
// ── iTunes 30s preview ───────────────────────────────────────────────────
|
||||
// ── 30s preview (app-level PreviewController → global control bar, survives navigation) ──
|
||||
|
||||
fun togglePreview(track: ItunesItem) {
|
||||
val url = track.previewUrl ?: return
|
||||
val id = track.trackId ?: return
|
||||
if (_state.value.previewingId == id) stopPreview() else startPreview(url, id)
|
||||
}
|
||||
|
||||
private fun startPreview(url: String, id: Long) {
|
||||
stopPreview()
|
||||
player.pause()
|
||||
_state.value = _state.value.copy(previewingId = id)
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val mp = MediaPlayer().apply {
|
||||
setDataSource(url)
|
||||
prepare()
|
||||
start()
|
||||
setOnCompletionListener { _state.value = _state.value.copy(previewingId = null) }
|
||||
}
|
||||
mediaPlayer = mp
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
}
|
||||
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
|
||||
}
|
||||
|
||||
fun stopPreview() {
|
||||
mediaPlayer?.runCatching { stop(); release() }
|
||||
mediaPlayer = null
|
||||
_state.value = _state.value.copy(previewingId = null)
|
||||
}
|
||||
fun stopPreview() = preview.stop()
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
stopPreview()
|
||||
}
|
||||
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
|
||||
// global bar) after leaving this screen.
|
||||
}
|
||||
Reference in new issue
Block a user