feat(android): global 30s-preview control bar + artist fan count
Preview bar:
- 30s previews used to keep playing with no control once you left the album/artist/Explore
screen they started on. Moved the preview player from ExploreViewModel to an app-level
PreviewController, and added a global PreviewBar (above the mini player, on every screen)
to pause/resume/stop it. Starting real library playback cancels the preview.
Artist fan count (parity with web):
- ItunesItem carries fanCount (Deezer nb_fan); shown on search artist cards and the artist
page ("559.542 Fans" / "1,2 Mio. Fans"). Combined with the existing nb_fan sort this makes
same-name collisions (5x "Joji") resolve to the right artist on Android too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
c92ac3d719
commit
9167bfaf0e
10 files changed
+233
-40
No files matched your search
+6
-1
@@ -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
|
||||
|
||||
---
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -97,6 +97,7 @@ fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
|
||||
artistId = id,
|
||||
artistName = name,
|
||||
artworkUrl100 = pictureXl(),
|
||||
fanCount = nb_fan,
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<PreviewState> = _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()
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -71,18 +70,19 @@ class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
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<ExploreUiState> = _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).
|
||||
}
|
||||
Reference in new issue
Block a user