//js/ui.js import { SVG_PLAY, SVG_DOWNLOAD, SVG_MENU, SVG_HEART, formatTime, createPlaceholder, trackDataStore, hasExplicitContent, getTrackArtists, getTrackTitle, calculateTotalDuration, formatDuration, escapeHtml, } from './utils.js'; import { openLyricsPanel } from './lyrics.js'; import { recentActivityManager, backgroundSettings, trackListSettings, cardSettings } from './storage.js'; import { db } from './db.js'; import { getVibrantColorFromImage } from './vibrant-color.js'; import { syncManager } from './firebase/sync.js'; export class UIRenderer { constructor(api, player) { this.api = api; this.player = player; this.currentTrack = null; this.searchAbortController = null; this.vibrantColorCache = new Map(); } // Helper for Heart Icon createHeartIcon(filled = false) { if (filled) { return SVG_HEART.replace('class="heart-icon"', 'class="heart-icon filled"'); } return SVG_HEART; } async extractAndApplyColor(url) { if (!backgroundSettings.isEnabled() || !url) { this.resetVibrantColor(); return; } // Check cache first if (this.vibrantColorCache.has(url)) { const cachedColor = this.vibrantColorCache.get(url); if (cachedColor) { this.setVibrantColor(cachedColor); return; } } const img = new Image(); img.crossOrigin = 'Anonymous'; // Add cache buster to bypass opaque response in cache const separator = url.includes('?') ? '&' : '?'; img.src = `${url}${separator}not-from-cache-please`; img.onload = () => { try { const color = getVibrantColorFromImage(img); if (color) { this.vibrantColorCache.set(url, color); this.setVibrantColor(color); } else { this.vibrantColorCache.set(url, null); this.resetVibrantColor(); } } catch (e) { this.vibrantColorCache.set(url, null); this.resetVibrantColor(); } }; img.onerror = () => { this.vibrantColorCache.set(url, null); this.resetVibrantColor(); }; } async updateLikeState(element, type, id) { const isLiked = await db.isFavorite(type, id); const btn = element.querySelector('.like-btn'); if (btn) { btn.innerHTML = this.createHeartIcon(isLiked); btn.classList.toggle('active', isLiked); btn.title = isLiked ? 'Remove from Liked' : 'Add to Liked'; } } setCurrentTrack(track) { this.currentTrack = track; this.updateGlobalTheme(); const likeBtn = document.getElementById('now-playing-like-btn'); const addPlaylistBtn = document.getElementById('now-playing-add-playlist-btn'); const mobileAddPlaylistBtn = document.getElementById('mobile-add-playlist-btn'); const lyricsBtn = document.getElementById('toggle-lyrics-btn'); if (track) { if (likeBtn) { likeBtn.style.display = 'flex'; this.updateLikeState(likeBtn.parentElement, 'track', track.id); } if (addPlaylistBtn) addPlaylistBtn.style.removeProperty('display'); if (mobileAddPlaylistBtn) mobileAddPlaylistBtn.style.removeProperty('display'); if (lyricsBtn) lyricsBtn.style.removeProperty('display'); } else { if (likeBtn) likeBtn.style.display = 'none'; if (addPlaylistBtn) addPlaylistBtn.style.setProperty('display', 'none', 'important'); if (mobileAddPlaylistBtn) mobileAddPlaylistBtn.style.setProperty('display', 'none', 'important'); if (lyricsBtn) lyricsBtn.style.display = 'none'; } } updateGlobalTheme() { // 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?.cover) { this.extractAndApplyColor(this.api.getCoverUrl(this.currentTrack.album.cover, '80')); } else { this.resetVibrantColor(); } } createExplicitBadge() { return 'E'; } 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 ? `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); 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 = `
`; return `
${trackNumberHTML}
${escapeHtml(trackTitle)} ${explicitBadge}
${escapeHtml(trackArtists)}${yearDisplay}
${formatTime(track.duration)}
${actionsHTML}
`; } createBaseCardHTML({ type, id, href, title, subtitle, imageHTML, actionButtonsHTML, isCompact, extraClasses = '', }) { const playBtnHTML = type !== 'artist' ? ` ` : ''; const cardContent = type === 'artist' ? `

${title}

` : `

${title}

${subtitle}

