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:
HoneyandClaude Opus 4.8 committed 2026-07-31 16:31:55 +02:00
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.
}