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

+4 -3
View File
@@ -74,12 +74,12 @@ Big change: **the entire metadata/search stack moved from Apple's iTunes to Deez
- **Backend** — Removed the dead iTunes `/search` proxy and the Apple charts RSS proxy; the Deezer proxy (`/api/deezer/*`) now also serves charts and the editor lookup
### Added
- **Web + Android** — **Same-name artist disambiguation**: Deezer's artist search isn't ranked by popularity, so a name collision (5 artists named "Joji") returned a niche one first — the artist page then loaded the wrong discography and owned songs didn't show as downloaded. Artists are now sorted by Deezer fan count, and on a local artist page with a real collision the pick is tie-broken by **song overlap** with your library (each artist has different songs). The **fan count is shown** on search artist cards and the artist page
- **Android** — **Global 30s-preview control bar**: a preview started on an album/artist/Explore screen used to keep playing with no way to stop it once you navigated away. The preview now lives at the app level with a bar (above the mini player, on every screen) to pause/resume/stop it; starting real playback cancels the preview
- **Web + Android** — **Same-name artist disambiguation**: Deezer's artist search isn't ranked by popularity, so a name collision (5 artists named "Joji") returned a niche one first — the artist page then loaded the wrong discography and owned songs didn't show as downloaded. Artists are now sorted by Deezer fan count, and on a local artist page (web) with a real collision the pick is tie-broken by **song overlap** with your library (each artist has different songs). The **fan count is shown** on search artist cards and on both the Deezer *and* local Navidrome artist pages (web + Android)
- **Android** — **Global 30s-preview control bar**: a preview started on an album/artist/Explore screen used to keep playing with no way to stop it once you navigated away (the local artist page had its *own* uncontrollable preview too). All previews now run through one app-level player with a bar (above the mini player, on every screen) to pause/resume/stop it; starting real playback cancels the preview
- **Backend** — Unit tests for the Deezer mapping/matching logic (`backend/test/deezer.test.js`, run with `npm test`), incl. per-track numbering, mixed-artist album-artist stability, explicit flags and the Japanese-title match guard. A live smoke test runs with `RUN_LIVE=1`
### Notes
- Deploy: `git pull`, then rebuild the frontend (`docker compose up -d --build novasonic-frontend`) and restart the backend (`docker compose restart novasonic-backend`). Service worker bumped to `ns-v53`
- Deploy: `git pull`, then rebuild the frontend (`docker compose up -d --build novasonic-frontend`) and restart the backend (`docker compose restart novasonic-backend`). Service worker bumped to `ns-v54`
- Titles a user picked stay as chosen; only derived fields (album, album-artist, genre, year, artwork) follow Deezer
---
@@ -412,6 +412,7 @@ Primary causes identified:
- **Web + Android** — yt-dlp backend update / maintenance
### Known Bugs
- **Android (lyrics)** — Synced-lyrics **auto-scroll still does not follow playback**: the current line is highlighted but the list doesn't scroll to keep it centered, so you have to scroll by hand. Web autoscroll works. Not yet fixed.
- **Web (PWA, desktop)** — The Windows media tile (SMTC) briefly closes and reopens when skipping tracks. Swapping the `<audio>` element's source leaves a momentary gap with no active media output, so Windows tears down and rebuilds the tile. A real fix needs one continuous source (MSE), which requires streaming an MSE-compatible format. The native Android app is unaffected. (The two-element gapless handoff added in 1.1 makes skips effectively instant but does **not** fix this — switching elements still ends and restarts the OS session.)
- **Backend (download)** — The duplicate check requires at least a `firstArtist` folder match. If the same song was previously stored under a very different artist name (e.g. before an iTunes-name fix), it will not be detected as a duplicate and will be re-downloaded.
- **Backend (metadata/sorting)** — MusicBrainz `release['artist-credit']` is often absent in the standard recording search response (requires `inc=artist-credits` which can slow the endpoint). The albumArtist field from MusicBrainz autofill may fall back to the recording artist rather than the release artist.
@@ -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.
}
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'ns-v53';
const CACHE = 'ns-v54';
const PRECACHE = ['/', 'index.html', 'manifest.json', 'icon.svg'];
const SKIP_PATHS = ['/rest/stream', '/rest/download', '/config.js'];
// Auth token params stripped from cache keys so artwork survives across sessions/token refreshes.
+3 -3
View File
@@ -846,9 +846,9 @@ button:focus{outline:none}
.artist-hero-pfp-wrap{aspect-ratio:1/1;height:100%;flex-shrink:0;overflow:hidden;position:relative;background:linear-gradient(135deg,var(--surface2),var(--surface3,var(--surface)))}
.artist-hero-pfp{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;display:block}
.artist-hero-pfp-initial{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:52px;font-weight:700;color:var(--text2);line-height:1}
.artist-hero-info{min-width:0;flex:1;display:flex;align-items:flex-end;padding:20px 24px;background:linear-gradient(to left,rgba(7,7,15,.95) 0%,rgba(7,7,15,.1) 100%)}
.artist-hero-name{font-size:26px;font-weight:800;letter-spacing:-0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.artist-hero-fans{font-size:12px;font-weight:600;color:var(--text2);margin-top:3px;display:none}
.artist-hero-info{min-width:0;flex:1;display:flex;flex-direction:column;justify-content:flex-end;align-items:flex-start;padding:20px 24px;background:linear-gradient(to left,rgba(7,7,15,.95) 0%,rgba(7,7,15,.1) 100%)}
.artist-hero-name{max-width:100%;font-size:26px;font-weight:800;letter-spacing:-0.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.artist-hero-fans{font-size:13px;font-weight:600;color:rgba(255,255,255,.58);margin-top:6px;letter-spacing:.2px;display:none}
.artist-hero-fans.show{display:block}
@media(max-width:768px){
.artist-hero-bg{filter:brightness(0.75) saturate(0.85);transform:scale(1)}