//js/ui.js import { formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration } from './utils.js'; import { recentActivityManager, backgroundSettings, trackListSettings } from './storage.js'; export class UIRenderer { constructor(api) { this.api = api; this.currentTrack = 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 'E'; } createTrackMenuButton() { return ` `; } 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 = ''; const trackImageHTML = showCover ? `Track Cover` : ''; let displayIndex; if (hasMultipleDiscs && !showCover) { const discNum = track.volumeNumber ?? track.discNumber ?? 1; displayIndex = `${discNum}-${track.trackNumber}`; } else { displayIndex = index + 1; } const trackNumberHTML = `
${showCover ? trackImageHTML : displayIndex}
`; const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : ''; const trackArtists = getTrackArtists(track); const trackTitle = getTrackTitle(track); 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 = `
`; return `
${trackNumberHTML}
${trackTitle} ${explicitBadge}
${trackArtists}${yearDisplay}
${formatTime(track.duration)}
${actionsHTML}
`; } 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()}`; } } return `
${album.title}

${album.title} ${explicitBadge}

${album.artist?.name ?? ''}

${yearDisplay}

`; } createPlaylistCardHTML(playlist) { const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed return `
${playlist.title}

${playlist.title}

${playlist.numberOfTracks || 0} tracks

`; } createArtistCardHTML(artist) { return `
${artist.name}

${artist.name}

`; } createSkeletonTrack(showCover = false) { return `
${showCover ? '
' : '
'}
`; } createSkeletonCard(isArtist = false) { return `
${!isArtist ? '
' : ''}
`; } createSkeletonTracks(count = 5, showCover = false) { return `
${Array(count).fill(0).map(() => this.createSkeletonTrack(showCover)).join('')}
`; } createSkeletonCards(count = 6, isArtist = false) { return `
${Array(count).fill(0).map(() => this.createSkeletonCard(isArtist)).join('')}
`; } 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; // Calculate contrast text color const hex = color.replace('#', ''); const r = parseInt(hex.substr(0, 2), 16); const g = parseInt(hex.substr(2, 2), 16); const b = parseInt(hex.substr(4, 2), 16); const brightness = ((r * 299) + (g * 587) + (b * 114)) / 1000; const foreground = brightness > 128 ? '#000000' : '#ffffff'; // Set global CSS variables root.style.setProperty('--primary', color); root.style.setProperty('--primary-foreground', foreground); root.style.setProperty('--highlight', color); root.style.setProperty('--highlight-rgb', `${r}, ${g}, ${b}`); root.style.setProperty('--active-highlight', color); root.style.setProperty('--ring', color); // Calculate a safe hover color (darken if too light) 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); try { const [tracksResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([ this.api.searchTracks(query), this.api.searchArtists(query), this.api.searchAlbums(query), this.api.searchPlaylists(query) ]); 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) { 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'); imageEl.src = ''; imageEl.style.backgroundColor = 'var(--muted)'; titleEl.innerHTML = '
'; metaEl.innerHTML = '
'; prodEl.innerHTML = '
'; tracklistContainer.innerHTML = `
# Title Duration
${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 ${album.artist.name}` + (firstCopyright ? ` • ${firstCopyright}` : ''); tracklistContainer.innerHTML = `
# Title Duration
`; 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" Section try { // Remove any existing "More from" section if re-rendering const existingMoreSection = document.getElementById('album-more-from-artist'); if (existingMoreSection) existingMoreSection.remove(); const moreSection = document.createElement('section'); moreSection.id = 'album-more-from-artist'; moreSection.className = 'content-section'; moreSection.style.marginTop = '3rem'; moreSection.innerHTML = `

More from ${album.artist.name}

${this.createSkeletonCards(6, false)}
`; document.getElementById('page-album').appendChild(moreSection); const artistData = await this.api.getArtist(album.artist.id); // Filter out current album and duplicates const otherAlbums = artistData.albums .filter(a => a.id != album.id) .filter((a, index, self) => index === self.findIndex((t) => t.title === a.title) // Dedup by title ) .slice(0, 12); // Limit to 12 const moreContainer = document.getElementById('album-more-albums'); if (otherAlbums.length > 0) { moreContainer.innerHTML = otherAlbums.map(a => this.createAlbumCardHTML(a)).join(''); } else { moreSection.remove(); // Remove section if no other albums } } catch (err) { console.warn('Failed to load "More from artist":', err); document.getElementById('album-more-from-artist')?.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'); imageEl.src = ''; imageEl.style.backgroundColor = 'var(--muted)'; titleEl.innerHTML = '
'; metaEl.innerHTML = '
'; descEl.innerHTML = '
'; tracklistContainer.innerHTML = `
# Title Duration
${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 = `
# Title Duration
`; 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'); imageEl.src = ''; imageEl.style.backgroundColor = 'var(--muted)'; nameEl.innerHTML = '
'; metaEl.innerHTML = '
'; tracksContainer.innerHTML = this.createSkeletonTracks(5, true); albumsContainer.innerHTML = this.createSkeletonCards(6, false); 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.textContent = `${artist.popularity} popularity`; this.renderListWithTracks(tracksContainer, artist.tracks, true); albumsContainer.innerHTML = artist.albums.map(album => this.createAlbumCardHTML(album) ).join(''); 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 ? `Failed` : `${speedInfo.speed.toFixed(0)}ms`) : ''; return `
  • ${url}
    ${speedText}
  • `; }).join(''); const stats = this.api.getCacheStats(); const cacheInfo = document.getElementById('cache-info'); if (cacheInfo) { cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`; } }); } }