From e652726c224c4debd35f1154a26b711ff606346d Mon Sep 17 00:00:00 2001
From: Broque Thomas <26755000+Nezreka@users.noreply.github.com>
Date: Mon, 23 Mar 2026 20:42:33 -0700
Subject: [PATCH] Invert Tidal/Qobuz hero badges, add Artist Radio button and
Last.fm play buttons
- Tidal and Qobuz SVG logos inverted on artist detail hero badges
- New Artist Radio button: clears queue, plays random artist track, enables radio
- Play buttons on Last.fm top tracks (hover reveal, resolves from library)
- Fixed inline JS escaping with data attribute delegation
---
webui/index.html | 6 +++
webui/static/script.js | 78 ++++++++++++++++++++++++++++++++++-
webui/static/style.css | 94 ++++++++++++++++++++++++++++++++++++++++++
3 files changed, 177 insertions(+), 1 deletion(-)
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);