change: Konzerte ohne Zeitfenster — Chips nur noch bei den Veroeffentlichungen

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-27 10:46:05 +02:00
1 parent 17a677a5a5
commit ae357b0eb9
9 files changed
+52 -34

No files matched your search

+7 -3
View File
@@ -81,7 +81,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
## [3.1] — 2026-09-22 ## [3.1] — 2026-09-22
**Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer, 1.24 die Konzerte. **Android:** 1.15 (versionCode 18) → 1.23 (versionCode 26). 1.16 brachte den Rollbalken, 1.17 bis 1.21 machten ihn bedienbar, 1.19 das Bearbeiten der Warteschlange, 1.22 die Wischgeste, den Mischen-Fehler und die zwei neuen Designs, 1.23 den Honigtopf und die Gesamtdauer, 1.24 die Konzerte, 1.25 sie ohne Zeitfenster.
### Added ### Added
- **Konzerte deiner Künstler (Web + Android)** — die Zeitleiste hat einen zweiten Reiter: neben - **Konzerte deiner Künstler (Web + Android)** — die Zeitleiste hat einen zweiten Reiter: neben
@@ -92,8 +92,12 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
- **Bewusst nicht in eine gemeinsame Liste gemischt.** Veröffentlichungen liegen hinter dir, - **Bewusst nicht in eine gemeinsame Liste gemischt.** Veröffentlichungen liegen hinter dir,
Konzerte vor dir. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie, ob man Konzerte vor dir. Chronologisch gemischt stünde „heute" mittendrin und man wüsste nie, ob man
vorwärts oder rückwärts liest. Deshalb läuft die Konzertliste **nach vorn**: das nächste vorwärts oder rückwärts liest. Deshalb läuft die Konzertliste **nach vorn**: das nächste
Konzert zuerst. Das Zeitfenster (90/30/365) gilt je Reiter getrennt — bei den Konzerten zeigt Konzert zuerst, nach Monat gruppiert wie bei den Veröffentlichungen.
es nach vorn, Vorgabe ein Jahr. - **Kein Zeitfenster bei den Konzerten.** Die Chips 90/30/365 waren dort zuerst mit dabei und
ergaben keinen Sinn: Ticketmaster liefert ohnehin nur Anstehendes, und bei Terminen will man
alles Angekündigte sehen — eine Tagesgrenze schneidet nur willkürlich ab. Die Chip-Reihe
erscheint jetzt nur noch beim Reiter *Neu erschienen*; der Endpunkt beachtet ein
mitgeschicktes Fenster weiterhin, verlangt es aber nicht mehr.
- **Gegenprobe gegen die Stichwortsuche.** Ticketmaster wird über den Namen abgefragt - **Gegenprobe gegen die Stichwortsuche.** Ticketmaster wird über den Namen abgefragt
(`keyword=`), nicht über `attractionId` — dieselbe Unschärfe wie bei der Deezer-Zuordnung. (`keyword=`), nicht über `attractionId` — dieselbe Unschärfe wie bei der Deezer-Zuordnung.
Jeder Treffer wird deshalb gegengeprüft: der Künstlername muss im Veranstaltungs- **oder** Jeder Treffer wird deshalb gegengeprüft: der Künstlername muss im Veranstaltungs- **oder**
+2 -2
View File
@@ -14,8 +14,8 @@ android {
applicationId = "com.novasonic.app" applicationId = "com.novasonic.app"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 27 versionCode = 28
versionName = "1.24" versionName = "1.25"
// 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.
@@ -40,11 +40,12 @@ object ReleasesClient {
} }
/** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */ /** Anstehende Konzerte derselben Kuenstler — gleicher Weg, anderer Endpunkt. */
suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>, days: Int): ArtistKonzerteResponse { suspend fun konzerte(backendUrl: String, artists: List<ArtistRef>): ArtistKonzerteResponse {
if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse() if (backendUrl.isBlank() || artists.isEmpty()) return ArtistKonzerteResponse()
// Ohne `days`: das Backend liefert alles Angekuendigte.
return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") { return http.post("${backendUrl.trimEnd('/')}/api/artist-concerts") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
setBody(ArtistKonzerteRequest(artists, days)) setBody(ArtistKonzerteRequest(artists))
}.body() }.body()
} }
} }
@@ -56,4 +56,4 @@ data class ArtistKonzerteResponse(
) )
@Serializable @Serializable
data class ArtistKonzerteRequest(val artists: List<ArtistRef>, val days: Int) data class ArtistKonzerteRequest(val artists: List<ArtistRef>)
@@ -91,16 +91,17 @@ fun ArtistNewsScreen(
} }
} }
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft,
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
if (state.reiter == NewsReiter.RELEASES) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp), modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 10.dp),
) { ) {
// 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn. // 90 Tage zuerst, weil es die Vorgabe ist — der aktive Chip steht dann vorn.
// Bei den Konzerten zeigt dasselbe Fenster nach VORN, dort sind 1 Jahr die Vorgabe.
val aktuell = if (state.reiter == NewsReiter.KONZERTE) state.tageKonzerte else state.tage
listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) -> listOf(90 to "90 Tage", 30 to "30 Tage", 365 to "1 Jahr").forEach { (tage, label) ->
FilterChip( FilterChip(
selected = aktuell == tage, selected = state.tage == tage,
onClick = { vm.setzeTage(tage) }, onClick = { vm.setzeTage(tage) },
label = { Text(label, fontSize = 12.sp) }, label = { Text(label, fontSize = 12.sp) },
colors = FilterChipDefaults.filterChipColors( colors = FilterChipDefaults.filterChipColors(
@@ -110,6 +111,9 @@ fun ArtistNewsScreen(
) )
} }
} }
} else {
Spacer(Modifier.height(10.dp))
}
HorizontalDivider(color = MaterialTheme.colorScheme.outline) HorizontalDivider(color = MaterialTheme.colorScheme.outline)
when { when {
@@ -125,7 +129,7 @@ fun ArtistNewsScreen(
state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟", state.reiter == NewsReiter.KONZERTE && state.keinSchluessel -> Hinweis("🎟",
"Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.") "Kein Ticketmaster-Schlüssel hinterlegt", "Im Backend TICKETMASTER_API_KEY setzen.")
state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪", state.reiter == NewsReiter.KONZERTE && state.konzerte.isEmpty() -> Hinweis("🎪",
"Keine Termine in den nächsten ${state.tageKonzerte} Tagen", "Keine anstehenden Termine",
"Gesucht wird weltweit über den Künstlernamen.") "Gesucht wird weltweit über den Künstlernamen.")
state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) { state.reiter == NewsReiter.KONZERTE -> LazyColumn(contentPadding = PaddingValues(bottom = 90.dp)) {
var letzterMonat = "" var letzterMonat = ""
@@ -26,8 +26,6 @@ data class ArtistNewsUiState(
val reiter: NewsReiter = NewsReiter.RELEASES, val reiter: NewsReiter = NewsReiter.RELEASES,
val releases: List<ArtistRelease> = emptyList(), val releases: List<ArtistRelease> = emptyList(),
val konzerte: List<ArtistKonzert> = emptyList(), val konzerte: List<ArtistKonzert> = emptyList(),
/** Zeitfenster der Konzerte — nach VORN, deshalb ein eigener Wert. */
val tageKonzerte: Int = 365,
val keinSchluessel: Boolean = false, val keinSchluessel: Boolean = false,
/** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */ /** Schlüssel (Künstler + Album, normalisiert) alles dessen, was schon in der Bibliothek liegt. */
val vorhanden: Set<String> = emptySet(), val vorhanden: Set<String> = emptySet(),
@@ -59,13 +57,13 @@ class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte() if (r == NewsReiter.KONZERTE && _state.value.konzerte.isEmpty()) ladeKonzerte()
} }
/** Zeitfenster setzen — es gilt je Reiter, weil es in verschiedene Richtungen zeigt. */ /** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
fun setzeTage(tage: Int) { fun setzeTage(tage: Int) = laden(tage)
if (_state.value.reiter == NewsReiter.KONZERTE) ladeKonzerte(tage) else laden(tage)
}
fun ladeKonzerte(tage: Int = _state.value.tageKonzerte) = viewModelScope.launch { // Kein Zeitfenster: Ticketmaster liefert ohnehin nur Anstehendes, und dort will man alles
_state.value = _state.value.copy(isLoading = true, tageKonzerte = tage, error = null, // Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
fun ladeKonzerte() = viewModelScope.launch {
_state.value = _state.value.copy(isLoading = true, error = null,
keineGefolgten = false, keinBackend = false, keinSchluessel = false) keineGefolgten = false, keinBackend = false, keinSchluessel = false)
try { try {
val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("") val backendUrl = runCatching { repo.getBackendUrl() }.getOrDefault("")
@@ -78,7 +76,7 @@ class ArtistNewsViewModel(app: Application) : AndroidViewModel(app) {
_state.value = _state.value.copy(isLoading = false, keineGefolgten = true) _state.value = _state.value.copy(isLoading = false, keineGefolgten = true)
return@launch return@launch
} }
val antwort = ReleasesClient.konzerte(backendUrl, kuenstler, tage) val antwort = ReleasesClient.konzerte(backendUrl, kuenstler)
_state.value = _state.value.copy( _state.value = _state.value.copy(
konzerte = antwort.konzerte, konzerte = antwort.konzerte,
keinSchluessel = antwort.keinSchluessel, keinSchluessel = antwort.keinSchluessel,
+5 -3
View File
@@ -102,11 +102,13 @@ async function inHaeppchen(items, groesse, fn) {
/** /**
* @param {Array<{id,name}>} kuenstler Navidrome-Künstler * @param {Array<{id,name}>} kuenstler Navidrome-Künstler
* @param {string} apiKey Ticketmaster-Schlüssel * @param {string} apiKey Ticketmaster-Schlüssel
* @param {number} tage Zeitfenster nach vorn * @param {number} tage Zeitfenster nach vorn; 0 = kein Fenster (Vorgabe)
* @returns {{konzerte:Array, ohneTermine:Array<string>}} * @returns {{konzerte:Array, ohneTermine:Array<string>}}
*/ */
async function kommendeKonzerte(kuenstler, apiKey, tage = 365) { async function kommendeKonzerte(kuenstler, apiKey, tage = 0) {
const grenze = Date.now() + tage * 24 * 60 * 60 * 1000; // Kein Fenster als Vorgabe: Ticketmaster liefert ohnehin nur Anstehendes, und bei Konzerten
// will man ALLES sehen, was angekuendigt ist — eine Tagesgrenze schneidet nur willkuerlich ab.
const grenze = tage > 0 ? Date.now() + tage * 24 * 60 * 60 * 1000 : Infinity;
const ohneTermine = []; const ohneTermine = [];
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => { const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
+4 -1
View File
@@ -265,7 +265,10 @@ app.post('/api/artist-concerts', async (req, res) => {
if (!key) return res.json({ konzerte: [], ohneTermine: [], keinSchluessel: true }); if (!key) return res.json({ konzerte: [], ohneTermine: [], keinSchluessel: true });
const artists = Array.isArray(req.body?.artists) ? req.body.artists : []; const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 365, 1), 365); // Ohne `days` gibt es KEIN Fenster — Konzerte sind ohnehin nur Zukunft, und dort will man
// alles Angekuendigte sehen. Ein mitgeschicktes Fenster wird weiterhin beachtet.
const roh = parseInt(req.body?.days, 10);
const days = Number.isFinite(roh) && roh > 0 ? Math.min(roh, 3650) : 0;
if (!artists.length) return res.json({ konzerte: [], ohneTermine: [] }); if (!artists.length) return res.json({ konzerte: [], ohneTermine: [] });
// Obergrenze, damit ein kaputter Client nicht hunderte Ticketmaster-Aufrufe ausloest. // Obergrenze, damit ein kaputter Client nicht hunderte Ticketmaster-Aufrufe ausloest.
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' }); if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
+15 -9
View File
@@ -11,7 +11,6 @@ import { navigate, _navGoBack } from './navigation.js';
// Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen. // Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen.
let _tage = 90; let _tage = 90;
let _tageKonzerte = 365; // Konzerte schaut man in die Weite an, nicht auf 90 Tage
let _reiter = 'releases'; let _reiter = 'releases';
let _laeuft = false; let _laeuft = false;
@@ -26,14 +25,18 @@ export function showArtistNewsTab(reiter) {
? 'Konzerte <em>deiner Künstler</em>' ? 'Konzerte <em>deiner Künstler</em>'
: 'Neu von <em>deinen Künstlern</em>'; : 'Neu von <em>deinen Künstlern</em>';
} }
// Das Zeitfenster gilt nur fuer die Veroeffentlichungen. Konzerte sind ohnehin Zukunft, und
// dort will man alles Angekuendigte sehen — eine Tagesgrenze schnitte nur willkuerlich ab.
const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = _reiter === 'konzerte' ? 'none' : '';
if (_reiter === 'konzerte') loadKonzerte(); if (_reiter === 'konzerte') loadKonzerte();
else loadArtistNews(); else loadArtistNews();
} }
/** Zeitfenster setzen — es gilt je Reiter, weil es in verschiedene Richtungen zeigt. */ /** Zeitfenster setzen — gibt es nur bei den Veroeffentlichungen. */
export function setArtistNewsDays(tage) { export function setArtistNewsDays(tage) {
if (_reiter === 'konzerte') { _tageKonzerte = tage; loadKonzerte(); } _tage = tage;
else { _tage = tage; loadArtistNews(); } loadArtistNews();
} }
function _markiereTage(tage) { function _markiereTage(tage) {
@@ -44,6 +47,8 @@ function _markiereTage(tage) {
export async function loadArtistNews(tage = _tage) { export async function loadArtistNews(tage = _tage) {
_tage = tage; _tage = tage;
_reiter = 'releases'; _reiter = 'releases';
const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = '';
// Die Seite haengt an den Favoriten (Knopf "✨ Neu") und nicht mehr an der Seitenleiste, // Die Seite haengt an den Favoriten (Knopf "✨ Neu") und nicht mehr an der Seitenleiste,
// deshalb braucht sie einen Weg zurueck wie jede andere Unterseite. // deshalb braucht sie einen Weg zurueck wie jede andere Unterseite.
const zurueck = document.getElementById('artist-news-back-btn'); const zurueck = document.getElementById('artist-news-back-btn');
@@ -196,12 +201,12 @@ function _zeile(rel, schonDa) {
// Gegenstück zur Release-Zeitleiste. Die Liste läuft NACH VORN: das nächste Konzert zuerst. // Gegenstück zur Release-Zeitleiste. Die Liste läuft NACH VORN: das nächste Konzert zuerst.
// Die Termine kommen über das Backend von Ticketmaster, gecacht für alle Geräte gemeinsam. // Die Termine kommen über das Backend von Ticketmaster, gecacht für alle Geräte gemeinsam.
export async function loadKonzerte(tage = _tageKonzerte) { export async function loadKonzerte() {
_tageKonzerte = tage;
_reiter = 'konzerte'; _reiter = 'konzerte';
const zurueck = document.getElementById('artist-news-back-btn'); const zurueck = document.getElementById('artist-news-back-btn');
if (zurueck) zurueck.onclick = () => _navGoBack(); if (zurueck) zurueck.onclick = () => _navGoBack();
_markiereTage(tage); const tageReihe = document.getElementById('artist-news-sort');
if (tageReihe) tageReihe.style.display = 'none';
const el = document.getElementById('artist-news-list'); const el = document.getElementById('artist-news-list');
if (!el || _laeuft) return; if (!el || _laeuft) return;
@@ -226,7 +231,8 @@ export async function loadKonzerte(tage = _tageKonzerte) {
const res = await fetch(`${S.cfg.backendUrl}/api/artist-concerts`, { const res = await fetch(`${S.cfg.backendUrl}/api/artist-concerts`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists: kuenstler, days: tage }), // Ohne `days`: das Backend liefert alles Angekuendigte.
body: JSON.stringify({ artists: kuenstler }),
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { konzerte = [], keinSchluessel } = await res.json(); const { konzerte = [], keinSchluessel } = await res.json();
@@ -240,7 +246,7 @@ export async function loadKonzerte(tage = _tageKonzerte) {
if (!konzerte.length) { if (!konzerte.length) {
const leer = document.createElement('div'); const leer = document.createElement('div');
leer.className = 'empty-state'; leer.className = 'empty-state';
leer.innerHTML = `<div class="icon">🎪</div><p>Keine Termine in den nächsten ${tage} Tagen</p>` leer.innerHTML = '<div class="icon">🎪</div><p>Keine anstehenden Termine</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Gesucht wird weltweit über den Künstlernamen.</p>'; + '<p style="font-size:12px;color:var(--text3);margin-top:6px">Gesucht wird weltweit über den Künstlernamen.</p>';
el.appendChild(leer); el.appendChild(leer);
return; return;