`; // In compact mode, move the play button outside the wrapper to position it on the right side of the card const buttonsInWrapper = !isCompact ? playBtnHTML : ''; const buttonsOutside = isCompact ? playBtnHTML : ''; return `
${imageHTML} ${actionButtonsHTML} ${buttonsInWrapper}
${cardContent} ${buttonsOutside}
`; } createPlaylistCardHTML(playlist) { const imageId = playlist.squareImage || playlist.image || playlist.uuid; const isCompact = cardSettings.isCompactAlbum(); return this.createBaseCardHTML({ type: 'playlist', id: playlist.uuid, href: `#playlist/${playlist.uuid}`, title: playlist.title, subtitle: `${playlist.numberOfTracks || 0} tracks`, imageHTML: `${playlist.title}`, actionButtonsHTML: ` `, isCompact, }); } createMixCardHTML(mix) { const imageSrc = mix.cover || 'assets/appicon.png'; const description = mix.subTitle || mix.description || ''; const isCompact = cardSettings.isCompactAlbum(); return this.createBaseCardHTML({ type: 'mix', id: mix.id, href: `#mix/${mix.id}`, title: mix.title, subtitle: description, imageHTML: `${mix.title}`, actionButtonsHTML: ` `, isCompact, }); } createUserPlaylistCardHTML(playlist) { let imageHTML = ''; if (playlist.cover) { imageHTML = `${playlist.name}`; } else { const tracks = playlist.tracks || []; let uniqueCovers = playlist.images || []; const seenCovers = new Set(uniqueCovers); if (uniqueCovers.length === 0) { for (const track of tracks) { const cover = track.album?.cover; if (cover && !seenCovers.has(cover)) { seenCovers.add(cover); uniqueCovers.push(cover); if (uniqueCovers.length >= 4) break; } } } if (uniqueCovers.length >= 2) { const count = Math.min(uniqueCovers.length, 4); const itemsClass = count < 4 ? `items-${count}` : ''; const covers = uniqueCovers.slice(0, 4); imageHTML = `
${covers.map((cover) => ``).join('')}
`; } else if (uniqueCovers.length > 0) { imageHTML = `${playlist.name}`; } else { imageHTML = `${playlist.name}`; } } const isCompact = cardSettings.isCompactAlbum(); return this.createBaseCardHTML({ type: 'user-playlist', // Note: data-type logic in base might need adjustment if it uses this for buttons. // Actually Base uses type for data attributes. play-card uses data-type="user-playlist" which is correct. id: playlist.id, href: `#userplaylist/${playlist.id}`, title: escapeHtml(playlist.name), subtitle: `${playlist.tracks ? playlist.tracks.length : playlist.numberOfTracks || 0} tracks`, imageHTML: imageHTML, actionButtonsHTML: ` `, isCompact, extraClasses: 'user-playlist', }); } 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'; const isCompact = cardSettings.isCompactAlbum(); return this.createBaseCardHTML({ type: 'album', id: album.id, href: `#album/${album.id}`, title: `${escapeHtml(album.title)} ${explicitBadge}`, subtitle: `${escapeHtml(album.artist?.name ?? '')} • ${yearDisplay}${typeLabel}`, imageHTML: `${escapeHtml(album.title)}`, actionButtonsHTML: ` `, isCompact, }); } createArtistCardHTML(artist) { const isCompact = cardSettings.isCompactArtist(); return this.createBaseCardHTML({ type: 'artist', id: artist.id, href: `#artist/${artist.id}`, title: escapeHtml(artist.name), subtitle: '', imageHTML: `${escapeHtml(artist.name)}`, actionButtonsHTML: ` `, isCompact, extraClasses: 'artist', }); } 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); // Async update for like button this.updateLikeState(element, 'track', track.id); } }); } 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.max(r + 1, Math.floor(r * 1.15))); g = Math.min(255, Math.max(g + 1, Math.floor(g * 1.15))); b = Math.min(255, Math.max(b + 1, 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'); } async showFullscreenCover(track, nextTrack, lyricsManager, audioPlayer) { 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 lyricsContainer = document.getElementById('fullscreen-lyrics-container'); const lyricsToggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn'); const coverUrl = this.api.getCoverUrl(track.album?.cover, '1280'); image.src = coverUrl; title.textContent = track.title; artist.textContent = getTrackArtists(track); if (nextTrack) { nextTrackEl.style.display = 'flex'; nextTrackEl.querySelector('.value').textContent = `${nextTrack.title} • ${getTrackArtists(nextTrack)}`; nextTrackEl.classList.remove('animate-in'); void nextTrackEl.offsetWidth; nextTrackEl.classList.add('animate-in'); } else { nextTrackEl.style.display = 'none'; nextTrackEl.classList.remove('animate-in'); } overlay.style.setProperty('--bg-image', `url('${coverUrl}')`); if (lyricsManager && audioPlayer) { lyricsToggleBtn.style.display = 'flex'; lyricsToggleBtn.classList.remove('active'); const toggleLyrics = () => { openLyricsPanel(track, audioPlayer, lyricsManager); lyricsToggleBtn.classList.toggle('active'); }; const newToggleBtn = lyricsToggleBtn.cloneNode(true); lyricsToggleBtn.parentNode.replaceChild(newToggleBtn, lyricsToggleBtn); newToggleBtn.addEventListener('click', toggleLyrics); } else { lyricsToggleBtn.style.display = 'none'; } overlay.style.display = 'flex'; } closeFullscreenCover() { const overlay = document.getElementById('fullscreen-cover-overlay'); 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, artist, playlist, or mix page if (!['album', 'artist', 'playlist', 'mix'].includes(pageId)) { this.setPageBackground(null); this.updateGlobalTheme(); } if (pageId === 'settings') { this.renderApiSettings(); } } async renderLibraryPage() { this.showPage('library'); const tracksContainer = document.getElementById('library-tracks-container'); const albumsContainer = document.getElementById('library-albums-container'); const artistsContainer = document.getElementById('library-artists-container'); const playlistsContainer = document.getElementById('library-playlists-container'); const likedTracks = await db.getFavorites('track'); if (likedTracks.length) { this.renderListWithTracks(tracksContainer, likedTracks, true); } else { tracksContainer.innerHTML = createPlaceholder('No liked tracks yet.'); } const likedAlbums = await db.getFavorites('album'); if (likedAlbums.length) { albumsContainer.innerHTML = likedAlbums.map((a) => this.createAlbumCardHTML(a)).join(''); likedAlbums.forEach((album) => { const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`); if (el) { trackDataStore.set(el, album); this.updateLikeState(el, 'album', album.id); } }); } else { albumsContainer.innerHTML = createPlaceholder('No liked albums yet.'); } const likedArtists = await db.getFavorites('artist'); if (likedArtists.length) { artistsContainer.innerHTML = likedArtists.map((a) => this.createArtistCardHTML(a)).join(''); likedArtists.forEach((artist) => { const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`); if (el) { trackDataStore.set(el, artist); this.updateLikeState(el, 'artist', artist.id); } }); } else { artistsContainer.innerHTML = createPlaceholder('No liked artists yet.'); } const likedPlaylists = await db.getFavorites('playlist'); const likedMixes = await db.getFavorites('mix'); let mixedContent = []; if (likedPlaylists.length) mixedContent.push(...likedPlaylists.map((p) => ({ ...p, _type: 'playlist' }))); if (likedMixes.length) mixedContent.push(...likedMixes.map((m) => ({ ...m, _type: 'mix' }))); // Sort by addedAt descending mixedContent.sort((a, b) => b.addedAt - a.addedAt); if (mixedContent.length) { playlistsContainer.innerHTML = mixedContent .map((item) => { return item._type === 'playlist' ? this.createPlaylistCardHTML(item) : this.createMixCardHTML(item); }) .join(''); likedPlaylists.forEach((playlist) => { const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`); if (el) { trackDataStore.set(el, playlist); this.updateLikeState(el, 'playlist', playlist.uuid); } }); likedMixes.forEach((mix) => { const el = playlistsContainer.querySelector(`[data-mix-id="${mix.id}"]`); if (el) { trackDataStore.set(el, mix); this.updateLikeState(el, 'mix', mix.id); } }); } else { playlistsContainer.innerHTML = createPlaceholder('No liked playlists or mixes yet.'); } const myPlaylistsContainer = document.getElementById('my-playlists-container'); const myPlaylists = await db.getPlaylists(); if (myPlaylists.length) { myPlaylistsContainer.innerHTML = myPlaylists.map((p) => this.createUserPlaylistCardHTML(p)).join(''); myPlaylists.forEach((playlist) => { const el = myPlaylistsContainer.querySelector(`[data-user-playlist-id="${playlist.id}"]`); if (el) { trackDataStore.set(el, playlist); } }); } else { myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!'); } } 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'); if (recents.albums.length) { albumsContainer.innerHTML = recents.albums.map((album) => this.createAlbumCardHTML(album)).join(''); recents.albums.forEach((album) => { const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`); if (el) { trackDataStore.set(el, album); this.updateLikeState(el, 'album', album.id); } }); } else { albumsContainer.innerHTML = createPlaceholder("You haven't viewed any albums yet."); } if (recents.artists.length) { artistsContainer.innerHTML = recents.artists.map((artist) => this.createArtistCardHTML(artist)).join(''); recents.artists.forEach((artist) => { const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`); if (el) { trackDataStore.set(el, artist); this.updateLikeState(el, 'artist', artist.id); } }); } else { artistsContainer.innerHTML = createPlaceholder("You haven't viewed any artists yet."); } if (playlistsContainer) { const playlists = recents.playlists || []; const mixes = recents.mixes || []; // Note: Since we don't have a unified timestamp for recents in the separate arrays without normalizing, // we will just display playlists then mixes, or interleave them if we wanted to be fancy. // But usually recents are just lists. // Let's just concatenate them. const combinedRecents = [...playlists, ...mixes]; // Order: Playlists then Mixes if (combinedRecents.length) { playlistsContainer.innerHTML = combinedRecents .map((item) => { if (item.isUserPlaylist) { return this.createUserPlaylistCardHTML(item); } if (item.mixType) { // It's a mix return this.createMixCardHTML(item); } return this.createPlaylistCardHTML(item); }) .join(''); combinedRecents.forEach((item) => { if (item.isUserPlaylist) { const el = playlistsContainer.querySelector(`[data-user-playlist-id="${item.id}"]`); if (el) trackDataStore.set(el, item); } else if (item.mixType) { const el = playlistsContainer.querySelector(`[data-mix-id="${item.id}"]`); if (el) { trackDataStore.set(el, item); this.updateLikeState(el, 'mix', item.id); } } else { const el = playlistsContainer.querySelector(`[data-playlist-id="${item.uuid}"]`); if (el) { trackDataStore.set(el, item); this.updateLikeState(el, 'playlist', item.uuid); } } }); } else { playlistsContainer.innerHTML = createPlaceholder("You haven't viewed any playlists or mixes yet."); } } } 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.'); finalArtists.forEach((artist) => { const el = artistsContainer.querySelector(`[data-artist-id="${artist.id}"]`); if (el) { trackDataStore.set(el, artist); this.updateLikeState(el, 'artist', artist.id); } }); albumsContainer.innerHTML = finalAlbums.length ? finalAlbums.map((album) => this.createAlbumCardHTML(album)).join('') : createPlaceholder('No albums found.'); finalAlbums.forEach((album) => { const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`); if (el) { trackDataStore.set(el, album); this.updateLikeState(el, 'album', album.id); } }); playlistsContainer.innerHTML = finalPlaylists.length ? finalPlaylists.map((playlist) => this.createPlaylistCardHTML(playlist)).join('') : createPlaceholder('No playlists found.'); finalPlaylists.forEach((playlist) => { const el = playlistsContainer.querySelector(`[data-playlist-id="${playlist.uuid}"]`); if (el) { trackDataStore.set(el, playlist); this.updateLikeState(el, 'playlist', playlist.uuid); } }); } 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}Play Album`; const dlBtn = document.getElementById('download-album-btn'); if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD}Download Album`; const mixBtn = document.getElementById('album-mix-btn'); if (mixBtn) mixBtn.style.display = 'none'; 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); imageEl.src = coverUrl; imageEl.style.backgroundColor = ''; // Set background and vibrant color this.setPageBackground(coverUrl); if (backgroundSettings.isEnabled() && album.cover) { this.extractAndApplyColor(this.api.getCoverUrl(album.cover, '80')); } const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : ''; titleEl.innerHTML = `${escapeHtml(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); // Update header like button const albumLikeBtn = document.getElementById('like-album-btn'); if (albumLikeBtn) { const isLiked = await db.isFavorite('album', album.id); albumLikeBtn.innerHTML = this.createHeartIcon(isLiked); albumLikeBtn.classList.toggle('active', isLiked); } document.title = `${album.title} - ${album.artist.name}`; // "More from Artist" and Related Sections const moreAlbumsSection = document.getElementById('album-section-more-albums'); const moreAlbumsContainer = document.getElementById('album-detail-more-albums'); const moreAlbumsTitle = document.getElementById('album-title-more-albums'); const epsSection = document.getElementById('album-section-eps'); const epsContainer = document.getElementById('album-detail-eps'); const epsTitle = document.getElementById('album-title-eps'); const similarArtistsSection = document.getElementById('album-section-similar-artists'); const similarArtistsContainer = document.getElementById('album-detail-similar-artists'); const similarAlbumsSection = document.getElementById('album-section-similar-albums'); const similarAlbumsContainer = document.getElementById('album-detail-similar-albums'); // Hide all initially [moreAlbumsSection, epsSection, similarArtistsSection, similarAlbumsSection].forEach((el) => { if (el) el.style.display = 'none'; }); try { const artistData = await this.api.getArtist(album.artist.id); // Add Mix/Radio Button to header const mixBtn = document.getElementById('album-mix-btn'); if (mixBtn && artistData.mixes && artistData.mixes.ARTIST_MIX) { mixBtn.style.display = 'flex'; mixBtn.onclick = () => (window.location.hash = `#mix/${artistData.mixes.ARTIST_MIX}`); } const renderSection = (items, container, section, titleEl, titleText) => { if (!container || !section) return; 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; container.innerHTML = filtered.map((a) => this.createAlbumCardHTML(a)).join(''); if (titleEl && titleText) titleEl.textContent = titleText; section.style.display = 'block'; filtered.forEach((a) => { const el = container.querySelector(`[data-album-id="${a.id}"]`); if (el) { trackDataStore.set(el, a); this.updateLikeState(el, 'album', a.id); } }); }; renderSection( artistData.albums, moreAlbumsContainer, moreAlbumsSection, moreAlbumsTitle, `More albums from ${album.artist.name}` ); renderSection( artistData.eps, epsContainer, epsSection, epsTitle, `EPs and Singles from ${album.artist.name}` ); // Similar Artists this.api .getSimilarArtists(album.artist.id) .then((similar) => { if (similar && similar.length > 0 && similarArtistsContainer && similarArtistsSection) { similarArtistsContainer.innerHTML = similar .map((a) => this.createArtistCardHTML(a)) .join(''); similarArtistsSection.style.display = 'block'; } }) .catch((e) => console.warn('Failed to load similar artists:', e)); // Similar Albums this.api .getSimilarAlbums(albumId) .then((similar) => { if (similar && similar.length > 0 && similarAlbumsContainer && similarAlbumsSection) { similarAlbumsContainer.innerHTML = similar.map((a) => this.createAlbumCardHTML(a)).join(''); similarAlbumsSection.style.display = 'block'; similar.forEach((a) => { const el = similarAlbumsContainer.querySelector(`[data-album-id="${a.id}"]`); if (el) { trackDataStore.set(el, a); this.updateLikeState(el, 'album', a.id); } }); } }) .catch((e) => console.warn('Failed to load similar albums:', e)); } catch (err) { console.warn('Failed to load "More from artist":', err); } } catch (error) { console.error('Failed to load album:', error); tracklistContainer.innerHTML = createPlaceholder(`Could not load album details. ${error.message}`); } } async loadRecommendedSongsForPlaylist(tracks) { const recommendedSection = document.getElementById('playlist-section-recommended'); const recommendedContainer = document.getElementById('playlist-detail-recommended'); if (!recommendedSection || !recommendedContainer) { console.warn('Recommended songs section not found in DOM'); return; } try { const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(tracks, 20); if (recommendedTracks.length > 0) { this.renderListWithTracks(recommendedContainer, recommendedTracks, true); const trackItems = recommendedContainer.querySelectorAll('.track-item'); trackItems.forEach((item) => { const actionsDiv = item.querySelector('.track-item-actions'); if (actionsDiv) { const addToPlaylistBtn = document.createElement('button'); addToPlaylistBtn.className = 'track-action-btn add-to-playlist-btn'; addToPlaylistBtn.title = 'Add to this playlist'; addToPlaylistBtn.innerHTML = ''; addToPlaylistBtn.onclick = async (e) => { e.stopPropagation(); const trackData = trackDataStore.get(item); if (trackData) { try { const hash = window.location.hash; const playlistMatch = hash.match(/#userplaylist\/([^/]+)/); if (playlistMatch) { const playlistId = playlistMatch[1]; await db.addTrackToPlaylist(playlistId, trackData); const updatedPlaylist = await db.getPlaylist(playlistId); syncManager.syncUserPlaylist(updatedPlaylist, 'update'); const tracklistContainer = document.getElementById('playlist-detail-tracklist'); if (tracklistContainer && updatedPlaylist.tracks) { tracklistContainer.innerHTML = `
# Title Duration
`; this.renderListWithTracks(tracklistContainer, updatedPlaylist.tracks, true); if (document.querySelector('.remove-from-playlist-btn')) { this.enableTrackReordering( tracklistContainer, updatedPlaylist.tracks, playlistId, syncManager ); } // Update the playlist metadata const metaEl = document.getElementById('playlist-detail-meta'); if (metaEl) { const totalDuration = calculateTotalDuration(updatedPlaylist.tracks); metaEl.textContent = `${updatedPlaylist.tracks.length} tracks • ${formatDuration(totalDuration)}`; } } showNotification(`Added "${trackData.title}" to playlist`); } } catch (error) { console.error('Failed to add track to playlist:', error); showNotification('Failed to add track to playlist'); } } }; const menuBtn = actionsDiv.querySelector('.track-menu-btn'); if (menuBtn) { actionsDiv.insertBefore(addToPlaylistBtn, menuBtn); } else { actionsDiv.appendChild(addToPlaylistBtn); } } }); recommendedSection.style.display = 'block'; } else { recommendedSection.style.display = 'none'; } } catch (error) { console.error('Failed to load recommended songs:', error); recommendedSection.style.display = 'none'; } } async renderPlaylistPage(playlistId, source = null) { 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}Play`; const dlBtn = document.getElementById('download-playlist-btn'); if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD}Download`; imageEl.src = ''; imageEl.style.backgroundColor = 'var(--muted)'; titleEl.innerHTML = '
'; metaEl.innerHTML = '
'; descEl.innerHTML = '
'; tracklistContainer.innerHTML = `
# Title Duration
${this.createSkeletonTracks(10, true)} `; try { // Check if it's a user playlist (UUID format) const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(playlistId); let playlistData = null; let ownedPlaylist = null; // Priority: // 1. If source is 'user', check DB/Sync. // 2. If source is 'api', check API. // 3. If no source, check DB if UUID, then API. if (source === 'user' || (!source && isUUID)) { ownedPlaylist = await db.getPlaylist(playlistId); playlistData = ownedPlaylist; // If not in local DB, check if it's a public Firebase playlist if (!playlistData) { try { playlistData = await syncManager.getPublicPlaylist(playlistId); } catch (e) { console.warn('Failed to check public Firebase playlists:', e); } } } if (playlistData) { // ... (rest of the logic) // Render user or public firebase playlist imageEl.src = playlistData.cover || 'assets/appicon.png'; imageEl.style.backgroundColor = ''; titleEl.textContent = playlistData.name || playlistData.title; this.adjustTitleFontSize(titleEl, titleEl.textContent); const tracks = playlistData.tracks || []; const totalDuration = calculateTotalDuration(tracks); metaEl.textContent = `${tracks.length} tracks • ${formatDuration(totalDuration)}`; descEl.textContent = playlistData.description || ''; tracklistContainer.innerHTML = `
# Title Duration
`; this.renderListWithTracks(tracklistContainer, tracks, true); // Add remove buttons and enable reordering ONLY IF OWNED if (ownedPlaylist) { const trackItems = tracklistContainer.querySelectorAll('.track-item'); trackItems.forEach((item, index) => { const actionsDiv = item.querySelector('.track-item-actions'); const removeBtn = document.createElement('button'); removeBtn.className = 'track-action-btn remove-from-playlist-btn'; removeBtn.title = 'Remove from playlist'; removeBtn.innerHTML = ''; removeBtn.dataset.trackIndex = index; const menuBtn = actionsDiv.querySelector('.track-menu-btn'); actionsDiv.insertBefore(removeBtn, menuBtn); }); this.enableTrackReordering(tracklistContainer, tracks, playlistId, syncManager); } // Update header like button - hide for user playlists const playlistLikeBtn = document.getElementById('like-playlist-btn'); if (playlistLikeBtn) { playlistLikeBtn.style.display = 'none'; } // Load recommended songs thingy if (ownedPlaylist) { this.loadRecommendedSongsForPlaylist(tracks); } // Render Actions (Shuffle, Edit, Delete, Share) // If it is owned, isOwned = true. // If it is public, isPublic is in playlistData. this.updatePlaylistHeaderActions(playlistData, !!ownedPlaylist, tracks); const uniqueCovers = []; const seenCovers = new Set(); const trackList = playlistData.tracks || []; for (const track of trackList) { const cover = track.album?.cover; if (cover && !seenCovers.has(cover)) { seenCovers.add(cover); uniqueCovers.push(cover); if (uniqueCovers.length >= 4) break; } } recentActivityManager.addPlaylist({ id: playlistData.id || playlistData.uuid, name: playlistData.name || playlistData.title, title: playlistData.title || playlistData.name, uuid: playlistData.uuid || playlistData.id, cover: playlistData.cover, images: uniqueCovers, numberOfTracks: playlistData.tracks ? playlistData.tracks.length : 0, isUserPlaylist: true, }); document.title = `${playlistData.name || playlistData.title} - Monochrome`; } else { // If source was explicitly 'user' and we didn't find it, fail. if (source === 'user') { throw new Error('Playlist not found. If this is a custom playlist, make sure it is set to Public.'); } // Render API playlist let apiResult = await this.api.getPlaylist(playlistId); const { playlist, tracks } = apiResult; const imageId = playlist.squareImage || playlist.image; if (imageId) { imageEl.src = this.api.getCoverUrl(imageId, '1080'); this.setPageBackground(imageEl.src); this.extractAndApplyColor(this.api.getCoverUrl(imageId, '160')); } else { imageEl.src = 'assets/appicon.png'; this.setPageBackground(null); this.resetVibrantColor(); } 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); // Update header like button const playlistLikeBtn = document.getElementById('like-playlist-btn'); if (playlistLikeBtn) { const isLiked = await db.isFavorite('playlist', playlist.uuid); playlistLikeBtn.innerHTML = this.createHeartIcon(isLiked); playlistLikeBtn.classList.toggle('active', isLiked); playlistLikeBtn.style.display = 'flex'; } // Show/hide Delete button const deleteBtn = document.getElementById('delete-playlist-btn'); if (deleteBtn) { deleteBtn.style.display = 'none'; } // Hide recommended songs section for tidal playlists const recommendedSection = document.getElementById('playlist-section-recommended'); if (recommendedSection) { recommendedSection.style.display = 'none'; } // Render Actions (Shuffle + Share) this.updatePlaylistHeaderActions(playlist, false, tracks, false); recentActivityManager.addPlaylist(playlist); document.title = playlist.title || 'Artist Mix'; } } catch (error) { console.error('Failed to load playlist:', error); tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`); } } async renderMixPage(mixId) { this.showPage('mix'); const imageEl = document.getElementById('mix-detail-image'); const titleEl = document.getElementById('mix-detail-title'); const metaEl = document.getElementById('mix-detail-meta'); const descEl = document.getElementById('mix-detail-description'); const tracklistContainer = document.getElementById('mix-detail-tracklist'); const playBtn = document.getElementById('play-mix-btn'); if (playBtn) playBtn.innerHTML = `${SVG_PLAY}Play`; const dlBtn = document.getElementById('download-mix-btn'); if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD}Download`; // Skeleton loading imageEl.src = ''; imageEl.style.backgroundColor = 'var(--muted)'; titleEl.innerHTML = '
'; metaEl.innerHTML = '
'; descEl.innerHTML = '
'; tracklistContainer.innerHTML = `
# Title Duration
${this.createSkeletonTracks(10, true)} `; try { const { mix, tracks } = await this.api.getMix(mixId); if (mix.cover) { imageEl.src = mix.cover; this.setPageBackground(mix.cover); this.extractAndApplyColor(mix.cover); } else { // Try to get cover from first track album if (tracks.length > 0 && tracks[0].album?.cover) { imageEl.src = this.api.getCoverUrl(tracks[0].album.cover); this.setPageBackground(imageEl.src); this.extractAndApplyColor(this.api.getCoverUrl(tracks[0].album.cover, '160')); } else { imageEl.src = 'assets/appicon.png'; this.setPageBackground(null); this.resetVibrantColor(); } } imageEl.style.backgroundColor = ''; // Use title and subtitle from API directly const displayTitle = mix.title || 'Mix'; titleEl.textContent = displayTitle; this.adjustTitleFontSize(titleEl, displayTitle); const totalDuration = calculateTotalDuration(tracks); metaEl.textContent = `${tracks.length} tracks • ${formatDuration(totalDuration)}`; descEl.innerHTML = `${mix.subTitle}`; tracklistContainer.innerHTML = `
# Title Duration
`; this.renderListWithTracks(tracklistContainer, tracks, true); // Set play button action playBtn.onclick = () => { this.player.setQueue(tracks, 0); this.player.playTrackFromQueue(); }; recentActivityManager.addMix(mix); // Update header like button const mixLikeBtn = document.getElementById('like-mix-btn'); if (mixLikeBtn) { mixLikeBtn.style.display = 'flex'; const isLiked = await db.isFavorite('mix', mix.id); mixLikeBtn.innerHTML = this.createHeartIcon(isLiked); mixLikeBtn.classList.toggle('active', isLiked); } document.title = displayTitle; } catch (error) { console.error('Failed to load mix:', error); tracklistContainer.innerHTML = createPlaceholder(`Could not load mix 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 similarContainer = document.getElementById('artist-detail-similar'); const similarSection = document.getElementById('artist-section-similar'); const dlBtn = document.getElementById('download-discography-btn'); if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD}Download Discography`; imageEl.src = ''; imageEl.style.backgroundColor = 'var(--muted)'; nameEl.innerHTML = '
'; metaEl.innerHTML = '
'; 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'; if (similarContainer) similarContainer.innerHTML = this.createSkeletonCards(6, true); if (similarSection) similarSection.style.display = 'block'; try { const artist = await this.api.getArtist(artistId); // Handle Artist Mix Button const mixBtn = document.getElementById('artist-mix-btn'); if (mixBtn) { if (artist.mixes && artist.mixes.ARTIST_MIX) { mixBtn.style.display = 'flex'; mixBtn.onclick = () => (window.location.hash = `#mix/${artist.mixes.ARTIST_MIX}`); } else { mixBtn.style.display = 'none'; } } // Similar Artists if (similarContainer && similarSection) { this.api .getSimilarArtists(artistId) .then((similar) => { if (similar && similar.length > 0) { similarContainer.innerHTML = similar.map((a) => this.createArtistCardHTML(a)).join(''); similarSection.style.display = 'block'; } else { similarSection.style.display = 'none'; } }) .catch(() => { similarSection.style.display = 'none'; }); } imageEl.src = this.api.getArtistPictureUrl(artist.picture); imageEl.style.backgroundColor = ''; nameEl.textContent = artist.name; // Set background this.setPageBackground(imageEl.src); // Extract vibrant color using robust image extraction (160x160 for speed/accuracy balance) const artistPic160 = this.api.getArtistPictureUrl(artist.picture, '160'); this.extractAndApplyColor(artistPic160); this.adjustTitleFontSize(nameEl, artist.name); metaEl.innerHTML = ` ${artist.popularity}% popularity
${(artist.artistRoles || []) .filter((role) => role.category) .map((role) => `${role.category}`) .join('')}
`; this.renderListWithTracks(tracksContainer, artist.tracks, true); // Update header like button const artistLikeBtn = document.getElementById('like-artist-btn'); if (artistLikeBtn) { const isLiked = await db.isFavorite('artist', artist.id); artistLikeBtn.innerHTML = this.createHeartIcon(isLiked); artistLikeBtn.classList.toggle('active', isLiked); } albumsContainer.innerHTML = artist.albums.map((album) => this.createAlbumCardHTML(album)).join(''); // 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'; } } artist.albums.forEach((album) => { const el = albumsContainer.querySelector(`[data-album-id="${album.id}"]`); if (el) { trackDataStore.set(el, album); this.updateLikeState(el, 'album', album.id); } }); recentActivityManager.addArtist(artist); document.title = artist.name; } catch (error) { console.error('Failed to load artist:', error); tracksContainer.innerHTML = albumsContainer.innerHTML = createPlaceholder( `Could not load artist details. ${error.message}` ); } } async renderRecentPage() { this.showPage('recent'); const container = document.getElementById('recent-tracks-container'); container.innerHTML = this.createSkeletonTracks(10, true); try { const history = await db.getHistory(); if (history.length === 0) { container.innerHTML = createPlaceholder("You haven't played any tracks yet."); return; } // Group by date const groups = {}; const today = new Date().setHours(0, 0, 0, 0); const yesterday = new Date(today - 86400000).setHours(0, 0, 0, 0); history.forEach((item) => { const date = new Date(item.timestamp); const dayStart = new Date(date).setHours(0, 0, 0, 0); let label; if (dayStart === today) label = 'Today'; else if (dayStart === yesterday) label = 'Yesterday'; else label = date.toLocaleDateString(undefined, { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', }); if (!groups[label]) groups[label] = []; groups[label].push(item); }); container.innerHTML = ''; for (const [label, tracks] of Object.entries(groups)) { const header = document.createElement('h3'); header.className = 'track-list-header-group'; header.textContent = label; header.style.margin = '1.5rem 0 0.5rem 0'; header.style.fontSize = '1.1rem'; header.style.fontWeight = '600'; header.style.color = 'var(--foreground)'; header.style.paddingLeft = '0.5rem'; container.appendChild(header); // Use a temporary container to render tracks and then move them const tempContainer = document.createElement('div'); this.renderListWithTracks(tempContainer, tracks, true); // Move children to main container while (tempContainer.firstChild) { container.appendChild(tempContainer.firstChild); } } } catch (error) { console.error('Failed to load history:', error); container.innerHTML = createPlaceholder('Failed to load history.'); } } updatePlaylistHeaderActions(playlist, isOwned, tracks, showShare = false) { const actionsDiv = document.getElementById('page-playlist').querySelector('.detail-header-actions'); // Cleanup existing dynamic buttons ['shuffle-playlist-btn', 'edit-playlist-btn', 'delete-playlist-btn', 'share-playlist-btn'].forEach((id) => { const btn = actionsDiv.querySelector(`#${id}`); if (btn) btn.remove(); }); const fragment = document.createDocumentFragment(); // Shuffle const shuffleBtn = document.createElement('button'); shuffleBtn.id = 'shuffle-playlist-btn'; shuffleBtn.className = 'btn-primary'; shuffleBtn.innerHTML = 'Shuffle'; shuffleBtn.onclick = () => { const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5); this.player.setQueue(shuffledTracks, 0); this.player.playTrackFromQueue(); }; fragment.appendChild(shuffleBtn); // Edit/Delete (Owned Only) if (isOwned) { const editBtn = document.createElement('button'); editBtn.id = 'edit-playlist-btn'; editBtn.className = 'btn-secondary'; editBtn.innerHTML = 'Edit'; fragment.appendChild(editBtn); const deleteBtn = document.createElement('button'); deleteBtn.id = 'delete-playlist-btn'; deleteBtn.className = 'btn-secondary danger'; deleteBtn.innerHTML = 'Delete'; fragment.appendChild(deleteBtn); } // Share (User Playlists Only) if (showShare || (isOwned && playlist.isPublic)) { const shareBtn = document.createElement('button'); shareBtn.id = 'share-playlist-btn'; shareBtn.className = 'btn-secondary'; shareBtn.innerHTML = 'Share'; shareBtn.onclick = () => { const url = `${window.location.origin}${window.location.pathname}#userplaylist/${playlist.id || playlist.uuid}`; navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!')); }; fragment.appendChild(shareBtn); } // Insert before Download button if possible, else append const dlBtn = actionsDiv.querySelector('#download-playlist-btn'); if (dlBtn) { // We want Shuffle first, then Edit/Delete/Share. // But Download is usually first or second. // In renderPlaylistPage: Play, Download, Like. // We want Shuffle after Play? Or after Download? // Previous code: actionsDiv.insertBefore(shuffleBtn, dlBtn); => Shuffle before Download. // Then appended others. // Let's just append everything for now to keep it simple, or insert Shuffle specifically. // The Play button is static. Download is static. // If we want Shuffle before Download: // fragment has Shuffle, Edit, Delete, Share. // If we insert fragment before Download, all go before Download. // That might change the order. // Previous order: Shuffle (before Download), then Edit/Delete/Share (appended = after Like). // Let's split fragment? // Or just use append for all. // The user didn't complain about order, but consistency is good. // "Fix popup buttons" was the request. // Let's stick to appending for now to minimize visual layout shifts from previous (where Edit/Delete were appended). // Shuffle was inserted before Download. actionsDiv.insertBefore(shuffleBtn, dlBtn); // Append the rest while (fragment.firstChild) { actionsDiv.appendChild(fragment.firstChild); } } else { actionsDiv.appendChild(fragment); } } enableTrackReordering(container, tracks, playlistId, syncManager) { let draggedElement = null; let draggedIndex = -1; let trackItems = Array.from(container.querySelectorAll('.track-item')); trackItems.forEach((item, index) => { item.draggable = true; item.dataset.index = index; }); const dragStart = (e) => { draggedElement = e.target; draggedIndex = parseInt(e.target.dataset.index); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', draggedIndex); draggedElement.classList.add('dragging'); }; const dragEnd = () => { if (draggedElement) { draggedElement.classList.remove('dragging'); draggedElement = null; } }; const dragOver = (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (!draggedElement) return; const afterElement = getDragAfterElement(container, e.clientY); if (afterElement === draggedElement) return; if (afterElement) { container.insertBefore(draggedElement, afterElement); } else { container.appendChild(draggedElement); } }; const drop = async (e) => { e.preventDefault(); if (!draggedElement) return; try { // Get new order from DOM const newTrackItems = Array.from(container.querySelectorAll('.track-item')); const newTracks = newTrackItems.map((item) => { const originalIndex = parseInt(item.dataset.index); return tracks[originalIndex]; }); newTrackItems.forEach((item, index) => { item.dataset.index = index; }); tracks.splice(0, tracks.length, ...newTracks); // Save to DB const updatedPlaylist = await db.updatePlaylistTracks(playlistId, newTracks); syncManager.syncUserPlaylist(updatedPlaylist, 'update'); draggedElement = null; draggedIndex = -1; } catch (error) { console.error('Error updating playlist tracks:', error); if (draggedElement) { draggedElement.classList.remove('dragging'); draggedElement = null; } draggedIndex = -1; } }; container.addEventListener('dragstart', dragStart); container.addEventListener('dragend', dragEnd); container.addEventListener('dragover', dragOver); container.addEventListener('drop', drop); // Cache function to avoid recreating function getDragAfterElement(container, y) { const draggableElements = [...container.querySelectorAll('.track-item:not(.dragging)')]; return draggableElements.reduce( (closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY } ).element; } } getDragAfterElement(container, y) { const draggableElements = [...container.querySelectorAll('.track-item:not(.dragging)')]; return draggableElements.reduce( (closest, child) => { const box = child.getBoundingClientRect(); const offset = y - box.top - box.height / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY } ).element; } renderApiSettings() { const container = document.getElementById('api-instance-list'); Promise.all([this.api.settings.getInstances('api'), this.api.settings.getInstances('streaming')]).then( ([apiInstances, streamingInstances]) => { const cachedData = this.api.settings.getCachedSpeedTests(); const speeds = cachedData?.speeds || {}; const renderGroup = (instances, type) => { if (!instances || instances.length === 0) return ''; const listHtml = instances .map((url, index) => { const cacheKey = type === 'streaming' ? `${url}#streaming` : url; const speedInfo = speeds[cacheKey]; const speedText = speedInfo ? speedInfo.speed === Infinity || typeof speedInfo.speed !== 'number' ? `Failed` : `${speedInfo.speed.toFixed(0)}ms` : ''; return `
  • ${url}
    ${speedText}
  • `; }) .join(''); return `
  • ${type === 'api' ? 'API Instances' : 'Streaming Instances'}
  • ${listHtml} `; }; container.innerHTML = renderGroup(apiInstances, 'api') + renderGroup(streamingInstances, 'streaming'); const stats = this.api.getCacheStats(); const cacheInfo = document.getElementById('cache-info'); if (cacheInfo) { cacheInfo.textContent = `Cache: ${stats.memoryEntries}/${stats.maxSize} entries`; } } ); } }