feat(media-player): playable tracks across modals + lyrics + cleanups
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
<source_id>||<display> 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
<id>|| 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).
This commit is contained in:
parent
4ebbffb898
commit
de8e079a6d
11 changed files with 661 additions and 9 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -7275,6 +7275,22 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Lyrics Panel -->
|
||||
<div class="np-lyrics-panel collapsed" id="np-lyrics-panel">
|
||||
<div class="np-lyrics-header">
|
||||
<button class="np-lyrics-toggle" id="np-lyrics-toggle" title="Toggle lyrics" aria-expanded="false">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
||||
<span>Lyrics</span>
|
||||
<span class="np-lyrics-status" id="np-lyrics-status"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="np-lyrics-body hidden" id="np-lyrics-body">
|
||||
<div class="np-lyrics-content" id="np-lyrics-content">
|
||||
<div class="np-lyrics-empty">No lyrics loaded</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Queue Panel -->
|
||||
<div class="np-queue-panel" id="np-queue-panel">
|
||||
<div class="np-queue-header">
|
||||
|
|
|
|||
|
|
@ -582,7 +582,7 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam
|
|||
onchange="updateTrackSelectionCount('${virtualPlaylistId}')">
|
||||
</td>
|
||||
<td class="track-number">${index + 1}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)}</td>
|
||||
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
|
||||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||||
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
|
||||
|
|
@ -2143,7 +2143,7 @@ async function openDownloadMissingWishlistModal(category = null, selectedTrackId
|
|||
${tracks.map((track, index) => `
|
||||
<tr data-track-index="${index}">
|
||||
<td class="track-number">${index + 1}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)}</td>
|
||||
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
|
||||
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
|
||||
<td class="track-download-status" id="download-${playlistId}-${index}">-</td>
|
||||
|
|
@ -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 `<button class="modal-track-play-btn" onclick="event.stopPropagation(); playDownloadModalTrack('${escapeForInlineJs(playlistId)}', ${trackIndex})" title="Play track">▶</button>`;
|
||||
}
|
||||
|
||||
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 || '')
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ``<source_id>||<display>`` 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) {
|
||||
|
|
|
|||
|
|
@ -64,8 +64,20 @@ function toggleMediaPlayerExpansion() {
|
|||
function extractTrackTitle(filename) {
|
||||
if (!filename) return null;
|
||||
|
||||
// Strip the ``<source_id>||<display>`` 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 ``<source_id>||<display>``
|
||||
// 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 = `<span class="title-text">${escapeHtml(trackTitle)}</span>`;
|
||||
|
||||
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 = '<div class="np-lyrics-empty">No lyrics loaded</div>';
|
||||
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 = '<div class="np-lyrics-empty">No timestamped lyrics for this track</div>';
|
||||
return;
|
||||
}
|
||||
content.innerHTML = lines.map((line, idx) => {
|
||||
const safe = (line.text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])) || ' ';
|
||||
return `<div class="np-lyrics-line" data-idx="${idx}">${safe}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _npLyricsRenderPlain(text) {
|
||||
const content = document.getElementById('np-lyrics-content');
|
||||
if (!content) return;
|
||||
const safe = (text || '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
content.innerHTML = `<div class="np-lyrics-plain">${safe.replace(/\n/g, '<br>')}</div>`;
|
||||
}
|
||||
|
||||
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 = '<div class="np-lyrics-empty">No lyrics found</div>';
|
||||
if (status) status.textContent = '';
|
||||
} catch (e) {
|
||||
console.debug('lyrics fetch failed:', e);
|
||||
const content = document.getElementById('np-lyrics-content');
|
||||
if (content) content.innerHTML = '<div class="np-lyrics-empty">Lyrics unavailable</div>';
|
||||
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(); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1092,7 +1092,7 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis
|
|||
onchange="updateTrackSelectionCount('${virtualPlaylistId}')">
|
||||
</td>
|
||||
<td class="track-number">${index + 1}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)}</td>
|
||||
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
|
||||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||||
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1441,7 +1441,7 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
|
|||
onchange="updateTrackSelectionCount('${virtualPlaylistId}')">
|
||||
</td>
|
||||
<td class="track-number">${index + 1}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(virtualPlaylistId, index)}${escapeHtml(track.name)}</td>
|
||||
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
|
||||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||||
<td class="track-match-status match-checking" id="match-${virtualPlaylistId}-${index}">🔍 Pending</td>
|
||||
|
|
|
|||
|
|
@ -2314,7 +2314,7 @@ async function openDownloadMissingModal(playlistId) {
|
|||
onchange="updateTrackSelectionCount('${playlistId}')">
|
||||
</td>
|
||||
<td class="track-number">${index + 1}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${escapeHtml(track.name)}</td>
|
||||
<td class="track-name" title="${escapeHtml(track.name)}">${renderModalTrackPlayButton(playlistId, index)}${escapeHtml(track.name)}</td>
|
||||
<td class="track-artist" title="${escapeHtml(formatArtists(track.artists))}">${escapeHtml(formatArtists(track.artists))}</td>
|
||||
<td class="track-duration">${formatDuration(track.duration_ms)}</td>
|
||||
<td class="track-match-status match-checking" id="match-${playlistId}-${index}">🔍 Pending</td>
|
||||
|
|
|
|||
|
|
@ -883,10 +883,16 @@ function generateWishlistTrackList(tracks, trackOwnership) {
|
|||
const badge = isOwned
|
||||
? '<div class="wishlist-track-badge owned"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"></polyline></svg></div>'
|
||||
: '';
|
||||
// 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 = `<button class="modal-track-play-btn wishlist-track-play-btn" onclick="event.stopPropagation(); playWishlistModalTrack(${index})" title="${isOwned ? 'Play from library' : 'Play (stream fallback)'}">▶</button>`;
|
||||
|
||||
return `
|
||||
<div class="wishlist-track-item ${ownershipClass}">
|
||||
<div class="wishlist-track-number">${trackNumber}</div>
|
||||
${playButton}
|
||||
<div class="wishlist-track-info">
|
||||
<div class="wishlist-track-name">${trackName}</div>
|
||||
<div class="wishlist-track-artists">${artistsString}</div>
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue