diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ad4dd..42d4b3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,7 +40,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d | Playlist cover upload | ✅ | ✅ | | Besitzer geteilter Playlists | ✅ | ✅ | | Künstler folgen / Album merken | ✅ | ✅ | -| Neu von deinen Künstlern | ✅ | ❌ | +| Neu von deinen Künstlern | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Wiedergabe fortsetzen | ✅ | ✅ | @@ -79,7 +79,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d ## [2.7] — 2026-09-16 -**Android:** APK folgt, sobald das Folgen vollständig ist (Glocke + Benachrichtigungen fehlen noch). +**Android:** 1.13 (versionCode 16) — Folgen, Zeitleiste und der Gefolgt-Filter. ### Added - **Künstlern folgen und Alben merken (Web + Android)** — Grundlage für die geplanten Meldungen zu @@ -107,6 +107,14 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d kein `starred` mit, die Markierungen kommen deshalb aus `getStarred2` und werden 30 s gehalten, damit das Umschalten der Chips nicht jedes Mal nachfragt. Ein Herz-Klick verwirft diesen Zwischen- speicher sofort — sonst zeigte der Filter nach dem Entfolgen noch eine halbe Minute den alten Stand. +- **Filter „❤ Gefolgt" im Künstler-Reiter (Android)** — zwei Chips über der Liste, gleiche Stelle + wie im Web. Bewusst ein Filter und kein eigener Reiter: die Bibliotheksleiste trägt schon + Playlists, Alben, Künstler und Offline. Die markierten IDs werden bei jedem Einschalten frisch + geholt, damit ein eben entfolgter Künstler nicht noch dasteht. +- **Zeitleiste „Neu von deinen Künstlern" (Android)** — erreichbar über „✨ Neu" in den Favoriten, + dort, wo auch die gefolgten Künstler stehen. Gleiche Darstellung wie im Web: nach Monat gruppiert, + neueste zuerst, Vorhandenes mit Haken statt Download-Knopf, Künstlername anklickbar. Der + Download-Knopf hängt am bekannten Weg samt Benachrichtigung über den Fortschritt. - **Zeitleiste „Neu von deinen Künstlern" (Web)** — eigener Punkt in der Seitenleiste. Zeigt die jüngsten Veröffentlichungen aller Künstler, denen du folgst: nach Monat gruppiert, neueste zuerst, pro Eintrag Cover, Künstler, Art (Album/Single/EP), Titelzahl und Datum. Umschaltbar auf 30 Tage, diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 9939316..da38943 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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. diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/ReleasesClient.kt b/android/app/src/main/kotlin/com/novasonic/app/api/ReleasesClient.kt new file mode 100644 index 0000000..bb3ace8 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/ReleasesClient.kt @@ -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, 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() + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/api/model/ReleaseModels.kt b/android/app/src/main/kotlin/com/novasonic/app/api/model/ReleaseModels.kt new file mode 100644 index 0000000..1ea8194 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/api/model/ReleaseModels.kt @@ -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 = emptyList(), + /** Künstler, zu denen kein Deezer-Profil gefunden wurde — dort hilft nur ein Pin. */ + val ohneZuordnung: List = emptyList(), +) + +@Serializable +data class ArtistRef(val id: String, val name: String) + +@Serializable +data class ArtistReleasesRequest(val artists: List, val days: Int) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt index 0535995..0ec24a9 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/AppNavigation.kt @@ -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) { diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt new file mode 100644 index 0000000..e276b65 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/ArtistNewsScreen.kt @@ -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) { + 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) diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt index 715bd04..84409bd 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt @@ -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 { 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(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( } } } + } } } diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt index f3214b0..f7f3e7c 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/StarredScreen.kt @@ -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( diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistNewsViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistNewsViewModel.kt new file mode 100644 index 0000000..7d80145 --- /dev/null +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/ArtistNewsViewModel.kt @@ -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 = emptyList(), + /** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */ + val vorhanden: Set = emptySet(), + val ohneZuordnung: List = 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 = _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)}" + } +} diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt index fce86fb..e5e5ea2 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/LibraryViewModel.kt @@ -25,6 +25,9 @@ data class LibraryUiState( val albumsByName: List = emptyList(), // type=alphabeticalByName (for AZ + Oldest) val albumSort: AlbumSort = AlbumSort.NEWEST, val artistIndexes: List = emptyList(), + /** IDs der markierten ("gefolgten") Künstler — getArtists liefert kein `starred` mit. */ + val gefolgteIds: Set = emptySet(), + val nurGefolgte: Boolean = false, val playlists: List = 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 {