Redesigned media player with expanded Now Playing modal and smart radio

This commit is contained in:
Broque Thomas 2026-03-09 10:13:42 -07:00
parent 5b79ca1e88
commit 5f58432ca4
5 changed files with 750 additions and 35 deletions

View file

@ -7293,6 +7293,172 @@ class MusicDatabase:
logger.error(f"Error clearing automation run history: {e}")
return 0
def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]:
"""Find similar tracks for radio mode auto-play queue.
Strategy (each tier capped to ensure diversity):
1. Same artist, different albums (max 30% of limit)
2. Same genre from album genres + artist genres (other artists)
3. Same mood / style from album + artist metadata
4. Random library tracks (fallback)
Args:
track_id: The seed track ID.
limit: Maximum number of tracks to return.
exclude_ids: Optional list of track IDs to exclude.
Returns:
dict with ``success``, ``tracks`` list.
"""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
# Resolve the seed track and its album / artist
cursor.execute("""
SELECT t.id, t.artist_id, t.album_id,
al.genres AS album_genres,
al.mood AS album_mood,
al.style AS album_style,
ar.name AS artist_name,
ar.genres AS artist_genres,
ar.mood AS artist_mood,
ar.style AS artist_style
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
WHERE t.id = ?
""", (track_id,))
seed = cursor.fetchone()
if not seed:
return {'success': False, 'error': f'Track {track_id} not found'}
seed = dict(seed)
artist_name = seed['artist_name']
# Build the set of IDs to exclude (seed + caller-supplied)
excluded = {str(track_id)}
if exclude_ids:
excluded.update(str(eid) for eid in exclude_ids)
collected: list[dict] = []
seen_ids: set[str] = set(excluded)
def _exclude_placeholders():
return ','.join('?' * len(seen_ids))
def _exclude_values():
return list(seen_ids)
_track_select = """
SELECT t.id, t.title, t.track_number, t.duration,
t.file_path, t.bitrate,
t.album_id, t.artist_id,
al.title AS album,
COALESCE(al.thumb_url, ar.thumb_url) AS image_url,
ar.name AS artist
FROM tracks t
JOIN albums al ON al.id = t.album_id
JOIN artists ar ON ar.id = t.artist_id
"""
def _collect(rows, cap=None):
"""Append rows to collected. Stop at cap or limit."""
target = min(limit, (len(collected) + cap)) if cap else limit
for row in rows:
r = dict(row)
rid = str(r['id'])
if rid not in seen_ids:
seen_ids.add(rid)
collected.append(r)
if len(collected) >= target:
return True
return len(collected) >= limit
def _parse_tags(raw_val):
"""Parse a JSON array or comma-separated string into a list."""
if not raw_val:
return []
try:
parsed = json.loads(raw_val)
return parsed if isinstance(parsed, list) else [str(parsed)]
except (json.JSONDecodeError, ValueError):
return [t.strip() for t in raw_val.split(',') if t.strip()]
# --- 1. Same artist, different albums (capped at 30% of limit) ---
same_artist_cap = max(5, limit * 3 // 10)
cursor.execute(f"""
{_track_select}
WHERE ar.name = ? AND t.album_id != ? AND t.id NOT IN ({_exclude_placeholders()})
ORDER BY RANDOM()
LIMIT ?
""", [artist_name, seed['album_id']] + _exclude_values() + [same_artist_cap])
_collect(cursor.fetchall(), cap=same_artist_cap)
if len(collected) >= limit:
return {'success': True, 'tracks': collected}
# --- 2. Same genre (album genres + artist genres, other artists) ---
genre_list = _parse_tags(seed.get('album_genres'))
artist_genre_list = _parse_tags(seed.get('artist_genres'))
all_genres = list(dict.fromkeys(genre_list + artist_genre_list)) # dedupe, preserve order
if all_genres:
genre_conditions = ' OR '.join(
['al.genres LIKE ?' for _ in all_genres] +
['ar.genres LIKE ?' for _ in all_genres]
)
genre_params = [f'%{g}%' for g in all_genres] * 2
cursor.execute(f"""
{_track_select}
WHERE ({genre_conditions})
AND ar.name != ?
AND t.id NOT IN ({_exclude_placeholders()})
ORDER BY RANDOM()
LIMIT ?
""", genre_params + [artist_name] + _exclude_values() + [limit - len(collected)])
if _collect(cursor.fetchall()):
return {'success': True, 'tracks': collected}
# --- 3. Same mood / style (album + artist level) ---
for field_name in ('mood', 'style'):
album_tags = _parse_tags(seed.get(f'album_{field_name}'))
artist_tags = _parse_tags(seed.get(f'artist_{field_name}'))
all_tags = list(dict.fromkeys(album_tags + artist_tags))
if all_tags:
tag_conditions = ' OR '.join(
[f'al.{field_name} LIKE ?' for _ in all_tags] +
[f'ar.{field_name} LIKE ?' for _ in all_tags]
)
tag_params = [f'%{t}%' for t in all_tags] * 2
cursor.execute(f"""
{_track_select}
WHERE ({tag_conditions})
AND ar.name != ?
AND t.id NOT IN ({_exclude_placeholders()})
ORDER BY RANDOM()
LIMIT ?
""", tag_params + [artist_name] + _exclude_values() + [limit - len(collected)])
if _collect(cursor.fetchall()):
return {'success': True, 'tracks': collected}
# --- 4. Random library tracks ---
if len(collected) < limit:
cursor.execute(f"""
{_track_select}
WHERE t.id NOT IN ({_exclude_placeholders()})
ORDER BY RANDOM()
LIMIT ?
""", _exclude_values() + [limit - len(collected)])
_collect(cursor.fetchall())
return {'success': True, 'tracks': collected}
except Exception as e:
logger.error(f"Error getting radio tracks for track {track_id}: {e}")
return {'success': False, 'error': str(e)}
# Thread-safe singleton pattern for database access
_database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance
_database_lock = threading.Lock()

View file

@ -9298,6 +9298,32 @@ def library_delete_tracks_batch():
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/library/radio')
def library_radio():
"""Get a smart queue of similar tracks for radio mode auto-play."""
try:
track_id = request.args.get('track_id')
if not track_id:
return jsonify({"success": False, "error": "track_id is required"}), 400
limit = request.args.get('limit', 20, type=int)
exclude_raw = request.args.get('exclude', '')
exclude_ids = [eid.strip() for eid in exclude_raw.split(',') if eid.strip()] if exclude_raw else None
database = get_database()
result = database.get_radio_tracks(track_id, limit=limit, exclude_ids=exclude_ids)
if not result.get('success'):
return jsonify(result), 404
return jsonify(result)
except Exception as e:
print(f"Error getting radio tracks: {e}")
import traceback
traceback.print_exc()
return jsonify({"success": False, "error": str(e)}), 500
# ==================== End Enhanced Library Management ====================
@app.route('/api/stream/start', methods=['POST'])
@ -14121,6 +14147,22 @@ def get_version_info():
"title": "What's New in SoulSync",
"subtitle": "Version 1.8 — Latest Changes",
"sections": [
{
"title": "🎵 Now Playing Overhaul",
"description": "Redesigned media player with expanded Now Playing modal and smart radio",
"features": [
"• Expanded Now Playing modal — click the sidebar player to open a full-screen playback experience",
"• Album art ambient glow — dominant color extracted from cover art tints the modal background",
"• Smart Radio mode — toggle on to auto-queue up to 50 similar tracks based on genre, mood, style, and artist",
"• Queue system — add tracks from the Enhanced Library Manager, manage queue in the Now Playing modal",
"• Web Audio visualizer — real frequency-driven bars responding to actual audio playback",
"• Repeat modes — off, repeat-all (loop queue), and repeat-one with shuffle support",
"• Redesigned sidebar player — always-visible controls with album art thumbnail and progress bar",
"• Media Session API — OS-level media controls with prev/next track support",
"• Keyboard shortcuts — Space (play/pause), arrows (seek/volume), M (mute), Escape (close)",
"• Track transition animations and buffering state indicator"
]
},
{
"title": "📚 Enhanced Library Manager",
"description": "Professional-grade library management view on the artist detail page",

View file

@ -4084,7 +4084,9 @@
<!-- Expanded Now Playing Modal -->
<div class="np-modal-overlay hidden" id="np-modal-overlay">
<div class="np-modal">
<button class="np-close-btn" id="np-close-btn">&times;</button>
<button class="np-close-btn" id="np-close-btn" title="Minimize">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
</button>
<div class="np-body">
<!-- Left: album art + track info -->
<div class="np-left">
@ -4102,6 +4104,12 @@
<div class="np-album-name" id="np-album-name">Unknown Album</div>
<div class="np-format-badges" id="np-format-badges"></div>
</div>
<div class="np-action-buttons" id="np-action-buttons">
<button class="np-action-btn" id="np-goto-artist" title="Go to Artist">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
<span>View Artist</span>
</button>
</div>
</div>
<!-- Right: controls -->
<div class="np-right">
@ -4127,12 +4135,14 @@
<button class="np-btn np-btn-play" id="np-play-btn" title="Play">
<svg class="np-icon-play" width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
<svg class="np-icon-pause hidden" width="28" height="28" viewBox="0 0 24 24" fill="currentColor"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/></svg>
<div class="np-buffering-ring hidden" id="np-buffering-ring"></div>
</button>
<button class="np-btn np-btn-next" id="np-next-btn" title="Next" disabled>
<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="np-btn np-btn-repeat" id="np-repeat-btn" title="Repeat">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
<span class="np-repeat-one-badge hidden" id="np-repeat-one-badge">1</span>
</button>
</div>
<div class="np-volume-row">
@ -4172,7 +4182,12 @@
<span>Queue</span>
<span class="np-queue-count" id="np-queue-count"></span>
</button>
<button class="np-queue-clear-btn" id="np-queue-clear" title="Clear queue">Clear</button>
<div class="np-queue-header-actions">
<button class="np-radio-btn" id="np-radio-btn" title="Radio mode - auto-add similar tracks">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h.01"/><path d="M8.5 16.429a5 5 0 0 1 7 0"/><path d="M5 12.859a10 10 0 0 1 14 0"/><path d="M1.5 9.289a15 15 0 0 1 21 0"/></svg>
</button>
<button class="np-queue-clear-btn" id="np-queue-clear" title="Clear queue">Clear</button>
</div>
</div>
<div class="np-queue-body hidden" id="np-queue-body">
<div class="np-queue-empty" id="np-queue-empty">Queue is empty</div>

View file

@ -1826,6 +1826,7 @@ function handleVolumeChange(event) {
const npFill = document.getElementById('np-volume-fill');
if (npVol) npVol.value = volume;
if (npFill) npFill.style.width = volume + '%';
updateNpMuteIcon();
}
function handleProgressBarChange(event) {
@ -2446,8 +2447,18 @@ function onAudioEnded() {
}
// Repeat-one is handled by audioPlayer.loop (set in handleNpRepeat)
// Auto-advance to next track if queue exists (guard against race conditions)
if (npQueue.length > 0 && !npLoadingQueueItem) { playNextInQueue(); return; }
// Auto-advance to next track if queue has a next item (guard against race conditions)
if (npQueue.length > 0 && !npLoadingQueueItem) {
const hasNext = npShuffleOn
? npQueue.length > 1
: (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all');
if (hasNext) { playNextInQueue(); return; }
}
// Radio mode: auto-fetch similar tracks when queue is exhausted
if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) {
npFetchRadioTracks();
}
}
function onAudioError(event) {
@ -2613,7 +2624,7 @@ function canPlayAudioFormat(extension) {
// ===============================
let npModalOpen = false;
let npRepeatMode = 'off'; // 'off' | 'one'
let npRepeatMode = 'off'; // 'off' | 'all' | 'one'
let npShuffleOn = false;
let npQueue = [];
let npQueueIndex = -1;
@ -2621,6 +2632,13 @@ let npMuted = false;
let npPreMuteVolume = 70;
let npMediaSessionThrottle = 0;
let npLoadingQueueItem = false;
let npRadioMode = false;
let npRecentlyPlayedIds = [];
let npAudioContext = null;
let npAnalyser = null;
let npMediaSource = null;
let npVizAnimFrame = null;
let npVizInitialized = false;
function initExpandedPlayer() {
const closeBtn = document.getElementById('np-close-btn');
@ -2646,11 +2664,22 @@ function initExpandedPlayer() {
repeatBtn.addEventListener('click', handleNpRepeat);
muteBtn.addEventListener('click', handleNpMuteToggle);
// Progress bar
// Progress bar (mouse)
npProgressBar.addEventListener('input', handleNpProgressBarChange);
npProgressBar.addEventListener('mousedown', () => { npProgressBar.dataset.seeking = 'true'; });
npProgressBar.addEventListener('mouseup', () => { delete npProgressBar.dataset.seeking; });
// Progress bar (touch)
npProgressBar.addEventListener('touchstart', () => { npProgressBar.dataset.seeking = 'true'; }, { passive: true });
npProgressBar.addEventListener('touchmove', (e) => {
const touch = e.touches[0];
const rect = npProgressBar.getBoundingClientRect();
const pct = Math.max(0, Math.min(100, ((touch.clientX - rect.left) / rect.width) * 100));
npProgressBar.value = pct;
npProgressBar.dispatchEvent(new Event('input'));
}, { passive: true });
npProgressBar.addEventListener('touchend', () => { delete npProgressBar.dataset.seeking; }, { passive: true });
// Volume slider
npVolumeSlider.addEventListener('input', handleNpVolumeChange);
@ -2686,6 +2715,70 @@ function initExpandedPlayer() {
const queueClearBtn = document.getElementById('np-queue-clear');
if (queueClearBtn) queueClearBtn.addEventListener('click', () => { clearQueue(); });
// Radio mode button
const radioBtn = document.getElementById('np-radio-btn');
if (radioBtn) {
radioBtn.addEventListener('click', () => {
npRadioMode = !npRadioMode;
radioBtn.classList.toggle('active', npRadioMode);
showToast(npRadioMode ? 'Radio mode on — similar tracks will auto-queue' : 'Radio mode off', 'success');
// Immediately fetch radio tracks if turned on while playing with empty/exhausted queue
if (npRadioMode && currentTrack && currentTrack.id && !npLoadingQueueItem) {
const hasNext = npQueue.length > 0 && (npShuffleOn
? npQueue.length > 1
: (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'));
if (!hasNext) {
// Add current track to queue first so it appears as "now playing" in context
if (npQueue.length === 0 && currentTrack.is_library) {
npQueue.push({
title: currentTrack.title,
artist: currentTrack.artist,
album: currentTrack.album,
file_path: currentTrack.filename || currentTrack.file_path,
filename: currentTrack.filename || currentTrack.file_path,
is_library: true,
image_url: currentTrack.image_url,
id: currentTrack.id,
artist_id: currentTrack.artist_id,
album_id: currentTrack.album_id,
bitrate: currentTrack.bitrate
});
npQueueIndex = 0;
renderNpQueue();
updateNpPrevNextButtons();
}
npFetchRadioTracks();
}
}
});
}
// Action button (Go to Artist)
const gotoArtistBtn = document.getElementById('np-goto-artist');
if (gotoArtistBtn) {
gotoArtistBtn.addEventListener('click', () => {
if (currentTrack && currentTrack.artist_id) {
closeNowPlayingModal();
navigateToArtistDetail(currentTrack.artist_id, currentTrack.artist || '');
}
});
}
// Buffering state listeners on audioPlayer
if (audioPlayer) {
audioPlayer.addEventListener('waiting', () => {
const ring = document.getElementById('np-buffering-ring');
if (ring) ring.classList.remove('hidden');
});
audioPlayer.addEventListener('canplay', () => {
const ring = document.getElementById('np-buffering-ring');
if (ring) ring.classList.add('hidden');
});
audioPlayer.addEventListener('playing', () => {
const ring = document.getElementById('np-buffering-ring');
if (ring) ring.classList.add('hidden');
});
}
// Init Media Session API
initMediaSession();
}
@ -2697,6 +2790,8 @@ function openNowPlayingModal() {
overlay.classList.remove('hidden');
document.body.style.overflow = 'hidden';
syncExpandedPlayerUI();
// Start visualizer if already playing
if (isPlaying) { npInitVisualizer(); npStartVisualizerLoop(); }
}
function closeNowPlayingModal() {
@ -2705,6 +2800,7 @@ function closeNowPlayingModal() {
npModalOpen = false;
overlay.classList.add('hidden');
document.body.style.overflow = '';
npStopVisualizerLoop();
}
function syncExpandedPlayerUI() {
@ -2744,6 +2840,7 @@ function updateNpTrackInfo() {
const artImg = document.getElementById('np-album-art');
const artPlaceholder = document.getElementById('np-album-art-placeholder');
const badgesEl = document.getElementById('np-format-badges');
const actionBtns = document.getElementById('np-action-buttons');
if (!titleEl) return;
@ -2751,18 +2848,40 @@ function updateNpTrackInfo() {
const sidebarArt = document.getElementById('sidebar-album-art');
if (currentTrack) {
titleEl.textContent = currentTrack.title || 'Unknown Track';
// Track text transition animation
const textEls = [titleEl, artistEl, albumEl];
const oldTitle = titleEl.textContent;
const newTitle = currentTrack.title || 'Unknown Track';
const trackChanged = oldTitle !== newTitle && oldTitle !== 'No track';
titleEl.textContent = newTitle;
artistEl.textContent = currentTrack.artist || 'Unknown Artist';
albumEl.textContent = currentTrack.album || 'Unknown Album';
// Album art (modal + sidebar)
if (trackChanged) {
textEls.forEach(el => {
el.classList.remove('np-text-transition');
void el.offsetWidth; // force reflow
el.classList.add('np-text-transition');
});
}
// Album art (modal + sidebar) + ambient glow extraction
const artUrl = getNpAlbumArtUrl();
if (artUrl && artImg) {
// Only set crossOrigin for external URLs — local paths break with CORS headers
if (artUrl.startsWith('http')) {
artImg.crossOrigin = 'anonymous';
} else {
artImg.removeAttribute('crossOrigin');
}
artImg.src = artUrl;
artImg.classList.remove('hidden');
artImg.onerror = () => { artImg.classList.add('hidden'); };
artImg.onerror = () => { artImg.classList.add('hidden'); npResetAmbientGlow(); };
artImg.onload = () => { npExtractAmbientColor(artImg); };
} else if (artImg) {
artImg.classList.add('hidden');
npResetAmbientGlow();
}
if (sidebarArt) {
if (artUrl) {
@ -2774,20 +2893,43 @@ function updateNpTrackInfo() {
}
}
// Format badges
// Format badges (richer: include bitrate/sample_rate)
if (badgesEl) {
badgesEl.innerHTML = '';
const filename = currentTrack.filename || '';
if (filename) {
const ext = getFileExtension(filename);
if (ext) {
let label = ext.toUpperCase();
if (currentTrack.sample_rate) {
const khz = (currentTrack.sample_rate / 1000);
label += ' ' + (khz % 1 === 0 ? khz.toFixed(0) : khz.toFixed(1)) + 'kHz';
}
const badge = document.createElement('span');
badge.className = 'np-format-badge' + (ext === 'flac' ? ' flac' : '');
badge.textContent = ext.toUpperCase();
badge.textContent = label;
badgesEl.appendChild(badge);
}
if (currentTrack.bitrate) {
const brBadge = document.createElement('span');
brBadge.className = 'np-format-badge';
brBadge.textContent = currentTrack.bitrate + 'k';
badgesEl.appendChild(brBadge);
}
}
}
// Action buttons visibility
if (actionBtns) {
const hasArtist = currentTrack.artist_id;
actionBtns.classList.toggle('hidden', !hasArtist);
}
// Track recently played for radio mode
if (currentTrack.id && !npRecentlyPlayedIds.includes(currentTrack.id)) {
npRecentlyPlayedIds.push(currentTrack.id);
if (npRecentlyPlayedIds.length > 50) npRecentlyPlayedIds.shift();
}
} else {
titleEl.textContent = 'No track';
artistEl.textContent = 'Unknown Artist';
@ -2795,6 +2937,46 @@ function updateNpTrackInfo() {
if (artImg) artImg.classList.add('hidden');
if (sidebarArt) sidebarArt.src = '';
if (badgesEl) badgesEl.innerHTML = '';
if (actionBtns) actionBtns.classList.add('hidden');
npResetAmbientGlow();
}
}
function npExtractAmbientColor(imgEl) {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = 50;
canvas.height = 50;
ctx.drawImage(imgEl, 0, 0, 50, 50);
const data = ctx.getImageData(0, 0, 50, 50).data;
let rSum = 0, gSum = 0, bSum = 0, count = 0;
for (let i = 0; i < data.length; i += 16) { // sample every 4th pixel
const r = data[i], g = data[i + 1], b = data[i + 2];
const brightness = (r + g + b) / 3;
if (brightness > 20 && brightness < 230) {
rSum += r; gSum += g; bSum += b; count++;
}
}
if (count > 0) {
const modal = document.querySelector('.np-modal');
if (modal) {
modal.style.setProperty('--np-ambient-r', Math.round(rSum / count));
modal.style.setProperty('--np-ambient-g', Math.round(gSum / count));
modal.style.setProperty('--np-ambient-b', Math.round(bSum / count));
}
}
} catch (e) {
// Cross-origin or canvas error — ignore silently
}
}
function npResetAmbientGlow() {
const modal = document.querySelector('.np-modal');
if (modal) {
modal.style.setProperty('--np-ambient-r', '29');
modal.style.setProperty('--np-ambient-g', '185');
modal.style.setProperty('--np-ambient-b', '84');
}
}
@ -2808,6 +2990,14 @@ function updateNpPlayButton() {
const viz = document.getElementById('np-visualizer');
if (viz) viz.classList.toggle('playing', isPlaying);
// Drive Web Audio visualizer (only when modal is open to save CPU)
if (isPlaying && npModalOpen) {
npInitVisualizer();
npStartVisualizerLoop();
} else {
npStopVisualizerLoop();
}
}
function updateNpProgress() {
@ -2916,10 +3106,15 @@ function handleNpShuffle() {
npShuffleOn = !npShuffleOn;
const btn = document.getElementById('np-shuffle-btn');
if (btn) btn.classList.toggle('active', npShuffleOn);
updateNpPrevNextButtons();
}
function handleNpRepeat() {
const badge = document.getElementById('np-repeat-one-badge');
if (npRepeatMode === 'off') {
npRepeatMode = 'all';
if (audioPlayer) audioPlayer.loop = false;
} else if (npRepeatMode === 'all') {
npRepeatMode = 'one';
if (audioPlayer) audioPlayer.loop = true;
} else {
@ -2928,6 +3123,8 @@ function handleNpRepeat() {
}
const btn = document.getElementById('np-repeat-btn');
if (btn) btn.classList.toggle('active', npRepeatMode !== 'off');
if (badge) badge.classList.toggle('hidden', npRepeatMode !== 'one');
updateNpPrevNextButtons();
}
// ===============================
@ -2940,7 +3137,7 @@ function addToQueue(track) {
renderNpQueue();
updateNpPrevNextButtons();
// If nothing is currently playing, auto-play the first queued track
if (!currentTrack || (!isPlaying && audioPlayer && audioPlayer.paused && audioPlayer.currentTime === 0)) {
if (!currentTrack) {
playQueueItem(npQueue.length - 1);
}
}
@ -2952,6 +3149,7 @@ function removeFromQueue(index) {
// Adjust current index
if (npQueue.length === 0) {
npQueueIndex = -1;
// Current track keeps playing but queue is now empty — that's OK
} else if (index < npQueueIndex) {
npQueueIndex--;
} else if (wasCurrentTrack) {
@ -2986,7 +3184,13 @@ function playNextInQueue() {
playQueueItem(next);
} else {
const next = npQueueIndex + 1;
if (next >= npQueue.length) return; // End of queue
if (next >= npQueue.length) {
// End of queue — repeat-all wraps to start
if (npRepeatMode === 'all') {
playQueueItem(0);
}
return;
}
playQueueItem(next);
}
}
@ -3028,7 +3232,12 @@ async function playQueueItem(index) {
album: track.album,
filename: track.file_path,
is_library: true,
image_url: track.image_url
image_url: track.image_url,
id: track.id,
artist_id: track.artist_id,
album_id: track.album_id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
showLoadingAnimation();
@ -3056,7 +3265,12 @@ async function playQueueItem(index) {
album: track.album,
filename: track.filename || track.file_path,
is_library: false,
image_url: track.image_url
image_url: track.image_url,
id: track.id,
artist_id: track.artist_id,
album_id: track.album_id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
}
} catch (error) {
@ -3130,7 +3344,7 @@ function updateNpPrevNextButtons() {
prevBtn.disabled = !canPrev;
}
if (nextBtn) {
const canNext = npQueue.length > 0 && (npShuffleOn ? npQueue.length > 1 : npQueueIndex < npQueue.length - 1);
const canNext = npQueue.length > 0 && (npShuffleOn ? npQueue.length > 1 : (npQueueIndex < npQueue.length - 1 || npRepeatMode === 'all'));
nextBtn.disabled = !canNext;
}
}
@ -3207,6 +3421,126 @@ function getNpAlbumArtUrl() {
return currentTrack.image_url || currentTrack.album_cover_url || currentTrack.thumb_url || null;
}
// ===============================
// WEB AUDIO VISUALIZER
// ===============================
function npInitVisualizer() {
if (npVizInitialized || !audioPlayer) return;
try {
if (!npAudioContext) {
npAudioContext = new (window.AudioContext || window.webkitAudioContext)();
}
if (!npMediaSource) {
npMediaSource = npAudioContext.createMediaElementSource(audioPlayer);
npAnalyser = npAudioContext.createAnalyser();
npAnalyser.fftSize = 64;
npAnalyser.smoothingTimeConstant = 0.8;
npMediaSource.connect(npAnalyser);
npAnalyser.connect(npAudioContext.destination);
}
npVizInitialized = true;
} catch (e) {
console.warn('Web Audio visualizer init failed, using CSS fallback:', e.message);
// Mark as CSS fallback
const viz = document.getElementById('np-visualizer');
if (viz) viz.classList.add('np-viz-css-fallback');
npVizInitialized = true; // don't retry
}
}
function npStartVisualizerLoop() {
if (npVizAnimFrame) return; // Already running
if (!npAnalyser) return; // No analyser — CSS fallback handles it
if (npAudioContext && npAudioContext.state === 'suspended') {
npAudioContext.resume();
}
const bars = document.querySelectorAll('.np-viz-bar');
if (bars.length === 0) return;
const bufferLength = npAnalyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function draw() {
npVizAnimFrame = requestAnimationFrame(draw);
npAnalyser.getByteFrequencyData(dataArray);
// Map 7 bars to frequency bins (skip bin 0 which is DC offset)
const binCount = Math.min(bufferLength - 1, 7);
for (let i = 0; i < bars.length; i++) {
const binIndex = Math.min(i + 1, bufferLength - 1);
const value = dataArray[binIndex] / 255; // 0..1
const scale = Math.max(0.08, value); // minimum visible height
bars[i].style.transform = `scaleY(${scale})`;
}
}
draw();
}
function npStopVisualizerLoop() {
if (npVizAnimFrame) {
cancelAnimationFrame(npVizAnimFrame);
npVizAnimFrame = null;
}
// Reset bars to min
const bars = document.querySelectorAll('.np-viz-bar');
bars.forEach(bar => { bar.style.transform = 'scaleY(0.125)'; });
}
// ===============================
// RADIO MODE
// ===============================
async function npFetchRadioTracks() {
if (!currentTrack || !currentTrack.id) return;
try {
npLoadingQueueItem = true;
const excludeIds = npRecentlyPlayedIds.join(',');
const resp = await fetch(`/api/library/radio?track_id=${currentTrack.id}&limit=50&exclude=${encodeURIComponent(excludeIds)}`);
if (!resp.ok) {
console.warn('Radio endpoint returned', resp.status);
npLoadingQueueItem = false;
return;
}
const data = await resp.json();
// Bail if radio was toggled off during the fetch
if (!npRadioMode) { npLoadingQueueItem = false; return; }
if (data.tracks && data.tracks.length > 0) {
data.tracks.forEach(t => {
npQueue.push({
title: t.title || 'Unknown Track',
artist: t.artist || 'Unknown Artist',
album: t.album || 'Unknown Album',
file_path: t.file_path,
filename: t.file_path,
is_library: true,
image_url: t.image_url || null,
id: t.id,
artist_id: t.artist_id,
album_id: t.album_id,
bitrate: t.bitrate,
sample_rate: t.sample_rate
});
});
showToast(`Radio: Added ${data.tracks.length} similar tracks`, 'success');
renderNpQueue();
updateNpPrevNextButtons();
npLoadingQueueItem = false;
// Only auto-advance if nothing is currently playing (triggered by onAudioEnded)
if (!isPlaying) {
playNextInQueue();
}
} else {
showToast('Radio: No similar tracks found', 'info');
npLoadingQueueItem = false;
}
} catch (e) {
console.warn('Radio fetch error:', e);
npLoadingQueueItem = false;
}
}
// Media Session API
function initMediaSession() {
if (!('mediaSession' in navigator)) return;
@ -33676,7 +34010,12 @@ function renderTrackTable(album) {
file_path: track.file_path,
filename: track.file_path,
is_library: true,
image_url: albumArt
image_url: albumArt,
id: track.id,
artist_id: artistDetailPageState.enhancedData ? artistDetailPageState.enhancedData.artist.id : null,
album_id: album.id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
};
queueTd.appendChild(queueBtn);
@ -34530,7 +34869,12 @@ async function playLibraryTrack(track, albumTitle, artistName) {
album: albumTitle || 'Unknown Album',
filename: track.file_path,
is_library: true,
image_url: albumArt
image_url: albumArt,
id: track.id,
artist_id: track.artist_id,
album_id: track.album_id,
bitrate: track.bitrate,
sample_rate: track.sample_rate
});
// Show loading state
@ -34557,6 +34901,8 @@ async function playLibraryTrack(track, albumTitle, artistName) {
throw new Error(result.error || 'Failed to start library playback');
}
// Re-apply repeat-one loop property
if (audioPlayer) audioPlayer.loop = (npRepeatMode === 'one');
// Stream state is already "ready" — start audio playback directly
await startAudioPlayback();

View file

@ -31087,6 +31087,9 @@ textarea.enhanced-meta-field-input {
pointer-events: none;
}
.np-modal {
--np-ambient-r: 29;
--np-ambient-g: 185;
--np-ambient-b: 84;
width: 90vw;
max-width: 880px;
max-height: 85vh;
@ -31099,6 +31102,26 @@ textarea.enhanced-meta-field-input {
transform: scale(1);
transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.np-modal::before {
content: '';
position: absolute;
top: -40%;
left: -20%;
width: 140%;
height: 100%;
background: radial-gradient(ellipse at 30% 20%,
rgba(var(--np-ambient-r), var(--np-ambient-g), var(--np-ambient-b), 0.35) 0%,
rgba(var(--np-ambient-r), var(--np-ambient-g), var(--np-ambient-b), 0.08) 40%,
transparent 70%);
pointer-events: none;
z-index: 0;
transition: background 1.2s ease;
filter: blur(40px);
}
.np-modal > * {
position: relative;
z-index: 1;
}
.np-modal-overlay.hidden .np-modal {
transform: scale(0.92);
}
@ -31363,9 +31386,9 @@ textarea.enhanced-meta-field-input {
background: rgb(var(--accent-rgb));
color: #000;
box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.3);
position: relative;
}
.np-btn-play:hover {
background: rgb(calc(var(--accent-rgb) + 20));
filter: brightness(1.15);
transform: scale(1.05);
box-shadow: 0 6px 28px rgba(var(--accent-rgb), 0.4);
@ -31508,28 +31531,151 @@ textarea.enhanced-meta-field-input {
gap: 3px;
height: 24px;
margin-top: 8px;
opacity: 0.4;
opacity: 0.5;
}
.np-viz-bar {
width: 3px;
background: rgb(var(--accent-rgb));
border-radius: 1.5px;
height: 3px;
transition: height 0.1s ease;
height: 24px;
transform-origin: bottom;
transform: scaleY(0.125);
will-change: transform;
transition: transform 0.06s ease-out;
}
.np-visualizer.playing .np-viz-bar {
/* CSS fallback animation when Web Audio is not available */
.np-visualizer.playing.np-viz-css-fallback .np-viz-bar {
animation: npVizPulse 0.8s ease-in-out infinite alternate;
}
.np-visualizer.playing .np-viz-bar:nth-child(1) { animation-delay: 0s; }
.np-visualizer.playing .np-viz-bar:nth-child(2) { animation-delay: 0.1s; }
.np-visualizer.playing .np-viz-bar:nth-child(3) { animation-delay: 0.2s; }
.np-visualizer.playing .np-viz-bar:nth-child(4) { animation-delay: 0.15s; }
.np-visualizer.playing .np-viz-bar:nth-child(5) { animation-delay: 0.25s; }
.np-visualizer.playing .np-viz-bar:nth-child(6) { animation-delay: 0.05s; }
.np-visualizer.playing .np-viz-bar:nth-child(7) { animation-delay: 0.18s; }
.np-visualizer.playing.np-viz-css-fallback .np-viz-bar:nth-child(1) { animation-delay: 0s; }
.np-visualizer.playing.np-viz-css-fallback .np-viz-bar:nth-child(2) { animation-delay: 0.1s; }
.np-visualizer.playing.np-viz-css-fallback .np-viz-bar:nth-child(3) { animation-delay: 0.2s; }
.np-visualizer.playing.np-viz-css-fallback .np-viz-bar:nth-child(4) { animation-delay: 0.15s; }
.np-visualizer.playing.np-viz-css-fallback .np-viz-bar:nth-child(5) { animation-delay: 0.25s; }
.np-visualizer.playing.np-viz-css-fallback .np-viz-bar:nth-child(6) { animation-delay: 0.05s; }
.np-visualizer.playing.np-viz-css-fallback .np-viz-bar:nth-child(7) { animation-delay: 0.18s; }
@keyframes npVizPulse {
0% { height: 3px; }
100% { height: 20px; }
0% { transform: scaleY(0.125); }
100% { transform: scaleY(0.83); }
}
/* Track text transition animation */
.np-text-transition {
animation: npTextFade 0.35s ease;
}
@keyframes npTextFade {
0% { opacity: 0; transform: translateY(6px); }
100% { opacity: 1; transform: translateY(0); }
}
/* Repeat-one badge */
.np-repeat-one-badge {
position: absolute;
top: 2px;
right: 0px;
font-size: 9px;
font-weight: 700;
color: rgb(var(--accent-rgb));
line-height: 1;
pointer-events: none;
}
.np-repeat-one-badge.hidden {
display: none;
}
/* Action buttons row */
.np-action-buttons {
display: flex;
gap: 8px;
justify-content: center;
margin-top: 12px;
}
.np-action-btn {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 12px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
cursor: pointer;
transition: all 0.15s ease;
}
.np-action-btn:hover {
color: rgba(255, 255, 255, 0.8);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.12);
}
.np-action-buttons.hidden {
display: none;
}
/* Buffering ring on play button */
.np-buffering-ring {
position: absolute;
top: -4px;
left: -4px;
right: -4px;
bottom: -4px;
border-radius: 50%;
border: 2.5px solid transparent;
border-top-color: rgb(var(--accent-rgb));
animation: npBufferSpin 0.9s linear infinite;
pointer-events: none;
}
.np-buffering-ring.hidden {
display: none;
}
@keyframes npBufferSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* Radio mode button */
.np-radio-btn {
background: none;
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
padding: 3px 8px;
border-radius: 6px;
cursor: pointer;
display: flex;
align-items: center;
gap: 4px;
transition: all 0.15s ease;
}
.np-radio-btn:hover {
color: rgba(255, 255, 255, 0.7);
border-color: rgba(255, 255, 255, 0.2);
}
.np-radio-btn.active {
color: rgb(var(--accent-rgb));
border-color: rgba(var(--accent-rgb), 0.3);
background: rgba(var(--accent-rgb), 0.08);
}
.np-queue-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* Queue panel scrollbar */
.np-queue-body::-webkit-scrollbar {
width: 6px;
}
.np-queue-body::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.03);
border-radius: 3px;
}
.np-queue-body::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.np-queue-body::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.18);
}
/* Queue Panel */
@ -31609,8 +31755,8 @@ textarea.enhanced-meta-field-input {
background: rgba(255, 255, 255, 0.05);
}
.np-queue-item.active {
background: rgba(29, 185, 84, 0.12);
border-left: 3px solid #1db954;
background: rgba(var(--accent-rgb), 0.12);
border-left: 3px solid rgb(var(--accent-rgb));
}
.np-queue-item-info {
min-width: 0;
@ -31624,7 +31770,7 @@ textarea.enhanced-meta-field-input {
text-overflow: ellipsis;
}
.np-queue-item.active .np-queue-item-title {
color: #1db954;
color: rgb(var(--accent-light-rgb));
}
.np-queue-item-artist {
font-size: 11px;