Compare commits

..
5 Commits
Author SHA1 Message Date
HoneyandClaude Sonnet 4.6 6391857fda feat: configurable scrobble timing (15s / 30s / 1/3 / 2/3 / end)
Settings dropdown: 15s (default), 30s, 1/3 of duration, 2/3 of duration, song end.
Duration-based modes use s.duration from Navidrome metadata (always available).
'end' mode skips the timer and fires the scrobble in onEnded (web) / onQueueEnded (native).
Skipping a song never scrobbles in any mode — _cancelScrobble() still runs on skip/pause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 17:53:02 +02:00
HoneyandClaude Sonnet 4.6 acb15d8c00 fix: hero card shows romaji for album name and artist
loadHeroCard() now pre-fetches the romaji index (same pattern as loadAlbumRow)
and uses dispAlbumName()/dispArtistById() instead of raw album.name/artist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:05:10 +02:00
HoneyandClaude Sonnet 4.6 0663b1c923 refactor: split ui/ui.js into 5 focused modules
settings.js  — settings modal, romaji overrides, sidebar, theme, nav mode
modals.js    — context menu, playlist-add modal, confirm dialog, edit-playlist modal
metadata.js  — song info/edit modal, MusicBrainz/iTunes autofill, saveMetadata, deleteSong
notify.js    — notification panel, bell badge, _addNotif, _notifNavigate
downloads.js — download modal, Spotify download, jobs panel, startArtistSortFix
ui.js        — barrel re-export; all external imports unchanged

player.js left unsplit: shared private state (_nativePlaying, _playGen, _queueGen, etc.)
spans every section — splitting would require exporting internal state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 14:00:38 +02:00
HoneyandClaude Sonnet 4.6 2a2e5ea495 refactor: split views/views.js into 6 focused modules
navigation.js  — navigate(), history, closeHeaderSearch, _navGoBack
home.js        — home page, genres, album rows, loadArtists/loadAlbums, getArtistImage
detail.js      — artist/album/iTunes detail pages, quickPlay helpers, expandArtistBio
playlists.js   — playlists, playlist detail, reorder, starred, random
search.js      — local/header/global search, iTunes helpers, s_goArtist, playerBarGoX
discover.js    — new releases, recommendations (Last.fm + iTunes fallback)
views.js       — barrel re-export; all external imports unchanged

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-07 13:50:36 +02:00
HoneyandClaude Sonnet 4.6 308a27d293 refactor: restructure src into player/views/ui subfolders; fix notification skip buttons
- Move player.js + nowplaying.js → src/player/
- Move views.js + spotify/soundcloud/youtube/abs → src/views/
- Move ui.js + components.js → src/ui/
- Update all import paths (static + lazy) across every file
- MusicService: wrap ExoPlayer in ForwardingPlayer that always advertises
  next/prev commands so the notification skip buttons stay visible in radio
  mode even when the JS queue has only one native track

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-06 13:12:27 +02:00
25 changed files with 2584 additions and 2526 deletions

No files matched your search

