From de8e079a6d43585be066e901497c4821be28a762 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 22 May 2026 21:19:50 -0700 Subject: [PATCH] feat(media-player): playable tracks across modals + lyrics + cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three related improvements to the now-playing media player and the "add to wishlist" / "download missing" modals. 1. Play buttons across track-list modals Every track row in the download-missing modals (Spotify, Tidal, YouTube, services, artist album, wishlist download-missing) and the add-to-wishlist modal now carries a play button. Click runs playTrackFromLibraryOrStream: - If the track has a local file_path → playLibraryTrack - Else POST /api/stats/resolve-track to find it in the library by title + artist → playLibraryTrack - Else fall back to _gsPlayTrack streaming Backend ownership response gains track_id / title / file_path so the wishlist modal's owned tracks can hand the right metadata to the player without an extra round trip. The add-to-wishlist modal previously showed the play button only on owned tracks; now the button is unconditional so the streaming fallback can take over for unowned ones (matches the standard pattern from the rest of the app). 2. Clean media-player display titles YouTube / Tidal / Qobuz / torrent / usenet plugins encode their source-side identifier into the filename field as || so download() can recover it later. The media player's track-title renderer never knew about this convention and showed strings like "wvgFsXoGFnQ||Sometimes I Cry When I'm Alone" verbatim in the now-playing UI. extractTrackTitle and setTrackInfo now strip the || prefix defensively so any path into the player gets a clean display. Local library playback also fetches canonical metadata from /api/stats/resolve-track when track.id is present so title / artist / album / album art come straight from the SoulSync DB instead of whatever the caller passed in. Falls back silently to caller values on any error so playback never blocks on the metadata fetch. 3. Lyrics panel + View Artist close New collapsed lyrics panel between the playback controls and queue panel. POST /api/lyrics/fetch (new backend endpoint) prefers the local .lrc / .txt sidecar files SoulSync writes during post-processing so downloaded tracks resolve lyrics with zero network hits; falls back to LRClib exact-match (when album + duration are available) then to LRClib search. Synced LRC results are parsed (handles multi-stamp lines for repeated choruses), and the active line highlights + smooth- scrolls into the middle of the viewport on every audio timeupdate. Plain-text results render without highlighting. Per-track cache prevents re-fetching when the user revisits the same track. Lyrics fetch is fire-and-forget — failure shows "No lyrics found" without ever blocking playback. View Artist on the expanded player now calls closeNowPlayingModal before navigating; the modal was previously sitting open over the artist page, hiding it. Handler is bound once and is a no-op when no artist_id is attached. CSS additions are additive (new .modal-track-play-btn and .np-lyrics-* rules); no existing styles touched. Backend endpoint returns 200-with-success-false on any miss so callers can render "no lyrics" without treating it as an error. WHATS_NEW updated under 2.5.9 with two entries (lyrics + View Artist close). --- web_server.py | 98 +++++++++++++ webui/index.html | 16 +++ webui/static/downloads.js | 87 ++++++++++- webui/static/helper.js | 2 + webui/static/library.js | 39 +++++ webui/static/media-player.js | 254 ++++++++++++++++++++++++++++++++- webui/static/shared-helpers.js | 2 +- webui/static/style.css | 114 +++++++++++++++ webui/static/sync-services.js | 2 +- webui/static/sync-spotify.js | 2 +- webui/static/wishlist-tools.js | 54 +++++++ 11 files changed, 661 insertions(+), 9 deletions(-) diff --git a/web_server.py b/web_server.py index deea944a..f5b817fb 100644 --- a/web_server.py +++ b/web_server.py @@ -8738,6 +8738,9 @@ def library_check_tracks(): file_ext = os.path.splitext(matched_db_track.file_path or '')[1].lstrip('.').upper() or None owned_map[track_name] = { "owned": True, + "track_id": getattr(matched_db_track, 'id', None), + "title": getattr(matched_db_track, 'title', track_name), + "file_path": getattr(matched_db_track, 'file_path', None), "format": file_ext, "bitrate": matched_db_track.bitrate, "album": getattr(matched_db_track, 'album_title', None) @@ -33164,6 +33167,101 @@ def stats_recent(): except Exception as e: return jsonify({'success': False, 'error': str(e)}), 500 +@app.route('/api/lyrics/fetch', methods=['POST']) +def fetch_lyrics_endpoint(): + """Fetch lyrics for the now-playing media player. + + Body: ``{title, artist, album?, duration?}``. Returns + ``{success, synced, plain, source}`` where ``synced`` is an LRC + string with ``[mm:ss.xx] line`` timestamps (or None) and ``plain`` + is the untimestamped text (or None). ``source`` is the lookup + strategy that hit (``exact`` / ``search`` / ``sidecar``). + + Tries the local ``.lrc`` / ``.txt`` sidecar first when a + ``file_path`` is supplied — already-downloaded tracks should not + bounce LRClib on every play. Falls through to LRClib's exact- + match endpoint when title+artist+album+duration are all available, + then to its generic search endpoint. + """ + try: + data = request.get_json() or {} + title = (data.get('title') or '').strip() + artist = (data.get('artist') or '').strip() + album = (data.get('album') or '').strip() or None + try: + duration = int(data.get('duration') or 0) or None + except (TypeError, ValueError): + duration = None + file_path = data.get('file_path') or None + + if not title or not artist: + return jsonify({'success': False, 'error': 'title and artist required', + 'synced': None, 'plain': None, 'source': None}), 400 + + # 1. Sidecar — fastest, no network. The post-processing flow + # drops .lrc / .txt next to the audio for every successful + # enrichment, so existing downloads almost always have one. + if file_path: + try: + import os as _os + stem, _ = _os.path.splitext(file_path) + lrc_path = stem + '.lrc' + txt_path = stem + '.txt' + if _os.path.exists(lrc_path): + with open(lrc_path, 'r', encoding='utf-8') as fh: + body = fh.read().strip() + if body: + return jsonify({'success': True, 'synced': body, + 'plain': None, 'source': 'sidecar'}) + if _os.path.exists(txt_path): + with open(txt_path, 'r', encoding='utf-8') as fh: + body = fh.read().strip() + if body: + return jsonify({'success': True, 'synced': None, + 'plain': body, 'source': 'sidecar'}) + except Exception as sidecar_err: + logger.debug("lyrics sidecar read skipped: %s", sidecar_err) + + # 2. LRClib network lookup via the shared client instance. + from core.lyrics_client import lyrics_client as _lyrics_client + api = getattr(_lyrics_client, 'api', None) + if api is None: + return jsonify({'success': False, 'error': 'lrclib unavailable', + 'synced': None, 'plain': None, 'source': None}), 200 + + result = None + # Exact-match endpoint requires all four fields. LRClib's API + # will 404 on any miss; treat as soft failure and fall through + # to the search endpoint. + if album and duration: + try: + result = api.get_lyrics(track_name=title, artist_name=artist, + album_name=album, duration=duration) + except Exception as exact_err: + logger.debug("lrclib exact lookup failed: %s", exact_err) + + if result is None: + try: + hits = api.search_lyrics(track_name=title, artist_name=artist) + if hits: + result = hits[0] + except Exception as search_err: + logger.debug("lrclib search lookup failed: %s", search_err) + + if result is None: + return jsonify({'success': False, 'error': 'no lyrics found', + 'synced': None, 'plain': None, 'source': None}) + + synced = getattr(result, 'synced_lyrics', None) or None + plain = getattr(result, 'plain_lyrics', None) or None + return jsonify({'success': bool(synced or plain), 'synced': synced, + 'plain': plain, 'source': 'lrclib'}) + except Exception as e: + logger.error("lyrics fetch failed: %s", e) + return jsonify({'success': False, 'error': str(e), + 'synced': None, 'plain': None, 'source': None}), 500 + + @app.route('/api/stats/resolve-track', methods=['POST']) def stats_resolve_track(): """Resolve a track by title+artist to get its file_path for playback.""" diff --git a/webui/index.html b/webui/index.html index 41393284..fac604ed 100644 --- a/webui/index.html +++ b/webui/index.html @@ -7275,6 +7275,22 @@ + + +
diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 36142faf..a8c590cf 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -582,7 +582,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam onchange="updateTrackSelectionCount('${virtualPlaylistId}')"> ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} 🔍 Pending @@ -2143,7 +2143,7 @@ async function openDownloadMissingWishlistModal(category = null, selectedTrackId ${tracks.map((track, index) => ` ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} 🔍 Pending - @@ -2613,6 +2613,89 @@ function updateTrackAnalysisResults(playlistId, results) { } } +function getModalTrackArtistName(track, fallbackArtist = '') { + const formatted = formatArtists(track?.artists); + if (formatted && formatted !== 'Unknown Artist') return formatted; + return track?.artist_name || track?.artist || fallbackArtist || formatted || ''; +} + +function getModalTrackAlbumTitle(track, process = null) { + if (track?.album) { + if (typeof track.album === 'string') return track.album; + if (track.album.name) return track.album.name; + if (track.album.title) return track.album.title; + } + if (process?.album) { + return process.album.name || process.album.title || ''; + } + return ''; +} + +function renderModalTrackPlayButton(playlistId, trackIndex) { + return ``; +} + +async function playTrackFromLibraryOrStream(track, albumTitle = '', artistName = '') { + const title = track?.title || track?.name || ''; + if (!title) { + showToast('No track title available to play', 'error'); + return; + } + + if (track?.file_path && typeof playLibraryTrack === 'function') { + await playLibraryTrack({ + id: track.id || track.track_id || null, + title, + file_path: track.file_path, + _stats_image: track._stats_image || track.album_thumb_url || null, + bitrate: track.bitrate, + artist_id: track.artist_id, + album_id: track.album_id + }, albumTitle, artistName); + return; + } + + try { + const res = await fetch('/api/stats/resolve-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title, artist: artistName }) + }); + const data = await res.json(); + if (data.success && data.track && data.track.file_path && typeof playLibraryTrack === 'function') { + await playLibraryTrack({ + ...data.track, + title: data.track.title || title, + _stats_image: data.track.album_thumb_url || data.track.artist_thumb_url || null + }, data.track.album_title || albumTitle, data.track.artist_name || artistName); + return; + } + } catch (e) { + console.debug('Library resolve failed before stream fallback:', e); + } + + if (typeof _gsPlayTrack === 'function') { + await _gsPlayTrack(title, artistName, albumTitle); + } else { + showToast('Playback is not available here', 'error'); + } +} + +async function playDownloadModalTrack(playlistId, trackIndex) { + const process = activeDownloadProcesses[playlistId]; + const track = process?.tracks?.[trackIndex] || playlistTrackCache[playlistId]?.[trackIndex]; + if (!track) { + showToast('Track is no longer available in this modal', 'error'); + return; + } + + await playTrackFromLibraryOrStream( + track, + getModalTrackAlbumTitle(track, process), + getModalTrackArtistName(track, process?.artist?.name || '') + ); +} + // ============================================================================ diff --git a/webui/static/helper.js b/webui/static/helper.js index 2935a313..14aaa032 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,8 @@ function closeHelperSearch() { const WHATS_NEW = { '2.5.9': [ { date: 'May 21, 2026 — 2.5.9 release' }, + { title: 'Now-playing modal: lyrics panel', desc: 'new lyrics panel below the player controls in the expanded now-playing modal. fetches from LRClib via /api/lyrics/fetch, but prefers the local .lrc / .txt sidecar files SoulSync drops next to your audio during post-processing so downloaded tracks show lyrics instantly with zero network. synced LRC (timestamped) highlights the active line and auto-scrolls it into the middle of the viewport on every audio timeupdate; plain text renders without highlighting. status chip shows whether the result came back Synced or Plain. panel is collapsed by default — click the Lyrics header to expand. cached per track so revisiting a track doesn\'t refetch.' }, + { title: 'Now-playing modal: View Artist closes the modal first', desc: 'tapping View Artist on the expanded media player now closes the now-playing modal before navigating, so the artist page is actually visible instead of sitting under a modal you\'d have to manually dismiss. click is a no-op when no artist_id is attached to the current track.' }, { title: 'Torrent and Usenet release downloads', desc: 'torrent and usenet are now real download sources backed by Prowlarr plus your configured downloader client. album downloads use a release-first staging flow so SoulSync downloads one release, watches live progress, then imports the matching tracks from the staged files. hybrid mode keeps torrent / usenet out of album batches, but still lets them participate for playlist singles and wishlist tracks.' }, { title: 'Fix: HiFi public instance compatibility', desc: 'HiFi instance probing now understands both supported manifest shapes: the newer /trackManifests-style flow and the public hifi-api /track/ legacy flow. instances that can search and return a playable HLS manifest are no longer mislabeled as Search only. browser-openable pages can still be offline from the API side, so HTTP 502 / Offline labels are still shown honestly.' }, { title: 'Fix: Jellyfin full refresh imports tracks on older databases', desc: 'full refresh could import artists and albums but fail every Jellyfin track on upgraded databases that were missing newer media columns. startup repair now adds the missing tracks.file_size and albums.api_track_count columns before refresh work runs, so old databases can accept new Jellyfin track rows again.' }, diff --git a/webui/static/library.js b/webui/static/library.js index cbbcb74e..e9d4186e 100644 --- a/webui/static/library.js +++ b/webui/static/library.js @@ -7974,6 +7974,45 @@ async function playLibraryTrack(track, albumTitle, artistName) { return; } + // Library tracks have authoritative metadata in the SoulSync DB — + // any title / artist / album the caller passes in is downstream of + // whatever modal triggered playback and may carry noise like the + // ``||`` filename prefix from a Prowlarr result. + // When the caller has a track.id, fetch the canonical row from + // resolve-track and overwrite the caller-supplied fields with the + // DB values. Falls back silently to the caller-supplied values on + // any error so we never lose the play action over a metadata fetch. + if (track.id && (track.title || track.name) && (artistName || track.artist_name)) { + try { + const _dbResp = await fetch('/api/stats/resolve-track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: track.title || track.name, + artist: artistName || track.artist_name || '', + }), + }); + const _dbData = await _dbResp.json(); + if (_dbData && _dbData.success && _dbData.track) { + const _row = _dbData.track; + track = { + ...track, + id: _row.id ?? track.id, + title: _row.title || track.title, + file_path: _row.file_path || track.file_path, + bitrate: _row.bitrate ?? track.bitrate, + artist_id: _row.artist_id ?? track.artist_id, + album_id: _row.album_id ?? track.album_id, + _stats_image: _row.image_url || _row.album_thumb_url || track._stats_image || null, + }; + if (_row.album_title) albumTitle = _row.album_title; + if (_row.artist_name) artistName = _row.artist_name; + } + } catch (_dbErr) { + console.debug('library track DB refresh skipped:', _dbErr); + } + } + try { // Stop any current playback first if (audioPlayer && !audioPlayer.paused) { diff --git a/webui/static/media-player.js b/webui/static/media-player.js index b300de5a..fd7eb526 100644 --- a/webui/static/media-player.js +++ b/webui/static/media-player.js @@ -64,8 +64,20 @@ function toggleMediaPlayerExpansion() { function extractTrackTitle(filename) { if (!filename) return null; + // Strip the ``||`` prefix used by YouTube / + // Tidal / Qobuz / torrent / usenet plugins to thread the source- + // side identifier through ``filename`` without polluting the + // display string. The id always comes first, the human title + // after. If no separator is present, fall through with the raw + // value so existing slskd / streaming-source paths are untouched. + let title = filename; + const sepIdx = title.indexOf('||'); + if (sepIdx >= 0) { + title = title.slice(sepIdx + 2); + } + // Remove file extension - let title = filename.replace(/\.[^/.]+$/, ''); + title = title.replace(/\.[^/.]+$/, ''); // Remove path components, keep only the filename title = title.split('/').pop().split('\\').pop(); @@ -82,17 +94,28 @@ function extractTrackTitle(filename) { return title || null; } +function _stripSourceIdPrefix(value) { + // Defensive cleanup for callers that pass a raw ``||`` + // string straight into setTrackInfo without first running + // extractTrackTitle. The id always precedes the separator; the display + // string follows. Strings with no separator pass through unchanged. + if (!value || typeof value !== 'string') return value; + const idx = value.indexOf('||'); + if (idx < 0) return value; + return value.slice(idx + 2); +} + function setTrackInfo(track) { currentTrack = track; const trackTitleElement = document.getElementById('track-title'); - const trackTitle = track.title || 'Unknown Track'; + const trackTitle = _stripSourceIdPrefix(track.title) || 'Unknown Track'; // Set up the HTML structure for scrolling trackTitleElement.innerHTML = `${escapeHtml(trackTitle)}`; - document.getElementById('artist-name').textContent = track.artist || 'Unknown Artist'; - document.getElementById('album-name').textContent = track.album || 'Unknown Album'; + document.getElementById('artist-name').textContent = _stripSourceIdPrefix(track.artist) || 'Unknown Artist'; + document.getElementById('album-name').textContent = _stripSourceIdPrefix(track.album) || 'Unknown Album'; // Check if title needs scrolling (similar to GUI app) setTimeout(() => { @@ -120,12 +143,35 @@ function setTrackInfo(track) { gotoArtistBtn.setAttribute('aria-disabled', 'true'); gotoArtistBtn.tabIndex = -1; } + // Close the expanded now-playing modal when the user navigates + // to the artist page — otherwise the modal sits open over the + // page they just opened. ``_npGotoArtistHandlerAttached`` flag + // keeps us from binding multiple listeners across setTrackInfo + // calls (fires on every track change). + if (!gotoArtistBtn._npGotoArtistHandlerAttached) { + gotoArtistBtn.addEventListener('click', () => { + if (gotoArtistBtn.getAttribute('aria-disabled') === 'true') return; + try { closeNowPlayingModal(); } catch (e) { console.debug('closeNowPlayingModal failed:', e); } + }); + gotoArtistBtn._npGotoArtistHandlerAttached = true; + } } // Sync expanded player and media session updateNpTrackInfo(); updateMediaSessionMetadata(); updateMediaSessionPlaybackState(); + + // Kick off lyrics fetch for the new track. The panel stays + // collapsed by default — fetching in the background means the + // user gets instant lyrics the first time they expand it. + _npLyricsLoadForTrack({ + title: track.title, + artist: track.artist, + album: track.album, + is_library: track.is_library, + filename: track.filename, + }); } function checkAndEnableScrolling(element, text) { @@ -922,6 +968,202 @@ function updateAudioProgress() { // Sync expanded player modal if (npModalOpen) updateNpProgress(); + + // Sync lyrics highlight when synced LRC is loaded. + if (_npLyricsState.synced && _npLyricsState.lines.length) { + _npLyricsHighlight(audioPlayer.currentTime); + } +} + +// ───────────────────────────────────────────────────────────────── +// Lyrics panel (now-playing modal) +// ───────────────────────────────────────────────────────────────── + +// Module-level state for the currently-loaded lyrics. Reset on each +// track change. ``lines`` is an array of {time, text} for synced +// lyrics or null for plain text. ``activeIndex`` tracks the last +// highlighted line to avoid re-rendering on every timeupdate tick. +const _npLyricsState = { + trackKey: null, + lines: [], + synced: false, + activeIndex: -1, + fetchInFlight: false, + autoOpen: false, +}; + +function _npLyricsResetUI() { + const content = document.getElementById('np-lyrics-content'); + const status = document.getElementById('np-lyrics-status'); + if (content) content.innerHTML = '
No lyrics loaded
'; + if (status) status.textContent = ''; +} + +function _npLyricsParseLrc(synced) { + // Parse a standard LRC string. Lines without a timestamp are + // dropped (metadata tags like ``[ti:Title]`` aren't lyrics). The + // same line can carry multiple timestamps — emit one entry per + // timestamp so seeks land correctly when a chorus repeats. + const out = []; + if (!synced) return out; + const re = /\[(\d+):(\d+(?:\.\d+)?)\]/g; + synced.split(/\r?\n/).forEach(raw => { + const stamps = []; + let m; + re.lastIndex = 0; + while ((m = re.exec(raw)) !== null) { + const minutes = parseInt(m[1], 10); + const seconds = parseFloat(m[2]); + if (!Number.isFinite(minutes) || !Number.isFinite(seconds)) continue; + stamps.push(minutes * 60 + seconds); + } + if (!stamps.length) return; + const text = raw.replace(re, '').trim(); + stamps.forEach(t => out.push({ time: t, text })); + }); + out.sort((a, b) => a.time - b.time); + return out; +} + +function _npLyricsRenderSynced(lines) { + const content = document.getElementById('np-lyrics-content'); + if (!content) return; + if (!lines.length) { + content.innerHTML = '
No timestamped lyrics for this track
'; + return; + } + content.innerHTML = lines.map((line, idx) => { + const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) || ' '; + return `
${safe}
`; + }).join(''); +} + +function _npLyricsRenderPlain(text) { + const content = document.getElementById('np-lyrics-content'); + if (!content) return; + const safe = (text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + content.innerHTML = `
${safe.replace(/\n/g, '
')}
`; +} + +function _npLyricsHighlight(currentTime) { + const { lines } = _npLyricsState; + if (!lines.length) return; + let idx = -1; + // Binary-search style linear scan — N is small (200 lines max). + for (let i = 0; i < lines.length; i++) { + if (lines[i].time <= currentTime) idx = i; + else break; + } + if (idx === _npLyricsState.activeIndex) return; + _npLyricsState.activeIndex = idx; + const content = document.getElementById('np-lyrics-content'); + if (!content) return; + content.querySelectorAll('.np-lyrics-line').forEach((el, i) => { + el.classList.remove('active', 'passed', 'upcoming'); + if (i === idx) el.classList.add('active'); + else if (i < idx) el.classList.add('passed'); + else el.classList.add('upcoming'); + }); + const activeEl = content.querySelector('.np-lyrics-line.active'); + if (activeEl) { + // Smooth-scroll the active line into the middle of the lyrics body. + const body = document.getElementById('np-lyrics-body'); + if (body) { + const bodyRect = body.getBoundingClientRect(); + const lineRect = activeEl.getBoundingClientRect(); + const targetTop = (lineRect.top - bodyRect.top) - (bodyRect.height / 2) + (lineRect.height / 2); + body.scrollTo({ top: body.scrollTop + targetTop, behavior: 'smooth' }); + } + } +} + +function _npLyricsTrackKey(track) { + if (!track) return null; + return `${track.title || ''}|${track.artist || ''}|${track.album || ''}`; +} + +async function _npLyricsLoadForTrack(track) { + const key = _npLyricsTrackKey(track); + if (!key) return; + if (_npLyricsState.trackKey === key) return; // already loaded + if (_npLyricsState.fetchInFlight) return; + _npLyricsState.trackKey = key; + _npLyricsState.lines = []; + _npLyricsState.synced = false; + _npLyricsState.activeIndex = -1; + _npLyricsResetUI(); + const status = document.getElementById('np-lyrics-status'); + if (status) status.textContent = 'Fetching…'; + _npLyricsState.fetchInFlight = true; + try { + const resp = await fetch('/api/lyrics/fetch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + title: _stripSourceIdPrefix(track.title) || '', + artist: _stripSourceIdPrefix(track.artist) || '', + album: _stripSourceIdPrefix(track.album) || '', + duration: Math.round(audioPlayer?.duration || 0), + file_path: track.is_library ? track.filename : null, + }), + }); + const data = await resp.json(); + if (_npLyricsState.trackKey !== key) return; // track changed mid-fetch + if (data && data.success) { + if (data.synced) { + const parsed = _npLyricsParseLrc(data.synced); + if (parsed.length) { + _npLyricsState.synced = true; + _npLyricsState.lines = parsed; + _npLyricsRenderSynced(parsed); + if (status) status.textContent = 'Synced'; + return; + } + } + if (data.plain) { + _npLyricsState.synced = false; + _npLyricsState.lines = []; + _npLyricsRenderPlain(data.plain); + if (status) status.textContent = 'Plain'; + return; + } + } + const content = document.getElementById('np-lyrics-content'); + if (content) content.innerHTML = '
No lyrics found
'; + if (status) status.textContent = ''; + } catch (e) { + console.debug('lyrics fetch failed:', e); + const content = document.getElementById('np-lyrics-content'); + if (content) content.innerHTML = '
Lyrics unavailable
'; + if (status) status.textContent = ''; + } finally { + _npLyricsState.fetchInFlight = false; + } +} + +function _npLyricsTogglePanel(forceOpen = null) { + const panel = document.getElementById('np-lyrics-panel'); + const body = document.getElementById('np-lyrics-body'); + const toggle = document.getElementById('np-lyrics-toggle'); + if (!panel || !body || !toggle) return; + const willOpen = forceOpen === null ? body.classList.contains('hidden') : forceOpen; + if (willOpen) { + body.classList.remove('hidden'); + panel.classList.remove('collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + } else { + body.classList.add('hidden'); + panel.classList.add('collapsed'); + toggle.setAttribute('aria-expanded', 'false'); + } +} + +function _npLyricsInit() { + const toggle = document.getElementById('np-lyrics-toggle'); + if (toggle && !toggle._lyricsBound) { + toggle.addEventListener('click', () => _npLyricsTogglePanel()); + toggle._lyricsBound = true; + } } function onAudioEnded() { @@ -1287,6 +1529,10 @@ function openNowPlayingModal() { overlay.classList.remove('hidden'); document.body.style.overflow = 'hidden'; syncExpandedPlayerUI(); + // Bind lyrics toggle (idempotent — only attaches once). Lyrics + // fetch fires from setTrackInfo so by the time the modal opens + // the panel is usually already populated. + _npLyricsInit(); // Start visualizer if already playing if (isPlaying) { npInitVisualizer(); npStartVisualizerLoop(); } } diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 05ab4fa2..3fc8be27 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -1092,7 +1092,7 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis onchange="updateTrackSelectionCount('${virtualPlaylistId}')"> ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} 🔍 Pending diff --git a/webui/static/style.css b/webui/static/style.css index d6c531ed..24bf7425 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -7,6 +7,35 @@ --accent-neon-rgb: 34, 255, 107; } +.modal-track-play-btn { + width: 26px; + height: 26px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 50%; + background: rgba(76, 175, 80, 0.14); + color: #65d67a; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + line-height: 1; + margin-right: 8px; + vertical-align: middle; + transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease; +} + +.modal-track-play-btn:hover { + background: rgba(76, 175, 80, 0.24); + border-color: rgba(101, 214, 122, 0.45); + transform: scale(1.05); +} + +.wishlist-track-play-btn { + flex: 0 0 auto; + margin-right: 4px; +} + /* Reset and Base Styles */ * { margin: 0; @@ -48029,6 +48058,91 @@ textarea.enhanced-meta-field-input { background: rgba(255, 255, 255, 0.18); } +/* Lyrics Panel (now-playing modal) */ +.np-lyrics-panel { + border-top: 1px solid rgba(255, 255, 255, 0.06); + padding: 0 40px; +} + +.np-lyrics-header { + display: flex; + align-items: center; + padding: 12px 0 8px; +} + +.np-lyrics-toggle { + display: flex; + align-items: center; + gap: 8px; + background: none; + border: none; + color: rgba(255, 255, 255, 0.85); + font-size: 14px; + font-weight: 600; + letter-spacing: 0.2px; + cursor: pointer; + padding: 4px 6px; + border-radius: 6px; + transition: background 0.15s ease; +} + +.np-lyrics-toggle:hover { + background: rgba(255, 255, 255, 0.05); +} + +.np-lyrics-status { + font-size: 11px; + font-weight: 500; + color: rgba(255, 255, 255, 0.4); + letter-spacing: 0.5px; + text-transform: uppercase; + margin-left: 4px; +} + +.np-lyrics-body { + max-height: 260px; + overflow-y: auto; + padding-bottom: 14px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + margin-bottom: 10px; +} + +.np-lyrics-content { + padding: 14px 16px; + font-size: 14px; + line-height: 1.7; + color: rgba(255, 255, 255, 0.6); + white-space: pre-wrap; +} + +.np-lyrics-empty { + color: rgba(255, 255, 255, 0.35); + font-style: italic; + text-align: center; + padding: 12px 0; +} + +.np-lyrics-line { + padding: 2px 0; + transition: color 0.2s ease, transform 0.2s ease; +} + +.np-lyrics-line.active { + color: rgb(var(--accent-rgb)); + font-weight: 600; + transform: translateX(4px); +} + +.np-lyrics-line.passed { + color: rgba(255, 255, 255, 0.45); +} + +.np-lyrics-line.upcoming { + color: rgba(255, 255, 255, 0.55); +} + /* Queue Panel */ .np-queue-panel { border-top: 1px solid rgba(255, 255, 255, 0.06); diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 11b50933..c7fd1863 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -1441,7 +1441,7 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, onchange="updateTrackSelectionCount('${virtualPlaylistId}')"> ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} 🔍 Pending diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index ad8ba041..e0dbceb2 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -2314,7 +2314,7 @@ async function openDownloadMissingModal(playlistId) { onchange="updateTrackSelectionCount('${playlistId}')"> ${index + 1} - ${escapeHtml(track.name)} + ${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)} ${escapeHtml(formatArtists(track.artists))} ${formatDuration(track.duration_ms)} 🔍 Pending diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index fd8eee6b..a1fbc4b3 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -883,10 +883,16 @@ function generateWishlistTrackList(tracks, trackOwnership) { const badge = isOwned ? '
' : ''; + // Play button shows for every track. ``playWishlistModalTrack`` -> + // ``playTrackFromLibraryOrStream`` resolves a local file when one + // exists and falls back to the streaming source otherwise, matching + // the same pattern used by the download-missing modals elsewhere. + const playButton = ``; return `
${trackNumber}
+ ${playButton}
${trackName}
${artistsString}
@@ -1073,6 +1079,25 @@ async function lazyLoadTrackOwnership(artistName, tracks, sourceCard, albumName if (isOwned) { ownedCount++; item.classList.add('owned'); + // Play button is rendered up front for every track now. Upgrade + // the tooltip + click handler once ownership confirms so the + // local-file path is used directly without the resolve-track + // round trip. Falls back to creating a fresh button if the + // initial render somehow skipped it (defensive — should not + // happen post-refactor). + let playBtn = item.querySelector('.wishlist-track-play-btn'); + if (!playBtn) { + playBtn = document.createElement('button'); + playBtn.className = 'modal-track-play-btn wishlist-track-play-btn'; + playBtn.innerHTML = '▶'; + item.querySelector('.wishlist-track-number')?.after(playBtn); + } + playBtn.title = 'Play from library'; + playBtn.onclick = null; + playBtn.addEventListener('click', event => { + event.stopPropagation(); + playWishlistModalTrack(index, trackData); + }); // Add metadata line below track name const trackInfo = item.querySelector('.wishlist-track-info'); if (trackInfo && (trackData.format || trackData.bitrate)) { @@ -1162,6 +1187,35 @@ async function lazyLoadTrackOwnership(artistName, tracks, sourceCard, albumName } } +async function playWishlistModalTrack(index, ownershipData = null) { + if (!currentWishlistModalData || !currentWishlistModalData.tracks) { + showToast('Track is no longer available in this modal', 'error'); + return; + } + + const track = currentWishlistModalData.tracks[index]; + if (!track) { + showToast('Track is no longer available in this modal', 'error'); + return; + } + + const trackData = ownershipData || {}; + const playbackTrack = { + ...track, + id: trackData.track_id || track.id || null, + title: trackData.title || track.title || track.name, + file_path: trackData.file_path || track.file_path || null, + bitrate: trackData.bitrate || track.bitrate, + _stats_image: currentWishlistModalData.album?.image_url || currentWishlistModalData.album?.images?.[0]?.url || null + }; + + await playTrackFromLibraryOrStream( + playbackTrack, + trackData.album || currentWishlistModalData.album?.name || currentWishlistModalData.album?.title || '', + getModalTrackArtistName(track, currentWishlistModalData.artist?.name || '') + ); +} + /** * Close the Add to Wishlist modal */