feat(web): Playlists und Favoriten als Liste in der Seitenleiste

Statt zweier Knoepfe, die erst eine Uebersichtsseite oeffnen, steht unter den
Navigationsrubriken der Abschnitt "Deine Musik": angepinnt Favoriten und "Neu von
deinen Kuenstlern", darunter die Playlists mit Cover und Titelzahl.

Links und nicht rechts, weil rechts belegt ist: dort faehrt die Warteschlange als
290-px-Panel ein und am Rand sitzt der Rollbalken aus 3.1.

Der Listenteil rollt eigenstaendig (nav ist jetzt eine Flex-Spalte), auf niedrigen
Fenstern rollt als Rueckfall die ganze Leiste. Dieselbe gecachte Abfrage wie die
Uebersichtsseite; nach Anlegen/Umbenennen/Loeschen wird der Cache verworfen und neu
gezeichnet, ein Coverwechsel tauscht nur die betroffene Zeile (gleiche Adresse pl-<id>,
sonst kaeme dasselbe Bild aus dem Browser-Cache).

Die Kachel-Uebersicht bleibt per "Alle" erreichbar, die untere Leiste auf dem Handy
bleibt unveraendert.

Ausserdem: in 3.2 uebersehene Mischstelle nachgezogen — der Shuffle-Knopf der
Playlist-Seite benutzte weiter sort(() => Math.random()-.5). Mein Suchmuster hatte nur
die Schreibweise mit 0.5 erfasst, nicht die mit .5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-10-13 19:40:56 +02:00
1 parent 7cdcc352d6
commit 9afd73ae7f
7 files changed
+139 -10

No files matched your search

