Redesigned media player with expanded Now Playing modal and smart radio
This commit is contained in:
parent
5b79ca1e88
commit
5f58432ca4
5 changed files with 750 additions and 35 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">×</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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue