diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e6d4b..e603e0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ Versioning: two-part `MAJOR.MINOR` — each release bumps the minor (features an | Artist page (Navidrome) | ✅ | ✅ | | Artist page (Deezer fallback) | ✅ | ✅ | | Global search source | Deezer | Deezer | +| Same-name artist disambiguation | ✅ | ✅ | +| Artist fan count shown | ✅ | ✅ | +| 30s preview control bar | ✅ | ✅ | | Concert events (Ticketmaster) | ✅ | ✅ | | Genre browsing | ✅ | ✅ | | Global search | ✅ | ✅ | @@ -71,10 +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 - **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-v51` +- 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` - Titles a user picked stay as chosen; only derived fields (album, album-artist, genre, year, artwork) follow Deezer --- diff --git a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt index 53c3ca3..6c1c05e 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/NovaSonicApp.kt @@ -5,6 +5,7 @@ import com.novasonic.app.data.MusicRepository import com.novasonic.app.offline.OfflineRepository import com.novasonic.app.player.AbsPlayerController import com.novasonic.app.player.PlayerController +import com.novasonic.app.player.PreviewController class NovaSonicApp : Application() { @@ -16,6 +17,8 @@ class NovaSonicApp : Application() { private set lateinit var offlineRepository: OfflineRepository private set + // 30s preview player — app-level so its control bar survives navigation (see AppNavigation). + val previewController: PreviewController = PreviewController() override fun onCreate() { super.onCreate() diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/DeezerModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/DeezerModels.kt index 0a4f1bf..921861b 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/DeezerModels.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/DeezerModels.kt @@ -97,6 +97,7 @@ fun DzArtist.toItunesItem(): ItunesItem = ItunesItem( artistId = id, artistName = name, artworkUrl100 = pictureXl(), + fanCount = nb_fan, ) /** diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ItunesModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ItunesModels.kt index b09a5f3..88cd18c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/api/model/ItunesModels.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ItunesModels.kt @@ -27,8 +27,18 @@ data class ItunesItem( val trackCount: Int? = null, val trackNumber: Int? = null, val collectionPrice: Double? = null, + val fanCount: Long? = null, // Deezer nb_fan — disambiguates same-name artists + shown in UI ) +// German fan-count format: 559542 → "559.542", 1200000 → "1,2 Mio." (matches the web PWA). +fun fmtFanCount(n: Long): String { + if (n >= 1_000_000) { + val s = String.format(java.util.Locale.GERMAN, "%.1f", n / 1_000_000.0).removeSuffix(",0") + return "$s Mio." + } + return String.format(java.util.Locale.GERMAN, "%,d", n) +} + // iTunes RSS feed response @Serializable data class ItunesRssResponse( diff --git a/android/app/src/main/kotlin/com/novasonic/app/player/PreviewController.kt b/android/app/src/main/kotlin/com/novasonic/app/player/PreviewController.kt new file mode 100644 index 0000000..ec7a2f9 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/player/PreviewController.kt @@ -0,0 +1,79 @@ +package com.novasonic.app.player + +import android.media.MediaPlayer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** + * App-level 30s preview player (iTunes/Deezer preview clips). Kept on the Application — NOT in a + * screen ViewModel — so a preview keeps a **global control bar** (see AppNavigation) even after the + * user leaves the album/artist/Explore screen it started on. Previously the preview lived in + * ExploreViewModel and became uncontrollable once you navigated away. + */ +data class PreviewState( + val trackId: Long? = null, + val title: String = "", + val artist: String = "", + val artworkUrl: String? = null, + val isPlaying: Boolean = false, // true = actively playing, false = paused +) + +class PreviewController { + + private val _state = MutableStateFlow(PreviewState()) + val state: StateFlow = _state + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private var mp: MediaPlayer? = null + + /** Tap on a track: same track → toggle play/pause; different track → start it. */ + fun toggle(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) { + val cur = _state.value + when { + cur.trackId == trackId && cur.isPlaying -> pause() + cur.trackId == trackId && mp != null -> resume() + else -> start(trackId, url, title, artist, artworkUrl) + } + } + + private fun start(trackId: Long, url: String, title: String, artist: String, artworkUrl: String?) { + stop() + _state.value = PreviewState(trackId, title, artist, artworkUrl, isPlaying = true) + scope.launch(Dispatchers.IO) { + try { + val player = MediaPlayer().apply { + setDataSource(url) + prepare() + start() + setOnCompletionListener { stop() } + } + mp = player + } catch (e: Exception) { + stop() + } + } + } + + fun pause() { + mp?.runCatching { pause() } + _state.update { it.copy(isPlaying = false) } + } + + fun resume() { + val player = mp + if (player == null) return + player.runCatching { start() } + _state.update { it.copy(isPlaying = true) } + } + + fun stop() { + mp?.runCatching { stop(); release() } + mp = null + _state.value = PreviewState() + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 3019a27..7683be0 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -34,6 +34,7 @@ import com.novasonic.app.app import com.novasonic.app.data.AppNotification import com.novasonic.app.data.PrefsStore import com.novasonic.app.ui.component.MiniPlayer +import com.novasonic.app.ui.component.PreviewBar import com.novasonic.app.ui.component.NotificationSheet import com.novasonic.app.ui.component.PlaylistPickerSheet import com.novasonic.app.ui.component.SongContextSheet @@ -77,6 +78,11 @@ fun AppNavigation() { val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings()) val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null) + // 30s preview (app-level, so its control bar survives navigation). Starting real playback + // cancels any active preview so the two don't overlap. + val previewState by app.previewController.state.collectAsStateWithLifecycle() + LaunchedEffect(playerState.isPlaying) { if (playerState.isPlaying) app.previewController.stop() } + val absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank() val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) { if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/')) @@ -181,6 +187,19 @@ fun AppNavigation() { // NavigationBar to absorb the system-navigation inset, so pad the bar itself — otherwise // the MiniPlayer sits behind the phone's gesture/nav bar. Column(modifier = if (isTopLevel) Modifier else Modifier.navigationBarsPadding()) { + // 30s preview control bar — above the MiniPlayer, on every screen, so a preview + // started on an album/artist/Explore screen can be paused/stopped from anywhere. + AnimatedVisibility( + visible = previewState.trackId != null, + enter = slideInVertically { it } + fadeIn(tween(220)), + exit = slideOutVertically { it } + fadeOut(tween(180)), + ) { + PreviewBar( + state = previewState, + onToggle = { if (previewState.isPlaying) app.previewController.pause() else app.previewController.resume() }, + onClose = { app.previewController.stop() }, + ) + } // ABS audio routes through MusicService — one notification, one mini player. // When an ABS book is loaded, tapping the mini player opens the ABS overlay // instead of the music NowPlaying screen. diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/component/PreviewBar.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/component/PreviewBar.kt new file mode 100644 index 0000000..6f3711b --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/component/PreviewBar.kt @@ -0,0 +1,92 @@ +package com.novasonic.app.ui.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.novasonic.app.player.PreviewState +import com.novasonic.app.ui.theme.* + +/** + * Global 30s-preview control bar. Rendered above the MiniPlayer on every screen while a preview + * clip is active, so the user can pause/stop it after navigating away from the album/artist/Explore + * screen it was started on. + */ +@Composable +fun PreviewBar( + state: PreviewState, + onToggle: () -> Unit, + onClose: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 12.dp) + .padding(top = 6.dp, bottom = 2.dp) + .shadow(10.dp, RoundedCornerShape(16.dp)) + .clip(RoundedCornerShape(16.dp)) + .background(NsSurface2) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + AsyncImage( + model = state.artworkUrl, contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(38.dp).clip(RoundedCornerShape(7.dp)).background(NsSurface), + ) + Spacer(Modifier.width(11.dp)) + Column(Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "30s Preview", fontFamily = Outfit, fontWeight = FontWeight.Bold, + fontSize = 9.sp, color = NsAccent, letterSpacing = 0.5.sp, + ) + } + Text( + text = state.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, + fontSize = 13.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + if (state.artist.isNotBlank()) { + Text( + text = state.artist, fontFamily = Outfit, fontSize = 11.sp, + color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis, + ) + } + } + Box( + Modifier.size(36.dp).clip(CircleShape).background(NsAccent).clickable(onClick = onToggle), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (state.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow, + contentDescription = if (state.isPlaying) "Pause" else "Abspielen", + tint = Color.White, modifier = Modifier.size(20.dp), + ) + } + Spacer(Modifier.width(2.dp)) + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon(Icons.Default.Close, contentDescription = "Preview beenden", tint = NsOnSurface2, modifier = Modifier.size(20.dp)) + } + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt index e303ca1..5c59821 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ExploreScreen.kt @@ -447,6 +447,7 @@ private fun GlobalExploreContent( ArtistCard( name = a.artistName ?: "", imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"), + subtitle = a.fanCount?.takeIf { it > 0 }?.let { "${fmtFanCount(it)} Fans" }, ) { vm.openArtistDetail(a) } } } @@ -622,7 +623,7 @@ private fun ItunesTrackRow( } @Composable -private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) { +private fun ArtistCard(name: String, imageUrl: String?, subtitle: String? = null, onClick: () -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier @@ -656,6 +657,13 @@ private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) { color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = androidx.compose.ui.text.style.TextAlign.Center, ) + if (subtitle != null) { + Text( + subtitle, fontFamily = Outfit, fontSize = 10.sp, fontWeight = FontWeight.Medium, + color = NsOnSurface2, maxLines = 1, overflow = TextOverflow.Ellipsis, + textAlign = androidx.compose.ui.text.style.TextAlign.Center, + ) + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt index 8bd3000..0e7b6b2 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ItunesArtistScreen.kt @@ -34,6 +34,7 @@ import coil.compose.AsyncImage import com.novasonic.app.api.model.Album import com.novasonic.app.api.model.ItunesItem 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.theme.* import com.novasonic.app.viewmodel.ItunesArtistState @@ -185,6 +186,10 @@ fun ItunesArtistScreen( Text(artist.primaryGenreName!!, fontFamily = Outfit, fontSize = 13.sp, color = Color.White.copy(0.6f)) } + artist.fanCount?.takeIf { it > 0 }?.let { + Text("${fmtFanCount(it)} Fans", fontFamily = Outfit, fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, color = Color.White.copy(0.55f)) + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt index abc3b65..e4739d5 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ExploreViewModel.kt @@ -1,7 +1,6 @@ package com.novasonic.app.viewmodel import android.app.Application -import android.media.MediaPlayer import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.novasonic.app.api.ImporterClient @@ -69,20 +68,21 @@ data class ExploreUiState( class ExploreViewModel(app: Application) : AndroidViewModel(app) { - private val repo = app.app.repository - private val player = app.app.playerController + private val repo = app.app.repository + private val player = app.app.playerController + private val preview = app.app.previewController private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true)) val state: StateFlow = _state private var searchJob: Job? = null - private var mediaPlayer: MediaPlayer? = null init { loadCharts() + // Mirror the app-level preview state so the track rows still show which clip is playing. 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) } } } @@ -300,41 +300,12 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) { fun togglePreview(track: ItunesItem) { val url = track.previewUrl ?: return val id = track.trackId ?: return - val cur = _state.value.previewingId - if (cur == id) { - stopPreview() - } else { - startPreview(url, id) - } + player.pause() // don't overlap the 30s clip with library playback + preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100) } - 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) - } - } - } + fun stopPreview() = preview.stop() - fun stopPreview() { - mediaPlayer?.runCatching { stop(); release() } - mediaPlayer = null - _state.value = _state.value.copy(previewingId = null) - } - - override fun onCleared() { - super.onCleared() - stopPreview() - } + // NOTE: no onCleared stopPreview — the preview is app-level on purpose, so leaving the Explore + // tab keeps it playing with the global control bar (see AppNavigation). }