+24
View File
@@ -83,7 +83,31 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
**Android:** 1.26 (versionCode 29) → 1.27 (versionCode 30).
### Added
- **Deine Musik als Liste in der Seitenleiste (Web)** — Playlists und Favoriten sind keine
Knöpfe mehr, hinter denen erst eine Übersichtsseite aufgeht. Unter den Navigationsrubriken
steht jetzt der Abschnitt *Deine Musik*: angepinnt **Favoriten** und **Neu von deinen
Künstlern**, darunter deine Playlists mit Cover und Titelzahl, wie bei Spotify.
- **Links, nicht rechts.** Rechts ist belegt: dort fährt die Warteschlange als 290-px-Panel
ein, und am rechten Rand sitzt der Rollbalken aus 3.1. Eine feste rechte Spalte hätte sich
mit beiden einigen müssen; links war ohnehin schon eine 240-px-Leiste.
- **Der Listenteil rollt eigenständig**, die Navigation darüber bleibt also immer sichtbar.
Auf sehr niedrigen Fenstern rollt die ganze Leiste, statt die Liste auf null zu quetschen.
- **Kein zusätzlicher Netzverkehr:** dieselbe gecachte Abfrage wie die Übersichtsseite
(3 Minuten). Nach Anlegen, Umbenennen und Löschen wird der Cache verworfen und die Liste
neu gezeichnet; ein neues oder entferntes Cover tauscht nur die betroffene Zeile, weil die
Cover-Adresse (`pl-<id>`) gleich bleibt und ein Neuzeichnen dasselbe Bild aus dem
Browser-Cache geholt hätte.
- Die Übersichtsseite mit den Kacheln bleibt erreichbar (*Alle* im Abschnittskopf), und die
**untere Leiste auf dem Handy ist unverändert** — dort sind Playlists und Favoriten
weiterhin Reiter.
### Fixed
- **Eine Mischstelle war in 3.2 übersehen worden (Web)** — der Shuffle-Knopf auf der
Playlist-Seite benutzte weiter `sort(() => Math.random()-.5)`. Mein Suchmuster in 3.2 hatte
nur die Schreibweise mit `0.5` erfasst, nicht die mit `.5`; genau diese eine Stelle ist aber
der Knopf, mit dem man eine große Playlist tatsächlich gemischt startet. Jetzt Fisher-Yates
wie überall sonst.
- **Now-Playing passte sich der Anzeige nicht an (Android)** — gemeldet auf einem aufgeklappten
Fold: die Ansicht wirkte überdimensional. Ursache war, dass die Seite überhaupt keine
Breitengrenze hatte: das Cover war `fillMaxWidth()` mal `aspectRatio(1f)`. Auf einem Handy
+18 -6
View File
@@ -64,12 +64,6 @@
<div class="nav-item" onclick="navigate('albums')" data-page="albums">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/></svg>Alben
</div>
<div class="nav-item" onclick="navigate('playlists')" data-page="playlists">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>Playlists
</div>
<div class="nav-item" onclick="navigate('starred')" data-page="starred">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>Favoriten
</div>
</div>
<div class="nav-section">
<div class="nav-section-title">Entdecken</div>
@@ -92,6 +86,24 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>Bibliothek
</div>
</div>
<!-- Deine Musik: Favoriten, "Neu von deinen Kuenstlern" und die Playlists als LISTE
statt als Knopf, der erst eine Uebersichtsseite oeffnet. Der Listenteil rollt
eigenstaendig, damit die Navigation darueber immer sichtbar bleibt. -->
<div class="nav-section lib-section">
<div class="lib-head">
<span class="nav-section-title">Deine Musik</span>
<button class="lib-btn" onclick="navigate('playlists')" title="Alle Playlists als Kacheln">Alle</button>
<button class="lib-btn lib-btn-add" onclick="createPlaylistPrompt()" title="Neue Playlist" aria-label="Neue Playlist">+</button>
</div>
<div class="nav-item" onclick="navigate('starred')" data-page="starred">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>Favoriten
</div>
<div class="nav-item" onclick="navigate('artist-news')" data-page="artist-news">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9L12 3z"/></svg>Neu von deinen Künstlern
</div>
<div class="lib-list" id="sidebar-playlists"></div>
</div>
</nav>
</aside>
+3
View File
@@ -117,6 +117,9 @@ async function startApp() {
import('./ui/share.js')
.then(m => m.handleShareParam(navigate, playQueue))
.catch(() => {});
// Playlists in der Seitenleiste: gleich beim Start zeichnen, damit die Liste nicht erst beim
// ersten Besuch der Uebersichtsseite auftaucht. Nutzt denselben 3-Min-Cache.
import('./views/playlists.js').then(m => m.renderSidebarPlaylists()).catch(() => {});
initNotifBell();
initPlayLog(); // flush any queued plays to the scrobble service (offline-safe)
loadArtistOverrides().catch(() => {}); // pinned same-name artist → Deezer mappings (shared)
+8 -1
View File
@@ -3,7 +3,7 @@ import { esc, toast } from '../utils.js';
import { api, coverUrl, ndAuth, invalidateApiCache } from '../api.js';
// Circular — safe (only used inside function bodies)
import { playQueue, dlSongLocal, nativeInsertAt, renderQueue } from '../player/player.js';
import { navigate, loadPlaylists, loadPlaylistDetail } from '../views/views.js';
import { navigate, loadPlaylists, loadPlaylistDetail, renderSidebarPlaylists, bustSidebarCover } from '../views/views.js';
// ─── CONTEXT MENU ───
export function showCtxMenu(e, song, list, idx) {
@@ -120,6 +120,8 @@ export async function createAndAddPlaylist() {
const ids = S.plModalSongs.map(s => `&songId=${s.id}`).join('');
await api('createPlaylist', `&name=${encodeURIComponent(name)}${ids}`);
toast(`✅ Playlist "${name}" erstellt`);
invalidateApiCache('getPlaylists'); // sonst zeigt die Seitenleiste die neue Liste 3 Min. nicht
renderSidebarPlaylists();
if(document.getElementById('page-playlists').classList.contains('active')) loadPlaylists();
} catch(e) { toast('Fehler: ' + e.message); }
}
@@ -178,6 +180,8 @@ export async function saveEditPlaylist() {
if(descEl) { descEl.textContent=comment; descEl.style.display=comment?'':'none'; }
closeEditPlModal();
toast('Playlist aktualisiert');
invalidateApiCache('getPlaylists'); // Name/Sichtbarkeit stehen auch in der Seitenleiste
renderSidebarPlaylists();
} catch(e) { toast('Fehler: '+e.message); }
finally { btn.disabled=false; btn.textContent='Speichern'; }
}
@@ -202,6 +206,7 @@ export async function editPlCoverSelected(input) {
const detailImg = document.getElementById('playlist-cover');
if(detailImg) detailImg.innerHTML = `<img src="${bust}" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎶</div>'">`;
document.getElementById('edit-pl-cover').innerHTML = `<img src="${bust}" style="width:100%;height:100%;object-fit:cover">`;
bustSidebarCover(S._currentPlaylist.id, S._currentPlaylist.coverArt);
}, 800);
} catch(e) {
S._ndToken = null;
@@ -224,6 +229,7 @@ export async function editPlRemoveCover() {
S._currentPlaylist.coverArt = null;
document.getElementById('edit-pl-cover').innerHTML = '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
document.getElementById('playlist-cover').innerHTML = '<div class="no-art">🎶</div>';
bustSidebarCover(S._currentPlaylist?.id, null);
} catch(e) { S._ndToken = null; toast('Fehler: '+e.message); }
}
@@ -237,6 +243,7 @@ export async function deletePlaylist() {
closeEditPlModal();
toast(`🗑 "${pl.name}" gelöscht`);
invalidateApiCache('getPlaylists'); // drop the 3-min cache so the list reloads without the deleted playlist
renderSidebarPlaylists();
navigate('playlists');
} catch(e) { toast('Fehler beim Löschen: ' + e.message); }
}
+4 -1
View File
@@ -6,7 +6,7 @@ import { loadSoundCloudImporter } from './soundcloud.js';
// Circular — safe (all calls happen inside function bodies)
import { loadHomeData, loadAlbumRow, loadArtists, loadAlbums, loadGenreDetail } from './home.js';
import { loadArtistDetail, loadArtistAllSongs, loadAlbumDetail, loadDeezerAlbumDetail, loadPartialAlbumDetail } from './detail.js';
import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js';
import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom, markSidebarPlaylist } from './playlists.js';
import { loadNewReleases, loadRecommendations } from './discover.js';
import { onHeaderSearch } from './search.js';
import { invalidateApiCache } from '../api.js';
@@ -94,6 +94,9 @@ export function navigate(page, data=null) {
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
const cls = ['discover','new-releases','recommendations'].includes(page) ? 'active-blue' : 'active';
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
// Die Playlist-Zeilen in der Seitenleiste sind keine nav-items (sie tragen keine Seite,
// sondern eine Id), deshalb werden sie hier getrennt markiert.
markSidebarPlaylist(page === 'playlist-detail' ? data?.id : null);
const pageEl = document.getElementById(`page-${page}`);
if(pageEl) {
pageEl.classList.add('active');
+58 -1
View File
@@ -3,6 +3,7 @@ import { esc, toast, formatGesamtdauer } from '../utils.js';
import { api, apiCached, apiIsCached, coverUrl, romajiLookup, dispArtistById, getArtistOverride } from '../api.js';
import { makeCard, makeAlbumCard, makeSongItem, mkSection } from '../ui/components.js';
import { playQueue } from '../player/player.js';
import { mischeListe } from '../player/queueMath.js';
import { openEditPlModal } from '../ui/ui.js';
// Circular — safe (called inside event handlers)
import { navigate } from './navigation.js';
@@ -48,6 +49,62 @@ export async function loadPlaylists() {
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// ─── DEINE MUSIK (Seitenleiste) ───────────────────────────────────────────────
// Die Playlists stehen links als Liste, nicht hinter einem Knopf. Dieselbe gecachte Abfrage
// wie die Uebersichtsseite (3 Min.), es entsteht also kein zusaetzlicher Netzverkehr: wer die
// Seite oeffnet, hat den Stand schon, und umgekehrt.
export async function renderSidebarPlaylists() {
const el = document.getElementById('sidebar-playlists');
if (!el) return;
try {
const r = await apiCached('getPlaylists', '', 3 * 60 * 1000);
const lists = r.playlists?.playlist || [];
el.innerHTML = '';
if (!lists.length) {
el.innerHTML = '<div class="lib-empty">Noch keine Playlists — mit + anlegen.</div>';
return;
}
for (const pl of lists) {
const von = _besitzer(pl);
const cv = coverUrl(pl.coverArt, 128);
const row = document.createElement('div');
row.className = 'lib-item';
row.dataset.plId = pl.id;
row.title = pl.name + (von ? ' · von ' + von : '');
row.innerHTML =
`<div class="lib-art">${cv ? `<img src="${cv}" loading="lazy" onerror="_imgRetry(this,'🎶')">` : '🎶'}</div>` +
`<div class="lib-text"><div class="lib-name">${esc(pl.name)}</div>` +
`<div class="lib-sub">${pl.songCount || 0} Titel${von ? ' · ' + esc(von) : ''}</div></div>`;
row.onclick = () => navigate('playlist-detail', pl);
el.appendChild(row);
}
markSidebarPlaylist(S._currentPlaylist?.id);
} catch {
// Kein Alarm in der Seitenleiste: die Uebersichtsseite meldet Fehler ohnehin.
el.innerHTML = '<div class="lib-empty">Playlists nicht geladen.</div>';
}
}
/**
* Tauscht das Cover EINER Zeile aus, nachdem es gerade hochgeladen oder entfernt wurde.
* Noetig, weil die Cover-Adresse dieselbe bleibt (`pl-<id>`): ein Neuzeichnen holte dasselbe
* Bild aus dem Browser-Cache, und die Zeile zeigte weiter das alte Cover.
*/
export function bustSidebarCover(plId, coverArt) {
const row = document.querySelector(`#sidebar-playlists .lib-item[data-pl-id="${plId}"] .lib-art`);
if (!row) return;
const cv = coverArt ? coverUrl(coverArt, 128) + '&_cb=' + Date.now() : '';
row.innerHTML = cv ? `<img src="${cv}" onerror="_imgRetry(this,'🎶')">` : '🎶';
}
/** Hebt die laufend geoeffnete Playlist hervor. Aufgerufen aus navigate(). */
export function markSidebarPlaylist(plId) {
const id = plId == null ? '' : String(plId);
document.querySelectorAll('#sidebar-playlists .lib-item').forEach(el => {
el.classList.toggle('active', !!id && el.dataset.plId === id);
});
}
export async function loadPlaylistDetail(pl) {
S._currentPlaylist = {...pl};
document.getElementById('playlist-detail-name').textContent = pl.name;
@@ -73,7 +130,7 @@ export async function loadPlaylistDetail(pl) {
if(descEl) { descEl.textContent = full.comment||''; descEl.style.display = full.comment ? '' : 'none'; }
document.getElementById('btn-play-playlist').onclick = () => playQueue(songs, 0);
document.getElementById('btn-shuffle-playlist').onclick = () => {
playQueue([...songs].sort(() => Math.random()-.5), 0);
playQueue(mischeListe(songs), 0);
};
sl.innerHTML = '';
const plSearch = document.getElementById('playlist-search');
+24 -1
View File
@@ -95,7 +95,30 @@ html.ns-authed #setup-screen{display:none}
@media(max-width:768px){#header-search-overlay{max-height:calc(100vh - var(--player-h) - 160px)}}
@media(max-width:359px){#mobile-header .sidebar-logo span{display:none}}
nav{flex:1;overflow-y:auto;padding:4px 10px}
/* Die Navigation ist eine Spalte: oben die festen Eintraege, unten die Playlist-Liste, die
den Rest einnimmt und selbst rollt. `overflow-y:auto` bleibt als Rueckfall fuer sehr niedrige
Fenster — dann rollt die ganze Leiste, statt die Liste auf null zu quetschen. */
nav{flex:1;min-height:0;display:flex;flex-direction:column;overflow-y:auto;padding:4px 10px}
/* ── DEINE MUSIK (Seitenleiste) ── */
.lib-section{display:flex;flex-direction:column;flex:1;min-height:0;margin-bottom:0}
.lib-head{display:flex;align-items:center;gap:6px;padding:0 2px 3px 8px}
.lib-head .nav-section-title{flex:1;padding:0;margin:0}
.lib-btn{flex-shrink:0;background:none;border:none;color:var(--text3);font-family:var(--font);font-size:11px;font-weight:600;padding:2px 6px;border-radius:6px;cursor:pointer;transition:background .15s,color .15s}
.lib-btn:hover{background:var(--surface);color:var(--text)}
.lib-btn-add{font-size:16px;line-height:1;padding:0 6px 2px}
.lib-list{flex:1;min-height:140px;overflow-y:auto;overflow-x:hidden;margin-top:4px;padding-bottom:4px}
.lib-item{display:flex;align-items:center;gap:9px;padding:5px 8px;border-radius:8px;cursor:pointer;transition:background .15s;user-select:none}
.lib-item:hover{background:var(--surface)}
.lib-item.active{background:var(--accent-dim)}
.lib-item.active .lib-name{color:var(--accent)}
.lib-art{flex-shrink:0;width:30px;height:30px;border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;font-size:13px;overflow:hidden}
.lib-art img{width:100%;height:100%;object-fit:cover}
.lib-text{min-width:0;flex:1}
.lib-name{font-size:12.5px;font-weight:500;color:var(--text2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lib-item:hover .lib-name{color:var(--text)}
.lib-sub{font-size:10.5px;color:var(--text3);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lib-empty{font-size:11.5px;color:var(--text3);padding:8px 8px 4px;line-height:1.5}
.nav-section{margin-bottom:18px}
.nav-section-title{font-size:10px;font-weight:600;letter-spacing:1px;text-transform:uppercase;color:var(--text3);padding:0 8px;margin-bottom:3px}
.nav-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer;font-size:13px;font-weight:500;color:var(--text2);transition:background .15s,color .15s;user-select:none}