diff --git a/backend/lib/romaji.js b/backend/lib/romaji.js index cf75ec8..672e987 100644 --- a/backend/lib/romaji.js +++ b/backend/lib/romaji.js @@ -130,15 +130,18 @@ async function buildRomajiIndex(ndUrl, user, pass) { const index = songs .filter(s => JP_RE.test(s.title) || JP_RE.test(s.artist) || JP_RE.test(s.album)) .map(s => ({ - id: s.id, - r: [toRomaji(s.title), toRomaji(s.artist), toRomaji(s.album)].join(' '), - title: s.title, - artist: s.artist || '', - album: s.album || '', - coverArt: s.coverArt || '', - duration: s.duration || 0, - albumId: s.albumId || '', - artistId: s.artistId || '', + id: s.id, + r: [toRomaji(s.title), toRomaji(s.artist), toRomaji(s.album)].join(' '), + title: s.title, + artist: s.artist || '', + album: s.album || '', + titleRomaji: romajiForStorage(s.title) || s.title, + artistRomaji: romajiForStorage(s.artist) || s.artist || '', + albumRomaji: romajiForStorage(s.album) || s.album || '', + coverArt: s.coverArt || '', + duration: s.duration || 0, + albumId: s.albumId || '', + artistId: s.artistId || '', })); romajiCache.set(key, { ts: Date.now(), index }); return { fromCache: false, index }; diff --git a/backend/lib/ytdlp.js b/backend/lib/ytdlp.js index f224c34..56e5f9f 100644 --- a/backend/lib/ytdlp.js +++ b/backend/lib/ytdlp.js @@ -48,8 +48,11 @@ function buildVariantFilter(title) { return parts.length ? parts.join(' & ') : ''; } -async function downloadTrack({ jobId, artist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs, sourceUrl }) { +async function downloadTrack({ jobId, artist, rawArtist, albumArtist, title, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, outputDir, durationMs, sourceUrl }) { const mainArtist = albumArtist || artist; + + // Folder / filename: romaji of the resolved artist (iTunes name if available — already in English, + // otherwise kuromoji). rawArtist (original Japanese) is only used for tags + YTM search. const mainArtistRomaji = romajiForStorage(mainArtist) || mainArtist; const artistRomaji = romajiForStorage(artist) || artist; const titleRomaji = romajiForStorage(title) || title; @@ -73,11 +76,12 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t } const outputTemplate = path.join(dir, `${fileBase}.%(ext)s`); - // Strip only Unicode symbol chars (☆ ★ ♥ etc.) for ytsearch — YouTube search chokes on them - // and returns 0 results. Keep ASCII punctuation (!&-) so e.g. "N!GHT" stays "N!GHT" rather - // than collapsing to "NGHT" which YouTube misreads as the word "night". - const searchArtist = mainArtist.replace(/\p{S}/gu, '').trim(); - const query = `${searchArtist} - ${title}`; + // For YTM search: use the original (raw) artist when available — the YTM channel is registered + // under the Japanese/original name, not the iTunes-resolved English name. + // rawArtist is undefined for non-Japanese tracks, so fall back to mainArtist. + const ytmArtist = (rawArtist || mainArtist).replace(/\p{S}/gu, '').trim(); + const searchArtist = ytmArtist; + const query = `${ytmArtist} - ${title}`; const baseArgs = [ '--extract-audio', @@ -112,8 +116,8 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t }); const YTM_SONGS_PARAM = 'EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D'; - // Use original artist name (with special chars) for YTM URL — better search ranking than stripped version - const ytmQuery = `${mainArtist} - ${title}`; + // Use original (raw) artist for YTM URL — better ranking since channel is in original language + const ytmQuery = `${rawArtist || mainArtist} - ${title}`; const ytmUrl = `https://music.youtube.com/search?q=${encodeURIComponent(ytmQuery)}¶ms=${YTM_SONGS_PARAM}`; const expectedAudio = path.join(dir, `${fileBase}.${CONFIG.audioFmt}`); const hasAudio = () => fs.existsSync(expectedAudio); @@ -124,7 +128,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t await spawnYtdlp([sourceUrl, '--no-playlist', ...baseArgs]); const audioFile = hasAudio() ? expectedAudio : null; if (!audioFile) { log(jobId, '⚠️ Download fehlgeschlagen'); return { success: true, dir }; } - return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }); + return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }); } // Fetch YTM search result URLs as a flat list (no channel expansion) and return only @@ -172,7 +176,7 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t // downloading a same-title song by a different artist (e.g. wrong "Moonlight" for N!GHT). // Dropped in later fallback steps so genuine mismatches (label channels etc.) still get found. const escRe = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const artistFirstWord = mainArtist.split(/[\s,/]+/)[0]; + const artistFirstWord = (rawArtist || mainArtist).split(/[\s,/]+/)[0]; const artistChannelPart = artistFirstWord.length >= 3 ? ` & channel ~= (?i)${escRe(artistFirstWord)}` : ''; @@ -287,11 +291,11 @@ async function downloadTrack({ jobId, artist, albumArtist, title, album, year, t return { success: true, dir }; } - return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }); + return embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }); } // Shared ffmpeg metadata embedding — used by both yt-dlp and squid paths -async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }) { +async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, rawArtist, artist, mainArtist, album, year, trackNum, totalTracks, discNum, discTotal, compilation, genre, artworkUrl, explicit, title }) { const coverPath = await downloadArtwork(artworkUrl, dir); const hasCover = !!(coverPath && fs.existsSync(coverPath)); @@ -304,19 +308,32 @@ async function embedMetadata({ jobId, audioFile, dir, trackPrefix, safeTitle, ar if (hasCover) ffArgs.push('-i', coverPath); ffArgs.push('-map', '0:a'); if (hasCover) ffArgs.push('-map', '1:v'); - const storedTitle = romajiForStorage(title) || title; - const storedArtist = romajiForStorage(navidromeArtist(artist)) || navidromeArtist(artist); - const storedAlbumArtist = romajiForStorage(mainArtist.replace(/\s+&\s+/g, ', ')) || mainArtist.replace(/\s+&\s+/g, ', '); - const storedAlbum = album ? (romajiForStorage(album) || album) : null; + // Main tags: store original text (Japanese or whatever was passed in). + // rawArtist = original Spotify/source artist (may be Japanese); artist = iTunes-resolved (may be English). + // Use rawArtist for the stored tag so Apple Music / other apps see the authentic original. + const tagArtist = navidromeArtist(rawArtist || artist); + const tagAlbumArtist = (rawArtist || mainArtist).replace(/\s+&\s+/g, ', '); + // Sort tags: romaji for display / sorting in apps that support them (TITLESORT, ARTISTSORT, etc.) + // artist is already the iTunes-resolved English name when available — use directly (more accurate + // than kuromoji). For non-iTunes tracks romajiForStorage acts as kuromoji fallback. + const sortTitle = romajiForStorage(title) || title; + const sortArtist = romajiForStorage(navidromeArtist(artist)) || navidromeArtist(artist); + const sortAlbumArtist = romajiForStorage(mainArtist.replace(/\s+&\s+/g, ', ')) || mainArtist.replace(/\s+&\s+/g, ', '); + const sortAlbum = album ? (romajiForStorage(album) || album) : null; ffArgs.push( '-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3', - '-metadata', `title=${storedTitle}`, - '-metadata', `artist=${storedArtist}`, - '-metadata', `album_artist=${storedAlbumArtist}`, + '-metadata', `title=${title}`, + '-metadata', `artist=${tagArtist}`, + '-metadata', `album_artist=${tagAlbumArtist}`, ); - if (storedAlbum) ffArgs.push('-metadata', `album=${storedAlbum}`); + if (album) ffArgs.push('-metadata', `album=${album}`); + // Sort tags — only write when they differ from main (no-op for non-Japanese tracks) + if (sortTitle !== title) ffArgs.push('-metadata', `TITLESORT=${sortTitle}`); + if (sortArtist !== tagArtist) ffArgs.push('-metadata', `ARTISTSORT=${sortArtist}`); + if (sortAlbumArtist !== tagAlbumArtist) ffArgs.push('-metadata', `ALBUMARTISTSORT=${sortAlbumArtist}`); + if (sortAlbum && sortAlbum !== album) ffArgs.push('-metadata', `ALBUMSORT=${sortAlbum}`); if (year) ffArgs.push('-metadata', `date=${String(year)}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`); diff --git a/backend/novasonic-server.js b/backend/novasonic-server.js index a0031f0..e07c8d7 100644 --- a/backend/novasonic-server.js +++ b/backend/novasonic-server.js @@ -373,7 +373,7 @@ app.post('/api/download', async (req, res) => { log(jobId, `📋 Album aus Spotify: "${meta.album}"`); } const finalArtist = meta.itunesArtistName || artist; - const result = await downloadTrack({ jobId, artist: finalArtist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined }); + const result = await downloadTrack({ jobId, artist: finalArtist, rawArtist: artist, title, ...meta, sourceUrl: sourceUrl || sp.sourceUrl || undefined }); if (result?.skipped) { updateJob(jobId, { status: 'done', progress: 100, message: 'Bereits vorhanden – übersprungen', skipped: true }); } else { @@ -447,10 +447,13 @@ app.post('/api/download', async (req, res) => { log(jobId, `📋 iTunes: "${trackArtist} – ${trackTitle}"`); itunes = await lookupItunesTrackMeta(searchArtist, trackTitle); } - const resolvedArtist = itunes.itunesArtistName || trackArtist; + // Prefer iTunes-resolved name (from server lookup or frontend enrichment) for sort tags / folders. + // trackArtist stays as rawArtist for main tags and YTM search. + const resolvedArtist = itunes.itunesArtistName || track.itunesArtistName || trackArtist; return downloadTrack({ jobId, - artist: resolvedArtist, + artist: resolvedArtist, // iTunes English name → used for sort tags + folder names + rawArtist: trackArtist, // original Spotify/Japanese name → used for main tag + YTM search albumArtist: resolvedArtist, title: trackTitle, album: itunes.album || track.collectionName || track.album || '', @@ -689,11 +692,18 @@ app.post('/api/edit-metadata', async (req, res) => { ffArgs.push('-map', '0:v?'); // '?' = optional, no error if file has no cover } ffArgs.push('-c', 'copy', '-map_metadata', '-1', '-id3v2_version', '3'); - if (romajiTitle) ffArgs.push('-metadata', `title=${romajiTitle}`); - if (romajiArtist) ffArgs.push('-metadata', `artist=${romajiArtist}`); - const resolvedAlbumArtist = romajiAlbumArtist || (romajiArtist ? romajiArtist.split(';')[0].trim() : null); - if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`); - if (romajiAlbum) ffArgs.push('-metadata', `album=${romajiAlbum}`); + // Main tags: store original user input (may be Japanese — Apple Music / other apps see authentic text) + if (title) ffArgs.push('-metadata', `title=${title}`); + if (normalArtist) ffArgs.push('-metadata', `artist=${normalArtist}`); + const resolvedAlbumArtist = normalAlbumArtist || (normalArtist ? normalArtist.split(';')[0].trim() : null); + if (resolvedAlbumArtist) ffArgs.push('-metadata', `album_artist=${resolvedAlbumArtist}`); + if (album) ffArgs.push('-metadata', `album=${album}`); + // Sort tags: romaji versions for apps that use them for sorting / display + if (romajiTitle && romajiTitle !== title) ffArgs.push('-metadata', `TITLESORT=${romajiTitle}`); + if (romajiArtist && romajiArtist !== normalArtist) ffArgs.push('-metadata', `ARTISTSORT=${romajiArtist}`); + const resolvedRomajiAA = romajiAlbumArtist || (romajiArtist ? romajiArtist.split(';')[0].trim() : null); + if (resolvedRomajiAA && resolvedRomajiAA !== resolvedAlbumArtist) ffArgs.push('-metadata', `ALBUMARTISTSORT=${resolvedRomajiAA}`); + if (romajiAlbum && romajiAlbum !== album) ffArgs.push('-metadata', `ALBUMSORT=${romajiAlbum}`); if (year) ffArgs.push('-metadata', `date=${year}`); if (trackNum) ffArgs.push('-metadata', `track=${totalTracks ? `${trackNum}/${totalTracks}` : String(trackNum)}`); if (totalTracks) ffArgs.push('-metadata', `totaltracks=${String(totalTracks)}`); diff --git a/index.html b/index.html index 8e82933..434a352 100644 --- a/index.html +++ b/index.html @@ -656,6 +656,11 @@
Zeigt Wiedergabezahl in Album- und Playlist-Ansichten. Künstlerseite zeigt sie immer.
+
+ Romaji anzeigen +
+
+
Zeigt japanische Titel und Künstlernamen in Romaji (lateinische Umschrift). Originaltitel bleiben in den Datei-Tags erhalten.

diff --git a/src/api.js b/src/api.js index 0b3f97b..457b071 100644 --- a/src/api.js +++ b/src/api.js @@ -52,8 +52,15 @@ export async function ndAuth() { // ─── ROMAJI INDEX ─── // Fetched once per session from the backend, cached in memory. let _romajiIndex = null; +let _romajiMap = null; // Map let _romajiIndexFetch = null; // in-flight promise dedup +function _buildRomajiMap(idx) { + const m = new Map(); + if (Array.isArray(idx)) idx.forEach(e => m.set(String(e.id), e)); + return m; +} + export async function fetchRomajiIndex() { if (!S.cfg.backendUrl || !S.cfg.url || !S.cfg.user || !S.cfg.pass) return null; if (_romajiIndex) return _romajiIndex; @@ -61,12 +68,39 @@ export async function fetchRomajiIndex() { _romajiIndexFetch = fetch( `${S.cfg.backendUrl}/api/romaji-index?ndUrl=${encodeURIComponent(S.cfg.url)}&user=${encodeURIComponent(S.cfg.user)}&pass=${encodeURIComponent(S.cfg.pass)}` ).then(r => r.ok ? r.json() : null) - .then(idx => { _romajiIndex = idx; _romajiIndexFetch = null; return idx; }) + .then(idx => { _romajiIndex = idx; _romajiMap = _buildRomajiMap(idx); _romajiIndexFetch = null; return idx; }) .catch(() => { _romajiIndexFetch = null; return null; }); return _romajiIndexFetch; } -export function invalidateRomajiIndex() { _romajiIndex = null; } +export function invalidateRomajiIndex() { _romajiIndex = null; _romajiMap = null; } + +// Synchronous lookup — returns index entry for a song ID, or null if not in index. +export function romajiLookup(id) { return _romajiMap?.get(String(id)) ?? null; } + +// Display helpers — return romaji when showRomaji is on and the song is in the index, +// otherwise fall back to the original Navidrome values. +export function dispTitle(s) { + if (!S.cfg.showRomaji) return s.title || ''; + return romajiLookup(s.id)?.titleRomaji || s.title || ''; +} +export function dispArtist(s) { + if (!S.cfg.showRomaji) return s.artist || ''; + return romajiLookup(s.id)?.artistRomaji || s.artist || ''; +} +export function dispAlbum(s) { + if (!S.cfg.showRomaji) return s.album || ''; + return romajiLookup(s.id)?.albumRomaji || s.album || ''; +} +// Returns [{name, id}] like parseArtists but with romaji display names when showRomaji is on. +// Multi-artist romaji is collapsed into one entry since we can't reliably split the converted string. +export function dispArtistParts(s, parseArtistsFn) { + const parts = parseArtistsFn(s); + if (!S.cfg.showRomaji) return parts; + const info = romajiLookup(s.id); + if (!info?.artistRomaji) return parts; + return [{ name: info.artistRomaji, id: parts[0]?.id || '' }]; +} export async function romajiConvert(texts) { if (!S.cfg.backendUrl || !texts.length) return texts; diff --git a/src/components.js b/src/components.js index c5cb520..03d60eb 100644 --- a/src/components.js +++ b/src/components.js @@ -1,6 +1,6 @@ import { S } from './state.js'; import { esc, formatDur } from './utils.js'; -import { coverUrl } from './api.js'; +import { coverUrl, dispTitle, dispArtistParts, dispAlbum } from './api.js'; // Circular imports — safe (only used inside event handlers after module init) import { playQueue, toggleStar, playPreview, parseArtists } from './player.js'; import { showCtxMenu, openDlModal } from './ui.js'; @@ -33,7 +33,7 @@ export function makeSongItem(s, idx, list, { noArtistLink = false } = {}) { el.className = 'song-item' + (S.queue[S.qIdx]?.id === s.id ? ' playing' : ''); el.dataset.songId = s.id; const cv = coverUrl(s.coverArt, 128); - const artistParts = parseArtists(s); + const artistParts = dispArtistParts(s, parseArtists); const artistHtml = artistParts.length ? artistParts.map(a => noArtistLink ? `${esc(a.name)}` @@ -47,7 +47,7 @@ export function makeSongItem(s, idx, list, { noArtistLink = false } = {}) { el.innerHTML = `
${idx+1}
${cv ? `` : '
♪
'}
-
${esc(s.title)}
+
${esc(dispTitle(s))}
${artistHtml}
diff --git a/src/player.js b/src/player.js index 5739892..7b501eb 100644 --- a/src/player.js +++ b/src/player.js @@ -1,6 +1,6 @@ import { S } from './state.js'; import { esc, formatDur, toast } from './utils.js'; -import { api, streamUrl, coverUrl, downloadUrl } from './api.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 { updateLyricsHighlight, onNpSongChange } from './nowplaying.js'; @@ -451,13 +451,13 @@ export function parseArtists(s) { } export function updatePlayerUI(s, _skipMediaSession = false) { - document.getElementById('player-title').textContent = s.title; - const artistParts = parseArtists(s); + document.getElementById('player-title').textContent = dispTitle(s); + const artistParts = dispArtistParts(s, parseArtists); document.getElementById('player-artist-name').innerHTML = artistParts.length ? artistParts.map(a => `${esc(a.name)}`) .join(' • ') : '—'; - document.getElementById('player-album-name').textContent = s.album || ''; + document.getElementById('player-album-name').textContent = dispAlbum(s); const art = document.getElementById('player-art'); const cv = coverUrl(s.coverArt, 256); const existingImg = art.querySelector('img'); @@ -518,16 +518,16 @@ export function _syncNpDirect(s) { else cover.innerHTML = cv ? `` : '
♪
'; } _crossfadeNpBg(cv); - const t = document.getElementById('np-title'); if(t) t.textContent = s.title || '—'; + const t = document.getElementById('np-title'); if(t) t.textContent = dispTitle(s) || '—'; const an = document.getElementById('np-artist-name'); if(an) { - const parts = parseArtists(s); + const parts = dispArtistParts(s, parseArtists); an.innerHTML = parts.length ? parts.map(a => `${esc(a.name)}`) .join(' • ') : '—'; } - const aln = document.getElementById('np-album-name'); if(aln) aln.textContent = s.album || ''; + const aln = document.getElementById('np-album-name'); if(aln) aln.textContent = dispAlbum(s); syncNpStar(); } @@ -994,7 +994,7 @@ export function renderQueue() { const item = document.createElement('div'); item.className = 'queue-item' + (i === S.qIdx ? ' active' : ''); const cv = coverUrl(s.coverArt, 128); - item.innerHTML = `
${i+1}
${cv ? `` : '♪'}
${esc(s.title)}
${esc(s.artist||'')}
`; + item.innerHTML = `
${i+1}
${cv ? `` : '♪'}
${esc(dispTitle(s))}
${esc(dispArtist(s))}
`; item.onclick = () => { S.qIdx = i; playCurrent(); }; el.appendChild(item); }); @@ -1008,6 +1008,14 @@ export function toggleQueue() { _syncOverlayState(); } +// Called after showRomaji is toggled — re-renders all live player UI with new display mode. +export function refreshDisplayMode() { + _renderedQueueKey = ''; // force queue re-render + if (S.queueOpen) renderQueue(); + const s = S.queue[S.qIdx]; + if (s) { updatePlayerUI(s, true); _syncNpDirect(s); } +} + // ─── DOWNLOAD ───────────────────────────────────────────────────────────── export function dlSongLocal(s) { diff --git a/src/spotify.js b/src/spotify.js index 70220ba..f724ae8 100644 --- a/src/spotify.js +++ b/src/spotify.js @@ -295,8 +295,9 @@ async function _loadSpotifyTrackArtwork(tracks, listEl) { if (match.trackExplicitness) t.trackExplicitness = match.trackExplicitness; if (match.previewUrl) t.previewUrl = match.previewUrl; if (match.trackTimeMillis) t.durationMs = match.trackTimeMillis; - // Use iTunes canonical English/romaji artist name (Spotify may have Japanese or wrong order) - if (match.artistName) t.artistName = match.artistName; + // Store iTunes canonical name separately — keeps original Spotify/Japanese in artistName + // so the UI shows the original and YTM search uses the original channel name. + if (match.artistName) t.itunesArtistName = match.artistName; if (window._spotifyResolved?.tracks?.[idx]) Object.assign(window._spotifyResolved.tracks[idx], t); const url = match.artworkUrl100 || match.artworkUrl60 || ''; if (url) _setArt(artEls[idx], url); diff --git a/src/state.js b/src/state.js index 13056ed..8f5bdd4 100644 --- a/src/state.js +++ b/src/state.js @@ -1,6 +1,6 @@ // Shared mutable state — imported as { S } in every module export const S = { - cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false }, + cfg: { url: '', user: '', pass: '', lastfmKey: '', backendUrl: '', showPlayCount: false, showRomaji: false }, // Playback queue: [], diff --git a/src/ui.js b/src/ui.js index 29629f4..ddf82a8 100644 --- a/src/ui.js +++ b/src/ui.js @@ -3,7 +3,7 @@ import { esc, formatDur, toast } from './utils.js'; import { api, coverUrl, ndAuth, fetchRomajiIndex, romajiConvert } from './api.js'; import { THEMES, applyTheme, getCurrentThemeId } from './themes.js'; // Circular imports — safe (only used inside function bodies) -import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon, nativeInsertAt } from './player.js'; +import { playQueue, dlSongLocal, toggleStar, togglePlay, renderQueue, syncNpStar, updateStarBtn, npToggleStar, playCurrent, _syncNpDirect, setPlayIcon, nativeInsertAt, refreshDisplayMode } from './player.js'; import { navigate, loadPlaylists } from './views.js'; // ─── SIDEBAR ─── @@ -60,6 +60,7 @@ export function openSettings() { 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); _renderThemePicker(); document.getElementById('settings-modal').classList.add('open'); checkBackend(); @@ -92,9 +93,15 @@ export function saveSettings() { if(pass) S.cfg.pass = pass; 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'); + 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'); localStorage.setItem('ns_cfg', JSON.stringify(S.cfg)); closeSettings(); toast('✅ Gespeichert'); + if (romajiChanged) { + // Pre-load the index so display is instant, then refresh all live UI + fetchRomajiIndex().then(() => refreshDisplayMode()); + } } export async function checkBackend() { const dot = document.getElementById('backend-status-dot');