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
+235
-42
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 (Navidrome) | ✅ | ✅ |
|
||||||
| Artist page (Deezer fallback) | ✅ | ✅ |
|
| Artist page (Deezer fallback) | ✅ | ✅ |
|
||||||
| Global search source | Deezer | Deezer |
|
| Global search source | Deezer | Deezer |
|
||||||
|
| Same-name artist disambiguation | ✅ | ✅ |
|
||||||
|
| Artist fan count shown | ✅ | ✅ |
|
||||||
|
| 30s preview control bar | ✅ | ✅ |
|
||||||
| Concert events (Ticketmaster) | ✅ | ✅ |
|
| Concert events (Ticketmaster) | ✅ | ✅ |
|
||||||
| Genre browsing | ✅ | ✅ |
|
| Genre browsing | ✅ | ✅ |
|
||||||
| Global search | ✅ | ✅ |
|
| 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
|
- **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
|
### 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`
|
- **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
|
### 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
|
- 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.offline.OfflineRepository
|
||||||
import com.novasonic.app.player.AbsPlayerController
|
import com.novasonic.app.player.AbsPlayerController
|
||||||
import com.novasonic.app.player.PlayerController
|
import com.novasonic.app.player.PlayerController
|
||||||
|
import com.novasonic.app.player.PreviewController
|
||||||
|
|
||||||
class NovaSonicApp : Application() {
|
class NovaSonicApp : Application() {
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ class NovaSonicApp : Application() {
|
|||||||
private set
|
private set
|
||||||
lateinit var offlineRepository: OfflineRepository
|
lateinit var offlineRepository: OfflineRepository
|
||||||
private set
|
private set
|
||||||
|
// 30s preview player — app-level so its control bar survives navigation (see AppNavigation).
|
||||||
|
val previewController: PreviewController = PreviewController()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ fun DzArtist.toItunesItem(): ItunesItem = ItunesItem(
|
|||||||
artistId = id,
|
artistId = id,
|
||||||
artistName = name,
|
artistName = name,
|
||||||
artworkUrl100 = pictureXl(),
|
artworkUrl100 = pictureXl(),
|
||||||
|
fanCount = nb_fan,
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -27,8 +27,18 @@ data class ItunesItem(
|
|||||||
val trackCount: Int? = null,
|
val trackCount: Int? = null,
|
||||||
val trackNumber: Int? = null,
|
val trackNumber: Int? = null,
|
||||||
val collectionPrice: Double? = 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
|
// iTunes RSS feed response
|
||||||
@Serializable
|
@Serializable
|
||||||
data class ItunesRssResponse(
|
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.AppNotification
|
||||||
import com.novasonic.app.data.PrefsStore
|
import com.novasonic.app.data.PrefsStore
|
||||||
import com.novasonic.app.ui.component.MiniPlayer
|
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.NotificationSheet
|
||||||
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
import com.novasonic.app.ui.component.PlaylistPickerSheet
|
||||||
import com.novasonic.app.ui.component.SongContextSheet
|
import com.novasonic.app.ui.component.SongContextSheet
|
||||||
@@ -77,6 +78,11 @@ fun AppNavigation() {
|
|||||||
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
val appSettings by repo.appSettingsFlow().collectAsStateWithLifecycle(PrefsStore.AppSettings())
|
||||||
val lastPlayedSong by repo.lastPlayedSongFlow().collectAsStateWithLifecycle(null)
|
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 absConfigured = appSettings.absUrl.isNotBlank() && appSettings.absApiKey.isNotBlank()
|
||||||
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
|
val absClient: AbsClient? = remember(appSettings.absUrl, appSettings.absApiKey, appSettings.backendUrl) {
|
||||||
if (absConfigured) AbsClient(appSettings.absUrl.trimEnd('/'), appSettings.absApiKey, appSettings.backendUrl.trimEnd('/'))
|
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
|
// NavigationBar to absorb the system-navigation inset, so pad the bar itself — otherwise
|
||||||
// the MiniPlayer sits behind the phone's gesture/nav bar.
|
// the MiniPlayer sits behind the phone's gesture/nav bar.
|
||||||
Column(modifier = if (isTopLevel) Modifier else Modifier.navigationBarsPadding()) {
|
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.
|
// ABS audio routes through MusicService — one notification, one mini player.
|
||||||
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
|
// When an ABS book is loaded, tapping the mini player opens the ABS overlay
|
||||||
// instead of the music NowPlaying screen.
|
// 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(
|
ArtistCard(
|
||||||
name = a.artistName ?: "",
|
name = a.artistName ?: "",
|
||||||
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
imageUrl = a.artworkUrl100?.replace("100x100bb", "300x300bb"),
|
||||||
|
subtitle = a.fanCount?.takeIf { it > 0 }?.let { "${fmtFanCount(it)} Fans" },
|
||||||
) { vm.openArtistDetail(a) }
|
) { vm.openArtistDetail(a) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -622,7 +623,7 @@ private fun ItunesTrackRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) {
|
private fun ArtistCard(name: String, imageUrl: String?, subtitle: String? = null, onClick: () -> Unit) {
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -656,6 +657,13 @@ private fun ArtistCard(name: String, imageUrl: String?, onClick: () -> Unit) {
|
|||||||
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
color = NsOnSurface, maxLines = 2, overflow = TextOverflow.Ellipsis,
|
||||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
|
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.Album
|
||||||
import com.novasonic.app.api.model.ItunesItem
|
import com.novasonic.app.api.model.ItunesItem
|
||||||
import com.novasonic.app.api.model.Song
|
import com.novasonic.app.api.model.Song
|
||||||
|
import com.novasonic.app.api.model.fmtFanCount
|
||||||
import com.novasonic.app.player.PlayerState
|
import com.novasonic.app.player.PlayerState
|
||||||
import com.novasonic.app.ui.theme.*
|
import com.novasonic.app.ui.theme.*
|
||||||
import com.novasonic.app.viewmodel.ItunesArtistState
|
import com.novasonic.app.viewmodel.ItunesArtistState
|
||||||
@@ -185,6 +186,10 @@ fun ItunesArtistScreen(
|
|||||||
Text(artist.primaryGenreName!!, fontFamily = Outfit,
|
Text(artist.primaryGenreName!!, fontFamily = Outfit,
|
||||||
fontSize = 13.sp, color = Color.White.copy(0.6f))
|
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
|
package com.novasonic.app.viewmodel
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
import android.media.MediaPlayer
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.novasonic.app.api.ImporterClient
|
import com.novasonic.app.api.ImporterClient
|
||||||
@@ -69,20 +68,21 @@ data class ExploreUiState(
|
|||||||
|
|
||||||
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
class ExploreViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
|
|
||||||
private val repo = app.app.repository
|
private val repo = app.app.repository
|
||||||
private val player = app.app.playerController
|
private val player = app.app.playerController
|
||||||
|
private val preview = app.app.previewController
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
private val _state = MutableStateFlow(ExploreUiState(chartsLoading = true))
|
||||||
val state: StateFlow<ExploreUiState> = _state
|
val state: StateFlow<ExploreUiState> = _state
|
||||||
|
|
||||||
private var searchJob: Job? = null
|
private var searchJob: Job? = null
|
||||||
private var mediaPlayer: MediaPlayer? = null
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
loadCharts()
|
loadCharts()
|
||||||
|
// Mirror the app-level preview state so the track rows still show which clip is playing.
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
player.state.collect { ps ->
|
preview.state.collect { ps ->
|
||||||
if (ps.isPlaying && _state.value.previewingId != null) stopPreview()
|
_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) {
|
fun togglePreview(track: ItunesItem) {
|
||||||
val url = track.previewUrl ?: return
|
val url = track.previewUrl ?: return
|
||||||
val id = track.trackId ?: return
|
val id = track.trackId ?: return
|
||||||
val cur = _state.value.previewingId
|
player.pause() // don't overlap the 30s clip with library playback
|
||||||
if (cur == id) {
|
preview.toggle(id, url, track.trackName ?: "", track.artistName ?: "", track.artworkUrl100)
|
||||||
stopPreview()
|
|
||||||
} else {
|
|
||||||
startPreview(url, id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startPreview(url: String, id: Long) {
|
fun stopPreview() = preview.stop()
|
||||||
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() {
|
// NOTE: no onCleared stopPreview — the preview is app-level on purpose, so leaving the Explore
|
||||||
mediaPlayer?.runCatching { stop(); release() }
|
// tab keeps it playing with the global control bar (see AppNavigation).
|
||||||
mediaPlayer = null
|
|
||||||
_state.value = _state.value.copy(previewingId = null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCleared() {
|
|
||||||
super.onCleared()
|
|
||||||
stopPreview()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user