feat(ui): add "In Your Library" section to artist detail page
Show liked tracks and playlist tracks by the artist with source labels, collapsible chevron toggle, and real-time updates via favorites-changed and playlist-tracks-changed events.
This commit is contained in:
parent
433f859513
commit
52d5166363
4 changed files with 300 additions and 9 deletions
22
index.html
22
index.html
|
|
@ -1115,6 +1115,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div id="goto-playlist-modal" class="modal">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content">
|
||||
<h3>Go to Playlist</h3>
|
||||
<div id="goto-playlist-list" class="modal-list">
|
||||
<!-- Options will be injected here -->
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button id="goto-playlist-cancel" class="btn-secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="shortcuts-modal" class="modal">
|
||||
<div class="modal-overlay"></div>
|
||||
<div class="modal-content medium">
|
||||
|
|
@ -3283,6 +3296,15 @@
|
|||
<h2 class="section-title">Popular Tracks</h2>
|
||||
<div class="track-list" id="artist-detail-tracks"></div>
|
||||
</section>
|
||||
<section class="content-section" id="artist-section-in-library" style="display: none">
|
||||
<h2 class="section-title in-library-toggle" id="in-library-toggle" style="cursor: pointer; display: flex; align-items: center; gap: 0.5rem; user-select: none;">
|
||||
In Your Library
|
||||
<svg id="in-library-chevron" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition: transform 0.25s ease; flex-shrink: 0;">
|
||||
<polyline points="9 18 15 12 9 6"></polyline>
|
||||
</svg>
|
||||
</h2>
|
||||
<div class="track-list" id="artist-detail-in-library" style="display: none;"></div>
|
||||
</section>
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Albums</h2>
|
||||
<div class="card-grid" id="artist-detail-albums"></div>
|
||||
|
|
|
|||
5
js/db.js
5
js/db.js
|
|
@ -172,11 +172,13 @@ export class MusicDatabase {
|
|||
|
||||
if (exists) {
|
||||
await this.performTransaction(storeName, 'readwrite', (store) => store.delete(key));
|
||||
window.dispatchEvent(new CustomEvent('favorites-changed'));
|
||||
return false; // Removed
|
||||
} else {
|
||||
const minified = this._minifyItem(type, item);
|
||||
const entry = { ...minified, addedAt: Date.now() };
|
||||
await this.performTransaction(storeName, 'readwrite', (store) => store.put(entry));
|
||||
window.dispatchEvent(new CustomEvent('favorites-changed'));
|
||||
return true; // Added
|
||||
}
|
||||
}
|
||||
|
|
@ -600,6 +602,7 @@ export class MusicDatabase {
|
|||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
|
||||
this._dispatchPlaylistSync('update', playlist);
|
||||
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
|
@ -623,6 +626,7 @@ export class MusicDatabase {
|
|||
this._updatePlaylistMetadata(playlist);
|
||||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
this._dispatchPlaylistSync('update', playlist);
|
||||
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
||||
}
|
||||
|
||||
return playlist;
|
||||
|
|
@ -643,6 +647,7 @@ export class MusicDatabase {
|
|||
await this.performTransaction('user_playlists', 'readwrite', (store) => store.put(playlist));
|
||||
|
||||
this._dispatchPlaylistSync('update', playlist);
|
||||
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
|
|
|||
227
js/ui.js
227
js/ui.js
|
|
@ -3072,10 +3072,10 @@ export class UIRenderer {
|
|||
dateDisplay =
|
||||
window.innerWidth > 768
|
||||
? releaseDate.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: year;
|
||||
}
|
||||
}
|
||||
|
|
@ -3927,6 +3927,8 @@ export class UIRenderer {
|
|||
const epsSection = document.getElementById('artist-section-eps');
|
||||
const similarContainer = document.getElementById('artist-detail-similar');
|
||||
const similarSection = document.getElementById('artist-section-similar');
|
||||
const inLibraryContainer = document.getElementById('artist-detail-in-library');
|
||||
const inLibrarySection = document.getElementById('artist-section-in-library');
|
||||
const dlBtn = document.getElementById('download-discography-btn');
|
||||
if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD}<span>Download Discography</span>`;
|
||||
|
||||
|
|
@ -3948,6 +3950,14 @@ export class UIRenderer {
|
|||
if (loadUnreleasedSection) loadUnreleasedSection.style.display = 'none';
|
||||
if (similarContainer) similarContainer.innerHTML = this.createSkeletonCards(6, true);
|
||||
if (similarSection) similarSection.style.display = 'block';
|
||||
if (inLibrarySection) inLibrarySection.style.display = 'none';
|
||||
if (inLibraryContainer) {
|
||||
inLibraryContainer.innerHTML = '';
|
||||
inLibraryContainer.style.display = 'none';
|
||||
}
|
||||
// Reset chevron state
|
||||
const chevronEl = document.getElementById('in-library-chevron');
|
||||
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
|
||||
|
||||
try {
|
||||
const artist = await this.api.getArtist(artistId, provider);
|
||||
|
|
@ -4168,9 +4178,9 @@ export class UIRenderer {
|
|||
<span>${artist.popularity}% popularity</span>
|
||||
<div class="artist-tags">
|
||||
${(artist.artistRoles || [])
|
||||
.filter((role) => role.category)
|
||||
.map((role) => `<span class="artist-tag">${role.category}</span>`)
|
||||
.join('')}
|
||||
.filter((role) => role.category)
|
||||
.map((role) => `<span class="artist-tag">${role.category}</span>`)
|
||||
.join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
@ -4182,6 +4192,205 @@ export class UIRenderer {
|
|||
|
||||
this.renderListWithTracks(tracksContainer, artist.tracks, true);
|
||||
|
||||
// "In your library" section: find liked tracks and playlist tracks for this artist
|
||||
if (inLibraryContainer && inLibrarySection) {
|
||||
const artistNameLower = artist.name.toLowerCase();
|
||||
|
||||
const isTrackByArtist = (track) => {
|
||||
if (track.artists && Array.isArray(track.artists)) {
|
||||
return track.artists.some(
|
||||
(a) => a && a.name && a.name.toLowerCase() === artistNameLower
|
||||
);
|
||||
}
|
||||
if (track.artist) {
|
||||
const artistStr = typeof track.artist === 'string' ? track.artist : track.artist.name;
|
||||
if (artistStr && artistStr.toLowerCase() === artistNameLower) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const refreshInLibrary = async () => {
|
||||
try {
|
||||
const seenIds = new Set();
|
||||
const libraryTracks = [];
|
||||
const trackSourceMap = new Map(); // trackId -> Array<{ label, href }>
|
||||
|
||||
const addSource = (trackId, source) => {
|
||||
if (!trackSourceMap.has(trackId)) {
|
||||
trackSourceMap.set(trackId, []);
|
||||
}
|
||||
trackSourceMap.get(trackId).push(source);
|
||||
};
|
||||
|
||||
// Get liked tracks
|
||||
const likedTracks = await db.getFavorites('track');
|
||||
for (const track of likedTracks) {
|
||||
if (isTrackByArtist(track)) {
|
||||
if (!seenIds.has(track.id)) {
|
||||
seenIds.add(track.id);
|
||||
libraryTracks.push(track);
|
||||
}
|
||||
addSource(track.id, { label: 'Liked Tracks', href: '/library' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get tracks from user playlists
|
||||
const userPlaylists = await db.getPlaylists(true);
|
||||
for (const playlist of userPlaylists) {
|
||||
if (playlist.tracks && Array.isArray(playlist.tracks)) {
|
||||
for (const track of playlist.tracks) {
|
||||
if (isTrackByArtist(track)) {
|
||||
if (!seenIds.has(track.id)) {
|
||||
seenIds.add(track.id);
|
||||
libraryTracks.push(track);
|
||||
}
|
||||
const label = playlist.name || playlist.title || 'Playlist';
|
||||
addSource(track.id, {
|
||||
label,
|
||||
href: `/userplaylist/${playlist.id}`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort alphabetically by title
|
||||
libraryTracks.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
|
||||
if (libraryTracks.length > 0) {
|
||||
inLibrarySection.style.display = 'block';
|
||||
this.renderListWithTracks(inLibraryContainer, libraryTracks, true);
|
||||
|
||||
// Inject source labels into each track's .artist div
|
||||
const trackElements = inLibraryContainer.querySelectorAll('.track-item');
|
||||
trackElements.forEach((el, idx) => {
|
||||
const track = libraryTracks[idx];
|
||||
if (!track) return;
|
||||
const sources = trackSourceMap.get(track.id);
|
||||
if (!sources || sources.length === 0) return;
|
||||
const artistDiv = el.querySelector('.track-item-details .artist');
|
||||
if (!artistDiv) return;
|
||||
|
||||
// Extract artist name and year from existing content
|
||||
const artistLinks = artistDiv.querySelectorAll('.artist-link');
|
||||
const artistNames = Array.from(artistLinks).map(a => a.textContent).join(', ');
|
||||
const truncatedArtist = artistNames.length > 15 ? artistNames.slice(0, 20) + '…' : artistNames;
|
||||
|
||||
// Extract year from text content (pattern: " • 2024")
|
||||
const fullText = artistDiv.textContent;
|
||||
const yearMatch = fullText.match(/\s•\s(\d{4})/);
|
||||
const yearText = yearMatch ? ` • ${yearMatch[1]}` : '';
|
||||
|
||||
// Build source content
|
||||
const sourceSpan = document.createElement('span');
|
||||
sourceSpan.className = 'library-source';
|
||||
|
||||
if (sources.length === 1) {
|
||||
const srcLabel = sources[0].label.length > 15 ? sources[0].label.slice(0, 15) + '…' : sources[0].label;
|
||||
sourceSpan.innerHTML = `<span class="library-source-label">· Source: </span><span class="library-source-link">${srcLabel}</span>`;
|
||||
sourceSpan.style.cursor = 'pointer';
|
||||
sourceSpan.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(sources[0].href);
|
||||
});
|
||||
} else {
|
||||
sourceSpan.innerHTML = `<span class="library-source-label">· Source: </span><span class="library-source-link">Multiple Playlists</span>`;
|
||||
sourceSpan.style.cursor = 'pointer';
|
||||
sourceSpan.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const modal = document.getElementById('goto-playlist-modal');
|
||||
const list = document.getElementById('goto-playlist-list');
|
||||
const cancelBtn = document.getElementById('goto-playlist-cancel');
|
||||
const overlay = modal.querySelector('.modal-overlay');
|
||||
|
||||
list.innerHTML = sources.map((s) =>
|
||||
`<div class="modal-option" data-href="${s.href}">
|
||||
<span>${s.label}</span>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
const closeModal = () => {
|
||||
modal.classList.remove('active');
|
||||
};
|
||||
|
||||
list.onclick = (ev) => {
|
||||
const option = ev.target.closest('.modal-option');
|
||||
if (!option) return;
|
||||
const href = option.dataset.href;
|
||||
closeModal();
|
||||
if (href) navigate(href);
|
||||
};
|
||||
|
||||
cancelBtn.onclick = closeModal;
|
||||
overlay.onclick = closeModal;
|
||||
modal.classList.add('active');
|
||||
});
|
||||
}
|
||||
|
||||
// Rebuild artist div with structured layout
|
||||
artistDiv.innerHTML = '';
|
||||
artistDiv.classList.add('library-artist-flex');
|
||||
|
||||
const artistNameSpan = document.createElement('span');
|
||||
artistNameSpan.className = 'library-artist-name';
|
||||
artistNameSpan.textContent = truncatedArtist;
|
||||
|
||||
const yearSpan = document.createElement('span');
|
||||
yearSpan.className = 'library-year';
|
||||
yearSpan.textContent = yearText;
|
||||
|
||||
artistDiv.appendChild(artistNameSpan);
|
||||
artistDiv.appendChild(yearSpan);
|
||||
artistDiv.appendChild(sourceSpan);
|
||||
});
|
||||
} else {
|
||||
inLibrarySection.style.display = 'none';
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to load library tracks for artist:', err);
|
||||
inLibrarySection.style.display = 'none';
|
||||
}
|
||||
};
|
||||
|
||||
// Initial load
|
||||
refreshInLibrary().then(() => {
|
||||
inLibraryContainer.style.display = 'none';
|
||||
});
|
||||
|
||||
// Setup chevron toggle (once)
|
||||
const toggle = document.getElementById('in-library-toggle');
|
||||
const chevron = document.getElementById('in-library-chevron');
|
||||
if (toggle) {
|
||||
toggle.onclick = () => {
|
||||
const isOpen = inLibraryContainer.style.display !== 'none';
|
||||
inLibraryContainer.style.display = isOpen ? 'none' : '';
|
||||
if (chevron) {
|
||||
chevron.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Real-time updates: refresh when favorites or playlists change
|
||||
let refreshTimeout;
|
||||
const debouncedRefresh = () => {
|
||||
clearTimeout(refreshTimeout);
|
||||
refreshTimeout = setTimeout(() => refreshInLibrary(), 300);
|
||||
};
|
||||
window.addEventListener('favorites-changed', debouncedRefresh);
|
||||
window.addEventListener('playlist-tracks-changed', debouncedRefresh);
|
||||
|
||||
// Cleanup listeners when navigating away
|
||||
const cleanupOnNav = () => {
|
||||
window.removeEventListener('favorites-changed', debouncedRefresh);
|
||||
window.removeEventListener('playlist-tracks-changed', debouncedRefresh);
|
||||
window.removeEventListener('popstate', cleanupOnNav);
|
||||
};
|
||||
window.addEventListener('popstate', cleanupOnNav, { once: true });
|
||||
}
|
||||
|
||||
// Update header like button
|
||||
const artistLikeBtn = document.getElementById('like-artist-btn');
|
||||
if (artistLikeBtn) {
|
||||
|
|
@ -4812,13 +5021,13 @@ export class UIRenderer {
|
|||
<div class="controls">
|
||||
${
|
||||
isUser
|
||||
? `
|
||||
? `
|
||||
<button class="delete-instance" title="Delete Instance">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M3 6h18M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
|
||||
</svg>
|
||||
</button>`
|
||||
: ''
|
||||
: ''
|
||||
}
|
||||
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
|
|
|
|||
55
styles.css
55
styles.css
|
|
@ -1988,6 +1988,58 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
gap: 2px var(--spacing-xl);
|
||||
}
|
||||
|
||||
#artist-detail-in-library {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(580px, 1fr));
|
||||
gap: 2px var(--spacing-xl);
|
||||
}
|
||||
|
||||
.library-source {
|
||||
color: var(--muted-foreground);
|
||||
font-size: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
flex-shrink: 1;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.library-artist-flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.library-artist-name {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-year {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-source-label {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.library-source-link {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.library-source:hover .library-source-link {
|
||||
color: var(--highlight);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
#playlist-detail-recommended .track-item {
|
||||
grid-template-columns: 40px 1fr 32px auto;
|
||||
}
|
||||
|
|
@ -1995,6 +2047,7 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
@media (max-width: 1100px) {
|
||||
#home-recommended-songs,
|
||||
#artist-detail-tracks,
|
||||
#artist-detail-in-library,
|
||||
#playlist-detail-recommended {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -2313,6 +2366,8 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-header-info .title.long-title {
|
||||
|
|
|
|||
Loading…
Reference in a new issue