feat: Zeitleiste "Neu von deinen Künstlern" (Web) + Backend-Endpunkt

Eigener Punkt in der Seitenleiste: jüngste Veröffentlichungen aller
gefolgten Künstler, nach Monat gruppiert, neueste zuerst, umschaltbar auf
30/90/365 Tage. Was schon in der Bibliothek liegt, ist markiert (Vergleich
über Künstler + Albumname, akzent- und zeichenunabhängig); alles andere
bekommt einen Laden-Knopf zum vertrauten Download-Dialog.

Neuer Endpunkt POST /api/artist-releases (backend/lib/releases.js): die
Deezer-Zuordnung passiert serverseitig, weil die Künstler-Pins dort
ohnehin liegen. Gepinnte nutzen die feste ID, ungepinnte werden über den
Namen gesucht und nach Fan-Zahl sortiert; ohne Treffer kommt der Name als
ohneZuordnung zurück und die Oberfläche rät zum Pinnen.

Cache im Backend statt im Client — bei 30 gefolgten Künstlern wären das
sonst 30 Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für
die Namenszuordnung, max. 4 Anfragen parallel, bei Deezer-Fehlern wird
der abgelaufene Stand ausgeliefert statt einer leeren Liste.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-09-18 13:50:33 +02:00
1 parent 19f241e493
commit 908488a337
8 files changed
+345 -2

No files matched your search

