diff --git a/webui/index.html b/webui/index.html index 78a85e6d..46a7ee36 100644 --- a/webui/index.html +++ b/webui/index.html @@ -2534,6 +2534,12 @@ Enhance Quality + + diff --git a/webui/static/script.js b/webui/static/script.js index 2057a8e5..1c9ffd33 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -39166,13 +39166,24 @@ async function _loadArtistTopTracks(artistName) { return n.toLocaleString(); }; + const _escAttr = (s) => (s || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); container.innerHTML = data.tracks.map((t, i) => `
${i + 1} - ${t.name} + + ${_escAttr(t.name)} ${_fmtNum(t.playcount)}
`).join(''); + + // Attach play handlers via delegation (avoids inline JS escaping issues) + container.onclick = (e) => { + const btn = e.target.closest('.hero-top-track-play'); + if (btn) { + e.stopPropagation(); + playStatsTrack(btn.dataset.track, btn.dataset.artist, ''); + } + }; sidebar.style.display = ''; } catch (e) { console.debug('Failed to load top tracks:', e); @@ -61569,6 +61580,71 @@ async function checkArtistEnhanceEligibility(artistId) { } } +async function playArtistRadio() { + try { + const artistId = artistDetailPageState.currentArtistId; + const artistName = artistDetailPageState.currentArtistName || ''; + if (!artistId) { + showToast('No artist selected', 'error'); + return; + } + + // Get tracks from this artist's library + const resp = await fetch(`/api/library/artist/${artistId}/enhanced`); + if (!resp.ok) throw new Error('Failed to load artist data'); + const data = await resp.json(); + if (!data.success) throw new Error(data.error || 'Failed'); + + // Collect all tracks with file paths + const allTracks = []; + for (const album of (data.albums || [])) { + for (const track of (album.tracks || [])) { + if (track.file_path) { + allTracks.push({ track, album }); + } + } + } + + if (!allTracks.length) { + showToast('No playable tracks found for this artist', 'error'); + return; + } + + // Pick a random track + const random = allTracks[Math.floor(Math.random() * allTracks.length)]; + const albumArt = random.album.thumb_url || data.artist?.thumb_url || null; + + // Clear existing queue and disable radio before starting fresh + npRadioMode = false; + clearQueue(); + if (audioPlayer && !audioPlayer.paused) { + audioPlayer.pause(); + } + + // Play the track first, then enable radio mode after a short delay + // so currentTrack is set and the radio queue fill triggers + playLibraryTrack({ + id: random.track.id, + title: random.track.title, + file_path: random.track.file_path, + bitrate: random.track.bitrate, + artist_id: artistId, + album_id: random.album.id, + }, random.album.title || '', artistName); + + // Enable radio mode after track starts loading + setTimeout(() => { + npRadioMode = true; + const radioBtn = document.querySelector('.np-radio-btn'); + if (radioBtn) radioBtn.classList.add('active'); + }, 1000); + + showToast(`Playing ${artistName} radio — similar tracks will auto-queue`, 'success'); + } catch (e) { + showToast(`Failed to start artist radio: ${e.message}`, 'error'); + } +} + function openEnhanceQualityModal() { if (!_enhanceQualityData) return; const data = _enhanceQualityData; diff --git a/webui/static/style.css b/webui/static/style.css index d6fd2b1e..97f31f7b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -16827,6 +16827,71 @@ body { display: none; } +/* ─── Artist Radio Button ─── */ + +.library-artist-radio-btn { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 20px; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.02em; + color: rgba(255, 255, 255, 0.85); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 12px; + cursor: pointer; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + outline: none; + font-family: 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; + position: relative; + overflow: hidden; + backdrop-filter: blur(8px); +} + +.library-artist-radio-btn::before { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.2) 0%, rgba(var(--accent-rgb), 0.05) 100%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.library-artist-radio-btn:hover::before { + opacity: 1; +} + +.library-artist-radio-btn:hover { + color: #ffffff; + border-color: rgba(var(--accent-rgb), 0.35); + transform: translateY(-2px); + box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.2), 0 0 0 1px rgba(var(--accent-rgb), 0.1); +} + +.library-artist-radio-btn:active { + transform: translateY(0); + box-shadow: none; +} + +.library-artist-radio-btn .radio-icon { + font-size: 14px; + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + z-index: 1; +} + +.library-artist-radio-btn:hover .radio-icon { + transform: scale(1.15); +} + +.library-artist-radio-btn .radio-text { + position: relative; + z-index: 1; +} + /* ─── Enhance Quality Modal ─── */ .enhance-modal-overlay { @@ -19648,6 +19713,11 @@ body { object-fit: contain; opacity: 0.8; } +/* Invert dark SVG logos for Tidal and Qobuz */ +.artist-hero-badge[title="Tidal"] img, +.artist-hero-badge[title="Qobuz"] img { + filter: invert(1); +} .artist-hero-badge[data-url] { cursor: pointer; } @@ -19937,6 +20007,30 @@ body { .hero-top-track:hover { background: rgba(255, 255, 255, 0.04); } +.hero-top-track-play { + width: 20px; + height: 20px; + border-radius: 50%; + border: none; + background: rgba(var(--accent-rgb), 0.15); + color: rgb(var(--accent-rgb)); + font-size: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0; + transition: all 0.2s; +} +.hero-top-track:hover .hero-top-track-play { + opacity: 1; +} +.hero-top-track-play:hover { + background: rgb(var(--accent-rgb)); + color: #fff; + transform: scale(1.1); +} .hero-top-track-num { font-size: 0.72em; color: rgba(255, 255, 255, 0.25);