fix(web): Zurück-Knopf tat oft nichts + Besitzer geteilter Playlists (2.6)

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 <Name>", 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 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-12 14:12:07 +02:00
1 parent ea448e109f
commit 568bb5002c
8 files changed
+85 -14

No files matched your search

+27
View File
@@ -38,6 +38,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
| Playlist browsing | ✅ | ✅ | | Playlist browsing | ✅ | ✅ |
| Playlist create/edit | ✅ | ✅ | | Playlist create/edit | ✅ | ✅ |
| Playlist cover upload | ✅ | ✅ | | Playlist cover upload | ✅ | ✅ |
| Besitzer geteilter Playlists | ✅ | ✅ |
| Spotify / YouTube importer | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ |
| Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ |
| Wiedergabe fortsetzen | ✅ | ✅ | | 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 <Name>`, 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 ## [2.5] — 2026-09-10
**Android:** 1.8 (versionCode 11) — Sprung vom Album zum Künstler. **Android:** 1.8 (versionCode 11) — Sprung vom Album zum Künstler.
+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 = 11 versionCode = 12
versionName = "1.8" versionName = "1.9"
// 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.
@@ -327,9 +327,11 @@ fun LibraryScreen(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
) { ) {
items(state.playlists, key = { it.id }) { pl -> 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( PlaylistCard(
name = pl.name, name = pl.name,
subtitle = "${pl.songCount} Titel", subtitle = "${pl.songCount} Titel" + (von?.let { " · von $it" } ?: ""),
coverUrl = coverUrl(pl.coverArt), coverUrl = coverUrl(pl.coverArt),
downloaded = pl.id in downloadedPlaylistIds, downloaded = pl.id in downloadedPlaylistIds,
onClick = { onPlaylistClick(pl) }, onClick = { onPlaylistClick(pl) },
@@ -174,9 +174,11 @@ fun PlaylistDetailScreen(
fontSize = 26.sp, color = Color.White, fontSize = 26.sp, color = Color.White,
maxLines = 2, overflow = TextOverflow.Ellipsis, 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( val meta = listOfNotNull(
pl.owner?.takeIf { it.isNotBlank() },
if (pl.songCount > 0) "${pl.songCount} Songs" else null, if (pl.songCount > 0) "${pl.songCount} Songs" else null,
pl.owner?.takeIf { it.isNotBlank() && it != state.currentUser }?.let { "von $it" },
).joinToString(" · ") ).joinToString(" · ")
if (meta.isNotBlank()) { if (meta.isNotBlank()) {
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@@ -11,6 +11,7 @@ import com.novasonic.app.api.model.Playlist
import com.novasonic.app.app import com.novasonic.app.app
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -25,6 +26,8 @@ data class LibraryUiState(
val albumSort: AlbumSort = AlbumSort.NEWEST, val albumSort: AlbumSort = AlbumSort.NEWEST,
val artistIndexes: List<ArtistIndex> = emptyList(), val artistIndexes: List<ArtistIndex> = emptyList(),
val playlists: List<Playlist> = emptyList(), val playlists: List<Playlist> = emptyList(),
/** Angemeldeter Nutzer — der Besitzer wird nur bei FREMDEN Playlists angezeigt. */
val currentUser: String = "",
val isLoading: Boolean = false, val isLoading: Boolean = false,
val error: String? = null, val error: String? = null,
) )
@@ -42,7 +45,13 @@ class LibraryViewModel(app: Application) : AndroidViewModel(app) {
val albumsGridState = LazyGridState() val albumsGridState = LazyGridState()
val artistsListState = LazyListState() 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) { fun setAlbumSort(sort: AlbumSort) {
if (sort == _state.value.albumSort) return if (sort == _state.value.albumSort) return
@@ -11,6 +11,7 @@ import com.novasonic.app.offline.DownloadState
import com.novasonic.app.offline.GroupType import com.novasonic.app.offline.GroupType
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -22,6 +23,8 @@ data class PlaylistUiState(
val isSaving: Boolean = false, val isSaving: Boolean = false,
val deleted: Boolean = false, val deleted: Boolean = false,
val error: String? = null, 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) { 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 } } .map { tracks -> tracks.any { it.state == DownloadState.QUEUED || it.state == DownloadState.DOWNLOADING } }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) .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() { fun load() {
val id = playlistId val id = playlistId
+11 -4
View File
@@ -140,12 +140,19 @@ export function navigate(page, data=null) {
S._navStack = S._navStack.slice(0, S._navCursor + 1); S._navStack = S._navStack.slice(0, S._navCursor + 1);
const safe = data ? JSON.parse(JSON.stringify(data)) : null; const safe = data ? JSON.parse(JSON.stringify(data)) : null;
const last = S._navStack[S._navCursor]; const last = S._navStack[S._navCursor];
if(!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) { const istNeu = !last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe);
S._navStack.push({ page, data: safe }); if(istNeu) S._navStack.push({ page, data: safe });
}
S._navCursor = S._navStack.length - 1; S._navCursor = S._navStack.length - 1;
_navSave(); _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 {}
} }
} }
+19 -4
View File
@@ -8,6 +8,15 @@ import { openEditPlModal } from '../ui/ui.js';
import { navigate } from './navigation.js'; import { navigate } from './navigation.js';
import { quickPlayPlaylist } from './detail.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 ─── // ─── PLAYLISTS ───
export async function loadPlaylists() { export async function loadPlaylists() {
const g = document.getElementById('playlists-grid'); const g = document.getElementById('playlists-grid');
@@ -19,7 +28,9 @@ export async function loadPlaylists() {
if(!lists.length) { g.innerHTML = '<div class="empty-state"><div class="icon">🎶</div><p>Keine Playlists vorhanden</p></div>'; return; } if(!lists.length) { g.innerHTML = '<div class="empty-state"><div class="icon">🎶</div><p>Keine Playlists vorhanden</p></div>'; return; }
lists.forEach(pl => { lists.forEach(pl => {
const cv = coverUrl(pl.coverArt, 512); 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.dataset.plId = pl.id;
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); }; c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); };
c.querySelector('.card-info').onclick = () => navigate('playlist-detail', 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 => { api('getPlaylist', `&id=${pl.id}`).then(r => {
const count = (r.playlist?.entry || []).length; const count = (r.playlist?.entry || []).length;
const card = g.querySelector(`[data-pl-id="${pl.id}"]`); 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(() => {});
}); });
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; } } catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
@@ -50,9 +62,12 @@ export async function loadPlaylistDetail(pl) {
S._currentPlaylist = {...pl, ...full, id: pl.id}; S._currentPlaylist = {...pl, ...full, id: pl.id};
const songs = full.entry || []; const songs = full.entry || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60); 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}"]`); 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'); const descEl = document.getElementById('playlist-detail-desc');
if(descEl) { descEl.textContent = full.comment||''; descEl.style.display = full.comment ? '' : 'none'; } if(descEl) { descEl.textContent = full.comment||''; descEl.style.display = full.comment ? '' : 'none'; }
document.getElementById('btn-play-playlist').onclick = () => playQueue(songs, 0); document.getElementById('btn-play-playlist').onclick = () => playQueue(songs, 0);