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
+10
-2
@@ -40,7 +40,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
|
|||||||
| Playlist cover upload | ✅ | ✅ |
|
| Playlist cover upload | ✅ | ✅ |
|
||||||
| Besitzer geteilter Playlists | ✅ | ✅ |
|
| Besitzer geteilter Playlists | ✅ | ✅ |
|
||||||
| Künstler folgen / Album merken | ✅ | ✅ |
|
| Künstler folgen / Album merken | ✅ | ✅ |
|
||||||
| Neu von deinen Künstlern | ✅ | ❌ |
|
| Neu von deinen Künstlern | ✅ | ✅ |
|
||||||
| Spotify / YouTube importer | ✅ | ✅ |
|
| Spotify / YouTube importer | ✅ | ✅ |
|
||||||
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
| Musik-Duell (Anzeigetafel) | ✅ | ✅ |
|
||||||
| Wiedergabe fortsetzen | ✅ | ✅ |
|
| 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
|
## [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
|
### Added
|
||||||
- **Künstlern folgen und Alben merken (Web + Android)** — Grundlage für die geplanten Meldungen zu
|
- **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,
|
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-
|
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.
|
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
|
- **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,
|
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,
|
pro Eintrag Cover, Künstler, Art (Album/Single/EP), Titelzahl und Datum. Umschaltbar auf 30 Tage,
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ android {
|
|||||||
applicationId = "com.novasonic.app"
|
applicationId = "com.novasonic.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 15
|
versionCode = 16
|
||||||
versionName = "1.12"
|
versionName = "1.13"
|
||||||
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
// Host der eigenen NovaSonic-Web-App — Ziel der geteilten Links. Einmal in
|
||||||
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
// gradle.properties setzen (novasonicHost=music.example.com); ohne Angabe bleibt der
|
||||||
// Platzhalter stehen und der App-Link greift schlicht nicht.
|
// 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,
|
onShowContextMenu = ::showContextMenu,
|
||||||
onArtistClick = { id -> navController.navigate("artist/$id") },
|
onArtistClick = { id -> navController.navigate("artist/$id") },
|
||||||
onAlbumClick = { id -> navController.navigate("album/$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) {
|
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 -> {
|
LibraryTab.ARTISTS -> {
|
||||||
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
val listState = vm.artistsListState // hoisted → survives artist drill-in/back
|
||||||
val scope = rememberCoroutineScope()
|
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
|
// Map letter → flat LazyColumn index of its header item
|
||||||
val letterToIndex = remember(state.artistIndexes) {
|
val letterToIndex = remember(indizes) {
|
||||||
buildMap<String, Int> {
|
buildMap<String, Int> {
|
||||||
var flat = 0
|
var flat = 0
|
||||||
state.artistIndexes.forEach { idx ->
|
indizes.forEach { idx ->
|
||||||
put(idx.name, flat)
|
put(idx.name, flat)
|
||||||
flat += 1 + idx.artist.size
|
flat += 1 + idx.artist.size
|
||||||
}
|
}
|
||||||
@@ -204,13 +212,54 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
var activeLetter by remember { mutableStateOf<String?>(null) }
|
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(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
contentPadding = PaddingValues(top = 8.dp, bottom = 8.dp, end = 28.dp),
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
) {
|
) {
|
||||||
state.artistIndexes.forEach { idx ->
|
indizes.forEach { idx ->
|
||||||
item(key = "header_${idx.name}") {
|
item(key = "header_${idx.name}") {
|
||||||
Text(
|
Text(
|
||||||
text = idx.name,
|
text = idx.name,
|
||||||
@@ -316,6 +365,7 @@ fun LibraryScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ fun StarredScreen(
|
|||||||
onShowContextMenu: ((Song) -> Unit)? = null,
|
onShowContextMenu: ((Song) -> Unit)? = null,
|
||||||
onArtistClick: (String) -> Unit = {},
|
onArtistClick: (String) -> Unit = {},
|
||||||
onAlbumClick: (String) -> Unit = {},
|
onAlbumClick: (String) -> Unit = {},
|
||||||
|
onOpenNews: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val repo = context.app.repository
|
val repo = context.app.repository
|
||||||
@@ -76,12 +77,25 @@ fun StarredScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(Modifier.fillMaxSize()) {
|
Column(Modifier.fillMaxSize()) {
|
||||||
Text(
|
Row(
|
||||||
"Favoriten",
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
fontFamily = Outfit, fontWeight = FontWeight.Bold, fontSize = 26.sp,
|
modifier = Modifier.fillMaxWidth().padding(start = 20.dp, end = 12.dp, top = 24.dp, bottom = 8.dp),
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
) {
|
||||||
modifier = Modifier.padding(start = 20.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
|
// Sort chips
|
||||||
Row(
|
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 albumsByName: List<Album> = emptyList(), // type=alphabeticalByName (for AZ + Oldest)
|
||||||
val albumSort: AlbumSort = AlbumSort.NEWEST,
|
val albumSort: AlbumSort = AlbumSort.NEWEST,
|
||||||
val artistIndexes: List<ArtistIndex> = emptyList(),
|
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(),
|
val playlists: List<Playlist> = emptyList(),
|
||||||
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
|
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
|
||||||
val currentUser: String = "",
|
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 {
|
private fun loadArtists() = viewModelScope.launch {
|
||||||
_state.value = _state.value.copy(isLoading = true, error = null)
|
_state.value = _state.value.copy(isLoading = true, error = null)
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in new issue
Block a user