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 */