feat(android): Gefolgt-Filter im Künstler-Reiter und Zeitleiste "Neu von deinen Künstlern"
Filter statt eigenem Reiter: die Bibliotheksleiste trägt schon Playlists, Alben, Künstler und Offline. Die markierten IDs kommen bei jedem Einschalten frisch, damit ein eben entfolgter Künstler nicht dasteht. Die Zeitleiste ist über "✨ Neu" in den Favoriten erreichbar — dort stehen die gefolgten Künstler, also liegt die Frage nach Neuem dort nahe. Gleiche Darstellung wie im Web: nach Monat gruppiert, neueste zuerst, Vorhandenes mit Haken statt Download-Knopf, Künstlername anklickbar. Download läuft über den bekannten Weg samt Fortschrittsmeldung. Neu: api/ReleasesClient.kt, api/model/ReleaseModels.kt, viewmodel/ArtistNewsViewModel.kt, ui/screen/ArtistNewsScreen.kt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ea7167f851
commit
cb43c3375e
10 files changed
+521
-15
No files matched your search
@@ -14,8 +14,8 @@ android {
|
||||
applicationId = "com.novasonic.app"
|
||||
minSdk = 26
|
||||
targetSdk = 35
|
||||
versionCode = 15
|
||||
versionName = "1.12"
|
||||
versionCode = 16
|
||||
versionName = "1.13"
|
||||
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.novasonic.app.api
|
||||
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistReleasesRequest
|
||||
import com.novasonic.app.api.model.ArtistReleasesResponse
|
||||
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.http.*
|
||||
import io.ktor.serialization.kotlinx.json.*
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/**
|
||||
* Neue Veröffentlichungen der gefolgten Künstler. Die Deezer-Auflösung macht das Backend — dort
|
||||
* liegen auch die Künstler-Pins, und der Cache gilt dann für alle Geräte gemeinsam statt pro App.
|
||||
*/
|
||||
object ReleasesClient {
|
||||
|
||||
private val http = HttpClient(Android) {
|
||||
expectSuccess = true
|
||||
// Der erste Aufruf kann dauern: das Backend holt pro Künstler die Deezer-Diskografie.
|
||||
// Danach liegt es dort im Cache.
|
||||
install(HttpTimeout) { requestTimeoutMillis = 60_000; connectTimeoutMillis = 10_000 }
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true; coerceInputValues = true })
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetch(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistReleasesResponse {
|
||||
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistReleasesResponse()
|
||||
return http.post("${backendUrl.trimEnd('/')}/api/artist-releases") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(ArtistReleasesRequest(artists, days))
|
||||
}.body()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.novasonic.app.api.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** Eine Veröffentlichung eines gefolgten Künstlers, wie sie das Backend liefert. */
|
||||
@Serializable
|
||||
data class ArtistRelease(
|
||||
val artistId: String = "",
|
||||
val artistName: String = "",
|
||||
val deezerArtistId: String? = null,
|
||||
val albumId: String = "",
|
||||
val title: String = "",
|
||||
val cover: String = "",
|
||||
val releaseDate: String = "",
|
||||
val recordType: String = "album",
|
||||
val trackCount: Int? = null,
|
||||
val explicit: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesResponse(
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
/** Künstler, zu denen kein Deezer-Profil gefunden wurde — dort hilft nur ein Pin. */
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ArtistRef(val id: String, val name: String)
|
||||
|
||||
@Serializable
|
||||
data class ArtistReleasesRequest(val artists: List<ArtistRef>, val days: Int)
|
||||
@@ -417,6 +417,13 @@ fun AppNavigation() {
|
||||
onShowContextMenu = ::showContextMenu,
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
onAlbumClick = { id -> navController.navigate("album/$id") },
|
||||
onOpenNews = { navController.navigate("artist-news") },
|
||||
)
|
||||
}
|
||||
composable("artist-news") {
|
||||
ArtistNewsScreen(
|
||||
onBack = { navController.popBackStack() },
|
||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||
)
|
||||
}
|
||||
composable(TopLevel.ABS.route) {
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package com.novasonic.app.ui.screen
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.Download
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
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 androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.ui.theme.*
|
||||
import com.novasonic.app.viewmodel.ArtistNewsViewModel
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. Nach Monat gruppiert,
|
||||
* neueste zuerst. Was schon in der Bibliothek liegt, ist markiert statt anklickbar.
|
||||
*/
|
||||
@Composable
|
||||
fun ArtistNewsScreen(
|
||||
onBack: () -> Unit,
|
||||
onArtistClick: (String) -> Unit,
|
||||
) {
|
||||
val vm: ArtistNewsViewModel = viewModel()
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
val ctx = LocalContext.current
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.statusBarsPadding().padding(start = 4.dp, top = 4.dp),
|
||||
) {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück",
|
||||
tint = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
Text("Neu von deinen Künstlern", fontFamily = Outfit, fontWeight = FontWeight.Bold,
|
||||
fontSize = 17.sp, color = MaterialTheme.colorScheme.onSurface)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 4.dp, bottom = 10.dp),
|
||||
) {
|
||||
listOf(30 to "30 Tage", 90 to "90 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
|
||||
FilterChip(
|
||||
selected = state.tage == tage,
|
||||
onClick = { vm.laden(tage) },
|
||||
label = { Text(label, fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
|
||||
when {
|
||||
state.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = NsAccent)
|
||||
}
|
||||
state.keinBackend -> Hinweis("🔌", "Kein Backend konfiguriert",
|
||||
"Neue Veröffentlichungen kommen über das Backend von Deezer.")
|
||||
state.keineGefolgten -> Hinweis("❤", "Du folgst noch niemandem",
|
||||
"Auf einer Künstlerseite oben rechts aufs Herz tippen.")
|
||||
state.error != null -> Hinweis("⚠", "Laden fehlgeschlagen", state.error!!)
|
||||
state.releases.isEmpty() -> Hinweis("🌙",
|
||||
"Nichts Neues in den letzten ${state.tage} Tagen", "")
|
||||
else -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
|
||||
if (state.ohneZuordnung.isNotEmpty()) {
|
||||
item { ZuordnungsHinweis(state.ohneZuordnung) }
|
||||
}
|
||||
var letzterMonat = ""
|
||||
state.releases.forEach { rel ->
|
||||
val monat = monatVon(rel.releaseDate)
|
||||
if (monat != letzterMonat) {
|
||||
letzterMonat = monat
|
||||
item(key = "m_$monat") {
|
||||
Text(monat, fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsOnSurface2,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 16.dp, bottom = 6.dp))
|
||||
}
|
||||
}
|
||||
item(key = "${rel.artistId}_${rel.albumId}") {
|
||||
ReleaseZeile(
|
||||
rel = rel,
|
||||
schonDa = vm.hatSchon(rel),
|
||||
onArtist = { onArtistClick(rel.artistId) },
|
||||
onDownload = {
|
||||
vm.herunterladen(rel) { _, msg ->
|
||||
Toast.makeText(ctx, msg, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Hinweis(symbol: String, titel: String, text: String) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(symbol, fontSize = 34.sp)
|
||||
Spacer(Modifier.height(10.dp))
|
||||
Text(titel, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 15.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
if (text.isNotBlank()) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(text, fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ZuordnungsHinweis(namen: List<String>) {
|
||||
Box(
|
||||
Modifier.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(NsAccent.copy(alpha = 0.08f))
|
||||
.border(1.dp, NsAccent.copy(alpha = 0.28f), RoundedCornerShape(10.dp))
|
||||
.padding(12.dp),
|
||||
) {
|
||||
Text(
|
||||
"⚠ Für ${namen.size} Künstler wurde kein Deezer-Profil gefunden: " +
|
||||
namen.take(4).joinToString(", ") + (if (namen.size > 4) " …" else "") +
|
||||
". Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2, lineHeight = 17.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val TYP = mapOf("album" to "Album", "single" to "Single", "ep" to "EP",
|
||||
"compile" to "Compilation")
|
||||
|
||||
@Composable
|
||||
private fun ReleaseZeile(
|
||||
rel: ArtistRelease,
|
||||
schonDa: Boolean,
|
||||
onArtist: () -> Unit,
|
||||
onDownload: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 7.dp),
|
||||
) {
|
||||
AsyncImage(
|
||||
model = rel.cover.takeIf { it.isNotBlank() },
|
||||
contentDescription = rel.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.size(56.dp).clip(RoundedCornerShape(8.dp)).background(NsSurface2),
|
||||
)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(rel.title, fontFamily = Outfit, fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(rel.artistName, fontFamily = Outfit, fontSize = 12.sp, color = NsAccent,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtist))
|
||||
Text(
|
||||
" · " + (TYP[rel.recordType] ?: rel.recordType) +
|
||||
(rel.trackCount?.let { " · $it Titel" } ?: ""),
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2,
|
||||
maxLines = 1, overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(datumVon(rel.releaseDate), fontFamily = Outfit, fontSize = 11.sp,
|
||||
color = NsOnSurface3, modifier = Modifier.padding(top = 2.dp))
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
if (schonDa) {
|
||||
Icon(Icons.Default.CheckCircle, "In deiner Bibliothek",
|
||||
tint = NsOnSurface3, modifier = Modifier.size(20.dp))
|
||||
} else {
|
||||
IconButton(onClick = onDownload) {
|
||||
Icon(Icons.Default.Download, "Herunterladen", tint = NsAccent,
|
||||
modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deezer liefert "JJJJ-MM-TT".
|
||||
private val EIN = SimpleDateFormat("yyyy-MM-dd", Locale.GERMAN)
|
||||
private val MONAT = SimpleDateFormat("LLLL yyyy", Locale.GERMAN)
|
||||
private val DATUM = SimpleDateFormat("dd.MM.yyyy", Locale.GERMAN)
|
||||
|
||||
private fun monatVon(s: String) = runCatching { MONAT.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
private fun datumVon(s: String) = runCatching { DATUM.format(EIN.parse(s)!!) }.getOrDefault(s)
|
||||
@@ -191,12 +191,20 @@ fun LibraryScreen(
|
||||
LibraryTab.ARTISTS -> {
|
||||
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||
val scope = rememberCoroutineScope()
|
||||
val letters = remember(state.artistIndexes) { state.artistIndexes.map { it.name } }
|
||||
// "Gefolgt" filtert die vorhandene Liste — ein eigener Reiter wäre in einer Leiste,
|
||||
// die schon vier Kacheln trägt, nur Gedränge.
|
||||
val indizes = remember(state.artistIndexes, state.nurGefolgte, state.gefolgteIds) {
|
||||
if (!state.nurGefolgte) state.artistIndexes
|
||||
else state.artistIndexes
|
||||
.map { idx -> idx.copy(artist = idx.artist.filter { it.id in state.gefolgteIds }) }
|
||||
.filter { it.artist.isNotEmpty() }
|
||||
}
|
||||
val letters = remember(indizes) { indizes.map { it.name } }
|
||||
// Map letter → flat LazyColumn index of its header item
|
||||
val letterToIndex = remember(state.artistIndexes) {
|
||||
val letterToIndex = remember(indizes) {
|
||||
buildMap<String, Int> {
|
||||
var flat = 0
|
||||
state.artistIndexes.forEach { idx ->
|
||||
indizes.forEach { idx ->
|
||||
put(idx.name, flat)
|
||||
flat += 1 + idx.artist.size
|
||||
}
|
||||
@@ -204,13 +212,54 @@ fun LibraryScreen(
|
||||
}
|
||||
var activeLetter by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier.padding(start = 16.dp, top = 6.dp, bottom = 6.dp),
|
||||
) {
|
||||
FilterChip(
|
||||
selected = !state.nurGefolgte,
|
||||
onClick = { vm.nurGefolgteUmschalten(false) },
|
||||
label = { Text("Alle", fontSize = 12.sp) },
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent),
|
||||
)
|
||||
FilterChip(
|
||||
selected = state.nurGefolgte,
|
||||
onClick = { vm.nurGefolgteUmschalten(true) },
|
||||
label = {
|
||||
val n = state.gefolgteIds.size
|
||||
Text("❤ Gefolgt" + if (state.nurGefolgte && n > 0) " ($n)" else "",
|
||||
fontSize = 12.sp)
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = NsAccent.copy(alpha = 0.2f),
|
||||
selectedLabelColor = NsAccent),
|
||||
)
|
||||
}
|
||||
if (state.nurGefolgte && indizes.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text("❤", fontSize = 30.sp)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Text("Du folgst noch niemandem", fontFamily = Outfit,
|
||||
fontWeight = FontWeight.SemiBold, fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Auf einer Künstlerseite oben rechts aufs Herz tippen.",
|
||||
fontFamily = Outfit, fontSize = 12.sp, color = NsOnSurface2)
|
||||
}
|
||||
}
|
||||
return@Column
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
state.artistIndexes.forEach { idx ->
|
||||
indizes.forEach { idx ->
|
||||
item(key = "header_${idx.name}") {
|
||||
Text(
|
||||
text = idx.name,
|
||||
@@ -316,6 +365,7 @@ fun LibraryScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ fun StarredScreen(
|
||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||
onArtistClick: (String) -> Unit = {},
|
||||
onAlbumClick: (String) -> Unit = {},
|
||||
onOpenNews: () -> Unit = {},
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val repo = context.app.repository
|
||||
@@ -76,12 +77,25 @@ fun StarredScreen(
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Text(
|
||||
"Favoriten",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.padding(start = 20.dp, top = 24.dp, bottom = 8.dp),
|
||||
)
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 24.dp, bottom = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Favoriten",
|
||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// Einstieg in die Zeitleiste: hier stehen die gefolgten Künstler, also liegt die Frage
|
||||
// "was gibt es Neues von denen" nahe.
|
||||
if (gefolgteKuenstler.isNotEmpty()) {
|
||||
TextButton(onClick = onOpenNews) {
|
||||
Text("✨ Neu", fontFamily = Outfit, fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp, color = NsAccent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort chips
|
||||
Row(
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package com.novasonic.app.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.novasonic.app.api.ImporterClient
|
||||
import com.novasonic.app.api.ReleasesClient
|
||||
import com.novasonic.app.api.model.ArtistRef
|
||||
import com.novasonic.app.api.model.ArtistRelease
|
||||
import com.novasonic.app.api.model.DownloadRequest
|
||||
import com.novasonic.app.app
|
||||
import com.novasonic.app.data.NotificationStore
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ArtistNewsUiState(
|
||||
val releases: List<ArtistRelease> = emptyList(),
|
||||
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
|
||||
val vorhanden: Set<String> = emptySet(),
|
||||
val ohneZuordnung: List<String> = emptyList(),
|
||||
val tage: Int = 90,
|
||||
val isLoading: Boolean = true,
|
||||
val keineGefolgten: Boolean = false,
|
||||
val keinBackend: Boolean = false,
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* "Neu von deinen Künstlern". Gefolgt = Navidromes Stern, die Künstlerliste kommt also aus
|
||||
* getStarred2. Die Deezer-Auflösung macht das Backend — dort liegen die Pins und der gemeinsame
|
||||
* Cache, sonst würde jedes Gerät bei jedem Öffnen dieselben Abfragen wiederholen.
|
||||
*/
|
||||
class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
|
||||
|
||||
private val repo = app.app.repository
|
||||
|
||||
private val _state = MutableStateFlow(ArtistNewsUiState())
|
||||
val state: StateFlow<ArtistNewsUiState> = _state
|
||||
|
||||
init { laden(90) }
|
||||
|
||||
fun laden(tage: Int = _state.value.tage) = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, tage = tage, error = null,
|
||||
keineGefolgten = false, keinBackend = false)
|
||||
try {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keinBackend = true)
|
||||
return@launch
|
||||
}
|
||||
val markiert = repo.getStarred()
|
||||
val kuenstler = markiert.artist.map { ArtistRef(it.id, it.name) }
|
||||
if (kuenstler.isEmpty()) {
|
||||
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val antwort = ReleasesClient.fetch(backendUrl, kuenstler, tage)
|
||||
|
||||
// Was liegt schon in der Bibliothek? Verglichen wird über Künstler + Albumname —
|
||||
// Deezers Album-Id hat mit Navidromes nichts zu tun.
|
||||
val vorhanden = withContext(Dispatchers.IO) {
|
||||
val ausFavoriten = markiert.album.map { schluessel(it.artist ?: "", it.name) }
|
||||
val ausBibliothek = kuenstler.chunked(6).flatMap { haeppchen ->
|
||||
haeppchen.map { k ->
|
||||
async {
|
||||
runCatching { repo.getArtist(k.id).album.map { schluessel(k.name, it.name) } }
|
||||
.getOrDefault(emptyList())
|
||||
}
|
||||
}.awaitAll().flatten()
|
||||
}
|
||||
(ausFavoriten + ausBibliothek).toSet()
|
||||
}
|
||||
|
||||
_state.value = _state.value.copy(
|
||||
releases = antwort.releases,
|
||||
ohneZuordnung = antwort.ohneZuordnung,
|
||||
vorhanden = vorhanden,
|
||||
isLoading = false,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
_state.value = _state.value.copy(isLoading = false, error = e.message)
|
||||
}
|
||||
}
|
||||
|
||||
fun hatSchon(rel: ArtistRelease): Boolean =
|
||||
schluessel(rel.artistName, rel.title) in _state.value.vorhanden
|
||||
|
||||
fun herunterladen(rel: ArtistRelease, onDone: (Boolean, String) -> Unit) {
|
||||
viewModelScope.launch {
|
||||
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
|
||||
if (backendUrl.isBlank()) { onDone(false, "Kein Backend konfiguriert"); return@launch }
|
||||
val request = DownloadRequest(
|
||||
jobId = "job_${System.currentTimeMillis()}",
|
||||
type = "album",
|
||||
title = rel.title,
|
||||
artist = rel.artistName,
|
||||
artworkUrl = rel.cover.takeIf { it.isNotBlank() },
|
||||
collectionId = rel.albumId.toLongOrNull(),
|
||||
itunesId = rel.albumId.toLongOrNull(),
|
||||
)
|
||||
val client = ImporterClient(backendUrl)
|
||||
runCatching { client.download(request) }
|
||||
.onSuccess {
|
||||
NotificationStore.startPoll(
|
||||
backendUrl, request.jobId, request.title, request.artist ?: "",
|
||||
navType = "album", navAlbum = rel.title, navArtist = rel.artistName,
|
||||
)
|
||||
onDone(true, "✓ Download gestartet")
|
||||
}
|
||||
.onFailure { onDone(false, "Backend nicht erreichbar: ${it.message}") }
|
||||
client.close()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
fun norm(s: String) = s.lowercase()
|
||||
.replace(Regex("[^a-z0-9]"), "")
|
||||
fun schluessel(kuenstler: String, album: String) = "${norm(kuenstler)}~~${norm(album)}"
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ data class LibraryUiState(
|
||||
val albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
|
||||
val albumSort: AlbumSort = AlbumSort.NEWEST,
|
||||
val artistIndexes: List<ArtistIndex> = emptyList(),
|
||||
/** IDs der markierten ("gefolgten") Künstler — getArtists liefert kein `starred` mit. */
|
||||
val gefolgteIds: Set<String> = emptySet(),
|
||||
val nurGefolgte: Boolean = false,
|
||||
val playlists: List<Playlist> = emptyList(),
|
||||
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
|
||||
val currentUser: String = "",
|
||||
@@ -93,6 +96,18 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
|
||||
}
|
||||
}
|
||||
|
||||
fun nurGefolgteUmschalten(an: Boolean) {
|
||||
_state.value = _state.value.copy(nurGefolgte = an)
|
||||
if (an) ladeGefolgte()
|
||||
}
|
||||
|
||||
/** Markierte Künstler nachladen. Frisch bei jedem Einschalten, damit ein eben entfolgter
|
||||
* Künstler nicht noch in der Liste steht. */
|
||||
private fun ladeGefolgte() = viewModelScope.launch {
|
||||
runCatching { repo.getStarred().artist.map { it.id }.toSet() }
|
||||
.onSuccess { _state.value = _state.value.copy(gefolgteIds = it) }
|
||||
}
|
||||
|
||||
private fun loadArtists() = viewModelScope.launch {
|
||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||
try {
|
||||
|
||||
Reference in new issue
Block a user