feat(android): artist pin (same-name disambiguation) — parity with web
Android now reads AND writes the shared server-side artist overrides (/api/artist-overrides), so pins set on web apply here and can be set/removed in-app — closing the web-only gap. - ArtistOverride model + ArtistOverrideClient (GET/POST/DELETE on the backend); MusicRepository caches the map and exposes get/set/remove. - ArtistViewModel resolves the pin up front; when pinned it enriches the artist page straight from the pinned Deezer id (albums/top-tracks/fans/image) instead of the name-guess. Picker: Deezer search candidates + a "paste Deezer link/id" route (reliable when /search/artist can't surface a low-profile same-name artist), plus clear-pin. - ArtistDetailScreen: "⚙ Falscher Künstler? / Gepinnt" pill top-right of the hero, pinned Deezer picture overrides the hero image, ModalBottomSheet picker. - ItunesClient.lookupArtist(id) (Deezer-backed) for the link-paste flow. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
a5e5d414b4
commit
1ee85482f3
6 files changed
+351
-3
No files matched your search
@@ -0,0 +1,59 @@
|
|||||||
|
package com.novasonic.app.api
|
||||||
|
|
||||||
|
import com.novasonic.app.api.model.ArtistOverride
|
||||||
|
import io.ktor.client.*
|
||||||
|
import io.ktor.client.call.*
|
||||||
|
import io.ktor.client.engine.android.*
|
||||||
|
import io.ktor.client.plugins.*
|
||||||
|
import io.ktor.client.plugins.contentnegotiation.*
|
||||||
|
import io.ktor.client.request.*
|
||||||
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.*
|
||||||
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads/writes the shared artist-pin overrides on the download backend
|
||||||
|
* (GET/POST /api/artist-overrides, DELETE /api/artist-overrides/:id).
|
||||||
|
* The pins fix same-name artist collisions; see [ArtistOverride].
|
||||||
|
*/
|
||||||
|
class ArtistOverrideClient(private val backendUrl: String) {
|
||||||
|
|
||||||
|
private val http = HttpClient(Android) {
|
||||||
|
install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true; coerceInputValues = true }) }
|
||||||
|
install(HttpTimeout) { requestTimeoutMillis = 15_000; connectTimeoutMillis = 8_000 }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun base() = backendUrl.trimEnd('/')
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
private data class SetReq(
|
||||||
|
val ndArtistId: String,
|
||||||
|
val deezerArtistId: String,
|
||||||
|
val name: String,
|
||||||
|
val image: String,
|
||||||
|
val fanCount: Long?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Full map { ndArtistId -> override }. */
|
||||||
|
suspend fun loadAll(): Map<String, ArtistOverride> =
|
||||||
|
http.get("${base()}/api/artist-overrides").body()
|
||||||
|
|
||||||
|
/** Upsert a pin. Returns true on 2xx. */
|
||||||
|
suspend fun set(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||||
|
val resp: HttpResponse = http.post("${base()}/api/artist-overrides") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(SetReq(ndArtistId, ov.deezerArtistId, ov.name, ov.image, ov.fanCount))
|
||||||
|
}
|
||||||
|
return resp.status.isSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove a pin. Returns true on 2xx. */
|
||||||
|
suspend fun remove(ndArtistId: String): Boolean {
|
||||||
|
val resp: HttpResponse = http.delete("${base()}/api/artist-overrides/${ndArtistId.encodeURLPath()}")
|
||||||
|
return resp.status.isSuccess()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() = http.close()
|
||||||
|
}
|
||||||
@@ -72,6 +72,16 @@ object ItunesClient {
|
|||||||
return albums.data.filter { it.record_type == "album" }.map { it.toItunesItem() }
|
return albums.data.filter { it.record_type == "album" }.map { it.toItunesItem() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single Deezer artist by id (name / picture / fan count), as an ItunesItem. The reliable
|
||||||
|
* route when /search/artist can't surface a low-profile same-name artist — used by the
|
||||||
|
* artist-pin "paste Deezer link/id" flow. Null on a bad id / error.
|
||||||
|
*/
|
||||||
|
suspend fun lookupArtist(artistId: Long): ItunesItem? {
|
||||||
|
val a = runCatching { http.get("$BASE/artist/$artistId").body<DzArtist>() }.getOrNull()
|
||||||
|
return if (a?.id != null) a.toItunesItem() else null
|
||||||
|
}
|
||||||
|
|
||||||
/** Top tracks for a Deezer artist. */
|
/** Top tracks for a Deezer artist. */
|
||||||
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
|
suspend fun lookupArtistTopTracks(artistId: Long, limit: Int = 10): List<ItunesItem> {
|
||||||
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
|
val tracks: DzList<DzTrack> = http.get("$BASE/artist/$artistId/top") {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.novasonic.app.api.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A manual "pin" mapping a local Navidrome artist → a specific Deezer artist, fixing same-name
|
||||||
|
* collisions where NovaSonic would otherwise guess the wrong one (fans / top songs / albums / image).
|
||||||
|
* Stored server-side (shared across users) on the download backend at /api/artist-overrides, keyed
|
||||||
|
* by the Navidrome artist id. Mirrors the web PWA's override shape exactly.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ArtistOverride(
|
||||||
|
val deezerArtistId: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val image: String = "",
|
||||||
|
val fanCount: Long? = null,
|
||||||
|
)
|
||||||
@@ -64,6 +64,42 @@ class MusicRepository(private val context: Context) {
|
|||||||
playLog.fetchStats(from, to, tz, bucket, limit)
|
playLog.fetchStats(from, to, tz, bucket, limit)
|
||||||
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
|
suspend fun fetchScrobbleUsers() = playLog.fetchUsers()
|
||||||
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
|
suspend fun fetchScrobbleCompare(with: String) = playLog.fetchCompare(with)
|
||||||
|
// ── Artist pins (shared, server-side same-name disambiguation) ───────────────
|
||||||
|
@Volatile private var _artistOverrides: Map<String, ArtistOverride>? = null
|
||||||
|
|
||||||
|
/** Full pin map, cached after first load. Empty if no backend / unreachable. */
|
||||||
|
suspend fun artistOverrides(): Map<String, ArtistOverride> {
|
||||||
|
_artistOverrides?.let { return it }
|
||||||
|
val url = getBackendUrl()
|
||||||
|
if (url.isBlank()) return emptyMap<String, ArtistOverride>().also { _artistOverrides = it }
|
||||||
|
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||||
|
val m = runCatching { c.loadAll() }.getOrDefault(emptyMap())
|
||||||
|
c.close()
|
||||||
|
return m.also { _artistOverrides = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getArtistOverride(ndArtistId: String): ArtistOverride? = artistOverrides()[ndArtistId]
|
||||||
|
|
||||||
|
suspend fun setArtistOverride(ndArtistId: String, ov: ArtistOverride): Boolean {
|
||||||
|
val url = getBackendUrl()
|
||||||
|
if (url.isBlank()) return false
|
||||||
|
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||||
|
val ok = runCatching { c.set(ndArtistId, ov) }.getOrDefault(false)
|
||||||
|
c.close()
|
||||||
|
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) + (ndArtistId to ov)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun removeArtistOverride(ndArtistId: String): Boolean {
|
||||||
|
val url = getBackendUrl()
|
||||||
|
if (url.isBlank()) return false
|
||||||
|
val c = com.novasonic.app.api.ArtistOverrideClient(url)
|
||||||
|
val ok = runCatching { c.remove(ndArtistId) }.getOrDefault(false)
|
||||||
|
c.close()
|
||||||
|
if (ok) _artistOverrides = (_artistOverrides ?: emptyMap()) - ndArtistId
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
suspend fun getArtistInfo(id: String) = api().getArtistInfo(id)
|
||||||
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
suspend fun getTopSongs(artistName: String) = api().getTopSongs(artistName)
|
||||||
suspend fun getGenres() = api().getGenres()
|
suspend fun getGenres() = api().getGenres()
|
||||||
|
|||||||
@@ -111,6 +111,22 @@ fun ArtistDetailScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Artist-pin picker
|
||||||
|
if (state.showPinPicker) {
|
||||||
|
ArtistPinSheet(
|
||||||
|
artistName = state.artist?.name ?: "",
|
||||||
|
pinned = state.pin,
|
||||||
|
searching = state.pinSearching,
|
||||||
|
candidates = state.pinCandidates,
|
||||||
|
onPick = vm::pinCandidate,
|
||||||
|
onPinLink = { link -> vm.pinByLink(link) { ok ->
|
||||||
|
if (!ok) Toast.makeText(context, "Deezer-Künstler nicht gefunden", Toast.LENGTH_SHORT).show()
|
||||||
|
} },
|
||||||
|
onClear = vm::clearPin,
|
||||||
|
onDismiss = vm::dismissPinPicker,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
CircularProgressIndicator(color = NsAccent)
|
CircularProgressIndicator(color = NsAccent)
|
||||||
@@ -218,7 +234,10 @@ fun ArtistDetailScreen(
|
|||||||
|
|
||||||
// Hero card
|
// Hero card
|
||||||
item {
|
item {
|
||||||
val imgUrl = state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
// A pin's Deezer picture wins over the Last.fm image (fixes same-name artists whose
|
||||||
|
// Navidrome/Last.fm image is the wrong person).
|
||||||
|
val imgUrl = state.pinnedImage?.takeIf { it.isNotBlank() }
|
||||||
|
?: state.info?.largeImageUrl?.takeIf { it.isNotBlank() }
|
||||||
?: coverUrl(artist.coverArt)
|
?: coverUrl(artist.coverArt)
|
||||||
Box(
|
Box(
|
||||||
Modifier
|
Modifier
|
||||||
@@ -234,6 +253,20 @@ fun ArtistDetailScreen(
|
|||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
|
// "Wrong artist?" pin — top-right over the hero.
|
||||||
|
Surface(
|
||||||
|
onClick = { vm.openPinPicker() },
|
||||||
|
shape = RoundedCornerShape(999.dp),
|
||||||
|
color = Color(0x66000000),
|
||||||
|
modifier = Modifier.align(Alignment.TopEnd).padding(10.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (state.pin != null) "⚙ Gepinnt" else "⚙ Falscher Künstler?",
|
||||||
|
fontFamily = Outfit, fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White.copy(0.92f),
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxSize().background(
|
Modifier.fillMaxSize().background(
|
||||||
Brush.verticalGradient(
|
Brush.verticalGradient(
|
||||||
@@ -629,3 +662,93 @@ internal fun AllAlbumsOverlay(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Artist-pin picker ────────────────────────────────────────────────────────
|
||||||
|
// Pin a local (Navidrome) artist to a specific Deezer artist, fixing same-name collisions.
|
||||||
|
// A pasted Deezer link/id is the reliable route when /search/artist can't surface the artist.
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ArtistPinSheet(
|
||||||
|
artistName: String,
|
||||||
|
pinned: com.novasonic.app.api.model.ArtistOverride?,
|
||||||
|
searching: Boolean,
|
||||||
|
candidates: List<com.novasonic.app.api.model.ItunesItem>,
|
||||||
|
onPick: (com.novasonic.app.api.model.ItunesItem) -> Unit,
|
||||||
|
onPinLink: (String) -> Unit,
|
||||||
|
onClear: () -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
var link by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState, containerColor = NsSurface) {
|
||||||
|
Column(
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(horizontal = 20.dp)
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
) {
|
||||||
|
Text("Richtigen Künstler wählen", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||||
|
fontSize = 18.sp, color = NsOnSurface)
|
||||||
|
Text("Für „$artistName\"", fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(top = 2.dp, bottom = 14.dp))
|
||||||
|
|
||||||
|
if (pinned != null) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = onClear,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(bottom = 12.dp),
|
||||||
|
) {
|
||||||
|
Text("Pin entfernen (aktuell: ${pinned.name.ifBlank { "gepinnt" }})",
|
||||||
|
color = NsError, fontFamily = Outfit, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste a Deezer link/id → pin directly (reliable when search misses the artist).
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = link, onValueChange = { link = it },
|
||||||
|
placeholder = { Text("Deezer-Link oder ID", fontFamily = Outfit, fontSize = 13.sp) },
|
||||||
|
singleLine = true,
|
||||||
|
textStyle = androidx.compose.ui.text.TextStyle(fontFamily = Outfit, fontSize = 14.sp, color = NsOnSurface),
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Button(
|
||||||
|
onClick = { if (link.isNotBlank()) onPinLink(link) },
|
||||||
|
shape = RoundedCornerShape(10.dp),
|
||||||
|
) { Text("Pinnen", fontFamily = Outfit, fontWeight = FontWeight.Bold) }
|
||||||
|
}
|
||||||
|
Text("Nicht in der Liste? Künstler auf deezer.com öffnen und Link einfügen.",
|
||||||
|
fontFamily = Outfit, fontSize = 11.sp, color = NsOnSurface2,
|
||||||
|
modifier = Modifier.padding(top = 6.dp, bottom = 14.dp))
|
||||||
|
|
||||||
|
when {
|
||||||
|
searching -> Box(Modifier.fillMaxWidth().padding(24.dp), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator(color = NsAccent, modifier = Modifier.size(28.dp))
|
||||||
|
}
|
||||||
|
candidates.isEmpty() -> Text("Keine Deezer-Künstler gefunden.", fontFamily = Outfit,
|
||||||
|
fontSize = 13.sp, color = NsOnSurface2, modifier = Modifier.padding(vertical = 12.dp))
|
||||||
|
else -> Column(Modifier.fillMaxWidth()) {
|
||||||
|
candidates.forEach { c ->
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
modifier = Modifier.fillMaxWidth().clickable { onPick(c) }.padding(vertical = 7.dp),
|
||||||
|
) {
|
||||||
|
Box(Modifier.size(48.dp).clip(CircleShape).background(NsSurface2)) {
|
||||||
|
AsyncImage(model = c.artworkUrl100, contentDescription = null,
|
||||||
|
contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||||
|
}
|
||||||
|
Column(Modifier.weight(1f).padding(horizontal = 12.dp)) {
|
||||||
|
Text(c.artistName ?: "—", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||||
|
fontSize = 14.sp, color = NsOnSurface, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
|
Text(c.fanCount?.let { "${fmtFanCount(it)} Fans" } ?: "Deezer",
|
||||||
|
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import com.novasonic.app.api.EventsClient
|
|||||||
import com.novasonic.app.api.ImporterClient
|
import com.novasonic.app.api.ImporterClient
|
||||||
import com.novasonic.app.api.ItunesClient
|
import com.novasonic.app.api.ItunesClient
|
||||||
import com.novasonic.app.api.model.Artist
|
import com.novasonic.app.api.model.Artist
|
||||||
|
import com.novasonic.app.api.model.ArtistOverride
|
||||||
import com.novasonic.app.api.model.ConcertEvent
|
import com.novasonic.app.api.model.ConcertEvent
|
||||||
import com.novasonic.app.api.model.ArtistInfo
|
import com.novasonic.app.api.model.ArtistInfo
|
||||||
import com.novasonic.app.api.model.DownloadRequest
|
import com.novasonic.app.api.model.DownloadRequest
|
||||||
@@ -46,6 +47,13 @@ data class ArtistUiState(
|
|||||||
// Preview + download
|
// Preview + download
|
||||||
val previewingId: Long? = null,
|
val previewingId: Long? = null,
|
||||||
val downloadSheetItem: ItunesItem? = null,
|
val downloadSheetItem: ItunesItem? = null,
|
||||||
|
// Artist pin (same-name disambiguation): the active pin, the image it overrides the hero with,
|
||||||
|
// and the picker state (link/id paste + Deezer search candidates).
|
||||||
|
val pin: ArtistOverride? = null,
|
||||||
|
val pinnedImage: String? = null,
|
||||||
|
val showPinPicker: Boolean = false,
|
||||||
|
val pinSearching: Boolean = false,
|
||||||
|
val pinCandidates: List<ItunesItem> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) {
|
||||||
@@ -84,17 +92,22 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
val info = infoDef.await()
|
val info = infoDef.await()
|
||||||
|
|
||||||
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
val topDef = async { runCatching { repo.getTopSongs(artist.name) }.getOrDefault(emptyList()) }
|
||||||
|
// A manual pin (shared, server-side) fixes same-name collisions — resolve it up front
|
||||||
|
// so the Deezer enrichment below uses the pinned artist, not a name-guess.
|
||||||
|
val pinDef = async { runCatching { repo.getArtistOverride(artist.id) }.getOrNull() }
|
||||||
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
|
// Navidrome can hold the same track twice (single + album track) → duplicate top-song
|
||||||
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
|
// rows ("PIXELATED KISSES" x2). Collapse by title+album (keeps the highest play count).
|
||||||
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
|
val top = topDef.await().distinctBy { "${it.title}|${it.album}".lowercase() }
|
||||||
|
val pin = pinDef.await()
|
||||||
|
|
||||||
_state.value = ArtistUiState(
|
_state.value = ArtistUiState(
|
||||||
artist = artist, info = info, topSongs = top,
|
artist = artist, info = info, topSongs = top,
|
||||||
isLoading = false, itunesLoading = true,
|
isLoading = false, itunesLoading = true,
|
||||||
|
pin = pin, pinnedImage = pin?.image?.takeIf { it.isNotBlank() },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fetch iTunes + events in parallel without blocking the main screen
|
// Fetch iTunes + events in parallel without blocking the main screen
|
||||||
launch { loadItunesData(artist.name) }
|
launch { loadItunesData(artist.name, pin) }
|
||||||
launch {
|
launch {
|
||||||
val backendUrl = repo.getBackendUrl()
|
val backendUrl = repo.getBackendUrl()
|
||||||
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
|
val ev = runCatching { EventsClient.fetchEvents(backendUrl, artist.name) }.getOrDefault(emptyList())
|
||||||
@@ -106,7 +119,24 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun loadItunesData(artistName: String) {
|
private suspend fun loadItunesData(artistName: String, pin: ArtistOverride? = null) {
|
||||||
|
// Pinned: skip the name-guess entirely and enrich straight from the pinned Deezer artist id.
|
||||||
|
val pinnedId = pin?.deezerArtistId?.toLongOrNull()
|
||||||
|
if (pinnedId != null) {
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
fanCount = pin.fanCount ?: _state.value.fanCount,
|
||||||
|
pinnedImage = pin.image.takeIf { it.isNotBlank() } ?: _state.value.pinnedImage,
|
||||||
|
)
|
||||||
|
coroutineScope {
|
||||||
|
val iAlbumsD = async { runCatching { ItunesClient.lookupArtistAlbums(pinnedId) }.getOrDefault(emptyList()) }
|
||||||
|
val iTracksD = async { runCatching { ItunesClient.lookupArtistTopTracks(pinnedId, 10) }.getOrDefault(emptyList()) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
iAlbums = iAlbumsD.await(), iTracks = iTracksD.await(), itunesLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val searchResp = runCatching {
|
val searchResp = runCatching {
|
||||||
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
ItunesClient.search(artistName, entity = "musicArtist", limit = 5)
|
||||||
}.getOrNull()
|
}.getOrNull()
|
||||||
@@ -275,6 +305,79 @@ class ArtistViewModel(app: Application, savedState: SavedStateHandle) : AndroidV
|
|||||||
|
|
||||||
fun stopPreview() = preview.stop()
|
fun stopPreview() = preview.stop()
|
||||||
|
|
||||||
|
// ── Artist pin (same-name disambiguation) ────────────────────────────────
|
||||||
|
|
||||||
|
fun openPinPicker() {
|
||||||
|
_state.value = _state.value.copy(showPinPicker = true, pinCandidates = emptyList(), pinSearching = true)
|
||||||
|
val name = _state.value.artist?.name ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
val res = runCatching { ItunesClient.search(name, entity = "musicArtist", limit = 8) }.getOrNull()
|
||||||
|
val cands = res?.results?.filter { it.wrapperType == "artist" } ?: emptyList()
|
||||||
|
_state.value = _state.value.copy(pinCandidates = cands, pinSearching = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun dismissPinPicker() { _state.value = _state.value.copy(showPinPicker = false) }
|
||||||
|
|
||||||
|
/** Pin one of the searched candidates. */
|
||||||
|
fun pinCandidate(item: ItunesItem) {
|
||||||
|
val dzId = item.artistId ?: return
|
||||||
|
applyPin(ArtistOverride(
|
||||||
|
deezerArtistId = dzId.toString(),
|
||||||
|
name = item.artistName ?: _state.value.artist?.name ?: "",
|
||||||
|
image = item.artworkUrl100 ?: "",
|
||||||
|
fanCount = item.fanCount,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pin by a pasted Deezer link or bare id (the reliable route when search can't surface it). */
|
||||||
|
fun pinByLink(raw: String, onResult: (Boolean) -> Unit) {
|
||||||
|
val id = parseDeezerArtistId(raw)?.toLongOrNull()
|
||||||
|
if (id == null) { onResult(false); return }
|
||||||
|
viewModelScope.launch {
|
||||||
|
val a = runCatching { ItunesClient.lookupArtist(id) }.getOrNull()
|
||||||
|
if (a?.artistId == null) { onResult(false); return@launch }
|
||||||
|
applyPin(ArtistOverride(
|
||||||
|
deezerArtistId = a.artistId.toString(),
|
||||||
|
name = a.artistName ?: _state.value.artist?.name ?: "",
|
||||||
|
image = a.artworkUrl100 ?: "",
|
||||||
|
fanCount = a.fanCount,
|
||||||
|
))
|
||||||
|
onResult(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearPin() {
|
||||||
|
val id = artistId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repo.removeArtistOverride(id) }
|
||||||
|
_state.value = _state.value.copy(pin = null, pinnedImage = null, showPinPicker = false, itunesLoading = true)
|
||||||
|
loadItunesData(_state.value.artist?.name ?: "", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyPin(ov: ArtistOverride) {
|
||||||
|
val id = artistId ?: return
|
||||||
|
viewModelScope.launch {
|
||||||
|
runCatching { repo.setArtistOverride(id, ov) }
|
||||||
|
_state.value = _state.value.copy(
|
||||||
|
pin = ov,
|
||||||
|
pinnedImage = ov.image.takeIf { it.isNotBlank() },
|
||||||
|
showPinPicker = false,
|
||||||
|
itunesLoading = true,
|
||||||
|
)
|
||||||
|
loadItunesData(_state.value.artist?.name ?: "", ov)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull a Deezer artist id out of a pasted URL (deezer.com/.../artist/12345) or a bare id.
|
||||||
|
private fun parseDeezerArtistId(raw: String): String? {
|
||||||
|
val s = raw.trim()
|
||||||
|
if (s.isEmpty()) return null
|
||||||
|
Regex("artist/(\\d+)").find(s)?.let { return it.groupValues[1] }
|
||||||
|
return if (s.matches(Regex("\\d+"))) s else null
|
||||||
|
}
|
||||||
|
|
||||||
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
|
// No onCleared stopPreview — the preview is app-level so it keeps playing (controllable via the
|
||||||
// global bar) after leaving this screen.
|
// global bar) after leaving this screen.
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user