diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a956c6..173fb22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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-`) 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 diff --git a/index.html b/index.html index e607769..2891b61 100644 --- a/index.html +++ b/index.html @@ -64,12 +64,6 @@ - - + + + diff --git a/src/main.js b/src/main.js index c5e8c43..f8e1eb0 100644 --- a/src/main.js +++ b/src/main.js @@ -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) diff --git a/src/ui/modals.js b/src/ui/modals.js index 3cf06ce..56a5e1b 100644 --- a/src/ui/modals.js +++ b/src/ui/modals.js @@ -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 = ``; document.getElementById('edit-pl-cover').innerHTML = ``; + 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 = '
🎶
'; document.getElementById('playlist-cover').innerHTML = '
🎶
'; + 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); } } diff --git a/src/views/navigation.js b/src/views/navigation.js index 3bd9410..4f05919 100644 --- a/src/views/navigation.js +++ b/src/views/navigation.js @@ -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'); diff --git a/src/views/playlists.js b/src/views/playlists.js index a64984a..86761b6 100644 --- a/src/views/playlists.js +++ b/src/views/playlists.js @@ -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 = `

${e.message}

`; } } +// ─── 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 = '
Noch keine Playlists — mit + anlegen.
'; + 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 = + `
${cv ? `` : '🎶'}
` + + `
${esc(pl.name)}
` + + `
${pl.songCount || 0} Titel${von ? ' · ' + esc(von) : ''}
`; + 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 = '
Playlists nicht geladen.
'; + } +} + +/** + * Tauscht das Cover EINER Zeile aus, nachdem es gerade hochgeladen oder entfernt wurde. + * Noetig, weil die Cover-Adresse dieselbe bleibt (`pl-`): 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 ? `` : '🎶'; +} + +/** 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'); diff --git a/styles.css b/styles.css index e9967c8..ffcc28f 100644 --- a/styles.css +++ b/styles.css @@ -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}