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:
edideaur 2026-03-17 11:00:11 +02:00 committed by GitHub
commit e5b4cf6adf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 332 additions and 10 deletions

View file

@ -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>

View file

@ -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
View file

@ -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">

View file

@ -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 {