Merge pull request #335 from Xenuel/feature/in-your-library
feat(ui): add "In Your Library" section to artist detail page
This commit is contained in:
commit
e5b4cf6adf
4 changed files with 332 additions and 10 deletions
24
index.html
24
index.html
|
|
@ -1131,6 +1131,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">
|
||||
|
|
@ -3299,6 +3312,17 @@
|
|||
<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">
|
||||
<button id="in-library-toggle" aria-expanded="false" aria-controls="artist-detail-in-library" style="background: none; border: none; color: inherit; font: inherit; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; padding: 0; 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>
|
||||
</button>
|
||||
</h2>
|
||||
<div class="track-list" id="artist-detail-in-library" hidden></div>
|
||||
</section>
|
||||
<section class="content-section">
|
||||
<h2 class="section-title">Albums</h2>
|
||||
<div class="card-grid" id="artist-detail-albums"></div>
|
||||
|
|
|
|||
7
js/db.js
7
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
|
||||
}
|
||||
}
|
||||
|
|
@ -583,6 +585,7 @@ export class MusicDatabase {
|
|||
|
||||
// TRIGGER SYNC
|
||||
this._dispatchPlaylistSync('create', playlist);
|
||||
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
|
@ -600,6 +603,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 +627,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 +648,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;
|
||||
}
|
||||
|
|
@ -652,6 +658,7 @@ export class MusicDatabase {
|
|||
|
||||
// TRIGGER SYNC (but for deleting)
|
||||
this._dispatchPlaylistSync('delete', { id: playlistId });
|
||||
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
||||
}
|
||||
|
||||
async getPlaylist(playlistId) {
|
||||
|
|
|
|||
250
js/ui.js
250
js/ui.js
|
|
@ -3075,10 +3075,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -3930,6 +3930,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>`;
|
||||
|
||||
|
|
@ -3951,6 +3953,16 @@ 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.hidden = true;
|
||||
}
|
||||
// Reset chevron and toggle state
|
||||
const chevronEl = document.getElementById('in-library-chevron');
|
||||
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
|
||||
const toggleBtn = document.getElementById('in-library-toggle');
|
||||
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
|
||||
|
||||
try {
|
||||
const artist = await this.api.getArtist(artistId, provider);
|
||||
|
|
@ -4171,9 +4183,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>
|
||||
`;
|
||||
|
||||
|
|
@ -4185,6 +4197,226 @@ 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 && ((artist.id && a.id === artist.id) || (a.name && a.name.toLowerCase() === artistNameLower))
|
||||
);
|
||||
}
|
||||
if (track.artist) {
|
||||
if (typeof track.artist === 'object') {
|
||||
if (artist.id && track.artist.id === artist.id) return true;
|
||||
if (track.artist.name && track.artist.name.toLowerCase() === artistNameLower) return true;
|
||||
} else if (typeof track.artist === 'string') {
|
||||
if (track.artist.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';
|
||||
|
||||
const labelSpan = document.createElement('span');
|
||||
labelSpan.className = 'library-source-label';
|
||||
labelSpan.textContent = '· Source:\u00a0';
|
||||
|
||||
const linkSpan = document.createElement('span');
|
||||
linkSpan.className = 'library-source-link';
|
||||
|
||||
sourceSpan.style.cursor = 'pointer';
|
||||
sourceSpan.appendChild(labelSpan);
|
||||
sourceSpan.appendChild(linkSpan);
|
||||
|
||||
if (sources.length === 1) {
|
||||
const srcLabel = sources[0].label.length > 15 ? sources[0].label.slice(0, 15) + '…' : sources[0].label;
|
||||
linkSpan.textContent = srcLabel;
|
||||
sourceSpan.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigate(sources[0].href);
|
||||
});
|
||||
} else {
|
||||
linkSpan.textContent = 'Multiple Playlists';
|
||||
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.forEach((s) => {
|
||||
const option = document.createElement('div');
|
||||
option.className = 'modal-option';
|
||||
option.dataset.href = s.href;
|
||||
const span = document.createElement('span');
|
||||
span.textContent = s.label;
|
||||
option.appendChild(span);
|
||||
list.appendChild(option);
|
||||
});
|
||||
|
||||
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.hidden = true;
|
||||
});
|
||||
|
||||
// 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.hidden;
|
||||
inLibraryContainer.hidden = isOpen;
|
||||
toggle.setAttribute('aria-expanded', String(!isOpen));
|
||||
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);
|
||||
};
|
||||
|
||||
// Cleanup previous listeners before attaching new ones
|
||||
const cleanupOnNav = () => {
|
||||
window.removeEventListener('favorites-changed', debouncedRefresh);
|
||||
window.removeEventListener('playlist-tracks-changed', debouncedRefresh);
|
||||
window.removeEventListener('popstate', cleanupOnNav);
|
||||
};
|
||||
cleanupOnNav();
|
||||
|
||||
window.addEventListener('favorites-changed', debouncedRefresh);
|
||||
window.addEventListener('playlist-tracks-changed', debouncedRefresh);
|
||||
window.addEventListener('popstate', cleanupOnNav, { once: true });
|
||||
}
|
||||
|
||||
// Update header like button
|
||||
const artistLikeBtn = document.getElementById('like-artist-btn');
|
||||
if (artistLikeBtn) {
|
||||
|
|
@ -4816,13 +5048,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">
|
||||
|
|
|
|||
61
styles.css
61
styles.css
|
|
@ -1996,6 +1996,62 @@ 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);
|
||||
}
|
||||
|
||||
#artist-detail-in-library[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
@ -2003,6 +2059,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;
|
||||
}
|
||||
|
|
@ -2320,7 +2377,9 @@ input[type='search']::-webkit-search-cancel-button {
|
|||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
overflow-wrap: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: normal;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.detail-header-info .title.long-title {
|
||||
|
|
|
|||
Loading…
Reference in a new issue