kv-music/js/ui.js
Julien Maille 45a31075a5 Improve vibrant color contrast in light mode
Modified setVibrantColor in ui.js to darken bright colors when in light mode and lighten dark colors in dark mode, ensuring text and button readability.
2025-12-26 12:28:05 +01:00

840 lines
36 KiB
JavaScript

//js/ui.js
import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js';
import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js';
export class UIRenderer {
constructor(api, player) {
this.api = api;
this.player = player;
this.currentTrack = null;
this.searchAbortController = null;
}
setCurrentTrack(track) {
this.currentTrack = track;
this.updateGlobalTheme();
}
updateGlobalTheme() {
// If the album background setting is disabled, we don't do global coloring
// except possibly for the album page which handles its own check.
// But here we are handling the "not on album page" case or general updates.
// Check if we are currently viewing an album page
const isAlbumPage = document.getElementById('page-album').classList.contains('active');
if (isAlbumPage) {
// The album page render logic handles its own coloring.
// We shouldn't override it here.
return;
}
if (backgroundSettings.isEnabled() && this.currentTrack?.album?.vibrantColor) {
this.setVibrantColor(this.currentTrack.album.vibrantColor);
} else {
this.resetVibrantColor();
}
}
createExplicitBadge() {
return '<span class="explicit-badge" title="Explicit">E</span>';
}
createTrackMenuButton() {
return `
<button class="track-menu-btn" onclick="event.stopPropagation();" title="More options">
${SVG_MENU}
</button>
`;
}
adjustTitleFontSize(element, text) {
element.classList.remove('long-title', 'very-long-title');
if (text.length > 40) {
element.classList.add('very-long-title');
} else if (text.length > 25) {
element.classList.add('long-title');
}
}
createTrackItemHTML(track, index, showCover = false, hasMultipleDiscs = false) {
const playIconSmall = SVG_PLAY;
const trackImageHTML = showCover ? `<img src="${this.api.getCoverUrl(track.album?.cover, '80')}" alt="Track Cover" class="track-item-cover" loading="lazy">` : '';
let displayIndex;
if (hasMultipleDiscs && !showCover) {
const discNum = track.volumeNumber ?? track.discNumber ?? 1;
displayIndex = `${discNum}-${track.trackNumber}`;
} else {
displayIndex = index + 1;
}
const trackNumberHTML = `<div class="track-number">${showCover ? trackImageHTML : displayIndex}</div>`;
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
const trackArtists = getTrackArtists(track);
const trackTitle = getTrackTitle(track);
const isCurrentTrack = this.player?.currentTrack?.id === track.id;
let yearDisplay = '';
const releaseDate = track.album?.releaseDate || track.streamStartDate;
if (releaseDate) {
const date = new Date(releaseDate);
if (!isNaN(date.getTime())) {
yearDisplay = `${date.getFullYear()}`;
}
}
const actionsHTML = `
<div class="track-actions-inline">
<button class="track-action-btn" data-action="play-next" title="Play Next">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M2 6h6" />
<path d="M5 3v6" />
<path d="M11 6h10" />
<path d="M3 12h18" />
<path d="M3 18h18" />
</svg>
</button>
<button class="track-action-btn" data-action="add-to-queue" title="Add to Queue">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18" />
<path d="M3 12h18" />
<path d="M3 18h10" />
<path d="M16 18h6" />
<path d="M19 15v6" />
</svg>
</button>
<button class="track-action-btn" data-action="download" title="Download">
${SVG_DOWNLOAD}
</button>
</div>
<button class="track-menu-btn" type="button" title="More options">
${SVG_MENU}
</button>
`;
return `
<div class="track-item ${isCurrentTrack ? 'playing' : ''}" data-track-id="${track.id}">
${trackNumberHTML}
<div class="track-item-info">
<div class="track-item-details">
<div class="title">
${trackTitle}
${explicitBadge}
</div>
<div class="artist">${trackArtists}${yearDisplay}</div>
</div>
</div>
<div class="track-item-duration">${formatTime(track.duration)}</div>
<div class="track-item-actions">
${actionsHTML}
</div>
</div>
`;
}
createAlbumCardHTML(album) {
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
let yearDisplay = '';
if (album.releaseDate) {
const date = new Date(album.releaseDate);
if (!isNaN(date.getTime())) {
yearDisplay = `${date.getFullYear()}`;
}
}
let typeLabel = '';
if (album.type === 'EP') {
typeLabel = ' • EP';
} else if (album.type === 'SINGLE') {
typeLabel = ' • Single';
} else if (!album.type && album.numberOfTracks) {
if (album.numberOfTracks <= 3) typeLabel = ' • Single';
else if (album.numberOfTracks <= 6) typeLabel = ' • EP';
}
return `
<a href="#album/${album.id}" class="card">
<div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(album.cover, '320')}" alt="${album.title}" class="card-image" loading="lazy">
</div>
<h3 class="card-title">${album.title} ${explicitBadge}</h3>
<p class="card-subtitle">${album.artist?.name ?? ''}</p>
<p class="card-subtitle">${yearDisplay}${typeLabel}</p>
</a>
`;
}
createPlaylistCardHTML(playlist) {
const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed
return `
<a href="#playlist/${playlist.uuid}" class="card">
<div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(imageId, '320')}" alt="${playlist.title}" class="card-image" loading="lazy">
</div>
<h3 class="card-title">${playlist.title}</h3>
<p class="card-subtitle">${playlist.numberOfTracks || 0} tracks</p>
</a>
`;
}
createArtistCardHTML(artist) {
return `
<a href="#artist/${artist.id}" class="card artist">
<div class="card-image-wrapper">
<img src="${this.api.getArtistPictureUrl(artist.picture, '320')}" alt="${artist.name}" class="card-image" loading="lazy">
</div>
<h3 class="card-title">${artist.name}</h3>
</a>
`;
}
createSkeletonTrack(showCover = false) {
return `
<div class="skeleton-track">
${showCover ? '<div class="skeleton skeleton-track-cover"></div>' : '<div class="skeleton skeleton-track-number"></div>'}
<div class="skeleton-track-info">
<div class="skeleton-track-details">
<div class="skeleton skeleton-track-title"></div>
<div class="skeleton skeleton-track-artist"></div>
</div>
</div>
<div class="skeleton skeleton-track-duration"></div>
</div>
`;
}
createSkeletonCard(isArtist = false) {
return `
<div class="skeleton-card ${isArtist ? 'artist' : ''}">
<div class="skeleton skeleton-card-image"></div>
<div class="skeleton skeleton-card-title"></div>
${!isArtist ? '<div class="skeleton skeleton-card-subtitle"></div>' : ''}
</div>
`;
}
createSkeletonTracks(count = 5, showCover = false) {
return `<div class="skeleton-container">${Array(count).fill(0).map(() => this.createSkeletonTrack(showCover)).join('')}</div>`;
}
createSkeletonCards(count = 6, isArtist = false) {
return `<div class="card-grid">${Array(count).fill(0).map(() => this.createSkeletonCard(isArtist)).join('')}</div>`;
}
renderListWithTracks(container, tracks, showCover) {
const fragment = document.createDocumentFragment();
const tempDiv = document.createElement('div');
// Check if there are multiple discs in the tracks array
const hasMultipleDiscs = tracks.some(t => (t.volumeNumber || t.discNumber || 1) > 1);
tempDiv.innerHTML = tracks.map((track, i) =>
this.createTrackItemHTML(track, i, showCover, hasMultipleDiscs)
).join('');
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
container.innerHTML = '';
container.appendChild(fragment);
tracks.forEach(track => {
const element = container.querySelector(`[data-track-id="${track.id}"]`);
if (element) trackDataStore.set(element, track);
});
}
setPageBackground(imageUrl) {
const bgElement = document.getElementById('page-background');
if (backgroundSettings.isEnabled() && imageUrl) {
bgElement.style.backgroundImage = `url('${imageUrl}')`;
bgElement.classList.add('active');
document.body.classList.add('has-page-background');
} else {
bgElement.classList.remove('active');
document.body.classList.remove('has-page-background');
// Delay clearing the image to allow transition
setTimeout(() => {
if (!bgElement.classList.contains('active')) {
bgElement.style.backgroundImage = '';
}
}, 500);
}
}
setVibrantColor(color) {
if (!color) return;
const root = document.documentElement;
const theme = root.getAttribute('data-theme');
const isLightMode = theme === 'light';
let hex = color.replace('#', '');
// Handle shorthand hex
if (hex.length === 3) {
hex = hex.split('').map(char => char + char).join('');
}
let r = parseInt(hex.substr(0, 2), 16);
let g = parseInt(hex.substr(2, 2), 16);
let b = parseInt(hex.substr(4, 2), 16);
// Calculate perceived brightness
let brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000;
if (isLightMode) {
// In light mode, the background is white.
// We need the color (used for text/highlights) to be dark enough.
// If brightness is too high (> 150), darken it.
while (brightness > 150) {
r = Math.floor(r * 0.9);
g = Math.floor(g * 0.9);
b = Math.floor(b * 0.9);
brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000;
}
} else {
// In dark mode, the background is dark.
// We need the color to be light enough.
// If brightness is too low (< 80), lighten it.
while (brightness < 80) {
r = Math.min(255, Math.floor(r * 1.15));
g = Math.min(255, Math.floor(g * 1.15));
b = Math.min(255, Math.floor(b * 1.15));
brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000;
// Break if we hit white or can't get brighter to avoid infinite loop
if (r >= 255 && g >= 255 && b >= 255) break;
}
}
const adjustedColor = `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
// Calculate contrast text color for buttons (text on top of the vibrant color)
const foreground = brightness > 128 ? '#000000' : '#ffffff';
// Set global CSS variables
root.style.setProperty('--primary', adjustedColor);
root.style.setProperty('--primary-foreground', foreground);
root.style.setProperty('--highlight', adjustedColor);
root.style.setProperty('--highlight-rgb', `${r}, ${g}, ${b}`);
root.style.setProperty('--active-highlight', adjustedColor);
root.style.setProperty('--ring', adjustedColor);
// Calculate a safe hover color
let hoverColor;
if (brightness > 200) {
const dr = Math.floor(r * 0.85);
const dg = Math.floor(g * 0.85);
const db = Math.floor(b * 0.85);
hoverColor = `rgba(${dr}, ${dg}, ${db}, 0.25)`;
} else {
hoverColor = `rgba(${r}, ${g}, ${b}, 0.15)`;
}
root.style.setProperty('--track-hover-bg', hoverColor);
}
resetVibrantColor() {
const root = document.documentElement;
root.style.removeProperty('--primary');
root.style.removeProperty('--primary-foreground');
root.style.removeProperty('--highlight');
root.style.removeProperty('--highlight-rgb');
root.style.removeProperty('--active-highlight');
root.style.removeProperty('--ring');
root.style.removeProperty('--track-hover-bg');
}
showFullscreenCover(track, nextTrack) {
if (!track) return;
const overlay = document.getElementById('fullscreen-cover-overlay');
const image = document.getElementById('fullscreen-cover-image');
const title = document.getElementById('fullscreen-track-title');
const artist = document.getElementById('fullscreen-track-artist');
const nextTrackEl = document.getElementById('fullscreen-next-track');
const coverUrl = this.api.getCoverUrl(track.album?.cover, '1280');
image.src = coverUrl;
title.textContent = track.title;
artist.textContent = track.artist?.name || 'Unknown Artist';
if (nextTrack) {
nextTrackEl.style.display = 'flex';
nextTrackEl.querySelector('.value').textContent = `${nextTrack.title}${nextTrack.artist?.name || 'Unknown'}`;
// Replay animation
nextTrackEl.classList.remove('animate-in');
void nextTrackEl.offsetWidth; // Trigger reflow
nextTrackEl.classList.add('animate-in');
} else {
nextTrackEl.style.display = 'none';
nextTrackEl.classList.remove('animate-in');
}
// Set the background image via CSS variable for the pseudo-element to use
overlay.style.setProperty('--bg-image', `url('${coverUrl}')`);
overlay.style.display = 'flex';
}
closeFullscreenCover() {
document.getElementById('fullscreen-cover-overlay').style.display = 'none';
}
showPage(pageId) {
document.querySelectorAll('.page').forEach(page => {
page.classList.toggle('active', page.id === `page-${pageId}`);
});
document.querySelectorAll('.sidebar-nav a').forEach(link => {
link.classList.toggle('active', link.hash === `#${pageId}`);
});
document.querySelector('.main-content').scrollTop = 0;
// Clear background and color if not on album page
if (pageId !== 'album') {
this.setPageBackground(null);
this.updateGlobalTheme();
}
if (pageId === 'settings') {
this.renderApiSettings();
}
}
async renderHomePage() {
this.showPage('home');
const recents = recentActivityManager.getRecents();
const albumsContainer = document.getElementById('home-recent-albums');
const artistsContainer = document.getElementById('home-recent-artists');
const playlistsContainer = document.getElementById('home-recent-playlists');
albumsContainer.innerHTML = recents.albums.length
? recents.albums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder("You haven't viewed any albums yet. Search for music to get started!");
artistsContainer.innerHTML = recents.artists.length
? recents.artists.map(artist => this.createArtistCardHTML(artist)).join('')
: createPlaceholder("You haven't viewed any artists yet. Search for music to get started!");
if (playlistsContainer) {
playlistsContainer.innerHTML = recents.playlists && recents.playlists.length
? recents.playlists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
: createPlaceholder("You haven't viewed any playlists yet. Search for music to get started!");
}
}
async renderSearchPage(query) {
this.showPage('search');
document.getElementById('search-results-title').textContent = `Search Results for "${query}"`;
const tracksContainer = document.getElementById('search-tracks-container');
const artistsContainer = document.getElementById('search-artists-container');
const albumsContainer = document.getElementById('search-albums-container');
const playlistsContainer = document.getElementById('search-playlists-container');
tracksContainer.innerHTML = this.createSkeletonTracks(8, true);
artistsContainer.innerHTML = this.createSkeletonCards(6, true);
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
playlistsContainer.innerHTML = this.createSkeletonCards(6, false);
if (this.searchAbortController) {
this.searchAbortController.abort();
}
this.searchAbortController = new AbortController();
const signal = this.searchAbortController.signal;
try {
const [tracksResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
this.api.searchTracks(query, { signal }),
this.api.searchArtists(query, { signal }),
this.api.searchAlbums(query, { signal }),
this.api.searchPlaylists(query, { signal })
]);
let finalTracks = tracksResult.items;
let finalArtists = artistsResult.items;
let finalAlbums = albumsResult.items;
let finalPlaylists = playlistsResult.items;
if (finalArtists.length === 0 && finalTracks.length > 0) {
const artistMap = new Map();
finalTracks.forEach(track => {
if (track.artist && !artistMap.has(track.artist.id)) {
artistMap.set(track.artist.id, track.artist);
}
if (track.artists) {
track.artists.forEach(artist => {
if (!artistMap.has(artist.id)) {
artistMap.set(artist.id, artist);
}
});
}
});
finalArtists = Array.from(artistMap.values());
}
if (finalAlbums.length === 0 && finalTracks.length > 0) {
const albumMap = new Map();
finalTracks.forEach(track => {
if (track.album && !albumMap.has(track.album.id)) {
albumMap.set(track.album.id, track.album);
}
});
finalAlbums = Array.from(albumMap.values());
}
if (finalTracks.length) {
this.renderListWithTracks(tracksContainer, finalTracks, true);
} else {
tracksContainer.innerHTML = createPlaceholder('No tracks found.');
}
artistsContainer.innerHTML = finalArtists.length
? finalArtists.map(artist => this.createArtistCardHTML(artist)).join('')
: createPlaceholder('No artists found.');
albumsContainer.innerHTML = finalAlbums.length
? finalAlbums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder('No albums found.');
playlistsContainer.innerHTML = finalPlaylists.length
? finalPlaylists.map(playlist => this.createPlaylistCardHTML(playlist)).join('')
: createPlaceholder('No playlists found.');
} catch (error) {
if (error.name === 'AbortError') return;
console.error("Search failed:", error);
const errorMsg = createPlaceholder(`Error during search. ${error.message}`);
tracksContainer.innerHTML = errorMsg;
artistsContainer.innerHTML = errorMsg;
albumsContainer.innerHTML = errorMsg;
playlistsContainer.innerHTML = errorMsg;
}
}
async renderAlbumPage(albumId) {
this.showPage('album');
const imageEl = document.getElementById('album-detail-image');
const titleEl = document.getElementById('album-detail-title');
const metaEl = document.getElementById('album-detail-meta');
const prodEl = document.getElementById('album-detail-producer');
const tracklistContainer = document.getElementById('album-detail-tracklist');
const playBtn = document.getElementById('play-album-btn');
if (playBtn) playBtn.innerHTML = `${SVG_PLAY}<span>Play Album</span>`;
const dlBtn = document.getElementById('download-album-btn');
if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD}<span>Download Album</span>`;
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
titleEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
prodEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
</div>
${this.createSkeletonTracks(10, false)}
`;
try {
const { album, tracks } = await this.api.getAlbum(albumId);
const coverUrl = this.api.getCoverUrl(album.cover, '1280');
imageEl.src = coverUrl;
imageEl.style.backgroundColor = '';
// Set background and vibrant color
this.setPageBackground(coverUrl);
if (backgroundSettings.isEnabled() && album.vibrantColor) {
this.setVibrantColor(album.vibrantColor);
} else {
this.resetVibrantColor();
}
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
titleEl.innerHTML = `${album.title} ${explicitBadge}`;
this.adjustTitleFontSize(titleEl, album.title);
const totalDuration = calculateTotalDuration(tracks);
let dateDisplay = '';
if (album.releaseDate) {
const releaseDate = new Date(album.releaseDate);
if (!isNaN(releaseDate.getTime())) {
const year = releaseDate.getFullYear();
dateDisplay = window.innerWidth > 768
? releaseDate.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })
: year;
}
}
const firstCopyright = tracks.find(track => track.copyright)?.copyright;
metaEl.innerHTML =
(dateDisplay ? `${dateDisplay}` : '') +
`${tracks.length} tracks • ${formatDuration(totalDuration)}`;
prodEl.innerHTML =
`By <a href="#artist/${album.artist.id}">${album.artist.name}</a>` +
(firstCopyright ? `${firstCopyright}` : '');
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
</div>
`;
tracks.sort((a, b) => {
const discA = a.volumeNumber ?? a.discNumber ?? 1;
const discB = b.volumeNumber ?? b.discNumber ?? 1;
if (discA !== discB) return discA - discB;
return a.trackNumber - b.trackNumber;
});
this.renderListWithTracks(tracklistContainer, tracks, false);
recentActivityManager.addAlbum(album);
document.title = `${album.title} - ${album.artist.name} - Monochrome`;
// "More from Artist" Sections
try {
// Remove any existing "More from" sections if re-rendering
document.querySelectorAll('.album-more-section').forEach(el => el.remove());
document.getElementById('album-more-from-artist')?.remove(); // Legacy cleanup
// Create placeholder section while loading
const placeholderSection = document.createElement('section');
placeholderSection.className = 'content-section album-more-section';
placeholderSection.style.marginTop = '3rem';
placeholderSection.innerHTML = `
<h2 class="section-title">More from ${album.artist.name}</h2>
<div class="card-grid">
${this.createSkeletonCards(6, false)}
</div>
`;
document.getElementById('page-album').appendChild(placeholderSection);
const artistData = await this.api.getArtist(album.artist.id);
// Remove placeholder
placeholderSection.remove();
const renderSection = (title, items) => {
const filtered = (items || [])
.filter(a => a.id != album.id)
.filter((a, index, self) =>
index === self.findIndex((t) => t.title === a.title) // Dedup by title
)
.slice(0, 12);
if (filtered.length === 0) return;
const section = document.createElement('section');
section.className = 'content-section album-more-section';
section.style.marginTop = '3rem';
section.innerHTML = `
<h2 class="section-title">${title}</h2>
<div class="card-grid">
${filtered.map(a => this.createAlbumCardHTML(a)).join('')}
</div>
`;
document.getElementById('page-album').appendChild(section);
};
renderSection(`More albums from ${album.artist.name}`, artistData.albums);
renderSection(`EPs and Singles from ${album.artist.name}`, artistData.eps);
} catch (err) {
console.warn('Failed to load "More from artist":', err);
document.querySelectorAll('.album-more-section').forEach(el => el.remove());
}
} catch (error) {
console.error("Failed to load album:", error);
tracklistContainer.innerHTML = createPlaceholder(`Could not load album details. ${error.message}`);
}
}
async renderPlaylistPage(playlistId) {
this.showPage('playlist');
const imageEl = document.getElementById('playlist-detail-image');
const titleEl = document.getElementById('playlist-detail-title');
const metaEl = document.getElementById('playlist-detail-meta');
const descEl = document.getElementById('playlist-detail-description');
const tracklistContainer = document.getElementById('playlist-detail-tracklist');
const playBtn = document.getElementById('play-playlist-btn');
if (playBtn) playBtn.innerHTML = `${SVG_PLAY}<span>Play</span>`;
const dlBtn = document.getElementById('download-playlist-btn');
if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD}<span>Download</span>`;
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
titleEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 200px; max-width: 80%;"></div>';
descEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 100%;"></div>';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
</div>
${this.createSkeletonTracks(10, true)}
`;
try {
const { playlist, tracks } = await this.api.getPlaylist(playlistId);
const imageId = playlist.squareImage || playlist.image;
imageEl.src = this.api.getCoverUrl(imageId, '1080');
imageEl.style.backgroundColor = '';
titleEl.textContent = playlist.title;
this.adjustTitleFontSize(titleEl, playlist.title);
const totalDuration = calculateTotalDuration(tracks);
metaEl.textContent = `${playlist.numberOfTracks} tracks • ${formatDuration(totalDuration)}`;
descEl.textContent = playlist.description || '';
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
</div>
`;
this.renderListWithTracks(tracklistContainer, tracks, true);
recentActivityManager.addPlaylist(playlist);
document.title = `${playlist.title || 'Artist Mix'} - Monochrome`;
} catch (error) {
console.error("Failed to load playlist:", error);
tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`);
}
}
async renderArtistPage(artistId) {
this.showPage('artist');
const imageEl = document.getElementById('artist-detail-image');
const nameEl = document.getElementById('artist-detail-name');
const metaEl = document.getElementById('artist-detail-meta');
const tracksContainer = document.getElementById('artist-detail-tracks');
const albumsContainer = document.getElementById('artist-detail-albums');
const epsContainer = document.getElementById('artist-detail-eps');
const epsSection = document.getElementById('artist-section-eps');
const dlBtn = document.getElementById('download-discography-btn');
if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD}<span>Download Discography</span>`;
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
nameEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
metaEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 150px;"></div>';
tracksContainer.innerHTML = this.createSkeletonTracks(5, true);
albumsContainer.innerHTML = this.createSkeletonCards(6, false);
if (epsContainer) epsContainer.innerHTML = this.createSkeletonCards(6, false);
if (epsSection) epsSection.style.display = 'none';
try {
const artist = await this.api.getArtist(artistId);
imageEl.src = this.api.getArtistPictureUrl(artist.picture, '750');
imageEl.style.backgroundColor = '';
nameEl.textContent = artist.name;
this.adjustTitleFontSize(nameEl, artist.name);
metaEl.innerHTML = `
<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('')}
</div>
`;
this.renderListWithTracks(tracksContainer, artist.tracks, true);
// Render Albums
albumsContainer.innerHTML = artist.albums.length
? artist.albums.map(album => this.createAlbumCardHTML(album)).join('')
: createPlaceholder('No albums found.');
// Render EPs and Singles
if (epsContainer && epsSection) {
if (artist.eps && artist.eps.length > 0) {
epsContainer.innerHTML = artist.eps.map(album => this.createAlbumCardHTML(album)).join('');
epsSection.style.display = 'block';
} else {
epsSection.style.display = 'none';
}
}
recentActivityManager.addArtist(artist);
document.title = `${artist.name} - Monochrome`;
} catch (error) {
console.error("Failed to load artist:", error);
tracksContainer.innerHTML = albumsContainer.innerHTML =
createPlaceholder(`Could not load artist details. ${error.message}`);
}
}
renderApiSettings() {
const container = document.getElementById('api-instance-list');
this.api.settings.getInstances().then(instances => {
const cachedData = this.api.settings.getCachedSpeedTests();
const speeds = cachedData?.speeds || {};
container.innerHTML = instances.map((url, index) => {
const speedInfo = speeds[url];
const speedText = speedInfo
? (speedInfo.speed === Infinity || typeof speedInfo.speed !== 'number'
? `<span style="color: var(--muted-foreground); font-size: 0.8rem;">Failed</span>`
: `<span style="color: var(--muted-foreground); font-size: 0.8rem;">${speedInfo.speed.toFixed(0)}ms</span>`)
: '';
return `
<li data-index="${index}">
<div style="flex: 1; min-width: 0;">
<div class="instance-url">${url}</div>
${speedText}
</div>
<div class="controls">
<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">
<path d="M12 19V5M5 12l7-7 7 7"/>
</svg>
</button>
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M19 12l-7 7-7-7"/>
</svg>
</button>
</div>
</li>
`;
}).join('');
const stats = this.api.getCacheStats();
const cacheInfo = document.getElementById('cache-info');
if (cacheInfo) {
cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`;
}
});
}
}