+19
View File
@@ -40,6 +40,7 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
| Playlist cover upload | ✅ | ✅ | | Playlist cover upload | ✅ | ✅ |
| Besitzer geteilter Playlists | ✅ | ✅ | | Besitzer geteilter Playlists | ✅ | ✅ |
| Künstler folgen / Album merken | ✅ | ✅ | | Künstler folgen / Album merken | ✅ | ✅ |
| Neu von deinen Künstlern | ✅ | ❌ |
| Spotify / YouTube importer | ✅ | ✅ | | Spotify / YouTube importer | ✅ | ✅ |
| Musik-Duell (Anzeigetafel) | ✅ | ✅ | | Musik-Duell (Anzeigetafel) | ✅ | ✅ |
| Wiedergabe fortsetzen | ✅ | ✅ | | Wiedergabe fortsetzen | ✅ | ✅ |
@@ -100,6 +101,24 @@ Die Android-App zählt eigenständig weiter (mehrere APKs pro Release). Welche d
- Wer ohne Künstler-Pin folgt, bekommt einen Hinweis: ohne Pinnung läuft die Deezer-Zuordnung über - Wer ohne Künstler-Pin folgt, bekommt einen Hinweis: ohne Pinnung läuft die Deezer-Zuordnung über
den Namen, und bei gleichnamigen Künstlern kämen dauerhaft die Neuheiten des Falschen. den Namen, und bei gleichnamigen Künstlern kämen dauerhaft die Neuheiten des Falschen.
- **Zeitleiste „Neu von deinen Künstlern" (Web)** — eigener Punkt in der Seitenleiste. Zeigt die
jüngsten Veröffentlichungen aller Künstler, denen du folgst: nach Monat gruppiert, neueste zuerst,
pro Eintrag Cover, Künstler, Art (Album/Single/EP), Titelzahl und Datum. Umschaltbar auf 30 Tage,
90 Tage oder ein Jahr.
- **Was du schon hast, ist markiert.** Verglichen wird über Künstler + Albumname (akzent- und
zeichenunabhängig) — Deezers Album-Id hat mit Navidromes nichts zu tun. Alles andere bekommt
direkt einen Laden-Knopf, der den vertrauten Download-Dialog öffnet.
- **Neuer Backend-Endpunkt `POST /api/artist-releases`** (`backend/lib/releases.js`). Die
Deezer-Zuordnung passiert dort, weil die Künstler-Pins ohnehin serverseitig liegen: der Client
schickt nur seine Navidrome-Künstler und muss über Deezer nichts wissen. Gepinnte Künstler nutzen
die feste ID, ungepinnte werden über den Namen gesucht — sortiert nach Fan-Zahl, weil Deezers
Suche nicht nach Bekanntheit ordnet. Künstler ohne Treffer kommen als `ohneZuordnung` zurück und
die Oberfläche rät zum Pinnen, statt still die Neuheiten des Falschen anzuzeigen.
- **Der Cache liegt im Backend, nicht im Client.** Bei 30 gefolgten Künstlern wären das sonst 30
Deezer-Aufrufe pro Gerät und App-Start. 6 h für Alben, 24 h für die Namenszuordnung, höchstens
vier Anfragen gleichzeitig; bei einem Deezer-Fehler wird der abgelaufene Stand ausgeliefert
statt einer leeren Liste.
### Changed ### Changed
- **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei - **Top Songs fragen jetzt über die Künstler-ID (Web + Android)** — Navidrome 0.64 nimmt bei
`getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg `getTopSongs` zusätzlich `id`. Bisher lief die Abfrage nur über den **Namen**, und der Namensweg
+95
View File
@@ -0,0 +1,95 @@
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ───────────────────────────────
// Liefert zu einer Liste von Navidrome-Künstlern die jüngsten Deezer-Releases, damit die App eine
// Zeitleiste "Neu von deinen Künstlern" zeigen kann.
//
// Warum serverseitig: bei 30 gefolgten Künstlern wären das 30 Deezer-Aufrufe — pro Gerät und pro
// App-Start. Der Cache liegt deshalb hier und gilt für alle Clients gemeinsam.
const { dzGet } = require('./deezer');
const _cache = new Map(); // deezerArtistId → { ts, alben }
const _idCache = new Map(); // nameLower → { ts, id }
const TTL = 6 * 60 * 60 * 1000; // 6 h — Alben erscheinen nicht minütlich
const ID_TTL = 24 * 60 * 60 * 1000; // 24 h — die Zuordnung Name → Deezer ändert sich kaum
const GLEICHZEITIG = 4; // höflich gegenüber Deezer, trotzdem zügig
const coverXl = a => a?.cover_xl || a?.cover_big || a?.cover_medium || '';
// Künstler stilisieren ihren Namen mit führenden Zeichen (".diedlonely", "†…") — ohne das Abschneiden
// findet Deezers Suche sie nicht. Gleiche Regel wie in deezer.js.
const stripLeadSym = s => (s || '').replace(/^[^\p{L}\p{N}]+/u, '').trim();
/** Deezer-ID zu einem Künstler: erst die gepinnte, sonst über den Namen gesucht (und gemerkt). */
async function deezerIdFuer(name, pinId) {
if (pinId) return String(pinId);
const key = stripLeadSym(name).toLowerCase();
if (!key) return null;
const c = _idCache.get(key);
if (c && Date.now() - c.ts < ID_TTL) return c.id;
try {
const treffer = (await dzGet(`search/artist?q=${encodeURIComponent(stripLeadSym(name))}&limit=5`)).data || [];
// Deezer sortiert nicht nach Bekanntheit — die Fan-Zahl trifft den gemeinten Künstler deutlich
// öfter als der erste Treffer. Ein Pin schlägt das trotzdem, siehe oben.
const best = treffer.slice().sort((a, b) => (b.nb_fan || 0) - (a.nb_fan || 0))[0];
const id = best ? String(best.id) : null;
_idCache.set(key, { ts: Date.now(), id });
return id;
} catch { return null; }
}
/** Albumliste eines Deezer-Künstlers, gecacht. */
async function albenFuer(deezerId) {
const c = _cache.get(deezerId);
if (c && Date.now() - c.ts < TTL) return c.alben;
try {
const alben = (await dzGet(`artist/${deezerId}/albums?limit=50`)).data || [];
_cache.set(deezerId, { ts: Date.now(), alben });
return alben;
} catch {
// Bei einem Fehler lieber den alten Stand als gar nichts — abgelaufen, aber nicht falsch.
return c ? c.alben : [];
}
}
/** Führt eine Aufgabenliste mit begrenzter Gleichzeitigkeit aus. */
async function inHaeppchen(items, groesse, fn) {
const raus = [];
for (let i = 0; i < items.length; i += groesse) {
raus.push(...await Promise.all(items.slice(i, i + groesse).map(fn)));
}
return raus;
}
/**
* @param {Array<{id,name,deezerArtistId?}>} kuenstler Navidrome-Künstler (id + name)
* @param {number} tage Zeitfenster
* @returns {{releases:Array, ohneZuordnung:Array<string>}}
*/
async function neueReleases(kuenstler, tage = 90) {
const grenze = Date.now() - tage * 24 * 60 * 60 * 1000;
const ohneZuordnung = [];
const proKuenstler = await inHaeppchen(kuenstler, GLEICHZEITIG, async (k) => {
const dzId = await deezerIdFuer(k.name, k.deezerArtistId);
if (!dzId) { ohneZuordnung.push(k.name); return []; }
const alben = await albenFuer(dzId);
return alben
.filter(a => a.release_date && new Date(a.release_date).getTime() >= grenze)
.map(a => ({
artistId: k.id,
artistName: k.name,
deezerArtistId: dzId,
albumId: String(a.id),
title: a.title || '',
cover: coverXl(a),
releaseDate: a.release_date,
recordType: a.record_type || 'album',
trackCount: a.nb_tracks ?? null,
explicit: !!a.explicit_lyrics,
}));
});
const releases = proKuenstler.flat()
.sort((a, b) => new Date(b.releaseDate) - new Date(a.releaseDate));
return { releases, ohneZuordnung };
}
module.exports = { neueReleases, deezerIdFuer, albenFuer, _cache, _idCache };
+30
View File
@@ -266,6 +266,36 @@ app.post('/api/artist-overrides', (req, res) => {
res.json({ ok: true }); res.json({ ok: true });
}); });
// ─── NEUE VERÖFFENTLICHUNGEN GEFOLGTER KÜNSTLER ─────────────────────────────
// POST /api/artist-releases { artists: [{id, name}], days }
// Die Deezer-Zuordnung passiert HIER, weil die Pins ohnehin serverseitig liegen — der Client
// schickt also nur seine Navidrome-Künstler und muss nichts über Deezer wissen.
app.post('/api/artist-releases', async (req, res) => {
const artists = Array.isArray(req.body?.artists) ? req.body.artists : [];
const days = Math.min(Math.max(parseInt(req.body?.days, 10) || 90, 1), 365);
if (!artists.length) return res.json({ releases: [], ohneZuordnung: [] });
// Obergrenze, damit ein kaputter Client nicht hunderte Deezer-Aufrufe auslöst.
if (artists.length > 300) return res.status(400).json({ error: 'Zu viele Künstler (max. 300)' });
const pins = _loadArtistOverrides();
const mitPin = artists
.filter(a => a && a.id && a.name)
.map(a => ({
id: String(a.id),
name: String(a.name),
deezerArtistId: pins[String(a.id)]?.deezerArtistId || null,
}));
try {
const { neueReleases } = require('./lib/releases');
const ergebnis = await neueReleases(mitPin, days);
res.json(ergebnis);
} catch (e) {
console.error('[releases]', e.message);
res.status(502).json({ error: e.message });
}
});
// DELETE /api/artist-overrides/:ndArtistId — remove a pin // DELETE /api/artist-overrides/:ndArtistId — remove a pin
app.delete('/api/artist-overrides/:ndArtistId', (req, res) => { app.delete('/api/artist-overrides/:ndArtistId', (req, res) => {
const id = decodeURIComponent(req.params.ndArtistId); const id = decodeURIComponent(req.params.ndArtistId);
+15
View File
@@ -76,6 +76,9 @@
<div class="nav-item" onclick="navigate('discover')" data-page="discover"> <div class="nav-item" onclick="navigate('discover')" data-page="discover">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>Global Suche <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polygon points="10 8 16 12 10 16 10 8"/></svg>Global Suche
</div> </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="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>Von deinen Künstlern
</div>
<div class="nav-item" onclick="navigate('new-releases')" data-page="new-releases"> <div class="nav-item" onclick="navigate('new-releases')" data-page="new-releases">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>Neue Releases <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>Neue Releases
</div> </div>
@@ -406,6 +409,18 @@
</div> </div>
<!-- NEW RELEASES --> <!-- NEW RELEASES -->
<div class="page" id="page-artist-news">
<div class="page-header">
<div class="page-title">Neu von <em>deinen Künstlern</em></div>
</div>
<div id="artist-news-sort" class="sort-chips">
<button class="sort-chip active" data-days="90" onclick="loadArtistNews(90)">90 Tage</button>
<button class="sort-chip" data-days="30" onclick="loadArtistNews(30)">30 Tage</button>
<button class="sort-chip" data-days="365" onclick="loadArtistNews(365)">1 Jahr</button>
</div>
<div id="artist-news-list"></div>
</div>
<div class="page" id="page-new-releases"> <div class="page" id="page-new-releases">
<div class="page-header"> <div class="page-header">
<div class="page-title">Neue <em>Releases</em></div> <div class="page-title">Neue <em>Releases</em></div>
+2
View File
@@ -350,6 +350,8 @@ Object.assign(window, {
navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum, navigate, onHeaderSearch, closeHeaderSearch, onGlobalSearch, s_goArtist, s_goAlbum,
playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview, playerBarGoArtist, playerBarGoAlbum, searchSimilarArtist, searchAndPreview,
loadDeezerAlbum, closeEventsModal, loadDeezerAlbum, closeEventsModal,
// Lazy: die Zeitleiste zieht nur beim Öffnen ihren Code nach.
loadArtistNews: (tage) => import('./views/artistNews.js').then(m => m.loadArtistNews(tage)),
// Player // Player
playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat, playQueue, togglePlay, prevSong, nextSong, toggleShuffle, toggleRepeat,
toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview, toggleQueue, setVolume, seekTo, starCurrent, downloadCurrent, playPreview,
+161
View File
@@ -0,0 +1,161 @@
import { S } from '../state.js';
import { esc } from '../utils.js';
import { api } from '../api.js';
import { openDlModal } from '../ui/ui.js';
// Zirkulär — sicher, weil nur in Ereignisbehandlern aufgerufen
import { navigate } from './navigation.js';
// ─── NEU VON DEINEN KÜNSTLERN ───────────────────────────────────────────────
// Zeitleiste der jüngsten Veröffentlichungen aller Künstler, denen man folgt. "Folgen" ist
// Navidromes Stern, die Liste kommt also aus getStarred2 — kein eigener Speicher.
// Die Deezer-Auflösung macht das Backend, weil dort auch die Künstler-Pins liegen.
let _tage = 90;
let _laeuft = false;
export async function loadArtistNews(tage = _tage) {
_tage = tage;
document.querySelectorAll('#artist-news-sort .sort-chip').forEach(c =>
c.classList.toggle('active', Number(c.dataset.days) === tage));
const el = document.getElementById('artist-news-list');
if (!el || _laeuft) return;
if (!S.cfg?.backendUrl) {
el.innerHTML = '<div class="empty-state"><div class="icon">🔌</div><p>Kein Backend konfiguriert</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Neue Veröffentlichungen kommen über das Backend von Deezer.</p></div>';
return;
}
_laeuft = true;
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getStarred2');
const kuenstler = (r.starred2?.artist || []).map(a => ({ id: a.id, name: a.name }));
if (!kuenstler.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">❤</div><p>Du folgst noch niemandem</p>'
+ '<p style="font-size:12px;color:var(--text3);margin-top:6px">Auf einer Künstlerseite oben rechts aufs Herz tippen.</p></div>';
return;
}
const res = await fetch(`${S.cfg.backendUrl}/api/artist-releases`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artists: kuenstler, days: tage }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { releases = [], ohneZuordnung = [] } = await res.json();
// Was hat man schon? Verglichen wird über Künstler + Albumname — Deezers Album-Id hat mit
// Navidromes nichts zu tun.
const vorhanden = new Set();
(r.starred2?.album || []).forEach(a => vorhanden.add(_schluessel(a.artist, a.name)));
await _ergaenzeVorhandene(kuenstler, vorhanden);
el.innerHTML = '';
if (ohneZuordnung.length) el.appendChild(_hinweisOhneZuordnung(ohneZuordnung));
if (!releases.length) {
const leer = document.createElement('div');
leer.className = 'empty-state';
leer.innerHTML = `<div class="icon">🌙</div><p>Nichts Neues in den letzten ${tage} Tagen</p>`;
el.appendChild(leer);
return;
}
_renderZeitleiste(el, releases, vorhanden);
} catch (e) {
el.innerHTML = `<p style="color:var(--danger)">${esc(e.message)}</p>`;
} finally {
_laeuft = false;
}
}
const _norm = s => (s || '').toLowerCase().normalize('NFD').replace(/\p{M}+/gu, '')
.replace(/[^a-z0-9]/g, '');
const _schluessel = (kuenstler, album) => `${_norm(kuenstler)}~~${_norm(album)}`;
/** Alben der gefolgten Künstler aus der eigenen Bibliothek nachtragen (parallel, begrenzt). */
async function _ergaenzeVorhandene(kuenstler, vorhanden) {
const haeppchen = 6;
for (let i = 0; i < kuenstler.length; i += haeppchen) {
await Promise.all(kuenstler.slice(i, i + haeppchen).map(async k => {
try {
const a = await api('getArtist', `&id=${encodeURIComponent(k.id)}`);
(a.artist?.album || []).forEach(al => vorhanden.add(_schluessel(k.name, al.name)));
} catch {}
}));
}
}
function _hinweisOhneZuordnung(namen) {
const d = document.createElement('div');
d.className = 'artist-news-hint';
d.innerHTML = `⚠ Für ${namen.length} Künstler wurde kein Deezer-Profil gefunden: `
+ `<strong>${namen.slice(0, 4).map(esc).join(', ')}</strong>${namen.length > 4 ? ' …' : ''}. `
+ `Auf der Künstlerseite über ⚙ das richtige Profil festpinnen.`;
return d;
}
function _renderZeitleiste(el, releases, vorhanden) {
let letzterMonat = '';
releases.forEach(rel => {
const monat = new Date(rel.releaseDate)
.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (monat !== letzterMonat) {
letzterMonat = monat;
const h = document.createElement('div');
h.className = 'search-section-title';
h.style.cssText = 'margin:18px 0 8px';
h.textContent = monat;
el.appendChild(h);
}
el.appendChild(_zeile(rel, vorhanden.has(_schluessel(rel.artistName, rel.title))));
});
}
const _TYP = { album: 'Album', single: 'Single', ep: 'EP', compile: 'Compilation' };
function _zeile(rel, schonDa) {
const row = document.createElement('div');
row.className = 'artist-news-row';
const datum = new Date(rel.releaseDate).toLocaleDateString('de-DE',
{ day: '2-digit', month: '2-digit', year: 'numeric' });
const typ = _TYP[rel.recordType] || rel.recordType;
row.innerHTML = `
<div class="artist-news-art">${rel.cover ? `<img src="${esc(rel.cover)}" loading="lazy" alt="">` : '<div class="no-art">💿</div>'}</div>
<div class="artist-news-info">
<div class="artist-news-title">${esc(rel.title)}</div>
<div class="artist-news-sub"><span class="artist-news-artist">${esc(rel.artistName)}</span> · ${typ}${rel.trackCount ? ` · ${rel.trackCount} Titel` : ''}</div>
<div class="artist-news-date">${datum}</div>
</div>`;
row.querySelector('.artist-news-artist').onclick = (e) => {
e.stopPropagation();
navigate('artist-detail', { id: rel.artistId, name: rel.artistName });
};
const aktion = document.createElement('div');
aktion.className = 'artist-news-action';
if (schonDa) {
aktion.innerHTML = '<span class="artist-news-have" title="In deiner Bibliothek">✓ Hast du</span>';
} else {
const btn = document.createElement('button');
btn.className = 'btn-outline blue';
btn.style.cssText = 'font-size:12px;padding:7px 12px';
btn.textContent = '⬇ Laden';
btn.onclick = () => openDlModal({
artistName: rel.artistName,
trackName: rel.title,
collectionName: rel.title,
artworkUrl100: rel.cover || '',
isAlbum: true,
collectionId: rel.albumId,
deezerData: {
collectionId: rel.albumId, collectionName: rel.title,
artistName: rel.artistName, artworkUrl100: rel.cover || '',
releaseDate: rel.releaseDate, trackCount: rel.trackCount,
},
});
aktion.appendChild(btn);
}
row.appendChild(aktion);
return row;
}
+3 -2
View File
@@ -92,7 +92,7 @@ export function navigate(page, data=null) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue')); 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'; const cls = ['discover','new-releases','recommendations','artist-news'].includes(page) ? 'active-blue' : 'active';
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls)); document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
const pageEl = document.getElementById(`page-${page}`); const pageEl = document.getElementById(`page-${page}`);
if(pageEl) { if(pageEl) {
@@ -109,7 +109,7 @@ export function navigate(page, data=null) {
} }
} }
closeSidebar(); closeSidebar();
if(['discover','new-releases','recommendations','random'].includes(page)) switchNavMode('discover'); if(['discover','new-releases','recommendations','random','artist-news'].includes(page)) switchNavMode('discover');
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library'); else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state if(page==='home') loadHomeData(!S._navJumping); // explicit Home click refreshes; back/forward keeps state
@@ -123,6 +123,7 @@ export function navigate(page, data=null) {
if(page==='genre-detail' && data) loadGenreDetail(data); if(page==='genre-detail' && data) loadGenreDetail(data);
if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data); if(page==='deezer-album-detail' && data) loadDeezerAlbumDetail(data);
if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data); if(page==='partial-album-detail' && data) loadPartialAlbumDetail(data);
if(page==='artist-news') import('./artistNews.js').then(m => m.loadArtistNews());
if(page==='new-releases') loadNewReleases(); if(page==='new-releases') loadNewReleases();
if(page==='playlists') loadPlaylists(); if(page==='playlists') loadPlaylists();
if(page==='playlist-detail' && data) loadPlaylistDetail(data); if(page==='playlist-detail' && data) loadPlaylistDetail(data);
+20
View File
@@ -1178,3 +1178,23 @@ button:focus{outline:none}
.cmp-bar{height:6px;border-radius:3px;background:var(--accent)} .cmp-bar{height:6px;border-radius:3px;background:var(--accent)}
.cmp-track{display:flex;align-items:center;gap:10px;padding:6px 0;min-width:0} .cmp-track{display:flex;align-items:center;gap:10px;padding:6px 0;min-width:0}
.cmp-track img{width:40px;height:40px;border-radius:6px;object-fit:cover;flex-shrink:0} .cmp-track img{width:40px;height:40px;border-radius:6px;object-fit:cover;flex-shrink:0}
/* ─── NEU VON DEINEN KÜNSTLERN ─────────────────────────────────────────── */
.artist-news-hint{background:rgba(232,160,32,.08);border:1px solid rgba(232,160,32,.28);border-radius:10px;padding:10px 14px;font-size:12px;color:var(--text2);line-height:1.5;margin-bottom:14px}
.artist-news-hint strong{color:var(--text)}
.artist-news-row{display:flex;align-items:center;gap:14px;padding:10px 12px;border-radius:12px;transition:background .15s}
.artist-news-row:hover{background:var(--surface)}
.artist-news-art{width:56px;height:56px;flex-shrink:0;border-radius:8px;overflow:hidden;background:var(--surface2);display:flex;align-items:center;justify-content:center}
.artist-news-art img{width:100%;height:100%;object-fit:cover}
.artist-news-info{flex:1;min-width:0}
.artist-news-title{font-size:14px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.artist-news-sub{font-size:12px;color:var(--text2);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.artist-news-artist{cursor:pointer;transition:color .15s}
.artist-news-artist:hover{color:var(--accent);text-decoration:underline}
.artist-news-date{font-size:11px;color:var(--text3);margin-top:3px}
.artist-news-action{flex-shrink:0}
.artist-news-have{font-size:12px;color:var(--text3);white-space:nowrap}
@media(max-width:600px){
.artist-news-art{width:46px;height:46px}
.artist-news-row{gap:10px;padding:8px 6px}
}