view recommended artists.
This commit is contained in:
parent
8f2dd66aee
commit
1f73ba4f94
5 changed files with 624 additions and 8 deletions
102
web_server.py
102
web_server.py
|
|
@ -23087,6 +23087,108 @@ def get_discover_hero():
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/discover/similar-artists', methods=['GET'])
|
||||||
|
def get_discover_similar_artists():
|
||||||
|
"""Get all recommended similar artists (basic data, no enrichment for speed)"""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
active_source = _get_active_discovery_source()
|
||||||
|
|
||||||
|
similar_artists = database.get_top_similar_artists(limit=200)
|
||||||
|
|
||||||
|
if not similar_artists:
|
||||||
|
return jsonify({"success": True, "artists": [], "source": active_source, "count": 0})
|
||||||
|
|
||||||
|
# Filter to artists with valid ID for active source
|
||||||
|
result_artists = []
|
||||||
|
for artist in similar_artists:
|
||||||
|
has_spotify = bool(artist.similar_artist_spotify_id)
|
||||||
|
has_itunes = bool(artist.similar_artist_itunes_id)
|
||||||
|
|
||||||
|
if active_source == 'spotify' and not has_spotify and not has_itunes:
|
||||||
|
continue
|
||||||
|
if active_source == 'itunes' and not has_itunes and not has_spotify:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if active_source == 'spotify':
|
||||||
|
artist_id = artist.similar_artist_spotify_id or artist.similar_artist_itunes_id
|
||||||
|
else:
|
||||||
|
artist_id = artist.similar_artist_itunes_id or artist.similar_artist_spotify_id
|
||||||
|
|
||||||
|
result_artists.append({
|
||||||
|
"artist_id": artist_id,
|
||||||
|
"spotify_artist_id": artist.similar_artist_spotify_id,
|
||||||
|
"itunes_artist_id": artist.similar_artist_itunes_id,
|
||||||
|
"artist_name": artist.similar_artist_name,
|
||||||
|
"occurrence_count": artist.occurrence_count,
|
||||||
|
"similarity_rank": artist.similarity_rank,
|
||||||
|
"source": active_source,
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"[Similar Artists] {len(similar_artists)} from DB, {len(result_artists)} valid for {active_source}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"artists": result_artists,
|
||||||
|
"source": active_source,
|
||||||
|
"count": len(result_artists)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error getting similar artists: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/discover/similar-artists/enrich', methods=['POST'])
|
||||||
|
def enrich_similar_artists():
|
||||||
|
"""Enrich a batch of artist IDs with images/genres from Spotify or iTunes"""
|
||||||
|
try:
|
||||||
|
data = request.get_json()
|
||||||
|
artist_ids = data.get('artist_ids', [])
|
||||||
|
source = data.get('source', 'spotify')
|
||||||
|
|
||||||
|
if not artist_ids:
|
||||||
|
return jsonify({"success": True, "artists": {}})
|
||||||
|
|
||||||
|
enriched = {}
|
||||||
|
|
||||||
|
if source == 'spotify' and spotify_client and spotify_client.is_authenticated():
|
||||||
|
try:
|
||||||
|
batch_result = spotify_client.sp.artists(artist_ids[:50])
|
||||||
|
if batch_result and 'artists' in batch_result:
|
||||||
|
for sp_artist in batch_result['artists']:
|
||||||
|
if sp_artist:
|
||||||
|
enriched[sp_artist['id']] = {
|
||||||
|
"artist_name": sp_artist.get('name'),
|
||||||
|
"image_url": sp_artist['images'][0]['url'] if sp_artist.get('images') else None,
|
||||||
|
"genres": sp_artist.get('genres', [])[:3],
|
||||||
|
"popularity": sp_artist.get('popularity', 0)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error enriching Spotify batch: {e}")
|
||||||
|
else:
|
||||||
|
from core.itunes_client import iTunesClient
|
||||||
|
itunes_client = iTunesClient()
|
||||||
|
for aid in artist_ids[:50]:
|
||||||
|
try:
|
||||||
|
itunes_artist = itunes_client.get_artist(aid)
|
||||||
|
if itunes_artist:
|
||||||
|
enriched[aid] = {
|
||||||
|
"artist_name": itunes_artist.get('name'),
|
||||||
|
"image_url": itunes_artist.get('images', [{}])[0].get('url') if itunes_artist.get('images') else None,
|
||||||
|
"genres": itunes_artist.get('genres', [])[:3],
|
||||||
|
"popularity": 0
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({"success": True, "artists": enriched})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error enriching similar artists: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/discover/recent-releases', methods=['GET'])
|
@app.route('/api/discover/recent-releases', methods=['GET'])
|
||||||
def get_discover_recent_releases():
|
def get_discover_recent_releases():
|
||||||
"""Get cached recent albums from watchlist and similar artists"""
|
"""Get cached recent albums from watchlist and similar artists"""
|
||||||
|
|
|
||||||
|
|
@ -2190,11 +2190,16 @@
|
||||||
<!-- Slideshow Indicators -->
|
<!-- Slideshow Indicators -->
|
||||||
<div class="discover-hero-indicators" id="discover-hero-indicators"></div>
|
<div class="discover-hero-indicators" id="discover-hero-indicators"></div>
|
||||||
|
|
||||||
<!-- Watch All Button -->
|
<!-- Hero Bottom Buttons -->
|
||||||
<button class="discover-hero-watch-all" id="discover-hero-watch-all" onclick="watchAllHeroArtists(this)">
|
<div class="discover-hero-bottom-actions">
|
||||||
<span class="watch-all-icon">👁️</span>
|
<button class="discover-hero-watch-all" id="discover-hero-watch-all" onclick="watchAllHeroArtists(this)">
|
||||||
<span class="watch-all-text">Watch All</span>
|
<span class="watch-all-icon">👁️</span>
|
||||||
</button>
|
<span class="watch-all-text">Watch All</span>
|
||||||
|
</button>
|
||||||
|
<button class="discover-hero-view-all" id="discover-hero-view-all" onclick="openRecommendedArtistsModal()">
|
||||||
|
View Recommended
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Releases Section -->
|
<!-- Recent Releases Section -->
|
||||||
|
|
|
||||||
|
|
@ -913,6 +913,40 @@
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.discover-hero-bottom-actions {
|
||||||
|
bottom: 12px;
|
||||||
|
right: 12px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discover-hero-watch-all,
|
||||||
|
.discover-hero-view-all {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Recommended Artists Modal - Mobile */
|
||||||
|
.recommended-modal {
|
||||||
|
max-width: 100vw;
|
||||||
|
width: 100vw;
|
||||||
|
max-height: 100vh;
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-artists-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-search-container {
|
||||||
|
padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-watchlist-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.discover-section-header {
|
.discover-section-header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
|
|
||||||
|
|
@ -33596,6 +33596,311 @@ async function watchAllHeroArtists(btn) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache for recommended artists data so reopening is instant
|
||||||
|
let _recommendedArtistsCache = null;
|
||||||
|
|
||||||
|
async function openRecommendedArtistsModal() {
|
||||||
|
let modal = document.getElementById('recommended-artists-modal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'recommended-artists-modal';
|
||||||
|
modal.className = 'modal-overlay';
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
modal.addEventListener('click', function(e) {
|
||||||
|
if (e.target === modal) closeRecommendedArtistsModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If cached, render instantly and refresh watchlist statuses
|
||||||
|
if (_recommendedArtistsCache) {
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
renderRecommendedArtistsModal(modal, _recommendedArtistsCache);
|
||||||
|
checkRecommendedWatchlistStatuses(_recommendedArtistsCache);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-container playlist-modal recommended-modal">
|
||||||
|
<div class="playlist-modal-header">
|
||||||
|
<div class="playlist-header-content" style="width: 100%;">
|
||||||
|
<h2>Recommended Artists</h2>
|
||||||
|
<div class="playlist-quick-info">
|
||||||
|
<span class="playlist-track-count">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="playlist-modal-close" onclick="closeRecommendedArtistsModal()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-modal-body">
|
||||||
|
<div class="recommended-loading">Loading recommended artists...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Phase 1: Fetch basic data (instant — no API enrichment)
|
||||||
|
const response = await fetch('/api/discover/similar-artists');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.success || !data.artists || data.artists.length === 0) {
|
||||||
|
modal.querySelector('.playlist-modal-body').innerHTML = `
|
||||||
|
<div class="recommended-empty-state">
|
||||||
|
No recommended artists yet.<br>
|
||||||
|
Run a watchlist scan to generate recommendations.
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
modal.querySelector('.playlist-track-count').textContent = '0 artists';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render cards immediately with fallback images
|
||||||
|
_recommendedArtistsCache = data.artists;
|
||||||
|
renderRecommendedArtistsModal(modal, data.artists);
|
||||||
|
|
||||||
|
// Phase 2: Enrich with images/genres progressively in batches of 50
|
||||||
|
const source = data.source || 'spotify';
|
||||||
|
const idKey = source === 'spotify' ? 'spotify_artist_id' : 'itunes_artist_id';
|
||||||
|
const allIds = data.artists.map(a => a[idKey]).filter(Boolean);
|
||||||
|
|
||||||
|
for (let i = 0; i < allIds.length; i += 50) {
|
||||||
|
const batchIds = allIds.slice(i, i + 50);
|
||||||
|
try {
|
||||||
|
const enrichResp = await fetch('/api/discover/similar-artists/enrich', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ artist_ids: batchIds, source })
|
||||||
|
});
|
||||||
|
const enrichData = await enrichResp.json();
|
||||||
|
if (enrichData.success && enrichData.artists) {
|
||||||
|
// Update cards and cache as each batch arrives
|
||||||
|
for (const [aid, info] of Object.entries(enrichData.artists)) {
|
||||||
|
// Update the card in DOM
|
||||||
|
const card = modal.querySelector(`.recommended-artist-card[data-artist-id="${aid}"]`);
|
||||||
|
if (card && info.image_url) {
|
||||||
|
const imgContainer = card.querySelector('.recommended-card-image');
|
||||||
|
if (imgContainer) {
|
||||||
|
imgContainer.innerHTML = `<img src="${info.image_url}" alt="" loading="lazy"
|
||||||
|
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (card && info.genres && info.genres.length > 0) {
|
||||||
|
const genresContainer = card.querySelector('.recommended-card-genres');
|
||||||
|
if (genresContainer) {
|
||||||
|
genresContainer.innerHTML = info.genres.map(g =>
|
||||||
|
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
const infoDiv = card.querySelector('.recommended-card-info');
|
||||||
|
if (infoDiv) {
|
||||||
|
const genreDiv = document.createElement('div');
|
||||||
|
genreDiv.className = 'recommended-card-genres';
|
||||||
|
genreDiv.innerHTML = info.genres.map(g =>
|
||||||
|
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||||||
|
).join('');
|
||||||
|
infoDiv.appendChild(genreDiv);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
const cached = _recommendedArtistsCache.find(a => a.artist_id === aid || a.spotify_artist_id === aid || a.itunes_artist_id === aid);
|
||||||
|
if (cached) {
|
||||||
|
if (info.image_url) cached.image_url = info.image_url;
|
||||||
|
if (info.genres) cached.genres = info.genres;
|
||||||
|
if (info.artist_name) cached.artist_name = info.artist_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (enrichErr) {
|
||||||
|
console.error('Error enriching batch:', enrichErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Check watchlist statuses
|
||||||
|
checkRecommendedWatchlistStatuses(data.artists);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recommended artists:', error);
|
||||||
|
modal.querySelector('.playlist-modal-body').innerHTML = `
|
||||||
|
<div class="recommended-empty-state">Error loading recommended artists.</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecommendedArtistsModal(modal, artists) {
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="modal-container playlist-modal recommended-modal">
|
||||||
|
<div class="playlist-modal-header">
|
||||||
|
<div class="playlist-header-content" style="width: 100%;">
|
||||||
|
<h2>Recommended Artists</h2>
|
||||||
|
<div class="playlist-quick-info">
|
||||||
|
<span class="playlist-track-count">${artists.length} artist${artists.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="playlist-modal-close" onclick="closeRecommendedArtistsModal()">×</span>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-modal-body">
|
||||||
|
<div class="recommended-search-container">
|
||||||
|
<input type="text"
|
||||||
|
class="recommended-search-input"
|
||||||
|
id="recommended-search-input"
|
||||||
|
placeholder="Search recommended artists..."
|
||||||
|
oninput="filterRecommendedArtists()">
|
||||||
|
</div>
|
||||||
|
<div class="recommended-artists-grid" id="recommended-artists-grid">
|
||||||
|
${artists.map(artist => {
|
||||||
|
const genreTags = (artist.genres || []).slice(0, 3).map(g =>
|
||||||
|
`<span class="recommended-card-genre">${escapeHtml(g)}</span>`
|
||||||
|
).join('');
|
||||||
|
const similarText = artist.occurrence_count > 1
|
||||||
|
? `Similar to ${artist.occurrence_count} in your watchlist`
|
||||||
|
: 'Similar to an artist in your watchlist';
|
||||||
|
return `
|
||||||
|
<div class="recommended-artist-card"
|
||||||
|
data-artist-name="${escapeHtml(artist.artist_name).toLowerCase()}"
|
||||||
|
data-artist-id="${artist.artist_id}">
|
||||||
|
<button class="recommended-card-watchlist-btn"
|
||||||
|
data-artist-id="${artist.artist_id}"
|
||||||
|
data-artist-name="${escapeHtml(artist.artist_name)}">
|
||||||
|
Add to Watchlist
|
||||||
|
</button>
|
||||||
|
<div class="recommended-card-image">
|
||||||
|
${artist.image_url ? `
|
||||||
|
<img src="${artist.image_url}"
|
||||||
|
alt="${escapeHtml(artist.artist_name)}"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.parentElement.innerHTML='<div class=\\'recommended-card-image-fallback\\'>🎤</div>';">
|
||||||
|
` : `
|
||||||
|
<div class="recommended-card-image-fallback">🎤</div>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
<div class="recommended-card-info">
|
||||||
|
<span class="recommended-card-name">${escapeHtml(artist.artist_name)}</span>
|
||||||
|
<span class="recommended-card-similarity">${similarText}</span>
|
||||||
|
<div class="recommended-card-genres">${genreTags}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Event delegation for card clicks and watchlist buttons
|
||||||
|
const grid = modal.querySelector('#recommended-artists-grid');
|
||||||
|
if (grid) {
|
||||||
|
grid.addEventListener('click', function(e) {
|
||||||
|
const watchlistBtn = e.target.closest('.recommended-card-watchlist-btn');
|
||||||
|
if (watchlistBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleRecommendedWatchlist(watchlistBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = e.target.closest('.recommended-artist-card');
|
||||||
|
if (card) {
|
||||||
|
const artistId = card.getAttribute('data-artist-id');
|
||||||
|
const nameEl = card.querySelector('.recommended-card-name');
|
||||||
|
const artistName = nameEl ? nameEl.textContent : '';
|
||||||
|
viewRecommendedArtistDiscography(artistId, artistName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeRecommendedArtistsModal() {
|
||||||
|
const modal = document.getElementById('recommended-artists-modal');
|
||||||
|
if (modal) modal.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterRecommendedArtists() {
|
||||||
|
const query = (document.getElementById('recommended-search-input')?.value || '').toLowerCase();
|
||||||
|
const cards = document.querySelectorAll('.recommended-artist-card');
|
||||||
|
cards.forEach(card => {
|
||||||
|
const name = card.getAttribute('data-artist-name') || '';
|
||||||
|
card.style.display = name.includes(query) ? '' : 'none';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleRecommendedWatchlist(btn) {
|
||||||
|
const artistId = btn.getAttribute('data-artist-id');
|
||||||
|
const artistName = btn.getAttribute('data-artist-name');
|
||||||
|
if (!artistId || !artistName) return;
|
||||||
|
|
||||||
|
btn.disabled = true;
|
||||||
|
const wasWatching = btn.classList.contains('watching');
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (wasWatching) {
|
||||||
|
const resp = await fetch('/api/watchlist/remove', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ artist_id: artistId })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
btn.classList.remove('watching');
|
||||||
|
btn.textContent = 'Add to Watchlist';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const resp = await fetch('/api/watchlist/add', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ artist_id: artistId, artist_name: artistName })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success) {
|
||||||
|
btn.classList.add('watching');
|
||||||
|
btn.textContent = 'Watching';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof updateWatchlistButtonCount === 'function') updateWatchlistButtonCount();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling recommended watchlist:', error);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkRecommendedWatchlistStatuses(artists) {
|
||||||
|
for (const artist of artists) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/watchlist/check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ artist_id: artist.artist_id })
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success && data.is_watching) {
|
||||||
|
const btn = document.querySelector(`.recommended-card-watchlist-btn[data-artist-id="${artist.artist_id}"]`);
|
||||||
|
if (btn) {
|
||||||
|
btn.classList.add('watching');
|
||||||
|
btn.textContent = 'Watching';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function viewRecommendedArtistDiscography(artistId, artistName) {
|
||||||
|
closeRecommendedArtistsModal();
|
||||||
|
|
||||||
|
const artist = {
|
||||||
|
id: artistId,
|
||||||
|
name: artistName
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use same navigation pattern as hero slider
|
||||||
|
navigateToPage('artists');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
await selectArtistForDetail(artist);
|
||||||
|
}
|
||||||
|
|
||||||
async function checkAllHeroWatchlistStatus() {
|
async function checkAllHeroWatchlistStatus() {
|
||||||
const btn = document.getElementById('discover-hero-watch-all');
|
const btn = document.getElementById('discover-hero-watch-all');
|
||||||
if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
|
if (!btn || !discoverHeroArtists || discoverHeroArtists.length === 0) return;
|
||||||
|
|
|
||||||
|
|
@ -20179,12 +20179,19 @@ body {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Watch All Button */
|
/* Hero Bottom Action Buttons */
|
||||||
.discover-hero-watch-all {
|
.discover-hero-bottom-actions {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
right: 24px;
|
right: 24px;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discover-hero-watch-all,
|
||||||
|
.discover-hero-view-all {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
|
@ -20200,7 +20207,8 @@ body {
|
||||||
transition: background 0.2s ease, border-color 0.2s ease;
|
transition: background 0.2s ease, border-color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.discover-hero-watch-all:hover {
|
.discover-hero-watch-all:hover,
|
||||||
|
.discover-hero-view-all:hover {
|
||||||
background: rgba(0, 0, 0, 0.5);
|
background: rgba(0, 0, 0, 0.5);
|
||||||
border-color: rgba(255, 255, 255, 0.3);
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
@ -20221,6 +20229,168 @@ body {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Recommended Artists Modal */
|
||||||
|
.recommended-modal {
|
||||||
|
max-width: 95vw;
|
||||||
|
width: 95vw;
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-modal .playlist-modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-artists-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
padding: 20px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-artist-card {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-artist-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-image {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.15), rgba(var(--accent-rgb), 0.05));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-image-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 48px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-info {
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-similarity {
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(var(--accent-rgb), 0.8);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-genres {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-genre {
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-watchlist-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 8px;
|
||||||
|
right: 8px;
|
||||||
|
z-index: 3;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 14px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease, background 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-artist-card:hover .recommended-card-watchlist-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-card-watchlist-btn.watching {
|
||||||
|
background: rgba(var(--accent-rgb), 0.2);
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.5);
|
||||||
|
color: rgb(var(--accent-rgb));
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-search-container {
|
||||||
|
padding: 0 28px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-search-input:focus {
|
||||||
|
border-color: rgba(var(--accent-rgb), 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-search-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommended-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Discover Sections */
|
/* Discover Sections */
|
||||||
.discover-section {
|
.discover-section {
|
||||||
margin-bottom: 50px;
|
margin-bottom: 50px;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue