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:
1 parent
ea448e109f
commit
568bb5002c
8 files changed
+85
-14
No files matched your search
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user