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
+4
-3
@@ -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
@@ -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
@@ -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)}
|
||||
|
||||
Reference in new issue
Block a user