@@ -6,6 +6,7 @@ import android.os.Handler
import android.os.Looper
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
@@ -97,6 +98,28 @@ class MusicService : MediaSessionService() {
.setWakeMode(C.WAKE_MODE_NETWORK) // holds WakeLock + WifiLock through Doze
.build()
// ForwardingPlayer that always reports next/prev as available commands.
// ExoPlayer drops those commands when its queue has only one item (single-track model),
// which makes Media3 hide the skip buttons in the notification. JS still owns queue
// logic — the MediaSession callback intercepts the commands and delegates to JS.
val sessionPlayer = object : ForwardingPlayer(player) {
override fun getAvailableCommands(): Player.Commands =
super.getAvailableCommands().buildUpon()
.add(Player.COMMAND_SEEK_TO_NEXT)
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.add(Player.COMMAND_SEEK_TO_PREVIOUS)
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.build()
override fun isCommandAvailable(command: Int): Boolean = when (command) {
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM,
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> true
else -> super.isCommandAvailable(command)
}
}
// Tapping the notification opens the app directly to the Now Playing screen.
val notifTapIntent = Intent(this, MainActivity::class.java).apply {
putExtra("open_now_playing", true)
@@ -107,7 +130,7 @@ class MusicService : MediaSessionService() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
mediaSession = MediaSession.Builder(this, player)
mediaSession = MediaSession.Builder(this, sessionPlayer)
.setBitmapLoader(bitmapLoader)
.setSessionActivity(notifTapPending)
.setCallback(object : MediaSession.Callback {
@@ -148,6 +171,7 @@ class MusicService : MediaSessionService() {
// No play() — wait for JS to decide whether to auto-play
}
} catch (_: Exception) {}
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
+10
View File
@@ -661,6 +661,16 @@
<span>Romaji anzeigen</span>
<div id="s-show-romaji" class="toggle" onclick="this.classList.toggle('on')"></div>
</div>
<div class="edit-pl-toggle-row" style="margin-top:12px">
<span>Scrobble senden nach</span>
<select id="s-scrobble-mode" style="background:var(--surface2);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:6px 10px;font-family:var(--font);font-size:13px;cursor:pointer">
<option value="15s">15 Sekunden</option>
<option value="30s">30 Sekunden</option>
<option value="1/3">1/3 der Song-Dauer</option>
<option value="2/3">2/3 der Song-Dauer</option>
<option value="end">Song-Ende</option>
</select>
</div>
</div>
<div class="settings-section">
<div style="display:flex;align-items:center;justify-content:space-between">
+8 -8
View File
@@ -10,18 +10,18 @@ import {
toggleRadioMode, starCurrent, downloadCurrent, renderQueue, playPreview,
toggleStar, dlSongLocal, _applyRadioBtn, _applyShuffleRepeat, _restoreLastPlayed, setPlayIcon,
npToggleStar, updateMediaSession, updateNpColorMode, reconcileNativeState,
} from './player.js';
} from './player/player.js';
import {
navigate, _navGoBack, _navLoad, onHeaderSearch, closeHeaderSearch,
onGlobalSearch, s_goArtist, s_goAlbum, playerBarGoArtist, playerBarGoAlbum,
searchSimilarArtist, searchAndPreview, loadItunesAlbum, expandArtistBio,
loadAlbums, loadStarred, loadRandom, filterStarred, filterPlaylist,
plReorderEnter, plReorderSave, plReorderCancel, toggleAllGenres,
} from './views.js';
} from './views/views.js';
import { loadSpotifyImporter, resolveSpotifyLink, clearSpotifyPreview, spotifyDlAll,
onImporterInput, resolveImporterLink } from './spotify.js';
import { resolveYouTubeLink, executeYouTubeDownload } from './youtube.js';
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './soundcloud.js';
onImporterInput, resolveImporterLink } from './views/spotify.js';
import { resolveYouTubeLink, executeYouTubeDownload } from './views/youtube.js';
import { loadSoundCloudImporter, resolveSoundCloudLink, clearSoundCloudPreview, scDlAll, scDlSingle, scDlTrack } from './views/soundcloud.js';
import {
absInitFromStorage, absOpenSettings, absConnectSettings, absSetLibrary, absDisconnect, absVerifyApiKey, absToggleConnection,
absPlayItem, absTogglePlay, absSeek, absSkipSecs, absNextChapter, absPrevChapter,
@@ -29,11 +29,11 @@ import {
absToggleSpeedPicker, absToggleSleepPicker, absSeekToChapter, absProgDown, absProgMove,
absProgUp, loadAbsBooks, absOpenBook, loadAbsBookDetail, absPlayChapter,
absLoadSettingsLibraries, absToggleChapterTrack, absInterrupt,
} from './abs.js';
} from './views/abs.js';
import {
openNowPlaying, closeNowPlaying, switchNpTab, npGoArtist, npGoAlbum,
openNpMenu, closeNpMenu,
} from './nowplaying.js';
} from './player/nowplaying.js';
import {
openSettings, closeSettings, saveSettings, confirmDisconnect,
checkBackend, openSidebar, closeSidebar, switchNavMode, showCtxMenu,
@@ -48,7 +48,7 @@ import {
toggleInfoEdit, onInfoArtFile, autofillMusicBrainz, autofillItunes, saveMetadata, deleteSong,
addRomajiOverrideUI, removeRomajiOverrideUI,
openRomajiOverridesModal, closeRomajiOverridesModal,
} from './ui.js';
} from './ui/ui.js';
// ─── CONNECT ───
async function doConnect() {
@@ -1,10 +1,10 @@
import { S } from './state.js';
import { esc } from './utils.js';
import { coverUrl } from './api.js';
import { S } from '../state.js';
import { esc } from '../utils.js';
import { coverUrl } from '../api.js';
// Circular imports — safe (only used inside function bodies)
import { playCurrent, _syncNpDirect, dlSongLocal, npToggleStar, _syncOverlayState, isPlaying, seekToMs, updatePlayerUI } from './player.js';
import { navigate } from './views.js';
import { openPlModal, showSongInfo } from './ui.js';
import { navigate } from '../views/views.js';
import { openPlModal, showSongInfo } from '../ui/ui.js';
// ─── NOW PLAYING OVERLAY ───
export function openNowPlaying() {
@@ -148,7 +148,7 @@ async function _loadNpLyrics(songId) {
const token = ++_lyricsLoadToken;
const s = S.queue[S.qIdx];
const { getLyricsBySongId } = await import('./api.js');
const { getLyricsBySongId } = await import('../api.js');
if(token !== _lyricsLoadToken) return;
// 1. Navidrome (plugin handles fetching + caching server-side)
+25 -10
View File
@@ -1,8 +1,8 @@
import { S } from './state.js';
import { esc, formatDur, toast } from './utils.js';
import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from './api.js';
import { S } from '../state.js';
import { esc, formatDur, toast } from '../utils.js';
import { api, streamUrl, coverUrl, downloadUrl, dispTitle, dispArtist, dispArtistParts, dispAlbum } from '../api.js';
// views.js/ui.js circular imports — safe (only used inside function bodies)
import { loadRecommendations } from './views.js';
import { loadRecommendations } from '../views/views.js';
import { updateLyricsHighlight, onNpSongChange, syncNowPlaying } from './nowplaying.js';
// ─── NATIVE BRIDGE DETECTION ───────────────────────────────────────────────
@@ -20,20 +20,33 @@ let _bridgeReady = false; // true once onReady has fired; onQueueEnded is ign
// ─── SCROBBLE ─────────────────────────────────────────────────────────────
let _scrobbleTimer = null;
let _scrobbleId = null;
function _scheduleScrobble(id) {
if (_scrobbleId === String(id)) return; // already scheduled for this track
function _scheduleScrobble(id, durSec) {
if (_scrobbleId === String(id)) return;
_cancelScrobble();
_scrobbleId = String(id);
const mode = S.cfg.scrobbleMode || '15s';
if (mode === 'end') return; // fired explicitly in onEnded / onQueueEnded
let delay;
const dur = (durSec || 0) * 1000;
switch (mode) {
case '30s': delay = 30_000; break;
case '1/3': delay = dur > 0 ? Math.floor(dur / 3) : 30_000; break;
case '2/3': delay = dur > 0 ? Math.floor(dur * 2 / 3) : 60_000; break;
default: delay = 15_000; break;
}
_scrobbleTimer = setTimeout(() => {
if (_scrobbleId) api('scrobble', `&id=${_scrobbleId}&time=${Date.now()}`).catch(() => {});
_scrobbleId = null;
}, 15_000);
}, delay);
}
function _cancelScrobble() {
clearTimeout(_scrobbleTimer);
_scrobbleTimer = null;
_scrobbleId = null;
}
function _scrobbleNow(s) {
if (s) api('scrobble', `&id=${s.id}&time=${Date.now()}`).catch(() => {});
}
// Unified duration / position / seek helpers — use native state when available,
// fall back to HTMLAudioElement when not.
@@ -192,7 +205,7 @@ window.NSPlayer = {
try {
const s = S.queue[S.qIdx];
if (!s) return;
_scheduleScrobble(s.id);
_scheduleScrobble(s.id, s.duration);
if (S.radioMode && S.queue.length - S.qIdx < 5) fillRadioQueue();
} catch (e) {}
},
@@ -230,6 +243,7 @@ window.NSPlayer = {
if (!_bridgeReady || Date.now() - _readyTs < 1500) return;
_nativePlaying = false;
setPlayIcon(false);
if (S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]);
onEnded(); // handles repeat, radio top-up, and queue advance
},
@@ -376,7 +390,7 @@ export function playCurrent() {
updatePlayerUI(s, true);
onNpSongChange(s.id);
renderQueue();
_scheduleScrobble(s.id); // 12s timer — cancelled on pause/skip
_scheduleScrobble(s.id, s.duration);
const src = (_preId === s.id && _preUrl) ? _preUrl : streamUrl(s.id);
_clearPreload();
@@ -691,7 +705,8 @@ export function nextSong() {
export function onEnded() {
if(S.repeat === 'one') { S.audio.currentTime = 0; S.audio.play(); return; }
_cancelScrobble(); // timer irrelevant now — song already finished
if (!HAS_BRIDGE && S.cfg.scrobbleMode === 'end') _scrobbleNow(S.queue[S.qIdx]);
_cancelScrobble();
nextSong();
}
-1137
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,5 +1,5 @@
import { S } from './state.js';
import { esc, formatDur } from './utils.js';
import { S } from '../state.js';
import { esc, formatDur } from '../utils.js';
// Exposed on window so inline onerror= handlers can call it.
// Retries the same src up to 3 times with increasing delays, then shows fallback.
@@ -8,11 +8,11 @@ window._imgRetry = function(img, fallback) {
if (n > 3 || !img.src) { img.parentNode.innerHTML = `<div class="no-art">${fallback}</div>`; return; }
setTimeout(() => { const s = img.src; img.src = ''; img.src = s; }, 1500 * n);
};
import { coverUrl, dispTitle, dispArtistParts, dispAlbum, dispArtistById, dispAlbumName } from './api.js';
import { coverUrl, dispTitle, dispArtistParts, dispAlbum, dispArtistById, dispAlbumName } from '../api.js';
// Circular imports — safe (only used inside event handlers after module init)
import { playQueue, toggleStar, playPreview, parseArtists } from './player.js';
import { playQueue, toggleStar, playPreview, parseArtists } from '../player/player.js';
import { showCtxMenu, openDlModal } from './ui.js';
import { navigate, quickPlayAlbum, quickPlayArtist } from './views.js';
import { navigate, quickPlayAlbum, quickPlayArtist } from '../views/views.js';
// ─── GENERIC CARD ───
export function makeCard(title, sub, imgUrl, fallback='💿') {
+276
View File
@@ -0,0 +1,276 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, fetchRomajiIndex, romajiConvert, getRomajiOverrides, invalidateRomajiIndex } from '../api.js';
import { _addNotif } from './notify.js';
// ─── DOWNLOAD MODAL ───
export function openDlModal(track) {
if(!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert. Bitte in Einstellungen eintragen.'); return; }
S.downloadTarget = track; S.dlType = 'track';
document.querySelectorAll('.modal-opt').forEach(o => o.classList.toggle('selected', o.dataset.type==='track'));
const info = document.getElementById('dl-modal-info');
const art = track.artworkUrl100 || track.artworkUrl60 || '';
info.innerHTML = `${art ? `<div class="modal-art"><img src="${art}"></div>` : '<div class="modal-art" style="display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>'}
<div class="qi-info"><div class="mti-name">${esc(track.trackName||track.collectionName||'—')}</div><div class="mti-sub">${esc(track.artistName||'—')}${track.collectionName?' · '+esc(track.collectionName):''}</div></div>`;
document.getElementById('dl-album-sub').textContent = track.collectionName ? `"${track.collectionName}" herunterladen` : 'Album herunterladen';
document.getElementById('download-modal').classList.add('open');
}
export function closeDlModal() { document.getElementById('download-modal').classList.remove('open'); }
export function selectDlType(el, type) {
S.dlType = type;
document.querySelectorAll('.modal-opt').forEach(o => o.classList.remove('selected'));
el.classList.add('selected');
}
export async function executeDownload() {
if(!S.downloadTarget || !S.cfg.backendUrl) return;
closeDlModal();
const jobId = 'job_' + Date.now();
const ov = getRomajiOverrides();
const _artist = S.downloadTarget.artistName || 'Unknown Artist';
const _title = S.downloadTarget.trackName || S.downloadTarget.collectionName || 'Unknown';
const _album = S.downloadTarget.collectionName || '';
const payload = {
type: S.dlType,
artist: _artist,
title: _title,
album: _album,
artworkUrl: S.downloadTarget.artworkUrl100 || S.downloadTarget.artworkUrl60 || '',
itunesId: S.downloadTarget.trackId || S.downloadTarget.collectionId || null,
collectionId: S.downloadTarget.collectionId || null,
explicit: S.downloadTarget.trackExplicitness === 'explicit',
trackNum: S.downloadTarget.trackNumber || undefined,
totalTracks: S.downloadTarget.trackCount || undefined,
discNum: S.downloadTarget.discNumber || undefined,
discTotal: S.downloadTarget.discCount || undefined,
durationMs: S.downloadTarget.trackTimeMillis || undefined,
...(ov[_artist] && { artistSort: ov[_artist], albumArtistSort: ov[_artist] }),
...(ov[_title] && { titleSort: ov[_title] }),
...(ov[_album] && { albumSort: ov[_album] }),
};
addJob(jobId, payload.title, payload.artist, payload.type, payload.album);
try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({...payload, jobId}),
});
if(!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
} catch(e) {
updateJob(jobId, 'error', 'Verbindung zum Backend fehlgeschlagen');
toast('❌ Backend nicht erreichbar: '+e.message);
}
}
// ─── SPOTIFY DOWNLOAD ───
export async function executeSpotifyDownload({ type, title, artist, artworkUrl, tracks, createPlaylist, playlistName }) {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const jobId = 'job_' + Date.now();
addJob(jobId, title, artist, type);
const ov = getRomajiOverrides();
const enrichedTracks = (tracks || []).map(t => {
const a = t.artistName || '';
const n = t.trackName || '';
const al = t.collectionName || '';
return {
...t,
...(ov[a] && { artistSort: ov[a], albumArtistSort: ov[a] }),
...(ov[n] && { titleSort: ov[n] }),
...(ov[al] && { albumSort: ov[al] }),
};
});
try {
const res = await fetch(`${S.cfg.backendUrl}/api/download`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ jobId, type, title, artist, artworkUrl, tracks: enrichedTracks }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
if (createPlaylist && playlistName && tracks?.length) {
_pollAndCreatePlaylist(jobId, tracks, playlistName);
}
} catch(e) {
updateJob(jobId, 'error', 'Backend-Verbindung fehlgeschlagen: ' + e.message);
toast('❌ Backend nicht erreichbar: ' + e.message);
}
}
// Used by settings.js for romaji fix-sort job
export async function startArtistSortFix(kanji, romaji) {
if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return;
const jobId = 'job_' + Date.now();
addJob(jobId, `Romaji-Fix: ${romaji}`, kanji, 'fix-sort');
const res = await fetch(`${S.cfg.backendUrl}/api/fix-artist-sort`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, artistKanji: kanji, artistSort: romaji, jobId }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
pollJob(jobId);
}
async function _pollAndCreatePlaylist(jobId, tracks, playlistName) {
let lastStatus = 'pending';
while (true) {
await new Promise(r => setTimeout(r, 2500));
const job = S.jobs[jobId];
if (job) lastStatus = job.status;
if (!job || job.status === 'done' || job.status === 'error') break;
}
if (lastStatus !== 'done') return;
try {
await api('startScan', '');
toast('🔍 Navidrome scannt Bibliothek…');
await new Promise(r => setTimeout(r, 3000));
for (let i = 0; i < 30; i++) {
await new Promise(r => setTimeout(r, 2000));
const st = await api('getScanStatus', '');
if (!st?.scanStatus?.scanning) break;
}
await new Promise(r => setTimeout(r, 3000));
} catch {
await new Promise(r => setTimeout(r, 20000));
}
await _createNdPlaylist(tracks, playlistName);
}
async function _createNdPlaylist(tracks, name) {
try {
toast('🎵 Erstelle Navidrome-Playlist…');
const songIds = [];
const missing = [];
const norm = s => s?.toLowerCase().trim() || '';
const normA = s => norm(s).replace(/[^\w\s]/g, '');
const JP_RE = /[぀-ヿ一-鿿]/;
const hasJp = tracks.some(t => JP_RE.test(t.trackName) || JP_RE.test(t.artistName));
const romajiIdx = hasJp ? await fetchRomajiIndex().catch(() => null) : null;
const jpTitles = tracks.map(t => (hasJp && JP_RE.test(t.trackName)) ? t.trackName : '');
const romajiTitles = hasJp ? await romajiConvert(jpTitles).catch(() => jpTitles) : jpTitles;
const normR = s => (s || '').toLowerCase().replace(/[^\w\s]/g, ' ').replace(/\s+/g, ' ').trim();
for (const [idx, t] of tracks.entries()) {
if (!t.trackName) continue;
const missingLabel = t.artistName ? t.artistName + ' – ' + t.trackName : t.trackName;
try {
const firstArtist = t.artistName ? t.artistName.split(/[,/•]/)[0].trim() : '';
const artistQuery = firstArtist ? firstArtist + ' ' + t.trackName : t.trackName;
const r = await api('search3', '&query=' + encodeURIComponent(artistQuery) + '&songCount=20&albumCount=0&artistCount=0');
let songs = r.searchResult3?.song || [];
if (!songs.some(s => norm(s.title) === norm(t.trackName))) {
try {
const r2 = await api('search3', '&query=' + encodeURIComponent(t.trackName) + '&songCount=20&albumCount=0&artistCount=0');
songs = r2.searchResult3?.song || [];
} catch { /* keep original songs */ }
}
const firstWord = s => normA(s).split(/\s+/)[0];
const titleMatch = s => norm(s.title) === norm(t.trackName);
const artistMatch = s => firstWord(s.artist) === firstWord(firstArtist || t.artistName);
let match = songs.find(s => titleMatch(s) && artistMatch(s))
|| songs.find(s => titleMatch(s));
if (!match && romajiIdx && JP_RE.test(t.trackName)) {
const jpEntry = romajiIdx.find(e => norm(e.title) === norm(t.trackName));
if (jpEntry) match = { id: jpEntry.id };
}
if (!match && JP_RE.test(t.trackName) && romajiTitles[idx]) {
const rTitle = romajiTitles[idx];
try {
const r3 = await api('search3', '&query=' + encodeURIComponent(rTitle) + '&songCount=20&albumCount=0&artistCount=0');
const s3 = r3.searchResult3?.song || [];
match = s3.find(s => normR(s.title) === normR(rTitle)) || null;
} catch { /* ignore */ }
}
if (match) songIds.push(match.id);
else missing.push(missingLabel);
} catch { missing.push(missingLabel); }
}
if (!songIds.length) {
toast('⚠ Keine Tracks in Navidrome gefunden — zuerst Library scannen, dann Playlist manuell anlegen');
_addNotif({ id: 'pl_' + Date.now(), title: 'Playlist: ' + name, artist: '', status: 'error', message: 'Keine Tracks in Navidrome gefunden — Library scan nötig', missing, timestamp: Date.now(), read: false });
return;
}
const songParams = songIds.map(id => '&songId=' + encodeURIComponent(id)).join('');
const plRes = await api('createPlaylist', '&name=' + encodeURIComponent(name) + songParams);
const plId = plRes.playlist?.id || null;
const msg = songIds.length + '/' + tracks.length + ' Tracks hinzugefügt' + (missing.length ? ', ' + missing.length + ' nicht gefunden' : '');
toast('✅ Playlist "' + name + '" mit ' + songIds.length + '/' + tracks.length + ' Tracks erstellt');
_addNotif({ id: 'pl_' + Date.now(), title: 'Playlist: ' + name, artist: '', status: 'done', message: msg, missing, timestamp: Date.now(), read: false, navType: 'playlist', navId: plId, navName: name });
} catch(e) {
toast('Playlist-Erstellung fehlgeschlagen: ' + e.message);
}
}
// ─── JOBS ───
function addJob(id, title, artist, type, album) {
S.jobs[id] = {title, artist, type: type || '', album: album || '', progress:0, status:'pending'};
renderJobs();
}
function updateJob(id, status, msg, progress) {
if(!S.jobs[id]) return;
S.jobs[id] = {...S.jobs[id], status, msg:msg||'', progress: progress ?? S.jobs[id].progress};
renderJobs();
if(status==='done') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 5000);
if(status==='error') setTimeout(() => { delete S.jobs[id]; renderJobs(); }, 8000);
}
function renderJobs() {
const panel = document.getElementById('jobs-panel');
const activeIds = new Set(Object.keys(S.jobs));
panel.querySelectorAll('[data-job-id]').forEach(el => {
if (!activeIds.has(el.dataset.jobId)) el.remove();
});
Object.entries(S.jobs).forEach(([id, j]) => {
let c = panel.querySelector(`[data-job-id="${id}"]`);
if (!c) {
c = document.createElement('div');
c.className = 'job-card';
c.dataset.jobId = id;
panel.appendChild(c);
}
const isDone = j.status === 'done', isErr = j.status === 'error';
const statusText = isErr ? 'Fehler' : isDone ? '✓ Fertig! Bibliothek wird gescannt…' : j.msg || 'Wird heruntergeladen…';
c.innerHTML = `<div class="job-header">
<div class="job-title">${esc(j.title)}</div>
<div class="job-status">${esc(j.artist)}</div>
</div>
<div class="job-bar"><div class="job-fill ${isDone?'done':isErr?'error':''}" style="width:${isDone?100:isErr?100:j.progress}%"></div></div>
<div class="job-sub">${statusText}</div>`;
});
}
async function pollJob(jobId) {
if(!S.jobs[jobId]) return;
try {
const res = await fetch(`${S.cfg.backendUrl}/api/status/${jobId}`);
const d = await res.json();
updateJob(jobId, d.status, d.message, d.progress);
if(d.status !== 'done' && d.status !== 'error') {
setTimeout(() => pollJob(jobId), 1500);
} else {
const job = S.jobs[jobId];
const title = job?.title || 'Download';
_addNotif({
id: jobId,
title,
artist: job?.artist || '',
status: d.status,
message: d.message || (d.status === 'done' ? 'Fertig!' : 'Fehler'),
failed: _parseFailedTracks(d.log),
timestamp: Date.now(),
read: false,
navType: job?.type || '',
navArtist: job?.artist || '',
navAlbum: job?.album || '',
navTitle: title,
});
if (d.status === 'done') {
toast(d.skipped ? `⏭ "${title}" bereits auf dem Server` : `✅ ${title} gespeichert!`);
if (!d.skipped) invalidateRomajiIndex();
}
}
} catch { updateJob(jobId, 'error', 'Status-Abfrage fehlgeschlagen'); }
}
function _parseFailedTracks(log) {
const re = /Fehler bei "([^"]+)"/;
return (log || []).map(line => re.exec(line)?.[1]).filter(Boolean);
}
+308
View File
@@ -0,0 +1,308 @@
import { S } from '../state.js';
import { esc, formatDur, toast } from '../utils.js';
import { api, coverUrl, fetchRomajiIndex, romajiLookup, getRomajiOverrides } from '../api.js';
// Circular — safe (only used inside function bodies)
import { loadPlaylists, loadPlaylistDetail } from '../views/views.js';
import { showConfirm } from './modals.js';
// ─── SONG INFO MODAL ───
function _fmtArtists(str, sep) {
if (!str) return '';
return str.split(/\s*[•·;,]\s*/).map(s => s.trim()).filter(Boolean).join(sep);
}
function _renderInfoModal(s) {
const cv = coverUrl(s.coverArt, 200);
document.getElementById('info-cover').innerHTML = cv
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:32px">🎵</div>';
document.getElementById('info-top-title').textContent = s.title || '—';
document.getElementById('info-top-sub').textContent = (s.artist||'—') + (s.album ? ' · '+s.album : '');
const fields = [
['Titel', s.title],
['Künstler', s.artist],
['Album-Künstler', s.albumArtist],
['Album', s.album],
['Jahr', s.year],
['Track #', s.track],
['Disc #', s.discNumber],
['Genre', s.genre],
['Dauer', formatDur(s.duration)],
['Bitrate', s.bitRate ? s.bitRate+' kbps' : null],
['Format', s.suffix?.toUpperCase()],
['Dateigröße', s.size ? Math.round(s.size/1024/1024*10)/10+' MB' : null],
['Pfad', s.path],
['Plays', s.playCount],
].filter(([,v]) => v!=null && v!=='' && v!==undefined);
document.getElementById('info-rows').innerHTML = fields.map(([l,v]) =>
`<div class="info-row"><span class="info-label">${l}</span><span class="info-val">${esc(String(v))}</span></div>`
).join('');
}
export async function showSongInfo(s) {
S.infoSong = s;
S._infoArtData = null;
document.getElementById('info-modal').classList.add('open');
_leaveEditMode();
_renderInfoModal(s);
try {
const r = await api('getSong', `&id=${s.id}`);
const full = r.song;
if (full) {
if (!full.albumArtist && full.albumId) {
try {
const ar = await api('getAlbum', `&id=${full.albumId}`);
if (ar.album?.artist) full.albumArtist = ar.album.artist;
} catch {}
}
S.infoSong = full;
_renderInfoModal(full);
if (document.getElementById('info-edit-mode').style.display !== 'none') {
document.getElementById('ie-title').value = full.title || '';
document.getElementById('ie-artist').value = _fmtArtists(full.artist, '; ') || '';
document.getElementById('ie-albumartist').value = _fmtArtists(full.albumArtist, ', ') || '';
document.getElementById('ie-album').value = full.album || '';
document.getElementById('ie-genre').value = full.genre || '';
document.getElementById('ie-year').value = full.year || '';
document.getElementById('ie-track').value = full.track || '';
document.getElementById('ie-disc').value = full.discNumber || '';
document.getElementById('ie-totaltracks').value = full.albumSongCount || '';
}
}
} catch {}
}
export function closeInfoModal() {
document.getElementById('info-modal').classList.remove('open');
_leaveEditMode();
S.infoSong = null;
S._infoArtData = null;
}
function _leaveEditMode() {
document.getElementById('info-view-mode').style.display = '';
document.getElementById('info-edit-mode').style.display = 'none';
document.getElementById('info-edit-toggle').classList.remove('active');
document.getElementById('info-edit-status').textContent = '';
}
export function toggleInfoEdit() {
const editEl = document.getElementById('info-edit-mode');
const viewEl = document.getElementById('info-view-mode');
const btn = document.getElementById('info-edit-toggle');
const isEdit = editEl.style.display !== 'none';
if (isEdit) { _leaveEditMode(); return; }
const s = S.infoSong;
if (!s) return;
editEl.style.display = '';
viewEl.style.display = 'none';
btn.classList.add('active');
document.getElementById('ie-title').value = s.title || '';
document.getElementById('ie-artist').value = _fmtArtists(s.artist, '; ') || '';
document.getElementById('ie-albumartist').value = _fmtArtists(s.albumArtist, ', ') || '';
document.getElementById('ie-album').value = s.album || '';
document.getElementById('ie-genre').value = s.genre || '';
document.getElementById('ie-year').value = s.year || '';
document.getElementById('ie-track').value = s.track || '';
document.getElementById('ie-disc').value = s.discNumber || '';
document.getElementById('ie-totaltracks').value = s.albumSongCount || '';
document.getElementById('ie-titlesort').value = '';
document.getElementById('ie-artistsort').value = '';
document.getElementById('ie-albumartistsort').value = '';
document.getElementById('ie-albumsort').value = '';
const _sId = s.id;
fetchRomajiIndex().catch(() => null).then(() => {
const rm = romajiLookup(_sId);
if (rm) {
const _jp = /[ぁ-んァ-ン一-龯]/;
if (rm.titleRomaji && !_jp.test(rm.titleRomaji)) document.getElementById('ie-titlesort').value = rm.titleRomaji;
if (rm.artistRomaji && !_jp.test(rm.artistRomaji)) {
document.getElementById('ie-artistsort').value = rm.artistRomaji;
document.getElementById('ie-albumartistsort').value = rm.artistRomaji;
}
if (rm.albumRomaji && !_jp.test(rm.albumRomaji)) document.getElementById('ie-albumsort').value = rm.albumRomaji;
}
if (!S.cfg.backendUrl || !_sId) return null;
const p = new URLSearchParams({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: _sId });
return fetch(`${S.cfg.backendUrl}/api/song-sorttags?${p}`).then(r => r.ok ? r.json() : null).catch(() => null);
}).then(t => {
if (t) {
const _jp = /[ぁ-んァ-ン一-龯]/;
if (t.titleSort && !_jp.test(t.titleSort)) document.getElementById('ie-titlesort').value = t.titleSort;
if (t.artistSort && !_jp.test(t.artistSort)) document.getElementById('ie-artistsort').value = t.artistSort;
if (t.albumArtistSort && !_jp.test(t.albumArtistSort)) document.getElementById('ie-albumartistsort').value = t.albumArtistSort;
if (t.albumSort && !_jp.test(t.albumSort)) document.getElementById('ie-albumsort').value = t.albumSort;
}
const ov = getRomajiOverrides();
if (s.artist && ov[s.artist]) { document.getElementById('ie-artistsort').value = ov[s.artist]; document.getElementById('ie-albumartistsort').value = ov[s.artist]; }
if (s.albumArtist && ov[s.albumArtist]) document.getElementById('ie-albumartistsort').value = ov[s.albumArtist];
if (s.title && ov[s.title]) document.getElementById('ie-titlesort').value = ov[s.title];
if (s.album && ov[s.album]) document.getElementById('ie-albumsort').value = ov[s.album];
}).catch(() => {});
const cv = coverUrl(s.coverArt, 200);
const prev = document.getElementById('info-edit-art-preview');
prev.innerHTML = cv
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px">🎵</div>';
S._infoArtData = null;
}
export function onInfoArtFile(input) {
const file = input.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = e => {
const dataUrl = e.target.result;
const base64 = dataUrl.split(',')[1];
S._infoArtData = { base64, mime: file.type };
document.getElementById('info-edit-art-preview').innerHTML =
`<img src="${dataUrl}" style="width:100%;height:100%;object-fit:cover">`;
};
reader.readAsDataURL(file);
}
export async function autofillMusicBrainz() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong;
if (!s) return;
const artist = document.getElementById('ie-artist').value || s.artist || '';
const title = document.getElementById('ie-title').value || s.title || '';
const status = document.getElementById('info-edit-status');
status.textContent = '🔍 MusicBrainz…';
try {
const r = await fetch(`${S.cfg.backendUrl}/api/musicbrainz/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
const d = await r.json();
if (!d.found) { status.textContent = '❌ Kein Treffer auf MusicBrainz'; return; }
if (d.title) document.getElementById('ie-title').value = d.title;
if (d.artist) document.getElementById('ie-artist').value = d.artist;
if (d.album) document.getElementById('ie-album').value = d.album;
if (d.genre) document.getElementById('ie-genre').value = d.genre;
if (d.year) document.getElementById('ie-year').value = d.year;
if (d.trackNum) document.getElementById('ie-track').value = d.trackNum;
if (d.totalTracks) document.getElementById('ie-totaltracks').value = d.totalTracks;
if (d.coverUrl) {
status.textContent = '✅ Metadaten gefunden — Cover wird geladen…';
S._infoArtData = { url: d.coverUrl };
document.getElementById('info-edit-art-preview').innerHTML =
`<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`;
status.textContent = '✅ Metadaten + Cover von MusicBrainz';
} else {
status.textContent = '✅ Metadaten von MusicBrainz (kein Cover)';
}
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;
}
}
export async function autofillItunes() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong;
if (!s) return;
const artist = document.getElementById('ie-artist').value || s.artist || '';
const title = document.getElementById('ie-title').value || s.title || '';
const status = document.getElementById('info-edit-status');
status.textContent = '🔍 Apple Music…';
try {
const r = await fetch(`${S.cfg.backendUrl}/api/itunes/lookup?artist=${encodeURIComponent(artist)}&title=${encodeURIComponent(title)}`);
const d = await r.json();
if (!d.found) { status.textContent = '❌ Kein Treffer auf iTunes'; return; }
if (d.album) document.getElementById('ie-album').value = d.album;
if (d.genre) document.getElementById('ie-genre').value = d.genre;
if (d.year) document.getElementById('ie-year').value = d.year;
if (d.trackNum) document.getElementById('ie-track').value = d.trackNum;
if (d.totalTracks) document.getElementById('ie-totaltracks').value = d.totalTracks;
if (d.coverUrl) {
S._infoArtData = { url: d.coverUrl };
document.getElementById('info-edit-art-preview').innerHTML =
`<img src="${d.coverUrl}" style="width:100%;height:100%;object-fit:cover" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\' style=\\'width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:28px\\'>🎵</div>'">`;
status.textContent = '✅ Metadaten + Cover von Apple Music';
} else {
status.textContent = '✅ Metadaten von Apple Music (kein Cover)';
}
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;
}
}
export async function saveMetadata() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong;
if (!s?.id) return;
const status = document.getElementById('info-edit-status');
status.textContent = '💾 Speichern…';
const body = {
ndUrl: S.cfg.url,
user: S.cfg.user,
pass: S.cfg.pass,
songId: s.id,
title: document.getElementById('ie-title').value || undefined,
artist: document.getElementById('ie-artist').value || undefined,
albumArtist: document.getElementById('ie-albumartist').value || undefined,
album: document.getElementById('ie-album').value || undefined,
genre: document.getElementById('ie-genre').value || undefined,
year: parseInt(document.getElementById('ie-year').value) || undefined,
trackNum: parseInt(document.getElementById('ie-track').value) || undefined,
totalTracks: parseInt(document.getElementById('ie-totaltracks').value) || undefined,
discNum: parseInt(document.getElementById('ie-disc').value) || undefined,
titleSort: document.getElementById('ie-titlesort').value || undefined,
artistSort: document.getElementById('ie-artistsort').value || undefined,
albumArtistSort:document.getElementById('ie-albumartistsort').value || undefined,
albumSort: document.getElementById('ie-albumsort').value || undefined,
};
if (S._infoArtData?.base64) {
body.artworkBase64 = S._infoArtData.base64;
body.artworkMime = S._infoArtData.mime || 'image/jpeg';
} else if (S._infoArtData?.url) {
body.artworkUrl = S._infoArtData.url;
}
try {
const r = await fetch(`${S.cfg.backendUrl}/api/edit-metadata`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
status.textContent = '✅ Gespeichert! Navidrome scannt…';
toast('✅ Metadaten gespeichert');
setTimeout(() => { _leaveEditMode(); closeInfoModal(); }, 1500);
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;
}
}
export async function deleteSong() {
if (!S.cfg.backendUrl) { toast('⚠ Kein Backend konfiguriert'); return; }
const s = S.infoSong;
if (!s?.id) return;
const ok = await showConfirm(
'Song löschen',
`"${s.title}" wird unwiderruflich von der Festplatte gelöscht. Leere Album- und Künstlerordner werden ebenfalls entfernt.`,
'Löschen'
);
if (!ok) return;
const status = document.getElementById('info-edit-status');
status.textContent = '🗑 Löschen…';
try {
const r = await fetch(`${S.cfg.backendUrl}/api/delete-song`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ndUrl: S.cfg.url, user: S.cfg.user, pass: S.cfg.pass, songId: s.id }),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || `HTTP ${r.status}`);
toast(`🗑 "${s.title}" gelöscht`);
closeInfoModal();
setTimeout(() => {
loadPlaylists();
if (document.getElementById('page-playlist-detail')?.classList.contains('active') && S._currentPlaylist)
loadPlaylistDetail(S._currentPlaylist);
}, 3000);
} catch (e) {
status.textContent = '❌ Fehler: ' + e.message;
}
}
+236
View File
@@ -0,0 +1,236 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, coverUrl, ndAuth } 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';
// ─── CONTEXT MENU ───
export function showCtxMenu(e, song, list, idx) {
const menu = document.getElementById('ctx-menu');
menu.innerHTML = '';
const items = [
{ icon: '▶', label: 'Abspielen', fn: () => playQueue(list, idx) },
{ icon: '+', label: 'Als Nächstes einfügen', fn: () => {
const insertIdx = S.qIdx + 1;
S.queue.splice(insertIdx, 0, {...song});
nativeInsertAt(song, insertIdx);
renderQueue(); toast('In Warteschlange eingefügt');
}},
{ icon: '⬇', label: 'Am Ende der Queue', fn: () => {
S.queue.push({...song});
nativeInsertAt(song, -1);
renderQueue(); toast('Zur Queue hinzugefügt');
}},
{ sep: true },
{ icon: '🎶', label: 'Zu Playlist hinzufügen →', submenu: true, fn: () => openPlModal([song]) },
{ icon: '⭐', label: song._starred ? 'Aus Favoriten entfernen' : 'Zu Favoriten', fn: () => {
api(song._starred ? 'unstar' : 'star', `&id=${song.id}`)
.then(() => { song._starred = !song._starred; toast(song._starred ? '⭐ Favorit hinzugefügt' : 'Favorit entfernt'); })
.catch(err => toast('Fehler: ' + err.message));
}},
{ sep: true },
{ icon: '💿', label: 'Zum Album', fn: () => song.albumId && navigate('album-detail', { id: song.albumId, name: song.album||'', artist: song.artist||'', artistId: song.artistId||'', coverArt: song.coverArt||'' }) },
{ icon: 'ℹ', label: 'Song-Info', fn: () => import('./metadata.js').then(m => m.showSongInfo(song)) },
{ icon: '⬇', label: 'Herunterladen', fn: () => dlSongLocal(song) },
];
items.forEach(item => {
if(item.sep) { const d=document.createElement('div'); d.className='ctx-separator'; menu.appendChild(d); return; }
const el = document.createElement('div'); el.className = 'ctx-item';
el.innerHTML = `<span style="font-size:14px;width:18px;text-align:center">${item.icon}</span><span>${item.label}</span>`;
el.onclick = () => { hideCtxMenu(); item.fn(); };
menu.appendChild(el);
});
const vw=window.innerWidth, vh=window.innerHeight;
let x=e.clientX, y=e.clientY;
menu.style.left='-9999px'; menu.style.top='-9999px'; menu.style.display='block';
const w=menu.offsetWidth, h=menu.offsetHeight;
if(x+w>vw-8) x=vw-w-8;
if(y+h>vh-8) y=vh-h-8;
menu.style.left=x+'px'; menu.style.top=y+'px';
}
export function hideCtxMenu() {
const m = document.getElementById('ctx-menu');
if (!m) return;
m.style.display='none'; m.innerHTML='';
}
document.addEventListener('click', e => {
const m = document.getElementById('ctx-menu');
if (m && !m.contains(e.target)) hideCtxMenu();
});
document.addEventListener('scroll', () => {
const m = document.getElementById('ctx-menu');
if (m && m.style.display !== 'none') hideCtxMenu();
}, { passive: true, capture: true });
document.addEventListener('touchmove', () => {
const m = document.getElementById('ctx-menu');
if (m && m.style.display !== 'none') hideCtxMenu();
}, { passive: true, capture: true });
document.addEventListener('keydown', e => { if(e.key==='Escape') hideCtxMenu(); });
// ─── PLAYLIST MODAL (Add to / Create) ───
export async function openPlModal(songs) {
S.plModalSongs = songs;
document.getElementById('pl-modal-title').textContent =
songs.length===1 ? `"${songs[0].title}" zu Playlist` : `${songs.length} Songs zu Playlist`;
document.getElementById('pl-new-name').value = '';
const list = document.getElementById('pl-list');
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Lädt…</div>';
document.getElementById('pl-modal').classList.add('open');
try {
const r = await api('getPlaylists');
S.cachedPlaylists = r.playlists?.playlist || [];
list.innerHTML = '';
if(!S.cachedPlaylists.length) {
list.innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px">Noch keine Playlists vorhanden</div>';
return;
}
S.cachedPlaylists.forEach(pl => {
const row = document.createElement('div'); row.className = 'pl-row';
row.innerHTML = `<svg width="14" height="14" 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"/></svg>
<span>${esc(pl.name)}</span><span style="margin-left:auto;font-size:11px;color:var(--text3)">${pl.songCount} Titel</span>`;
row.onclick = () => addSongsToPlaylist(pl.id, pl.name);
list.appendChild(row);
});
} catch(e) { list.innerHTML = `<div style="color:var(--danger);font-size:12px;padding:8px">${e.message}</div>`; }
}
export function closePlModal() {
document.getElementById('pl-modal').classList.remove('open');
S.plModalSongs = [];
}
export async function addSongsToPlaylist(plId, plName) {
const songs = [...S.plModalSongs];
closePlModal();
try {
const ids = songs.map(s => `&songIdToAdd=${s.id}`).join('');
await api('updatePlaylist', `&playlistId=${plId}${ids}`);
toast(`✅ ${songs.length===1 ? songs[0].title : songs.length+' Songs'} → ${plName}`);
} catch(e) { toast('Fehler: ' + e.message); }
}
export async function createAndAddPlaylist() {
const name = document.getElementById('pl-new-name').value.trim();
if(!name) { document.getElementById('pl-new-name').focus(); return; }
closePlModal();
try {
const ids = S.plModalSongs.map(s => `&songId=${s.id}`).join('');
await api('createPlaylist', `&name=${encodeURIComponent(name)}${ids}`);
toast(`✅ Playlist "${name}" erstellt`);
if(document.getElementById('page-playlists').classList.contains('active')) loadPlaylists();
} catch(e) { toast('Fehler: ' + e.message); }
}
export function createPlaylistPrompt() {
document.getElementById('pl-new-name').value = '';
S.plModalSongs = [];
document.getElementById('pl-modal-title').textContent = 'Neue Playlist erstellen';
document.getElementById('pl-list').innerHTML = '<div style="font-size:12px;color:var(--text3);padding:8px 0">Füge Songs später per Rechtsklick hinzu.</div>';
document.getElementById('pl-modal').classList.add('open');
}
document.addEventListener('click', e => {
const m = document.getElementById('pl-modal');
if(m.classList.contains('open') && e.target===m) closePlModal();
});
// ─── CONFIRM DIALOG ───
let _confirmResolve = null;
export function _confirmOk() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(true); _confirmResolve = null; }
export function _confirmCancel() { document.getElementById('confirm-overlay').classList.remove('open'); _confirmResolve?.(false); _confirmResolve = null; }
export function showConfirm(title, msg, okLabel = 'Bestätigen') {
document.getElementById('confirm-title').textContent = title;
document.getElementById('confirm-msg').textContent = msg;
document.getElementById('confirm-ok-btn').textContent = okLabel;
document.getElementById('confirm-overlay').classList.add('open');
return new Promise(resolve => { _confirmResolve = resolve; });
}
// ─── EDIT PLAYLIST MODAL ───
export function openEditPlModal(pl) {
const overlay = document.getElementById('edit-pl-overlay');
document.getElementById('edit-pl-name').value = pl.name || '';
document.getElementById('edit-pl-desc').value = pl.comment || '';
const tog = document.getElementById('edit-pl-public-toggle');
tog.classList.toggle('on', !!pl.public);
const cv = coverUrl(pl.coverArt, 300);
document.getElementById('edit-pl-cover').innerHTML = cv
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
overlay.classList.add('open');
}
export function closeEditPlModal() { document.getElementById('edit-pl-overlay').classList.remove('open'); }
export async function saveEditPlaylist() {
const pl = S._currentPlaylist; if(!pl) return;
const name = document.getElementById('edit-pl-name').value.trim();
if(!name) { toast('Name darf nicht leer sein'); return; }
const comment = document.getElementById('edit-pl-desc').value.trim();
const pub = document.getElementById('edit-pl-public-toggle').classList.contains('on');
const btn = document.querySelector('.edit-pl-btn-save');
btn.disabled=true; btn.textContent='Speichern…';
try {
await api('updatePlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(name)}&comment=${encodeURIComponent(comment)}&public=${pub}`);
S._currentPlaylist = {...S._currentPlaylist, name, comment, public:pub};
document.getElementById('playlist-detail-name').textContent = name;
const descEl = document.getElementById('playlist-detail-desc');
if(descEl) { descEl.textContent=comment; descEl.style.display=comment?'':'none'; }
closeEditPlModal();
toast('Playlist aktualisiert');
} catch(e) { toast('Fehler: '+e.message); }
finally { btn.disabled=false; btn.textContent='Speichern'; }
}
export function editPlUploadCover() { document.getElementById('edit-pl-file').click(); }
export async function editPlCoverSelected(input) {
const file = input.files?.[0]; if(!file || !S._currentPlaylist) return;
input.value = '';
const preview = URL.createObjectURL(file);
document.getElementById('edit-pl-cover').innerHTML = `<img src="${preview}" style="width:100%;height:100%;object-fit:cover">`;
try {
const token = await ndAuth();
const form = new FormData(); form.append('image', file, file.name);
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
method: 'POST',
headers: {'X-Nd-Authorization': 'Bearer '+token},
body: form,
});
if(!res.ok) throw new Error(res.status);
toast('Cover aktualisiert');
setTimeout(() => {
const bust = coverUrl(S._currentPlaylist.coverArt, 300) + '&_cb=' + Date.now();
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">`;
}, 800);
} catch(e) {
S._ndToken = null;
toast('Cover-Upload fehlgeschlagen: '+e.message);
const cv = coverUrl(S._currentPlaylist.coverArt, 300);
document.getElementById('edit-pl-cover').innerHTML = cv
? `<img src="${cv}" style="width:100%;height:100%;object-fit:cover">`
: '<div class="no-art" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:36px">🎶</div>';
}
}
export async function editPlRemoveCover() {
if(!S._currentPlaylist) return;
try {
const token = await ndAuth();
const res = await fetch(`${S.cfg.url}/api/playlist/${S._currentPlaylist.id}/image`, {
method: 'DELETE', headers: {'X-Nd-Authorization': 'Bearer '+token},
});
if(!res.ok) throw new Error(res.status);
toast('Cover entfernt');
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>';
} catch(e) { S._ndToken = null; toast('Fehler: '+e.message); }
}
export async function deletePlaylist() {
const pl = S._currentPlaylist;
if (!pl) return;
const ok = await showConfirm('Playlist löschen', `"${pl.name}" wird unwiderruflich gelöscht.`, 'Löschen');
if (!ok) return;
try {
await api('deletePlaylist', `&id=${pl.id}`);
closeEditPlModal();
toast(`🗑 "${pl.name}" gelöscht`);
navigate('playlists');
} catch(e) { toast('Fehler beim Löschen: ' + e.message); }
}
+146
View File
@@ -0,0 +1,146 @@
import { esc } from '../utils.js';
// Circular — safe (only used inside function bodies)
import { navigate } from '../views/views.js';
import { api } from '../api.js';
const _NOTIF_KEY = 'ns_notifications';
function _loadNotifs() {
try { return JSON.parse(localStorage.getItem(_NOTIF_KEY) || '[]'); } catch { return []; }
}
function _saveNotifs(n) { localStorage.setItem(_NOTIF_KEY, JSON.stringify(n.slice(0, 100))); }
export function _addNotif(notif) {
const list = _loadNotifs();
list.unshift(notif);
_saveNotifs(list);
_renderNotifBell();
_renderNotifList();
}
export function deleteNotif(id) {
_saveNotifs(_loadNotifs().filter(n => n.id !== id));
_renderNotifBell();
_renderNotifList();
}
export function clearNotifs() {
_saveNotifs([]);
_renderNotifBell();
_renderNotifList();
}
export function toggleNotifPanel() {
const panel = document.getElementById('notif-panel');
const overlay = document.getElementById('notif-overlay');
if (!panel) return;
const isOpen = panel.classList.contains('open');
if (isOpen) {
panel.classList.remove('open');
overlay?.classList.remove('active');
} else {
_saveNotifs(_loadNotifs().map(n => ({...n, read: true})));
_renderNotifList();
_renderNotifBell();
panel.classList.add('open');
overlay?.classList.add('active');
}
}
export function initNotifBell() { _renderNotifBell(); }
function _renderNotifBell() {
const unread = _loadNotifs().filter(n => !n.read).length;
document.querySelectorAll('.notif-badge').forEach(b => {
b.textContent = unread > 9 ? '9+' : unread;
b.style.display = unread > 0 ? '' : 'none';
});
}
function _renderNotifList() {
const list = document.getElementById('notif-list');
if (!list) return;
const notifs = _loadNotifs();
if (!notifs.length) {
list.innerHTML = '<div class="notif-empty">Keine Benachrichtigungen</div>';
return;
}
list.innerHTML = notifs.map(n => {
const d = new Date(n.timestamp);
const time = d.toLocaleDateString('de-DE', {day:'2-digit', month:'2-digit'}) + ' ' + d.toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'});
const icon = n.status === 'done' ? '✅' : '❌';
const failedHtml = n.failed?.length
? `<details class="notif-failed"><summary>${n.failed.length} Track${n.failed.length > 1 ? 's' : ''} fehlgeschlagen</summary><ul>${n.failed.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
: '';
const missingHtml = n.missing?.length
? `<details class="notif-failed"><summary>${n.missing.length} Track${n.missing.length > 1 ? 's' : ''} nicht in Navidrome gefunden</summary><ul>${n.missing.map(f => `<li>${esc(f)}</li>`).join('')}</ul></details>`
: '';
const hasNav = n.status === 'done' && ((n.navType === 'playlist' && n.navId) || n.navArtist || n.navAlbum);
const navLabel = n.navType === 'artist' ? 'Zum Künstler →'
: (n.navType === 'track' || n.navType === 'album') ? 'Zum Album →'
: 'Öffnen →';
const navBtn = hasNav
? `<button class="notif-nav-btn" onclick="_notifNavigate('${esc(n.id)}')">${navLabel}</button>`
: '';
return `<div class="notif-card${n.read ? '' : ' unread'}">
<div class="notif-card-top">
<span class="notif-icon">${icon}</span>
<div class="notif-info">
<div class="notif-title">${esc(n.title)}</div>
<div class="notif-sub">${n.artist ? esc(n.artist) + ' · ' : ''}${time}</div>
</div>
<button class="notif-x" onclick="deleteNotif('${esc(n.id)}')">✕</button>
</div>
${n.message ? `<div class="notif-msg">${esc(n.message)}</div>` : ''}
${failedHtml}
${missingHtml}
${navBtn}
</div>`;
}).join('');
}
export async function _notifNavigate(notifId) {
const notifs = _loadNotifs();
const n = notifs.find(x => x.id === notifId);
if (!n) return;
const panel = document.getElementById('notif-panel');
const overlay = document.getElementById('notif-overlay');
panel?.classList.remove('open');
overlay?.classList.remove('active');
if (n.navType === 'playlist' && n.navId) {
navigate('playlist-detail', { id: n.navId, name: n.navName || n.title.replace(/^Playlist:\s*/, '') });
return;
}
const norm = s => s?.toLowerCase().trim() || '';
if (n.navType === 'track' || n.navType === 'album') {
const albumQuery = n.navAlbum || n.navTitle;
if (albumQuery) {
try {
const r = await api('search3', `&query=${encodeURIComponent(albumQuery)}&artistCount=0&albumCount=10&songCount=0`);
const albums = r.searchResult3?.album || [];
const match = albums.find(a => norm(a.name) === norm(albumQuery) && norm(a.artist) === norm(n.navArtist))
|| albums.find(a => norm(a.name) === norm(albumQuery))
|| albums[0];
if (match) {
navigate('album-detail', { id: match.id, name: match.name, artist: match.artist || '', artistId: match.artistId || '', coverArt: match.coverArt || '' });
return;
}
} catch {}
}
import('../utils.js').then(m => m.toast('Album nicht gefunden — Library-Scan nötig?'));
return;
}
if (n.navArtist) {
try {
const r = await api('search3', `&query=${encodeURIComponent(n.navArtist)}&artistCount=5&albumCount=0&songCount=0`);
const artists = r.searchResult3?.artist || [];
const match = artists.find(a => norm(a.name) === norm(n.navArtist)) || artists[0];
if (match) { navigate('artist-detail', { id: match.id, name: match.name }); return; }
} catch {}
import('../utils.js').then(m => m.toast('Künstler nicht gefunden — Library-Scan nötig?'));
}
}
+155
View File
@@ -0,0 +1,155 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, fetchRomajiIndex, invalidateCoverCache, getRomajiOverrides, setRomajiOverride, removeRomajiOverride } from '../api.js';
import { THEMES, applyTheme, getCurrentThemeId } from '../themes.js';
// Circular — safe (only used inside function bodies)
import { refreshDisplayMode, updateNpColorMode } from '../player/player.js';
import { startArtistSortFix } from './downloads.js';
// ─── SIDEBAR ───
export function openSidebar() {
document.getElementById('sidebar').classList.add('mobile-open');
document.getElementById('mobile-overlay').classList.add('active');
}
export function closeSidebar() {
document.getElementById('sidebar').classList.remove('mobile-open');
document.getElementById('mobile-overlay').classList.remove('active');
}
export function switchNavMode(mode) {
const lib = document.getElementById('bnav-library');
const disc = document.getElementById('bnav-discover');
if(!lib || !disc) return;
lib.style.display = mode==='library' ? 'flex' : 'none';
disc.style.display = mode==='discover' ? 'flex' : 'none';
document.getElementById('bnav-tab-library')?.classList.toggle('active', mode==='library');
document.getElementById('bnav-tab-discover')?.classList.toggle('active', mode==='discover');
}
// ─── MOBILE SEARCH (legacy stubs — header search is now always visible) ───
export function toggleMobileSearch() {
document.getElementById('header-search-input')?.focus();
}
export function closeMobileSearch() {
import('../views/views.js').then(m => m.closeHeaderSearch());
}
// ─── SETUP ───
export function switchSetupTab(t) {
document.querySelectorAll('.setup-tab').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1)));
document.querySelectorAll('.setup-pane').forEach((el,i) => el.classList.toggle('active', i===(t==='navidrome'?0:1)));
}
// ─── SETTINGS ───
export function openSettings() {
import('../views/abs.js').then(m => m.absOpenSettings());
document.getElementById('s-url').value = S.cfg.url || '';
document.getElementById('s-user').value = S.cfg.user || '';
document.getElementById('s-pass').value = '';
document.getElementById('s-lastfm').value = S.cfg.lastfmKey || '';
document.getElementById('s-backend').value = S.cfg.backendUrl || '';
document.getElementById('s-show-playcount').classList.toggle('on', !!S.cfg.showPlayCount);
document.getElementById('s-show-romaji').classList.toggle('on', !!S.cfg.showRomaji);
document.getElementById('s-cover-color-bg').classList.toggle('on', !!S.cfg.coverColorBg);
document.getElementById('s-scrobble-mode').value = S.cfg.scrobbleMode || '15s';
_renderThemePicker();
document.getElementById('settings-modal').classList.add('open');
checkBackend();
}
function _renderThemePicker() {
const el = document.getElementById('theme-picker');
if (!el) return;
const current = getCurrentThemeId();
el.innerHTML = THEMES.map(t => `
<div class="theme-swatch${t.id === current ? ' active' : ''}" data-id="${t.id}" onclick="_pickTheme('${t.id}')" style="--sw-bg:${t.bg};--sw-accent:${t.accent}">
<div class="theme-swatch-dot"></div>
<span>${esc(t.name)}</span>
</div>`).join('');
}
export function _pickTheme(id) {
applyTheme(id);
_renderThemePicker();
}
function _renderRomajiOverrides() {
const list = document.getElementById('romaji-overrides-list');
if (!list) return;
const entries = Object.entries(getRomajiOverrides());
if (!entries.length) {
list.innerHTML = '<div style="color:var(--text2);font-size:13px;padding:4px 0">Keine Korrekturen vorhanden</div>';
return;
}
list.innerHTML = entries.map(([k, v]) =>
`<div style="display:flex;align-items:center;gap:6px;padding:7px 10px;background:var(--bg2);border-radius:8px">
<span style="flex:1;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(k)}</span>
<span style="color:var(--text2);flex-shrink:0">→</span>
<span style="flex:1.2;font-size:13px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(v)}</span>
<button onclick="removeRomajiOverrideUI('${esc(k)}')" style="background:none;border:none;color:var(--danger);cursor:pointer;font-size:18px;line-height:1;padding:0 2px;flex-shrink:0">×</button>
</div>`
).join('');
}
export async function addRomajiOverrideUI() {
const k = (document.getElementById('ro-kanji')?.value || '').trim();
const v = (document.getElementById('ro-romaji')?.value || '').trim();
if (!k || !v) { toast('⚠ Kanji und Romaji eingeben'); return; }
await setRomajiOverride(k, v);
document.getElementById('ro-kanji').value = '';
document.getElementById('ro-romaji').value = '';
_renderRomajiOverrides();
try { await startArtistSortFix(k, v); } catch (e) { toast('⚠ Fix-Artist-Sort: ' + e.message); }
}
export async function removeRomajiOverrideUI(kanji) {
await removeRomajiOverride(kanji);
_renderRomajiOverrides();
}
export function openRomajiOverridesModal() {
_renderRomajiOverrides();
document.getElementById('romaji-overrides-modal').classList.add('open');
}
export function closeRomajiOverridesModal() {
document.getElementById('romaji-overrides-modal').classList.remove('open');
}
export function closeSettings() { document.getElementById('settings-modal').classList.remove('open'); }
export async function confirmDisconnect() {
const { showConfirm } = await import('./modals.js');
const ok = await showConfirm('Abmelden', 'Wirklich abmelden? Alle lokalen Einstellungen werden gelöscht.', 'Abmelden');
if (ok) { closeSettings(); window.disconnect(); }
}
export function saveSettings() {
const pass = document.getElementById('s-pass').value;
S.cfg.url = document.getElementById('s-url').value.trim().replace(/\/$/,'');
S.cfg.user = document.getElementById('s-user').value.trim();
const credChanged = S.cfg.url !== document.getElementById('s-url').value.trim().replace(/\/$/,'') || !!pass;
if(pass) S.cfg.pass = pass;
if(credChanged) invalidateCoverCache();
S.cfg.lastfmKey = document.getElementById('s-lastfm').value.trim();
S.cfg.backendUrl = document.getElementById('s-backend').value.trim().replace(/\/$/,'');
S.cfg.showPlayCount = document.getElementById('s-show-playcount').classList.contains('on');
const romajiChanged = S.cfg.showRomaji !== document.getElementById('s-show-romaji').classList.contains('on');
S.cfg.showRomaji = document.getElementById('s-show-romaji').classList.contains('on');
S.cfg.coverColorBg = document.getElementById('s-cover-color-bg').classList.contains('on');
S.cfg.scrobbleMode = document.getElementById('s-scrobble-mode').value || '15s';
updateNpColorMode();
localStorage.setItem('ns_cfg', JSON.stringify(S.cfg));
closeSettings(); toast('✅ Gespeichert');
if (romajiChanged) {
fetchRomajiIndex().then(() => refreshDisplayMode());
}
}
export async function checkBackend() {
const dot = document.getElementById('backend-status-dot');
const txt = document.getElementById('backend-status-text');
const url = document.getElementById('s-backend').value.trim() || S.cfg.backendUrl;
if(!url) { dot.className='status-dot'; txt.textContent='Keine URL konfiguriert'; return; }
dot.className='status-dot'; txt.textContent='Verbinde…';
try {
const r = await fetch(`${url}/api/health`, {signal: AbortSignal.timeout(4000)});
const d = await r.json();
dot.className='status-dot ok';
txt.textContent = `Verbunden · yt-dlp ${d.ytdlpVersion||'OK'} · ${d.musicPath||''}`;
} catch(e) { dot.className='status-dot err'; txt.textContent='Nicht erreichbar: '+e.message; }
}
+6
View File
@@ -0,0 +1,6 @@
// Barrel — re-exports everything so external imports continue to use a single entry point.
export * from './settings.js';
export * from './modals.js';
export * from './metadata.js';
export * from './notify.js';
export * from './downloads.js';
-1346
View File
File diff suppressed because it is too large. Load diff
+3 -3
View File
@@ -1,6 +1,6 @@
import { S } from './state.js';
import { esc, toast } from './utils.js';
import { cancelMusicAutoplay } from './player.js';
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { cancelMusicAutoplay } from '../player/player.js';
// ─── HTTP HELPERS ───
// API calls are routed through the NovaSonic backend proxy to avoid CORS restrictions.
+375
View File
@@ -0,0 +1,375 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, coverUrl, itunesSearch, itunesLookupAlbum, itunesLookupArtistSongs, itunesLookupArtistAlbums, fetchRomajiIndex, dispArtistById, dispAlbumName, dispAlbum } from '../api.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem } from '../ui/components.js';
import { playQueue, dlSongLocal, toggleStar, parseArtists } from '../player/player.js';
import { showCtxMenu, openDlModal } from '../ui/ui.js';
// Circular — safe (called inside event handlers / function bodies)
import { navigate, _navGoBack } from './navigation.js';
import { getArtistImage } from './home.js';
// ─── QUICK PLAY HELPERS ───
export async function quickPlayAlbum(a) {
try { const r = await api('getAlbum', `&id=${a.id}`); playQueue(r.album?.song || [], 0); }
catch(e) { toast('Fehler: ' + e.message); }
}
export async function quickPlayArtist(a) {
try {
toast('Lade Songs…');
const r = await api('getArtist', `&id=${a.id}`);
const albums = r.artist?.album || [];
if (!albums.length) { toast('Keine Alben gefunden'); return; }
const results = await Promise.all(albums.map(al => api('getAlbum', `&id=${al.id}`)));
const songs = results.flatMap(ar => ar.album?.song || []);
if (songs.length) playQueue(songs, 0);
else toast('Keine Songs gefunden');
} catch(e) { toast('Fehler: ' + e.message); }
}
export async function quickPlayPlaylist(pl) {
try { const r = await api('getPlaylist', `&id=${pl.id}`); playQueue(r.playlist?.entry || [], 0); }
catch(e) { toast('Fehler: ' + e.message); }
}
// ─── SHARED TOPSONG ROW ───
function _makeTopsongRow(s, num, subtitle, list, idx) {
const cv = coverUrl(s.coverArt, 128);
if (s.starred && !s._starred) s._starred = true;
const row = document.createElement('div'); row.className = 'topsong-item';
row.innerHTML = `<div class="topsong-num">${num}</div>
<div class="topsong-thumb">${cv ? `<img src="${cv}" loading="lazy">` : '<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:18px">🎵</div>'}</div>
<div style="min-width:0;flex:1"><div class="topsong-title">${esc(s.title)}</div>${subtitle ? `<div style="font-size:11px;color:var(--text2)">${esc(subtitle)}</div>` : ''}</div>
<div class="topsong-plays">${s.playCount ? s.playCount.toLocaleString() : '—'}</div>
<div class="song-actions" style="flex-shrink:0">
<button class="icon-btn${s._starred ? ' starred' : ''}" title="Favorit"><svg width="12" height="12" viewBox="0 0 24 24" fill="${s._starred ? 'currentColor' : '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></button>
<button class="icon-btn song-menu-btn" title="Mehr"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="5" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="12" cy="19" r="1.5"/></svg></button>
</div>`;
row.onclick = () => playQueue(list, idx, { noShuffle: true });
row.addEventListener('contextmenu', e => { e.preventDefault(); showCtxMenu(e, s, list, idx); });
const btns = row.querySelectorAll('.icon-btn');
btns[0].onclick = e => { e.stopPropagation(); toggleStar(s, btns[0]); };
btns[1].onclick = e => { e.stopPropagation(); showCtxMenu(e, s, list, idx); };
return row;
}
// ─── ARTIST DETAIL ───
export async function loadArtistDetail(artist) {
const nameEl = document.getElementById('artist-detail-name');
nameEl.textContent = artist.name;
document.getElementById('artist-back-btn').onclick = () => _navGoBack();
if (S.cfg.showRomaji) {
fetchRomajiIndex()
.then(() => { nameEl.textContent = dispArtistById(artist.id, artist.name); })
.catch(() => {});
}
const hero = document.getElementById('artist-detail-hero');
const bioEl = document.getElementById('artist-bio-section');
const simEl = document.getElementById('artist-similar-section');
const tsEl = document.getElementById('artist-topsongs-section');
const albEl = document.getElementById('artist-albums-section');
hero.style.background = 'linear-gradient(135deg, var(--surface2), var(--surface))';
const overlay = hero.querySelector('.artist-hero-overlay');
if(overlay) overlay.style.background = '';
[bioEl, simEl, tsEl, albEl].forEach(el => { if(el) el.innerHTML = ''; });
if(albEl) albEl.innerHTML = '<div class="spinner"></div>';
const hasND = !!artist.id;
const knownItunes = artist.itunesArtistId || null;
const [imgRes, albumRes, infoRes, topRes, iArtistRes] = await Promise.allSettled([
hasND ? getArtistImage(artist.id) : Promise.resolve(null),
hasND ? api('getArtist', `&id=${artist.id}`) : Promise.resolve(null),
hasND ? api('getArtistInfo2',`&id=${artist.id}&count=8`) : Promise.resolve(null),
hasND ? api('getTopSongs', `&artist=${encodeURIComponent(artist.name)}&count=10`) : Promise.resolve(null),
knownItunes
? Promise.resolve([{ artistId: knownItunes }])
: itunesSearch(artist.name, 'musicArtist', 5),
]);
const _normWords = s => new Set((s || '').toLowerCase().replace(/[^a-z0-9]/g, ' ').split(/\s+/).filter(Boolean));
const _queryWords = _normWords(artist.name);
const iArtistResults = iArtistRes.status === 'fulfilled' ? (iArtistRes.value || []) : [];
const iArtistMatch = iArtistResults.find(r => {
if (_queryWords.size === 0) return true;
const rw = _normWords(r.artistName);
return [..._queryWords].every(w => rw.has(w));
});
const itunesArtistId = knownItunes ?? iArtistMatch?.artistId ?? null;
const [iAlbumsRes, iTracksRes] = itunesArtistId
? await Promise.allSettled([
itunesLookupArtistAlbums(itunesArtistId),
itunesLookupArtistSongs(itunesArtistId, 15),
])
: [{ status: 'rejected' }, { status: 'rejected' }];
// ── Hero ──
const img = imgRes.status === 'fulfilled' ? imgRes.value : null;
if(img) {
hero.style.background = `url('${img}') center/cover no-repeat`;
if(overlay) overlay.style.background = 'linear-gradient(to right,rgba(7,7,15,.95) 0%,rgba(7,7,15,.35) 55%,rgba(7,7,15,.75) 100%)';
}
// ── Bio ──
const info = infoRes.status === 'fulfilled' ? infoRes.value?.artistInfo2 : null;
if(info?.biography && bioEl) {
const bio = info.biography.replace(/<a [^>]+>/g,'').replace(/<\/a>/g,'').replace(/<[^>]+>/g,'').trim();
const short = bio.length > 400 ? bio.slice(0,400)+'…' : bio;
if(short) {
window._artistBioFull = bio;
bioEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Über</div>
<div class="artist-bio" id="artist-bio-text">${esc(short)}${bio.length > 400
? ` <button onclick="expandArtistBio()" style="background:none;border:none;cursor:pointer;color:var(--accent);font-size:12px;padding:0;margin-left:6px">Mehr lesen</button>`
: ''}</div>`;
}
}
// ── Similar artists ──
const similar = info?.similarArtist || [];
if(similar.length && simEl) {
simEl.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div>
<div class="similar-chips">${similar.map(a =>
`<span class="similar-chip" onclick="navigate('artist-detail',{id:'${esc(a.id||a.name)}',name:'${esc(a.name)}'})">
${esc(a.name)}</span>`).join('')}</div>`;
}
// ── Top Songs ──
const ndAlbums = albumRes.status === 'fulfilled' ? (albumRes.value?.artist?.album || []) : [];
const topSongs = topRes.status === 'fulfilled' ? (topRes.value?.topSongs?.song || []) : [];
const iTracks = iTracksRes.status === 'fulfilled' ? (iTracksRes.value || []).filter(t => t.wrapperType === 'track') : [];
const iAlbums = iAlbumsRes.status === 'fulfilled' ? (iAlbumsRes.value || []) : [];
if((topSongs.length || iTracks.length) && tsEl) {
tsEl.innerHTML = '';
if(topSongs.length) {
const h = document.createElement('div');
h.className = 'section-title'; h.style.marginBottom = '10px'; h.textContent = 'Top Songs';
tsEl.appendChild(h);
const list = document.createElement('div'); list.style.marginBottom = '8px';
topSongs.forEach((s, i) => list.appendChild(_makeTopsongRow(s, i + 1, s.album || '', topSongs, i)));
tsEl.appendChild(list);
if(ndAlbums.length) {
const total = ndAlbums.reduce((n, a) => n + (a.songCount || 0), 0);
const btn = document.createElement('button');
btn.className = 'btn-outline'; btn.style.cssText = 'font-size:12px;padding:7px 14px;margin-bottom:16px;margin-top:4px';
btn.textContent = `Alle ${total} Songs in Library`;
btn.onclick = () => navigate('artist-all-songs', { albums: ndAlbums, artist });
tsEl.appendChild(btn);
}
}
if(iTracks.length) {
const heading = topSongs.length ? 'Weltweit populär (iTunes)' : 'Populäre Titel';
const sec = document.createElement('div');
sec.innerHTML = `<div class="section-title" style="margin-top:${topSongs.length?'8px':'0'};margin-bottom:10px">${heading}</div>`;
const list = document.createElement('div'); list.className = 'song-list'; list.style.marginBottom = '20px';
iTracks.forEach((t, i) => list.appendChild(makeGlobalTrackItem(t, i, iTracks)));
sec.appendChild(list); tsEl.appendChild(sec);
}
}
// ── Action bar ──
const actionBar = document.getElementById('artist-action-bar');
if(actionBar) {
actionBar.innerHTML = '';
if(topSongs.length || ndAlbums.length) {
const playTopBtn = document.createElement('button');
playTopBtn.className = 'artist-action-btn';
playTopBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg> Top Songs`;
playTopBtn.onclick = () => topSongs.length ? playQueue(topSongs, 0) : quickPlayArtist(artist);
const shuffleBtn = document.createElement('button');
shuffleBtn.className = 'artist-action-btn';
shuffleBtn.innerHTML = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/></svg> Shuffle`;
shuffleBtn.onclick = async () => {
S.shuffle = true;
localStorage.setItem('ns_shuffle', '1');
import('../player/player.js').then(({ _applyShuffleRepeat }) => _applyShuffleRepeat());
await quickPlayArtist(artist);
};
actionBar.appendChild(playTopBtn);
actionBar.appendChild(shuffleBtn);
}
}
if(albEl) { albEl.innerHTML = ''; _renderArtistAlbums(albEl, ndAlbums, iAlbums, artist); }
}
function _normAlbum(s) {
return (s || '').toLowerCase().replace(/[^a-z0-9\s]/g, ' ').replace(/\s+/g, ' ').trim();
}
function _renderArtistAlbums(el, ndAlbums, iAlbums, artist) {
const downloaded = [], partial = [], notDl = [];
const usedNdIds = new Set();
for(const ia of iAlbums) {
const iName = _normAlbum(ia.collectionName);
const iCount = ia.trackCount || 0;
const match = ndAlbums.find(a => {
const n = _normAlbum(a.name);
return n === iName || (n.length > 3 && iName.startsWith(n)) || (iName.length > 3 && n.startsWith(iName));
});
if(!match) {
notDl.push(ia);
} else if(iCount > 0 && match.songCount >= iCount) {
downloaded.push({ nd: match, itunes: ia }); usedNdIds.add(match.id);
} else {
partial.push({ nd: match, itunes: ia, have: match.songCount || 0, total: iCount }); usedNdIds.add(match.id);
}
}
for(const na of ndAlbums) {
if(!usedNdIds.has(na.id)) downloaded.push({ nd: na, itunes: null });
}
downloaded.sort((a, b) => (b.nd.year||0) - (a.nd.year||0));
partial.sort((a, b) => (b.nd.year||0) - (a.nd.year||0));
notDl.sort((a, b) => new Date(b.releaseDate||0) - new Date(a.releaseDate||0));
function addGrid(title, count) {
const h = document.createElement('div');
h.className = 'section-title';
h.style.cssText = 'margin-bottom:12px;margin-top:20px;display:flex;align-items:center;gap:8px';
h.innerHTML = `${title}<span class="section-count">${count}</span>`;
el.appendChild(h);
const g = document.createElement('div'); g.className = 'grid grid-4'; el.appendChild(g);
return g;
}
if(downloaded.length) {
const g = addGrid('In Library', downloaded.length);
downloaded.forEach(({ nd }) => g.appendChild(makeAlbumCard({ ...nd, _fromArtist: artist })));
}
if(partial.length) {
const g = addGrid('Unvollständig', partial.length);
partial.forEach(({ nd, itunes, have, total }) => {
const art = itunes?.artworkUrl100?.replace('100x100','512x512') || coverUrl(nd.coverArt, 512);
const c = makeCard(nd.name, `${have} / ${total} Tracks`, art, '💿');
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayAlbum(nd); };
c.querySelector('.card-info').onclick = () => itunes ? navigate('itunes-album-detail', itunes) : navigate('album-detail', { ...nd, _fromArtist: artist });
const artEl = c.querySelector('.card-art');
const badge = document.createElement('div');
badge.className = 'album-partial-badge'; badge.textContent = `${have}/${total}`;
artEl.appendChild(badge);
if(itunes) {
const btn = document.createElement('button');
btn.className = 'card-dl-btn'; btn.title = 'Fehlende Tracks laden';
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: artist.name, trackName: nd.name, collectionName: nd.name, artworkUrl100: itunes.artworkUrl100||'', isAlbum: true, itunesData: itunes, collectionId: itunes.collectionId }); };
artEl.appendChild(btn);
}
g.appendChild(c);
});
}
if(notDl.length) {
const g = addGrid('Noch nicht heruntergeladen', notDl.length);
notDl.forEach(ia => {
const art = ia.artworkUrl100?.replace('100x100','300x300');
const c = makeCard(ia.collectionName, ia.releaseDate?.slice(0,4)||'', art, '💿');
c.onclick = () => navigate('itunes-album-detail', ia);
const artEl = c.querySelector('.card-art');
const btn = document.createElement('button');
btn.className = 'card-dl-btn'; btn.title = 'Album laden';
btn.innerHTML = `<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
btn.onclick = (e) => { e.stopPropagation(); openDlModal({ artistName: ia.artistName, trackName: ia.collectionName, collectionName: ia.collectionName, artworkUrl100: ia.artworkUrl100||'', isAlbum: true, itunesData: ia, collectionId: ia.collectionId }); };
artEl.appendChild(btn);
g.appendChild(c);
});
}
if(!downloaded.length && !partial.length && !notDl.length && ndAlbums.length) {
const g = addGrid('Alben', ndAlbums.length);
ndAlbums.forEach(a => g.appendChild(makeAlbumCard({ ...a, _fromArtist: artist })));
}
}
export async function loadArtistAllSongs({ albums, artist }) {
document.getElementById('artist-songs-title').textContent = `Alle Songs · ${artist.name}`;
document.getElementById('artist-songs-back-btn').onclick = () => _navGoBack();
const el = document.getElementById('artist-songs-list');
el.innerHTML = '<div class="spinner"></div>';
const results = await Promise.allSettled(albums.map(a => api('getAlbum', `&id=${a.id}`)));
el.innerHTML = '';
const allSongs = results
.filter(r => r.status === 'fulfilled')
.flatMap(r => { const alb = r.value?.album; return (alb?.song || []).map(s => ({ ...s, _alb: alb })); })
.sort((a, b) => {
if(a._alb?.year !== b._alb?.year) return (b._alb?.year||0) - (a._alb?.year||0);
if(a._alb?.id !== b._alb?.id) return (a._alb?.name||'').localeCompare(b._alb?.name||'');
return (a.trackNumber||0) - (b.trackNumber||0);
});
let curId = '';
allSongs.forEach((s, i) => {
if(s._alb?.id !== curId) {
curId = s._alb?.id || '';
const h = document.createElement('div');
h.style.cssText = 'font-size:12px;font-weight:700;color:var(--text2);padding:12px 0 4px;border-top:1px solid var(--border);margin-top:4px';
h.textContent = s._alb?.name || '';
el.appendChild(h);
}
el.appendChild(_makeTopsongRow(s, s.trackNumber || '', null, allSongs, i));
});
}
export async function loadItunesAlbumDetail(album) {
document.getElementById('itunes-album-back-btn').onclick = () => _navGoBack();
document.getElementById('itunes-album-detail-name').textContent = album.collectionName || '—';
document.getElementById('itunes-album-detail-artist').textContent = album.artistName || '';
const cw = document.getElementById('itunes-album-cover-wrap');
const art = (album.artworkUrl100 || '').replace('100x100bb','600x600bb').replace('100x100','600x600');
cw.innerHTML = art ? `<img src="${art}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
const dlBtn = document.getElementById('itunes-album-dl-btn');
dlBtn.onclick = () => openDlModal({ artistName: album.artistName, trackName: album.collectionName, collectionName: album.collectionName, artworkUrl100: album.artworkUrl100||'', isAlbum: true, itunesData: album, collectionId: album.collectionId });
const sl = document.getElementById('itunes-album-songs');
sl.innerHTML = '<div class="spinner"></div>';
try {
const tracks = await itunesLookupAlbum(album.collectionId);
const year = album.releaseDate ? new Date(album.releaseDate).getFullYear() : '';
document.getElementById('itunes-album-detail-stats').textContent = `${tracks.length} Titel${year ? ' · ' + year : ''}`;
sl.innerHTML = '';
if(!tracks.length) { sl.innerHTML = '<div style="padding:16px;color:var(--text3)">Keine Tracks gefunden</div>'; return; }
tracks.forEach((t, i) => sl.appendChild(makeGlobalTrackItem(t, i, tracks)));
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// ─── ALBUM DETAIL ───
export async function loadAlbumDetail(album) {
document.getElementById('album-back-btn').onclick = () =>
album._fromArtist ? navigate('artist-detail', album._fromArtist) : navigate('albums');
document.getElementById('album-detail-name').textContent = dispAlbumName(album.name);
const artistEl = document.getElementById('album-detail-artist');
artistEl.innerHTML = parseArtists(album)
.map(a => `<span class="song-link" onclick="event.stopPropagation();s_goArtist(event,'${esc(a.originalName||a.name)}','${esc(a.id||'')}')">${esc(dispArtistById(a.id, a.name))}</span>`)
.join('<span style="color:var(--text3)"> • </span>') || '';
const cw = document.getElementById('album-cover-wrap');
const cv = coverUrl(album.coverArt, 512);
cw.innerHTML = cv ? `<img src="${cv}" onerror="_imgRetry(this,'💿')">` : '<div class="no-art">💿</div>';
const sl = document.getElementById('album-songs');
sl.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getAlbum', `&id=${album.id}`);
const songs = r.album?.song || [];
const totalMin = Math.round(songs.reduce((s,t) => s+(t.duration||0), 0) / 60);
document.getElementById('album-detail-stats').textContent = `${songs.length} Titel · ${r.album.year||''} · ${totalMin} Min.`;
if (songs[0]) {
document.getElementById('album-detail-name').textContent = dispAlbum(songs[0]) || dispAlbumName(album.name);
artistEl.innerHTML = parseArtists(songs[0])
.map(a => `<span class="song-link" onclick="event.stopPropagation();s_goArtist(event,'${esc(a.originalName||a.name)}','${esc(a.id||'')}')">${esc(dispArtistById(a.id, a.name))}</span>`)
.join('<span style="color:var(--text3)"> • </span>') || '';
}
document.getElementById('btn-play-album').onclick = () => playQueue(songs, 0);
document.getElementById('btn-dl-album').onclick = () => {
songs.forEach((s,i) => setTimeout(() => dlSongLocal(s), i*400));
toast(`⬇ ${songs.length} Titel Download gestartet`);
};
sl.innerHTML = '';
songs.forEach((s,i) => sl.appendChild(makeSongItem(s, i, songs)));
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// ─── HELPERS ───
export function expandArtistBio() {
const el = document.getElementById('artist-bio-text');
if(el && window._artistBioFull) el.textContent = window._artistBioFull;
}
+138
View File
@@ -0,0 +1,138 @@
import { S } from '../state.js';
import { esc } from '../utils.js';
import { itunesSearch, itunesTopCharts, itunesTopSongs } from '../api.js';
import { makeCard, makeGlobalTrackItem } from '../ui/components.js';
import { playPreview } from '../player/player.js';
import { openDlModal } from '../ui/ui.js';
// Circular — safe (called inside event handlers)
import { loadItunesAlbum, searchAndPreview } from './search.js';
// ─── NEW RELEASES ───
export async function loadNewReleases() {
const albumEl = document.getElementById('nr-albums');
const songEl = document.getElementById('nr-songs');
albumEl.innerHTML = '<div class="spinner"></div>'; songEl.innerHTML = '';
try {
const [albums, songs] = await Promise.all([itunesTopCharts(), itunesTopSongs()]);
albumEl.innerHTML = '';
albums.slice(0,30).forEach(a => {
const art = a.artworkUrl100?.replace('100x100','300x300') || a.artworkUrl;
const c = makeCard(a.name, a.artistName, art, '💿');
c.onclick = () => itunesSearch(a.name+' '+a.artistName,'album',5).then(res => { if(res[0]) loadItunesAlbum(res[0]); });
albumEl.appendChild(c);
});
songs.slice(0,40).forEach((s, i) => {
const el = document.createElement('div'); el.className = 'gtrack-item';
const art = s.artworkUrl?.replace('100x100','60x60');
el.innerHTML = `
<div class="gtrack-art">${art ? `<img src="${art}">` : `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div>`}</div>
<div class="gtrack-info">
<div class="gtrack-title">${esc(s.name)}</div>
<div class="gtrack-sub">${esc(s.artistName||'')}</div>
</div>
<div class="gtrack-actions">
<span style="font-size:11px;color:var(--text3);font-family:var(--mono)">#${i+1}</span>
<button class="icon-btn green" title="Auf Server laden" onclick="event.stopPropagation();openDlModal({trackName:'${esc(s.name)}',artistName:'${esc(s.artistName||'')}',artworkUrl100:'${s.artworkUrl||''}',collectionName:''})">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
</button>
</div>`;
el.onclick = () => { itunesSearch(s.name+' '+s.artistName,'musicTrack',1).then(res => { if(res[0]?.previewUrl) playPreview(res[0]); }); };
songEl.appendChild(el);
});
} catch(e) { albumEl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// ─── RECOMMENDATIONS ───
export async function loadRecommendations() {
const el = document.getElementById('reco-content');
const currentSong = S.queue[S.qIdx];
if(!currentSong) {
el.innerHTML = '<div class="empty-state"><div class="icon">🎵</div><p>Spiele zuerst Musik in deiner Bibliothek</p></div>';
return;
}
el.innerHTML = '<div class="spinner"></div>';
try {
const artist = currentSong.artist || '';
const title = currentSong.title || '';
let html = `<div style="padding:14px;background:var(--surface);border-radius:10px;margin-bottom:20px;display:flex;align-items:center;gap:12px">
<div style="font-size:28px">🎧</div>
<div><div style="font-size:12px;color:var(--text2)">Basierend auf</div><div style="font-size:15px;font-weight:700">${esc(title)}</div><div style="font-size:13px;color:var(--text2)">${esc(artist)}</div></div>
</div>`;
el.innerHTML = html + '<div class="spinner"></div>';
if(S.cfg.lastfmKey) {
await _loadLastfmRecommendations(el, artist, title, html);
} else {
await _loadItunesFallbackRecommendations(el, artist, title, html);
const note = document.createElement('div');
note.style.cssText = 'margin-top:16px;padding:12px;background:var(--bg2);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text3)';
note.innerHTML = '💡 <strong>Tipp:</strong> Füge einen Last.fm API Key in den Einstellungen hinzu für bessere Empfehlungen & Similar Artists.';
el.appendChild(note);
}
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
async function _loadLastfmRecommendations(el, artist, title, html) {
const BASE = 'https://ws.audioscrobbler.com/2.0';
const key = S.cfg.lastfmKey;
try {
const [simArtists, topTracks, simTracks] = await Promise.all([
fetch(`${BASE}/?method=artist.getSimilar&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=12`).then(r=>r.json()),
fetch(`${BASE}/?method=artist.getTopTracks&artist=${encodeURIComponent(artist)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()),
fetch(`${BASE}/?method=track.getSimilar&artist=${encodeURIComponent(artist)}&track=${encodeURIComponent(title)}&api_key=${key}&format=json&limit=15`).then(r=>r.json()),
]);
el.innerHTML = html;
const simArtistList = simArtists.similarartists?.artist || [];
const topTrackList = topTracks.toptracks?.track || [];
const simTrackList = simTracks.similartracks?.track || [];
if(simArtistList.length) {
el.innerHTML += `<div class="section-title" style="margin-bottom:10px">Ähnliche Künstler</div><div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:24px">`;
el.innerHTML += simArtistList.map(a => `<button class="reco-artist-chip" onclick="searchSimilarArtist('${esc(a.name)}')">${esc(a.name)}</button>`).join('');
el.innerHTML += '</div>';
}
if(simTrackList.length) {
const sec = document.createElement('div');
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Ähnliche Tracks</div>`;
const list = document.createElement('div'); list.className = 'song-list';
simTrackList.slice(0,15).forEach(t => {
const row = document.createElement('div'); row.className = 'gtrack-item';
row.innerHTML = `<div class="gtrack-art"><div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div></div>
<div class="gtrack-info"><div class="gtrack-title">${esc(t.name)}</div><div class="gtrack-sub">${esc(t.artist?.name||'')}</div></div>
<div class="gtrack-actions">
<button class="icon-btn blue" onclick="event.stopPropagation();searchAndPreview('${esc(t.name)}','${esc(t.artist?.name||'')}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
<button class="icon-btn green" onclick="event.stopPropagation();openDlModal({trackName:'${esc(t.name)}',artistName:'${esc(t.artist?.name||'')}',artworkUrl100:'',collectionName:''})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
</div>`;
row.onclick = () => searchAndPreview(t.name, t.artist?.name||'');
list.appendChild(row);
});
sec.appendChild(list); el.appendChild(sec);
}
if(topTrackList.length) {
const sec = document.createElement('div'); sec.style.marginTop = '20px';
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Top Tracks von ${esc(artist)}</div>`;
const list = document.createElement('div'); list.className = 'song-list';
topTrackList.slice(0,10).forEach(t => {
const row = document.createElement('div'); row.className = 'gtrack-item';
row.innerHTML = `<div class="gtrack-art"><div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;font-size:22px">🎵</div></div>
<div class="gtrack-info"><div class="gtrack-title">${esc(t.name)}</div><div class="gtrack-sub">${esc(t.artist?.name||artist)}</div></div>
<div class="gtrack-actions">
<button class="icon-btn blue" onclick="event.stopPropagation();searchAndPreview('${esc(t.name)}','${esc(artist)}')"><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg></button>
<button class="icon-btn green" onclick="event.stopPropagation();openDlModal({trackName:'${esc(t.name)}',artistName:'${esc(artist)}',artworkUrl100:'',collectionName:''})"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg></button>
</div>`;
row.onclick = () => searchAndPreview(t.name, artist);
list.appendChild(row);
});
sec.appendChild(list); el.appendChild(sec);
}
} catch { await _loadItunesFallbackRecommendations(el, artist, title, html); }
}
async function _loadItunesFallbackRecommendations(el, artist, title, html) {
const results = await itunesSearch(artist, 'musicTrack', 20);
el.innerHTML = html;
const sec = document.createElement('div');
sec.innerHTML = `<div class="section-title" style="margin-bottom:10px">Mehr von ${esc(artist)}</div>`;
const list = document.createElement('div'); list.className = 'song-list';
results.filter(t => t.artistName===artist).slice(0,15).forEach(t => list.appendChild(makeGlobalTrackItem(t, 0, [t])));
if(!list.children.length) results.slice(0,15).forEach(t => list.appendChild(makeGlobalTrackItem(t, 0, [t])));
sec.appendChild(list); el.appendChild(sec);
}
+242
View File
@@ -0,0 +1,242 @@
import { S } from '../state.js';
import { esc } from '../utils.js';
import { api, apiCached, apiIsCached, coverUrl, fetchRomajiIndex, dispArtistById, dispAlbumName } from '../api.js';
import { makeCard, makeAlbumCard, makeSongItem } from '../ui/components.js';
import { playQueue } from '../player/player.js';
// Circular — safe (called inside event handlers)
import { navigate, _navGoBack } from './navigation.js';
// ─── ARTIST IMAGE ───
export async function getArtistImage(artistId) {
if(S.artistImgCache[artistId] !== undefined) return S.artistImgCache[artistId];
try {
const r = await api('getArtistInfo2', `&id=${artistId}&count=0`);
const info = r.artistInfo2 || {};
const img = info.largeImageUrl || info.mediumImageUrl || info.smallImageUrl || null;
const isBlank = img && (img.includes('2a96cbd8b46e442fc41c2b86b821562f') || img.endsWith('/noimage/'));
S.artistImgCache[artistId] = isBlank ? null : img;
return S.artistImgCache[artistId];
} catch { S.artistImgCache[artistId] = null; return null; }
}
// ─── HOME ───
export async function loadHomeData() {
const hour = new Date().getHours();
const greeting = hour < 12 ? 'Guten Morgen' : hour < 18 ? 'Guten Tag' : 'Guten Abend';
const name = S.cfg.user ? `, ${S.cfg.user}` : '';
const el = document.getElementById('home-greeting');
if (el) el.textContent = `${greeting}${name} 👋`;
loadHeroCard();
loadGenreSection();
loadAlbumRow('home-newest', 'getAlbumList2', '&type=newest&size=20');
loadAlbumRow('home-recent', 'getAlbumList2', '&type=recent&size=20');
loadAlbumRow('home-random', 'getAlbumList2', '&type=random&size=20');
loadAlbumRow('home-frequent', 'getAlbumList2', '&type=frequent&size=20');
}
async function loadHeroCard() {
const el = document.getElementById('home-hero');
if (!el) return;
try {
const [freqRes, recRes] = await Promise.allSettled([
api('getAlbumList2', '&type=frequent&size=5'),
api('getAlbumList2', '&type=recent&size=5'),
S.cfg.showRomaji ? fetchRomajiIndex().catch(() => {}) : Promise.resolve(),
]);
const freqList = freqRes.status === 'fulfilled' ? (freqRes.value.albumList2?.album || []) : [];
const recList = recRes.status === 'fulfilled' ? (recRes.value.albumList2?.album || []) : [];
const freqIds = new Set(freqList.map(a => a.id));
const seen = new Set();
const pool = [...freqList, ...recList].filter(a => a.id && !seen.has(a.id) && seen.add(a.id));
const album = pool.length ? pool[Math.floor(Math.random() * pool.length)] : null;
if (!album) return;
const heroLabel = freqIds.has(album.id) ? 'Meistgespielt' : 'Zuletzt gehört';
const bgUrl = coverUrl(album.coverArt, 1024);
const thumbUrl = coverUrl(album.coverArt, 512);
el.innerHTML = `
${bgUrl ? `<img class="home-hero-bg" src="${bgUrl}" alt="">` : ''}
<div class="home-hero-overlay">
${thumbUrl ? `<img class="home-hero-cover" src="${thumbUrl}" alt="${esc(album.name)}">` : ''}
<div class="home-hero-info">
<div class="home-hero-label">${heroLabel}</div>
<div class="home-hero-title">${esc(dispAlbumName(album.name))}</div>
<div class="home-hero-artist">${esc(dispArtistById(album.artistId, album.artist || ''))}</div>
</div>
<button class="home-hero-play" title="Abspielen">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>
</div>`;
el.querySelector('.home-hero-cover')?.addEventListener('click', () => navigate('album-detail', album));
el.querySelector('.home-hero-artist').onclick = () => navigate('album-detail', album);
el.querySelector('.home-hero-play').onclick = () => quickPlayAlbum(album);
el.classList.add('loaded');
} catch {}
}
async function quickPlayAlbum(a) {
try { const r = await api('getAlbum', `&id=${a.id}`); playQueue(r.album?.song || [], 0); }
catch {}
}
const _GENRE_COLORS = ['#e8445a','#3b82f6','#f97316','#22c55e','#a855f7','#06b6d4','#eab308','#ec4899','#14b8a6','#f43f5e','#8b5cf6','#84cc16'];
function _genreColor(name) {
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return _GENRE_COLORS[h % _GENRE_COLORS.length];
}
let _allGenres = [];
let _genresExpanded = false;
const GENRE_LIMIT = () => window.innerWidth >= 700 ? 24 : 6;
async function loadGenreSection() {
const el = document.getElementById('home-genres');
const more = document.getElementById('home-genres-more');
if (!el) return;
try {
const r = await api('getGenres');
_allGenres = (r.genres?.genre || [])
.filter(g => (g.songCount || 0) > 0)
.sort((a, b) => (b.songCount || 0) - (a.songCount || 0));
_renderGenres();
if (_allGenres.length > GENRE_LIMIT() && more) more.style.display = '';
} catch { el.style.display = 'none'; }
if (S.cfg.backendUrl && _allGenres.length) {
fetch(`${S.cfg.backendUrl}/api/genre-playcounts?ndUrl=${encodeURIComponent(S.cfg.url)}&user=${encodeURIComponent(S.cfg.user)}&pass=${encodeURIComponent(S.cfg.pass)}`)
.then(r => r.ok ? r.json() : null)
.then(plays => {
if (!plays || !_allGenres.length) return;
_allGenres.sort((a, b) => (plays[b.value] || 0) - (plays[a.value] || 0));
_renderGenres();
})
.catch(() => {});
}
}
function _renderGenres() {
const el = document.getElementById('home-genres');
if (!el) return;
const list = _genresExpanded ? _allGenres : _allGenres.slice(0, GENRE_LIMIT());
el.innerHTML = '';
list.forEach(g => {
const card = document.createElement('div');
card.className = 'genre-card';
card.title = `${g.songCount} Titel`;
const color = _genreColor(g.value);
card.innerHTML = `
<div class="genre-card-bar" style="background:${color}"></div>
<div class="genre-card-name">${esc(g.value)}</div>
<button class="genre-card-play" title="Shuffle spielen">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</button>`;
card.querySelector('.genre-card-play').onclick = (e) => { e.stopPropagation(); playGenre(g.value); };
card.onclick = () => navigate('genre-detail', g);
el.appendChild(card);
});
}
export function toggleAllGenres() {
_genresExpanded = !_genresExpanded;
const more = document.getElementById('home-genres-more');
if (more) more.textContent = _genresExpanded ? 'Weniger' : 'Alle anzeigen';
_renderGenres();
}
async function playGenre(genre) {
try {
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=200`);
const songs = r.songsByGenre?.song || [];
if (!songs.length) return;
playQueue([...songs].sort(() => Math.random() - 0.5), 0);
} catch {}
}
export async function loadGenreDetail(g) {
const genre = g.value || g;
const color = _genreColor(genre);
document.getElementById('genre-detail-name').textContent = genre;
document.getElementById('genre-detail-stats').textContent = g.songCount ? `${g.songCount} Titel` : '';
const coverWrap = document.getElementById('genre-cover-wrap');
coverWrap.style.background = `linear-gradient(135deg, ${color}33, ${color}11)`;
coverWrap.style.borderRadius = '10px';
coverWrap.style.border = `2px solid ${color}55`;
document.getElementById('genre-back-btn').onclick = () => _navGoBack();
const list = document.getElementById('genre-songs');
list.innerHTML = '<div class="spinner"></div>';
let songs = [];
try {
const r = await api('getSongsByGenre', `&genre=${encodeURIComponent(genre)}&count=500`);
songs = r.songsByGenre?.song || [];
} catch { list.innerHTML = '<p style="color:var(--danger)">Fehler beim Laden</p>'; return; }
document.getElementById('genre-detail-stats').textContent = `${songs.length} Titel`;
document.getElementById('btn-play-genre').onclick = () => {
playQueue([...songs].sort(() => Math.random() - 0.5), 0);
};
list.innerHTML = '';
songs.forEach((s, i) => list.appendChild(makeSongItem(s, i, songs)));
}
export async function loadAlbumRow(id, method, extra) {
const c = document.getElementById(id);
if(!c) return;
if (!apiIsCached(method, extra)) c.innerHTML = '<div class="spinner"></div>';
try {
const [r] = await Promise.all([
apiCached(method, extra),
S.cfg.showRomaji ? fetchRomajiIndex().catch(() => {}) : Promise.resolve(),
]);
c.innerHTML = '';
(r.albumList2?.album || []).forEach(a => c.appendChild(makeAlbumCard(a)));
} catch(e) { c.innerHTML = `<div style="color:var(--text3);font-size:13px;padding:16px">${e.message}</div>`; }
}
// ─── ARTISTS ───
export async function loadArtists() {
const g = document.getElementById('artists-grid');
if (!apiIsCached('getArtists')) g.innerHTML = '<div class="spinner"></div>';
try {
const r = await apiCached('getArtists');
const all = (r.artists?.index || []).flatMap(i => i.artist || []);
if (S.cfg.showRomaji) await fetchRomajiIndex().catch(() => {});
g.innerHTML = '';
all.forEach(a => {
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', null, '🎤');
c.classList.add('artist-card');
c.onclick = () => navigate('artist-detail', a);
g.appendChild(c);
getArtistImage(a.id).then(imgUrl => {
if(!imgUrl) return;
const artEl = c.querySelector('.card-art');
if(!artEl) return;
const overlay = artEl.querySelector('.play-overlay');
const img = document.createElement('img');
img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s';
artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay);
c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)');
c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)');
});
});
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// ─── ALBUMS ───
let _albumSort = 'alphabeticalByName';
export async function loadAlbums(type = _albumSort) {
_albumSort = type;
document.querySelectorAll('#albums-sort-chips .sort-chip').forEach(c =>
c.classList.toggle('active', c.dataset.sort === type));
const g = document.getElementById('albums-grid');
const extra = `&type=${type}&size=500`;
if (!apiIsCached('getAlbumList2', extra)) g.innerHTML = '<div class="spinner"></div>';
try {
const r = await apiCached('getAlbumList2', extra);
g.innerHTML = '';
(r.albumList2?.album || []).forEach(a => g.appendChild(makeAlbumCard(a)));
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
+100
View File
@@ -0,0 +1,100 @@
import { S } from '../state.js';
import { closeSidebar, switchNavMode } from '../ui/ui.js';
import { closeNowPlaying, closeNpMenu } from '../player/nowplaying.js';
import { loadSpotifyImporter } from './spotify.js';
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, loadItunesAlbumDetail } from './detail.js';
import { loadPlaylists, loadPlaylistDetail, loadStarred, loadRandom } from './playlists.js';
import { loadNewReleases, loadRecommendations } from './discover.js';
// ─── NAVIGATION HISTORY ───
export function _navSave() {
try { sessionStorage.setItem('ns_nav', JSON.stringify({stack: S._navStack, cursor: S._navCursor})); } catch {}
}
export function _navLoad() {
try {
const s = JSON.parse(sessionStorage.getItem('ns_nav') || 'null');
if(s && Array.isArray(s.stack) && s.stack.length) {
S._navStack = s.stack;
S._navCursor = s.cursor;
return true;
}
} catch {}
return false;
}
export function closeHeaderSearch() {
const overlay = document.getElementById('header-search-overlay');
const input = document.getElementById('header-search-input');
const clearBtn = document.getElementById('header-search-clear');
if (overlay) overlay.style.display = 'none';
if (input) input.value = '';
if (clearBtn) clearBtn.style.display = 'none';
clearTimeout(S._headerSearchTimer);
}
export function navigate(page, data=null) {
closeHeaderSearch();
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item,.bnav-item').forEach(n => n.classList.remove('active','active-blue'));
const cls = ['discover','new-releases','recommendations','spotify-importer'].includes(page) ? 'active-blue' : 'active';
document.querySelectorAll(`[data-page="${page}"]`).forEach(el => el.classList.add(cls));
const pageEl = document.getElementById(`page-${page}`);
if(pageEl) { pageEl.classList.add('active'); document.getElementById('main-content').scrollTop = 0; }
closeSidebar();
if(['discover','new-releases','recommendations','random','spotify-importer'].includes(page)) switchNavMode('discover');
else if(['home','artists','albums','starred','playlists'].includes(page)) switchNavMode('library');
if(page==='home') loadHomeData();
if(page==='artists' && !document.getElementById('artists-grid').children.length) loadArtists();
if(page==='albums' && !document.getElementById('albums-grid').children.length) loadAlbums();
if(page==='starred') loadStarred();
if(page==='random') loadRandom();
if(page==='artist-detail' && data) loadArtistDetail(data);
if(page==='artist-all-songs' && data) loadArtistAllSongs(data);
if(page==='album-detail' && data) loadAlbumDetail(data);
if(page==='genre-detail' && data) loadGenreDetail(data);
if(page==='itunes-album-detail'&& data) loadItunesAlbumDetail(data);
if(page==='new-releases') loadNewReleases();
if(page==='playlists') loadPlaylists();
if(page==='playlist-detail' && data) loadPlaylistDetail(data);
if(page==='recommendations') loadRecommendations();
if(page==='spotify-importer') { loadSpotifyImporter(); loadSoundCloudImporter(); }
if(page==='audiobooks') {
import('./abs.js').then(m => {
if (S.abs.currentLibraryId) m.loadAbsBooks(S.abs.currentLibraryId);
});
}
if(page==='abs-book-detail' && data) {
import('./abs.js').then(m => m.loadAbsBookDetail(data.id));
}
if(!S._navJumping) {
S._navStack = S._navStack.slice(0, S._navCursor + 1);
const safe = data ? JSON.parse(JSON.stringify(data)) : null;
const last = S._navStack[S._navCursor];
if(!last || last.page !== page || JSON.stringify(last.data) !== JSON.stringify(safe)) {
S._navStack.push({ page, data: safe });
}
S._navCursor = S._navStack.length - 1;
_navSave();
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
}
}
export function _navGoBack() {
if(document.getElementById('np-sheet')?.classList.contains('open')) { closeNpMenu(); return; }
if(S.npOpen) { closeNowPlaying(); return; }
if(S._navCursor > 0) {
S._navCursor--;
const entry = S._navStack[S._navCursor];
_navSave();
S._navJumping = true;
navigate(entry.page, entry.data);
S._navJumping = false;
}
try { history.pushState({ nsIdx: S._navCursor }, '', ''); } catch {}
}
+250
View File
@@ -0,0 +1,250 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, apiCached, apiIsCached, coverUrl, romajiLookup } from '../api.js';
import { makeCard, makeSongItem } from '../ui/components.js';
import { playQueue } from '../player/player.js';
import { openEditPlModal } from '../ui/ui.js';
// Circular — safe (called inside event handlers)
import { navigate } from './navigation.js';
import { quickPlayPlaylist } from './detail.js';
// ─── PLAYLISTS ───
export async function loadPlaylists() {
const g = document.getElementById('playlists-grid');
if (!apiIsCached('getPlaylists')) g.innerHTML = '<div class="spinner"></div>';
try {
const r = await apiCached('getPlaylists', '', 3 * 60 * 1000);
const lists = r.playlists?.playlist || [];
g.innerHTML = '';
if(!lists.length) { g.innerHTML = '<div class="empty-state"><div class="icon">🎶</div><p>Keine Playlists vorhanden</p></div>'; return; }
lists.forEach(pl => {
const cv = coverUrl(pl.coverArt, 512);
const c = makeCard(pl.name, pl.songCount+' Titel', cv, '🎶');
c.dataset.plId = pl.id;
c.querySelector('.card-art').onclick = (e) => { e.stopPropagation(); quickPlayPlaylist(pl); };
c.querySelector('.card-info').onclick = () => navigate('playlist-detail', pl);
g.appendChild(c);
});
lists.forEach(pl => {
api('getPlaylist', `&id=${pl.id}`).then(r => {
const count = (r.playlist?.entry || []).length;
const card = g.querySelector(`[data-pl-id="${pl.id}"]`);
if (card) card.querySelector('.card-sub').textContent = count + ' Titel';
}).catch(() => {});
});
} catch(e) { g.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
export async function loadPlaylistDetail(pl) {
S._currentPlaylist = {...pl};
document.getElementById('playlist-detail-name').textContent = pl.name;
const cw = document.getElementById('playlist-cover');
const cv = coverUrl(pl.coverArt, 512);
cw.innerHTML = cv ? `<img src="${cv}" onerror="_imgRetry(this,'🎶')">` : '<div class="no-art">🎶</div>';
document.getElementById('btn-edit-playlist').onclick = () => openEditPlModal(S._currentPlaylist);
const sl = document.getElementById('playlist-songs');
sl.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getPlaylist', `&id=${pl.id}`);
const full = r.playlist || {};
S._currentPlaylist = {...pl, ...full, id: pl.id};
const songs = full.entry || [];
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 overviewCard = document.querySelector(`#playlists-grid [data-pl-id="${pl.id}"]`);
if (overviewCard) overviewCard.querySelector('.card-sub').textContent = songs.length + ' Titel';
const descEl = document.getElementById('playlist-detail-desc');
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);
};
sl.innerHTML = '';
const plSearch = document.getElementById('playlist-search');
if (plSearch) plSearch.value = '';
songs.forEach((s,i) => sl.appendChild(makeSongItem(s, i, songs, { noArtistLink: true })));
_setupReorderMode(sl, songs, S._currentPlaylist);
} catch(e) { sl.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
export function filterPlaylist(val) {
const q = val.trim().toLowerCase();
document.querySelectorAll('#playlist-songs .song-item').forEach(item => {
const title = (item.querySelector('.song-title')?.textContent || '').toLowerCase();
const artist = (item.querySelector('.song-artist')?.textContent || '').toLowerCase();
if (!q || title.includes(q) || artist.includes(q)) { item.style.display = ''; return; }
const rm = romajiLookup(item.dataset.songId);
item.style.display = ((rm?.titleRomaji||'').toLowerCase().includes(q) || (rm?.artistRomaji||'').toLowerCase().includes(q)) ? '' : 'none';
});
}
// ─── PLAYLIST REORDER ───
let _reorderCtx = null;
function _setupReorderMode(container, songs, pl) {
if (_reorderCtx) _exitReorderMode(false);
_reorderCtx = { container, songs, pl };
const btn = document.getElementById('btn-reorder-playlist');
if (btn) btn.onclick = plReorderEnter;
const bar = document.getElementById('pl-reorder-bar');
if (bar) bar.style.display = 'none';
}
export function plReorderEnter() {
if (!_reorderCtx || _reorderCtx.active) return;
_reorderCtx.active = true;
const { container, songs } = _reorderCtx;
container.classList.add('pl-draggable');
container.addEventListener('contextmenu', _blockCtxMenu, true);
let dragSrc = null, touchDragSrc = null;
Array.from(container.children).forEach((item, i) => {
const handle = document.createElement('div');
handle.className = 'pl-drag-handle';
handle.innerHTML = '<svg width="10" height="14" viewBox="0 0 10 14" fill="currentColor"><circle cx="3" cy="2" r="1.3"/><circle cx="7" cy="2" r="1.3"/><circle cx="3" cy="7" r="1.3"/><circle cx="7" cy="7" r="1.3"/><circle cx="3" cy="12" r="1.3"/><circle cx="7" cy="12" r="1.3"/></svg>';
item.insertBefore(handle, item.firstChild);
item._songRef = songs[i];
let fromHandle = false;
handle.addEventListener('mousedown', () => { fromHandle = true; item.draggable = true; });
item.addEventListener('dragstart', e => {
if (!fromHandle) { e.preventDefault(); item.draggable = false; return; }
fromHandle = false; dragSrc = item;
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', '');
setTimeout(() => item.classList.add('dragging'), 0);
});
item.addEventListener('dragend', () => {
fromHandle = false; item.draggable = false; dragSrc = null;
item.classList.remove('dragging');
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
});
item.addEventListener('dragover', e => {
e.preventDefault(); e.dataTransfer.dropEffect = 'move';
if (item === dragSrc) return;
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
item.classList.add('drag-over');
});
item.addEventListener('dragleave', e => { if (!item.contains(e.relatedTarget)) item.classList.remove('drag-over'); });
item.addEventListener('drop', e => {
e.preventDefault(); e.stopPropagation();
if (!dragSrc || item === dragSrc) return;
item.classList.remove('drag-over');
_doReorder(container, songs, dragSrc, item);
});
handle.addEventListener('touchstart', e => {
e.preventDefault(); e.stopPropagation();
touchDragSrc = item; item.classList.add('dragging');
}, { passive: false });
handle.addEventListener('touchmove', e => {
if (!touchDragSrc) return;
e.preventDefault();
const t = e.touches[0];
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
const over = _itemAtPoint(container, t.clientX, t.clientY, touchDragSrc);
if (over) over.classList.add('drag-over');
}, { passive: false });
handle.addEventListener('touchend', e => {
if (!touchDragSrc) return;
const t = e.changedTouches[0];
touchDragSrc.classList.remove('dragging');
container.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'));
const over = _itemAtPoint(container, t.clientX, t.clientY, touchDragSrc);
if (over) _doReorder(container, songs, touchDragSrc, over);
touchDragSrc = null;
}, { passive: false });
});
const bar = document.getElementById('pl-reorder-bar');
if (bar) bar.style.display = 'flex';
}
function _blockCtxMenu(e) { e.preventDefault(); e.stopPropagation(); }
function _itemAtPoint(container, x, y, exclude) {
return (document.elementsFromPoint(x, y) || [])
.find(el => el.classList?.contains('song-item') && el !== exclude && container.contains(el)) || null;
}
function _doReorder(container, songs, src, target) {
const all = Array.from(container.children);
const from = all.indexOf(src), to = all.indexOf(target);
container.insertBefore(src, from < to ? target.nextSibling : target);
const newOrder = Array.from(container.children).map(el => el._songRef);
songs.splice(0, songs.length, ...newOrder);
Array.from(container.children).forEach((el, i) => {
const n = el.querySelector('.song-num'); if (n) n.textContent = i + 1;
});
}
export function plReorderSave() { _exitReorderMode(true); }
export function plReorderCancel() { _exitReorderMode(false); }
function _exitReorderMode(save) {
if (!_reorderCtx) return;
const { container, songs, pl } = _reorderCtx;
_reorderCtx.active = false;
container.classList.remove('pl-draggable');
container.removeEventListener('contextmenu', _blockCtxMenu, true);
container.querySelectorAll('.pl-drag-handle').forEach(h => h.remove());
const bar = document.getElementById('pl-reorder-bar');
if (bar) bar.style.display = 'none';
if (save) _savePlaylistOrder(pl, songs);
}
async function _savePlaylistOrder(pl, songs) {
try {
const songIds = songs.map(s => `&songId=${s.id}`).join('');
await api('createPlaylist', `&playlistId=${pl.id}&name=${encodeURIComponent(pl.name)}${songIds}`);
toast('✅ Reihenfolge gespeichert');
} catch(e) { toast('Fehler beim Speichern: ' + e.message); }
}
// ─── STARRED / RANDOM ───
let _starredSort = 'newest';
export async function loadStarred(sort = _starredSort) {
_starredSort = sort;
document.querySelectorAll('#starred-sort-chips .sort-chip').forEach(c =>
c.classList.toggle('active', c.dataset.sort === sort));
const el = document.getElementById('starred-songs');
el.innerHTML = '<div class="spinner"></div>';
const searchEl = document.getElementById('starred-search');
if (searchEl) searchEl.value = '';
try {
const r = await api('getStarred2');
let songs = (r.starred2?.song || []).map(s => ({...s, _starred: true}));
if (sort === 'newest') songs.sort((a, b) => new Date(b.starred||0) - new Date(a.starred||0));
else if (sort === 'oldest') songs.sort((a, b) => new Date(a.starred||0) - new Date(b.starred||0));
else if (sort === 'az') songs.sort((a, b) => (a.title||'').localeCompare(b.title||''));
else if (sort === 'artist') songs.sort((a, b) => (a.artist||'').localeCompare(b.artist||''));
else if (sort === 'plays') songs.sort((a, b) => (b.playCount||0) - (a.playCount||0));
el.innerHTML = '';
if(!songs.length) { el.innerHTML = '<div class="empty-state"><div class="icon">⭐</div><p>Noch keine Favoriten</p></div>'; return; }
songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs, { noArtistLink: true })));
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
export function filterStarred(val) {
const q = val.trim().toLowerCase();
document.querySelectorAll('#starred-songs .song-item').forEach(item => {
const title = (item.querySelector('.song-title')?.textContent || '').toLowerCase();
const artist = (item.querySelector('.song-artist')?.textContent || '').toLowerCase();
if (!q || title.includes(q) || artist.includes(q)) { item.style.display = ''; return; }
const rm = romajiLookup(item.dataset.songId);
item.style.display = ((rm?.titleRomaji||'').toLowerCase().includes(q) || (rm?.artistRomaji||'').toLowerCase().includes(q)) ? '' : 'none';
});
}
export async function loadRandom() {
const el = document.getElementById('random-songs');
el.innerHTML = '<div class="spinner"></div>';
try {
const r = await api('getRandomSongs', '&size=50');
const songs = r.randomSongs?.song || [];
el.innerHTML = '';
songs.forEach((s,i) => el.appendChild(makeSongItem(s, i, songs)));
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
+252
View File
@@ -0,0 +1,252 @@
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { api, itunesSearch, itunesLookupAlbum, fetchRomajiIndex, dispArtistById } from '../api.js';
import { makeCard, makeAlbumCard, makeGlobalTrackItem, makeSongItem, mkSection } from '../ui/components.js';
import { playPreview } from '../player/player.js';
import { openDlModal } from '../ui/ui.js';
import { closeNowPlaying } from '../player/nowplaying.js';
// Circular — safe (called inside function bodies)
import { navigate } from './navigation.js';
import { getArtistImage } from './home.js';
// ─── ROMAJI → HIRAGANA ───
let _wanakana = null;
async function _romajiToHiragana(q) {
if (!/^[a-z\s'-]+$/i.test(q.trim())) return null;
try {
if (!_wanakana) _wanakana = await import('https://cdn.jsdelivr.net/npm/wanakana@5/esm/wanakana.js');
const h = _wanakana.toHiragana(q.trim(), { passRomaji: false });
return /[぀-ゟ]/.test(h) ? h : null;
} catch { return null; }
}
function _mergeUniq(a, b) {
const seen = new Set(a.map(x => x.id));
return [...a, ...(b || []).filter(x => !seen.has(x.id))];
}
// ─── LOCAL SEARCH ───
export function onLocalSearch(val) {
clearTimeout(S.searchTimer);
if(!val.trim()) { navigate('home'); return; }
navigate('search');
S.searchTimer = setTimeout(() => doLocalSearch(val), 350);
}
export async function doLocalSearch(q, el) {
if (!el) el = document.getElementById('search-results');
el.innerHTML = '<div class="spinner"></div>';
try {
const hiragana = await _romajiToHiragana(q);
const isRomaji = /^[a-z\s'-]+$/i.test(q.trim());
const [r, r2, romajiIdx] = await Promise.all([
api('search3', `&query=${encodeURIComponent(q)}&songCount=30&albumCount=10&artistCount=6`),
hiragana ? api('search3', `&query=${encodeURIComponent(hiragana)}&songCount=20&albumCount=6&artistCount=4`) : Promise.resolve(null),
isRomaji ? fetchRomajiIndex() : Promise.resolve(null),
]);
const res = r.searchResult3 || {};
const res2 = r2?.searchResult3 || {};
el.innerHTML = '';
const artists = _mergeUniq(res.artist||[], res2.artist);
const albums = _mergeUniq(res.album ||[], res2.album);
const qLow = q.trim().toLowerCase();
const romajiHits = romajiIdx ? romajiIdx.filter(e => e.r.includes(qLow)) : [];
const songs = _mergeUniq(_mergeUniq(res.song||[], res2.song), romajiHits);
if(!artists.length && !albums.length && !songs.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse</p></div>'; return;
}
if(artists.length) {
const s = mkSection('Künstler');
const g = document.createElement('div'); g.className = 'scroll-row';
artists.forEach(a => {
const c = makeCard(dispArtistById(a.id, a.name), a.albumCount+' Alben', null, '🎤');
c.classList.add('artist-card');
c.onclick = () => navigate('artist-detail', a);
g.appendChild(c);
getArtistImage(a.id).then(img => {
if(!img) return;
const artEl = c.querySelector('.card-art');
if(!artEl) return;
artEl.innerHTML = `<img src="${img}" loading="lazy" onerror="this.parentNode.innerHTML='<div class=\\'no-art\\'>🎤</div>'">`;
});
});
s.appendChild(g); el.appendChild(s);
}
if(albums.length) {
const s = mkSection('Alben');
const g = document.createElement('div'); g.className = 'scroll-row';
albums.forEach(a => g.appendChild(makeAlbumCard(a)));
s.appendChild(g); el.appendChild(s);
}
if(songs.length) {
const sec = mkSection('Titel');
const list = document.createElement('div'); list.className = 'song-list';
songs.forEach((so,i) => list.appendChild(makeSongItem(so, i, songs)));
sec.appendChild(list); el.appendChild(sec);
}
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">${e.message}</p>`; }
}
// ─── HEADER SEARCH ───
export function onHeaderSearch(val) {
const overlay = document.getElementById('header-search-overlay');
const clearBtn = document.getElementById('header-search-clear');
if (clearBtn) clearBtn.style.display = val ? 'block' : 'none';
clearTimeout(S._headerSearchTimer);
if (!val.trim()) { if (overlay) overlay.style.display = 'none'; return; }
if (overlay) {
const header = document.getElementById('mobile-header');
if (header) overlay.style.top = header.offsetHeight + 'px';
overlay.style.display = 'block';
}
const el = document.getElementById('header-search-results');
S._headerSearchTimer = setTimeout(() => doLocalSearch(val, el), 300);
}
export function onMobileSearch(val) { onHeaderSearch(val); }
// ─── GLOBAL (ITUNES) SEARCH ───
export function onGlobalSearch(val) {
clearTimeout(S.globalSearchTimer);
const el = document.getElementById('global-search-results');
if(!val.trim()) { el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Gib einen Suchbegriff ein</p></div>'; return; }
el.innerHTML = '<div class="spinner"></div>';
S.globalSearchTimer = setTimeout(() => doGlobalSearch(val), 400);
}
export async function doGlobalSearch(q) {
const el = document.getElementById('global-search-results');
try {
const [tracks, albums, artists] = await Promise.all([
itunesSearch(q,'musicTrack',25),
itunesSearch(q,'album',10),
itunesSearch(q,'musicArtist',6),
]);
el.innerHTML = '';
const trackResults = tracks.filter(t => t.wrapperType === 'track');
if(artists.length) {
const s = mkSection('Künstler');
const row = document.createElement('div'); row.className = 'scroll-row';
artists.forEach(a => {
const c = makeCard(a.artistName, 'iTunes Künstler', null, '🎤');
c.onclick = () => loadItunesArtistResults(a.artistId, a.artistName);
row.appendChild(c);
itunesSearch(a.artistName,'album',1).then(res => {
if(!res[0]) return;
const imgUrl = res[0].artworkUrl100?.replace('100x100','300x300');
if(!imgUrl) return;
const artEl = c.querySelector('.card-art'); if(!artEl) return;
const img = document.createElement('img');
img.src = imgUrl; img.loading = 'lazy'; img.style.cssText = 'width:100%;height:100%;object-fit:cover;transition:transform .4s';
const overlay = artEl.querySelector('.play-overlay');
artEl.innerHTML = ''; artEl.appendChild(img); if(overlay) artEl.appendChild(overlay);
img.onerror = () => { artEl.innerHTML = '<div class="no-art">🎤</div>'; if(overlay) artEl.appendChild(overlay); };
c.addEventListener('mouseenter', () => img.style.transform = 'scale(1.05)');
c.addEventListener('mouseleave', () => img.style.transform = 'scale(1)');
}).catch(() => {});
});
s.appendChild(row); el.appendChild(s);
}
if(albums.length) {
const s = mkSection('Alben');
const row = document.createElement('div'); row.className = 'scroll-row';
albums.forEach(a => {
const art = a.artworkUrl100?.replace('100x100','300x300');
const c = makeCard(a.collectionName, a.artistName, art, '💿');
c.querySelector('.play-btn').onclick = (e) => { e.stopPropagation(); loadItunesAlbum(a); };
c.querySelector('.dl-btn-overlay')?.remove();
const dlOverlay = document.createElement('button');
dlOverlay.className = 'dl-btn-overlay';
dlOverlay.title = 'Auf Server laden';
dlOverlay.innerHTML = `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
dlOverlay.onclick = (e) => { e.stopPropagation(); openDlModal({artistName:a.artistName,trackName:a.collectionName,artworkUrl100:a.artworkUrl100,collectionName:a.collectionName,isAlbum:true,itunesData:a}); };
c.querySelector('.play-overlay').appendChild(dlOverlay);
c.onclick = () => loadItunesAlbum(a);
row.appendChild(c);
});
s.appendChild(row); el.appendChild(s);
}
if(trackResults.length) {
const s = mkSection('Titel');
const list = document.createElement('div'); list.className = 'song-list';
trackResults.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, trackResults)));
s.appendChild(list); el.appendChild(s);
}
if(!artists.length && !albums.length && !trackResults.length) {
el.innerHTML = '<div class="empty-state"><div class="icon">🔍</div><p>Keine Ergebnisse gefunden</p></div>';
}
} catch(e) { el.innerHTML = `<p style="color:var(--danger)">Suche fehlgeschlagen: ${e.message}</p>`; }
}
export async function loadItunesAlbum(album) {
try {
const tracks = await itunesLookupAlbum(album.collectionId);
if(!tracks.length) { toast('Keine Tracks gefunden'); return; }
const el = document.getElementById('global-search-results');
const s = document.createElement('div'); s.className = 'search-section';
s.innerHTML = `<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
<button class="back-btn" style="margin:0" onclick="document.getElementById('global-search-input').dispatchEvent(new Event('input'))"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>Zurück</button>
<div style="flex:1">
<div style="font-size:15px;font-weight:700">${esc(album.collectionName)}</div>
<div style="font-size:12px;color:var(--text2)">${esc(album.artistName)}</div>
</div>
<button class="btn-outline blue" style="font-size:12px;padding:7px 12px" onclick="openDlModal({artistName:'${esc(album.artistName)}',trackName:'${esc(album.collectionName)}',artworkUrl100:'${album.artworkUrl100||''}',collectionName:'${esc(album.collectionName)}',isAlbum:true,itunesData:${JSON.stringify(album).replace(/"/g,'&quot;')}})">⬇ Album laden</button>
</div>
<div class="search-section-title">Tracks</div>`;
const list = document.createElement('div'); list.className = 'song-list';
tracks.forEach((t,i) => list.appendChild(makeGlobalTrackItem(t, i, tracks)));
s.appendChild(list);
el.innerHTML = ''; el.appendChild(s);
} catch(e) { toast('Fehler: ' + e.message); }
}
export function loadItunesArtistResults(artistId, artistName) {
navigate('artist-detail', { id: null, name: artistName, itunesArtistId: artistId });
}
// ─── SEARCH HELPERS ───
export function searchSimilarArtist(artistName) {
navigate('discover');
document.getElementById('global-search-input').value = artistName;
onGlobalSearch(artistName);
}
export async function searchAndPreview(trackName, artistName) {
try {
const res = await itunesSearch(trackName+' '+artistName, 'musicTrack', 1);
if(res[0]?.previewUrl) playPreview(res[0]); else toast('Kein Preview verfügbar');
} catch { toast('Suche fehlgeschlagen'); }
}
// ─── NAVIGATION SHORTCUTS ───
export async function s_goArtist(e, name, id) {
if(!name) return;
if(id) { navigate('artist-detail', {id, name}); return; }
try {
const r = await api('search3', `&query=${encodeURIComponent(name)}&artistCount=5&albumCount=0&songCount=0`);
const artists = r.searchResult3?.artist || [];
const match = artists.find(a => a.name.toLowerCase() === name.toLowerCase()) || artists[0];
if(match) navigate('artist-detail', {id: match.id, name: match.name});
else toast('Künstler nicht gefunden');
} catch { toast('Suche fehlgeschlagen'); }
}
export function s_goAlbum(e, albumId, albumName, artist, artistId) {
if(albumId) navigate('album-detail', {id:albumId, name:albumName, artist, artistId:artistId||'', coverArt:''});
}
export function playerBarGoAlbum() {
const s = S.queue[S.qIdx];
if(!s) return;
if(S.npOpen) closeNowPlaying();
if(s.albumId) navigate('album-detail', {id:s.albumId, name:s.album||'', artist:s.artist||'', artistId:s.artistId||'', coverArt:s.coverArt||''});
}
export function playerBarGoArtist() {
const s = S.queue[S.qIdx];
if(!s || !s.artist) return;
if(S.npOpen) closeNowPlaying();
navigate('artist-detail', {id: s.artistId||s.artist, name: s.artist});
}
@@ -1,5 +1,5 @@
import { S } from './state.js';
import { esc, toast } from './utils.js';
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
export function loadSoundCloudImporter() {
// Hint is managed by the unified importer input in spotify.js
+5 -5
View File
@@ -1,9 +1,9 @@
import { S } from './state.js';
import { esc, toast } from './utils.js';
import { itunesSearch } from './api.js';
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { itunesSearch } from '../api.js';
// Circular imports — safe (only used inside function bodies)
import { makeGlobalTrackItem } from './components.js';
import { openDlModal } from './ui.js';
import { makeGlobalTrackItem } from '../ui/components.js';
import { openDlModal } from '../ui/ui.js';
// ─── UNIFIED IMPORTER INPUT ───
export function onImporterInput(val) {
+8
View File
@@ -0,0 +1,8 @@
// Barrel — re-exports everything so external imports (main.js, player.js, etc.)
// continue to use a single entry point without path changes.
export * from './navigation.js';
export * from './home.js';
export * from './detail.js';
export * from './playlists.js';
export * from './search.js';
export * from './discover.js';
+3 -3
View File
@@ -1,6 +1,6 @@
import { S } from './state.js';
import { esc, toast } from './utils.js';
import { makeGlobalTrackItem } from './components.js';
import { S } from '../state.js';
import { esc, toast } from '../utils.js';
import { makeGlobalTrackItem } from '../ui/components.js';
export async function resolveYouTubeLink() {
const url = document.getElementById('importer-link-input')?.value.trim();