From 568bb5002cc265a2303a1f1b754eedf67f11bc2d Mon Sep 17 00:00:00 2001 From: marc Date: Sat, 12 Sep 2026 14:12:07 +0200 Subject: [PATCH] =?UTF-8?q?fix(web):=20Zur=C3=BCck-Knopf=20tat=20oft=20nic?= =?UTF-8?q?hts=20+=20Besitzer=20geteilter=20Playlists=20(2.6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit navigate() ließ den Stack-Eintrag weg, wenn sich die Ansicht nicht geändert hatte, rief history.pushState aber trotzdem. Zweimal auf denselben Tab geklickt = zwei Browser-Einträge auf dieselbe Ansicht, jeder davon ein Zurück-Druck ohne sichtbare Wirkung. Im Test: 3x "Alben" -> 3 Browser-Einträge bei 1 Stack-Eintrag, danach bewegten 4 Zurück- Drücke die Ansicht einmal. Jetzt pusht nur eine wirklich neue Ansicht, sonst replaceState. Vorwärts mitgetestet, unverändert. Playlists: Navidrome liefert owner mit, das Web nutzte es nie. Übersicht und Detail zeigen "· von ", nur bei fremden Listen. Android zeigte ihn bisher bei jeder Liste und unbeschriftet; beide Plattformen jetzt gleich, Bibliotheks-Übersicht ebenfalls. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 27 +++++++++++++++++++ android/app/build.gradle.kts | 4 +-- .../novasonic/app/ui/screen/LibraryScreen.kt | 4 ++- .../app/ui/screen/PlaylistDetailScreen.kt | 4 ++- .../app/viewmodel/LibraryViewModel.kt | 11 +++++++- .../app/viewmodel/PlaylistViewModel.kt | 11 +++++++- src/views/navigation.js | 15 ++++++++--- src/views/playlists.js | 23 +++++++++++++--- 8 files changed, 85 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5b58db..857158f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d | Playlist browsing | ✅ | ✅ | | Playlist create/edit | ✅ | ✅ | | Playlist cover upload | ✅ | ✅ | +| Besitzer geteilter Playlists | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Wiedergabe fortsetzen | ✅ | ✅ | @@ -73,6 +74,32 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d --- +## [2.6] — 2026-09-12 + +**Android:** 1.9 (versionCode 12) — Besitzer geteilter Playlists. + +### Fixed +- **Der Zurück-Knopf tat oft nichts (Web)** — `navigate()` prüfte zwar, ob die Ansicht sich + überhaupt geändert hat, und ließ den Verlaufseintrag dann weg — **rief `history.pushState` aber + trotzdem**. Zweimal auf denselben Tab geklickt hieß also zwei Browser-Einträge auf dieselbe + Ansicht, und jeder davon kostete einen Zurück-Druck, bei dem sichtbar nichts passierte. Gemessen + im Test: dreimal „Alben" erzeugte **drei** Browser-Einträge bei **einem** Stack-Eintrag, danach + bewegten vier Zurück-Drücke die Ansicht genau einmal. Jetzt bekommt nur eine wirklich neue Ansicht + einen Verlaufseintrag, sonst wird der bestehende ersetzt. Der allererste Aufruf ersetzt ebenfalls, + weil `main.js` die schon offene Seite bereits auf `nsIdx 0` gesetzt hatte — auch das war ein + toter Zurück-Druck gleich zu Beginn. Vorwärts funktioniert unverändert (mitgetestet). + +### Added +- **Bei geteilten Playlists steht jetzt dabei, von wem sie sind (Web + Android)** — Navidrome + liefert `owner` in `getPlaylists`/`getPlaylist` mit, im Web wurde das Feld nie benutzt. Übersicht + und Detailseite zeigen es als `· von `, und zwar **nur bei fremden** Listen — bei den + eigenen wäre „von mir" nur Rauschen. Android zeigte den Besitzer auf der Detailseite bisher bei + **jeder** Liste und unbeschriftet, sodass der eigene Name wie ein Teil des Titels aussah; jetzt + verhalten sich beide Plattformen gleich, und die Playlist-Übersicht in der Bibliothek zeigt es + ebenfalls. + +--- + ## [2.5] — 2026-09-10 **Android:** 1.8 (versionCode 11) — Sprung vom Album zum Künstler. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index ec72995..eb0cbef 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 = 11 - versionName = "1.8" + versionCode = 12 + versionName = "1.9" // 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/ui/screen/LibraryScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/LibraryScreen.kt index f86617f..715bd04 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 @@ -327,9 +327,11 @@ fun LibraryScreen( modifier = Modifier.fillMaxSize(), ) { items(state.playlists, key = { it.id }) { pl -> + // Besitzer nur bei fremden (geteilten) Listen — bei den eigenen waere er Rauschen. + val von = pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser } PlaylistCard( name = pl.name, - subtitle = "${pl.songCount} Titel", + subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""), coverUrl = coverUrl(pl.coverArt), downloaded = pl.id in downloadedPlaylistIds, onClick = { onPlaylistClick(pl) }, diff --git a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt index bd7c156..105cad7 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/ui/screen/PlaylistDetailScreen.kt @@ -174,9 +174,11 @@ fun PlaylistDetailScreen( fontSize = 26.sp, color = Color.White, maxLines = 2, overflow = TextOverflow.Ellipsis, ) + // Der Besitzer stand hier bisher bei JEDER Liste, auch bei den eigenen, und ohne + // Beschriftung — bei einer geteilten Liste ist genau das die interessante Angabe. val meta = listOfNotNull( - pl.owner?.takeIf { it.isNotBlank() }, if (pl.songCount > 0) "${pl.songCount} Songs" else null, + pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" }, ).joinToString(" · ") if (meta.isNotBlank()) { Spacer(Modifier.height(4.dp)) 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 525ac71..fce86fb 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 @@ -11,6 +11,7 @@ import com.novasonic.app.api.model.Playlist import com.novasonic.app.app import kotlinx.coroutines.async import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -25,6 +26,8 @@ data class LibraryUiState( val albumSort: AlbumSort = AlbumSort.NEWEST, val artistIndexes: List = emptyList(), val playlists: List = emptyList(), + /** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */ + val currentUser: String = "", val isLoading: Boolean = false, val error: String? = null, ) @@ -42,7 +45,13 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) { val albumsGridState = LazyGridState() val artistsListState = LazyListState() - init { loadPlaylists() } + init { + loadPlaylists() + viewModelScope.launch { + val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("") + _state.value = _state.value.copy(currentUser = u) + } + } fun setAlbumSort(sort: AlbumSort) { if (sort == _state.value.albumSort) return diff --git a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt index 8f15d1e..8f06a1f 100644 --- a/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt +++ b/android/app/src/main/kotlin/com/novasonic/app/viewmodel/PlaylistViewModel.kt @@ -11,6 +11,7 @@ import com.novasonic.app.offline.DownloadState import com.novasonic.app.offline.GroupType import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @@ -22,6 +23,8 @@ data class PlaylistUiState( val isSaving: Boolean = false, val deleted: Boolean = false, val error: String? = null, + /** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */ + val currentUser: String = "", ) class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : AndroidViewModel(app) { @@ -42,7 +45,13 @@ class PlaylistViewModel(app: Application, savedState: SavedStateHandle) : Androi .map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) - init { load() } + init { + load() + viewModelScope.launch { + val u = runCatching { repo.credentialsFlow().first().username }.getOrDefault("") + _state.value = _state.value.copy(currentUser = u) + } + } fun load() { val id = playlistId diff --git a/src/views/navigation.js b/src/views/navigation.js index 7242c70..2f5fda9 100644 --- a/src/views/navigation.js +++ b/src/views/navigation.js @@ -140,12 +140,19 @@ export function navigate(page, data=null) { S._navStack = S._navStack.slice(0, S._navCursor + 1); const safe = data ? JSON.parse(JSON.stringify(data)) : null; const last = S._navStack[S._navCursor]; - if(!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) { - S._navStack.push({ page, data: safe }); - } + const istNeu = !last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe); + if(istNeu) S._navStack.push({ page, data: safe }); S._navCursor = S._navStack.length - 1; _navSave(); - try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {} + // Nur eine WIRKLICH neue Ansicht bekommt einen Browser-Eintrag. Vorher wurde immer gepusht, + // auch wenn der Eintrag wegen der Dublettenpruefung gar nicht in den Stack wanderte: zweimal + // auf denselben Tab geklickt hiess zwei Browser-Eintraege auf dieselbe Ansicht, und jeder davon + // kostete einen Zurueck-Druck, der sichtbar nichts tat. Der allererste Eintrag ersetzt ebenfalls, + // weil main.js die schon bestehende Seite bereits auf nsIdx 0 gesetzt hat. + try { + if(istNeu && S._navStack.length > 1) history.pushState({ nsIdx: S._navCursor }, '', ''); + else history.replaceState({ nsIdx: S._navCursor }, '', ''); + } catch {} } } diff --git a/src/views/playlists.js b/src/views/playlists.js index 2316bfa..b8cf454 100644 --- a/src/views/playlists.js +++ b/src/views/playlists.js @@ -8,6 +8,15 @@ import { openEditPlModal } from '../ui/ui.js'; import { navigate } from './navigation.js'; import { quickPlayPlaylist } from './detail.js'; +// Besitzer einer Playlist, sofern es nicht die eigene ist. Navidrome liefert `owner` und `public` +// in getPlaylists/getPlaylist mit; interessant ist die Angabe nur bei geteilten (oeffentlichen) +// Listen — bei den eigenen waere "von mir" nur Rauschen. +function _besitzer(pl) { + const o = pl?.owner; + if (!o || o === S.cfg.user) return ''; + return o; +} + // ─── PLAYLISTS ─── export async function loadPlaylists() { const g = document.getElementById('playlists-grid'); @@ -19,7 +28,9 @@ export async function loadPlaylists() { if(!lists.length) { g.innerHTML = '
🎶

Keine Playlists vorhanden

'; return; } lists.forEach(pl => { const cv = coverUrl(pl.coverArt, 512); - const c = makeCard(pl.name, pl.songCount+' Titel', cv, '🎶'); + const von = _besitzer(pl); + const c = makeCard(pl.name, pl.songCount+' Titel' + (von ? ' · von '+von : ''), cv, '🎶'); + c.dataset.plOwner = von; c.dataset.plId = pl.id; c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); }; c.querySelector('.card-info').onclick = () => navigate('playlist-detail', pl); @@ -29,7 +40,8 @@ export async function loadPlaylists() { api('getPlaylist', `&id=${pl.id}`).then(r => { const count = (r.playlist?.entry || []).length; const card = g.querySelector(`[data-pl-id="${pl.id}"]`); - if (card) card.querySelector('.card-sub').textContent = count + ' Titel'; + if (card) card.querySelector('.card-sub').textContent = + count + ' Titel' + (card.dataset.plOwner ? ' · von ' + card.dataset.plOwner : ''); }).catch(() => {}); }); } catch(e) { g.innerHTML = `

${e.message}

`; } @@ -50,9 +62,12 @@ export async function loadPlaylistDetail(pl) { S._currentPlaylist = {...pl, ...full, id: pl.id}; const songs = full.entry || []; const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60); - document.getElementById('playlist-detail-stats').textContent = `${songs.length} Titel · ${totalMin} Min.`; + const von = _besitzer({ ...pl, ...full }); + document.getElementById('playlist-detail-stats').textContent = + `${songs.length} Titel · ${totalMin} Min.` + (von ? ` · von ${von}` : ''); const overviewCard = document.querySelector(`#playlists-grid [data-pl-id="${pl.id}"]`); - if (overviewCard) overviewCard.querySelector('.card-sub').textContent = songs.length + ' Titel'; + if (overviewCard) overviewCard.querySelector('.card-sub').textContent = + songs.length + ' Titel' + (von ? ' · von ' + von : ''); const descEl = document.getElementById('playlist-detail-desc'); if(descEl) { descEl.textContent = full.comment||''; descEl.style.display = full.comment ? '' : 'none'; } document.getElementById('btn-play-playlist').onclick = () => playQueue(songs, 0);