kv-music/js/ui.js

5268 lines
231 KiB
JavaScript

//js/ui.js
import { showNotification } from './downloads.js';
import {
formatTime,
createPlaceholder,
trackDataStore,
hasExplicitContent,
getTrackArtists,
getTrackArtistsHTML,
getTrackTitle,
getTrackYearDisplay,
createQualityBadgeHTML,
calculateTotalDuration,
formatDuration,
escapeHtml,
getShareUrl,
} from './utils.js';
import { openLyricsPanel } from './lyrics.js';
import {
recentActivityManager,
backgroundSettings,
dynamicColorSettings,
cardSettings,
visualizerSettings,
homePageSettings,
fontSettings,
contentBlockingSettings,
settingsUiState,
} from './storage.js';
import { db } from './db.js';
import { getVibrantColorFromImage } from './vibrant-color.js';
import { syncManager } from './accounts/pocketbase.js';
import { Visualizer } from './visualizer.js';
import { navigate } from './router.js';
import { sidePanelManager } from './side-panel.js';
import {
renderUnreleasedPage as renderUnreleasedTrackerPage,
renderTrackerArtistPage as renderTrackerArtistContent,
renderTrackerProjectPage as renderTrackerProjectContent,
renderTrackerTrackPage as renderTrackerTrackContent,
findTrackerArtistByName,
getArtistUnreleasedProjects,
createProjectCardHTML,
createTrackFromSong,
} from './tracker.js';
import { trackSearch, trackChangeSort } from './analytics.js';
fontSettings.applyFont();
fontSettings.applyFontSize();
import {
SVG_PLAY,
SVG_DOWNLOAD,
SVG_MENU,
SVG_HEART,
SVG_VOLUME,
SVG_MUTE,
SVG_HEART_FILLED,
SVG_CLOSE,
SVG_SORT,
SVG_BIN,
SVG_TRASH,
SVG_GLOBE,
SVG_INSTAGRAM,
SVG_FACEBOOK,
SVG_YOUTUBE,
SVG_TWITTER,
SVG_LINK,
SVG_SOUNDCLOUD,
SVG_APPLE,
SVG_REPEAT,
SVG_REPEAT_ONE,
SVG_PLAY_LARGE,
SVG_PAUSE_LARGE,
SVG_MINUS,
SVG_SQUARE_PEN,
SVG_SHARE,
SVG_SHUFFLE,
SVG_VIDEO,
SVG_LEFT_ARROW,
SVG_RIGHT_ARROW,
SVG_CLOCK,
SVG_MOVE_UP,
SVG_MOVE_DOWN,
} from './icons.js';
function sortTracks(tracks, sortType) {
if (sortType === 'custom') return [...tracks];
const sorted = [...tracks];
switch (sortType) {
case 'added-newest':
return sorted.sort((a, b) => (b.addedAt || 0) - (a.addedAt || 0));
case 'added-oldest':
return sorted.sort((a, b) => (a.addedAt || 0) - (b.addedAt || 0));
case 'title':
return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
case 'artist':
return sorted.sort((a, b) => {
const artistA = a.artist?.name || a.artists?.[0]?.name || '';
const artistB = b.artist?.name || b.artists?.[0]?.name || '';
return artistA.localeCompare(artistB);
});
case 'album':
return sorted.sort((a, b) => {
const albumA = a.album?.title || '';
const albumB = b.album?.title || '';
const albumCompare = albumA.localeCompare(albumB);
if (albumCompare !== 0) return albumCompare;
const trackNumA = a.trackNumber || a.position || 0;
const trackNumB = b.trackNumber || b.position || 0;
return trackNumA - trackNumB;
});
default:
return sorted;
}
}
export class UIRenderer {
constructor(api, player) {
this.api = api;
this.player = player;
this.currentTrack = null;
this.searchAbortController = null;
this.vibrantColorCache = new Map();
this.visualizer = null;
this.renderLock = false;
this.lastRecommendedTracks = [];
// Listen for dynamic color reset events
window.addEventListener('reset-dynamic-color', () => {
this.resetVibrantColor();
});
// Listen for theme changes to re-apply vibrant colors
window.addEventListener('theme-changed', () => {
this.updateGlobalTheme();
});
window.addEventListener('visualizer-dim-change', () => {
if (this.visualizer) {
this.visualizer.updateDimming();
}
});
}
// Helper for Heart Icon
createHeartIcon(filled = false) {
if (filled) {
return SVG_HEART_FILLED(20);
}
return SVG_HEART(20);
}
async extractAndApplyColor(url) {
if (!url) {
this.resetVibrantColor();
return;
}
// Check if dynamic coloring is enabled
if (!dynamicColorSettings.isEnabled()) {
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 {
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';
}
}
async renderPinnedItems() {
const nav = document.getElementById('pinned-items-nav');
const list = document.getElementById('pinned-items-list');
if (!nav || !list) return;
const pinnedItems = await db.getPinned();
if (pinnedItems.length === 0) {
nav.style.display = 'none';
return;
}
nav.style.display = '';
list.innerHTML = pinnedItems
.map((item) => {
let iconHTML;
if (item.type === 'user-playlist' && !item.cover && item.images && item.images.length > 0) {
const images = item.images.slice(0, 4);
const imgsHTML = images
.map((src) => `<img src="${this.api.getCoverUrl(src)}" loading="lazy">`)
.join('');
iconHTML = `<div class="pinned-item-collage">${imgsHTML}</div>`;
} else {
const coverUrl =
item.type === 'artist'
? this.api.getArtistPictureUrl(item.cover)
: this.api.getCoverUrl(item.cover);
const coverClass = item.type === 'artist' ? 'artist' : '';
iconHTML = `<img src="${coverUrl}" class="pinned-item-cover ${coverClass}" alt="${escapeHtml(item.name)}" loading="lazy" onerror="this.src='assets/logo.svg'">`;
}
return `
<li class="nav-item">
<a href="${item.href}">
${iconHTML}
<span class="pinned-item-name">${escapeHtml(item.name)}</span>
</a>
</li>
`;
})
.join('');
}
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');
const fsLikeBtn = document.getElementById('fs-like-btn');
const fsAddPlaylistBtn = document.getElementById('fs-add-playlist-btn');
if (track) {
const isLocal = track.isLocal;
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
const shouldHideLikes = isLocal || isTracker;
if (likeBtn) {
if (shouldHideLikes) {
likeBtn.style.display = 'none';
} else {
likeBtn.style.display = 'flex';
this.updateLikeState(likeBtn.parentElement, track.type || 'track', track.id);
}
}
if (addPlaylistBtn) {
if (isLocal) {
addPlaylistBtn.style.setProperty('display', 'none', 'important');
} else {
addPlaylistBtn.style.removeProperty('display');
addPlaylistBtn.style.display = 'flex';
}
}
if (mobileAddPlaylistBtn) {
if (isLocal) {
mobileAddPlaylistBtn.style.setProperty('display', 'none', 'important');
} else {
mobileAddPlaylistBtn.style.removeProperty('display');
mobileAddPlaylistBtn.style.display = 'flex';
}
}
if (lyricsBtn) {
if (isLocal) lyricsBtn.style.display = 'none';
else lyricsBtn.style.removeProperty('display');
}
if (fsLikeBtn) {
if (shouldHideLikes) {
fsLikeBtn.style.display = 'none';
} else {
fsLikeBtn.style.display = 'flex';
this.updateLikeState(fsLikeBtn.parentElement, track.type || 'track', track.id);
}
}
if (fsAddPlaylistBtn) {
if (shouldHideLikes) fsAddPlaylistBtn.style.display = 'none';
else fsAddPlaylistBtn.style.display = 'flex';
}
} 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';
if (fsLikeBtn) fsLikeBtn.style.display = 'none';
if (fsAddPlaylistBtn) fsAddPlaylistBtn.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 '<span class="explicit-badge" title="Explicit">E</span>';
}
adjustTitleFontSize(element, text) {
element.classList.remove('long-title', 'very-long-title');
if (!text) return;
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, useTrackNumber = false) {
const isUnavailable = track.isUnavailable;
const isBlocked = contentBlockingSettings?.shouldHideTrack(track);
const isVideo = track.type === 'video';
let trackImageHTML = '';
if (showCover) {
if (isVideo && this.currentPage === 'playlist') {
const videoCoverUrl = this.api.getVideoCoverUrl(track.imageId);
if (videoCoverUrl) {
trackImageHTML = `<img src="${videoCoverUrl}" alt="" class="track-item-cover" loading="lazy">`;
} else {
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);">${SVG_VIDEO(20, { style: 'opacity: 0.7;' })}</div>`;
}
} else if (isVideo && (this.currentPage === 'search' || this.currentPage === 'library')) {
const videoCoverUrl = this.api.getVideoCoverUrl(track.imageId);
if (videoCoverUrl) {
trackImageHTML = `<img src="${videoCoverUrl}" alt="" class="track-item-cover" loading="lazy">`;
} else {
trackImageHTML = `<div class="track-item-cover video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary);">${SVG_PLAY(16, { style: 'opacity: 0.7;' })}</div>`;
}
} else {
trackImageHTML = this.getCoverHTML(
track.image || track.cover || track.album?.cover,
'Track Cover',
'track-item-cover',
'lazy'
);
}
}
let displayIndex;
if (hasMultipleDiscs && !showCover) {
const discNum = track.volumeNumber ?? track.discNumber ?? 1;
displayIndex = `${discNum}-${track.trackNumber}`;
} else if (useTrackNumber && track.trackNumber) {
displayIndex = track.trackNumber;
} else {
displayIndex = index + 1;
}
const videoIcon = isVideo
? `<span class="video-item-icon" title="Music Video" style="display: inline-flex; align-items: center; margin-right: 4px; color: var(--muted-foreground);">${SVG_VIDEO(14)}</span>`
: '';
const trackNumberHTML = `<div class="track-number">${showCover ? trackImageHTML : displayIndex}</div>`;
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
const qualityBadge = createQualityBadgeHTML(track);
const trackTitle = getTrackTitle(track);
const isCurrentTrack = this.player?.currentTrack?.id === track.id;
if (track.isLocal && (!track.album?.cover || track.album.cover === 'assets/appicon.png')) {
showCover = false;
}
const yearDisplay = getTrackYearDisplay(track);
const actionsHTML = isUnavailable
? ''
: `
<button class="track-menu-btn" type="button" title="More options" ${track.isLocal ? 'style="display:none"' : ''}>
${SVG_MENU(20)}
</button>
`;
const blockedTitle = isBlocked
? `title="Blocked: ${contentBlockingSettings.isTrackBlocked(track.id) ? 'Track blocked' : contentBlockingSettings.isArtistBlocked(track.artist?.id) ? 'Artist blocked' : 'Album blocked'}"`
: '';
const classList = [
'track-item',
isVideo ? 'video-track-item' : '',
isCurrentTrack ? 'playing' : '',
isUnavailable ? 'unavailable' : '',
isBlocked ? 'blocked' : '',
]
.filter(Boolean)
.join(' ');
return `
<div class="${classList}"
data-track-id="${track.id}"
${isVideo ? 'data-type="video"' : 'data-type="track"'}
${track.isLocal ? 'data-is-local="true"' : ''}
${isUnavailable ? 'title="This track is currently unavailable"' : ''}
${blockedTitle}>
${trackNumberHTML}
<div class="track-item-info">
<div class="track-item-details">
<div class="title">
${videoIcon}
${escapeHtml(trackTitle)}
${explicitBadge}
${qualityBadge}
</div>
<div class="artist">${getTrackArtistsHTML(track)}${yearDisplay}</div>
</div>
</div>
<div class="track-item-duration">${isUnavailable || isBlocked ? '--:--' : track.duration ? formatTime(track.duration) : '--:--'}</div>
<div class="track-item-actions">
${actionsHTML}
</div>
</div>
`;
}
getCoverHTML(cover, alt, className = 'card-image', loading = 'lazy', videoCoverUrl = null) {
const imageUrl = this.api.getCoverUrl(cover);
if (videoCoverUrl) {
return `<video src="${videoCoverUrl}" poster="${imageUrl}" class="${className}" alt="${alt}" preload="metadata" playsinline muted></video>`;
}
return `<img src="${imageUrl}" class="${className}" alt="${alt}" loading="${loading}">`;
}
createBaseCardHTML({
type,
id,
href,
title,
subtitle,
imageHTML,
actionButtonsHTML,
isCompact,
extraAttributes = '',
extraClasses = '',
}) {
const playBtnHTML =
type !== 'artist'
? `
<button class="play-btn card-play-btn" data-action="play-card" data-type="${type}" data-id="${id}" title="Play">
${SVG_PLAY(20)}
</button>
<button class="card-menu-btn" data-action="card-menu" data-type="${type}" data-id="${id}" title="Menu">
${SVG_MENU(20)}
</button>
`
: '';
const cardContent = `
<div class="card-info">
<h4 class="card-title">${title}</h4>
${subtitle ? `<p class="card-subtitle">${subtitle}</p>` : ''}
</div>`;
// 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 `
<div class="card ${extraClasses} ${isCompact ? 'compact' : ''}" data-${type}-id="${id}" data-href="${href}" style="cursor: pointer;" ${extraAttributes}>
<div class="card-image-wrapper">
${imageHTML}
${actionButtonsHTML}
${buttonsInWrapper}
</div>
${cardContent}
${buttonsOutside}
</div>
`;
}
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: `<img src="${this.api.getCoverUrl(imageId)}" alt="${playlist.title}" class="card-image" loading="lazy">`,
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="playlist" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact,
});
}
createFolderCardHTML(folder) {
const imageSrc = folder.cover || 'assets/folder.png';
const isCompact = cardSettings.isCompactAlbum();
return this.createBaseCardHTML({
type: 'folder',
id: folder.id,
href: `/folder/${folder.id}`,
title: escapeHtml(folder.name),
subtitle: `${folder.playlists ? folder.playlists.length : 0} playlists`,
imageHTML: `<img src="${imageSrc}" alt="${escapeHtml(folder.name)}" class="card-image" loading="lazy" onerror="this.src='/assets/folder.png'">`,
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: `<img src="${imageSrc}" alt="${mix.title}" class="card-image" loading="lazy">`,
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="mix" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact,
});
}
createUserPlaylistCardHTML(playlist, customSubtitle = null) {
let imageHTML = '';
if (playlist.cover) {
imageHTML = `<img src="${playlist.cover}" alt="${playlist.name}" class="card-image" loading="lazy">`;
} 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 = `
<div class="card-image card-collage ${itemsClass}">
${covers.map((cover) => `<img src="${this.api.getCoverUrl(cover)}" alt="" loading="lazy">`).join('')}
</div>
`;
} else if (uniqueCovers.length > 0) {
imageHTML = `<img src="${this.api.getCoverUrl(uniqueCovers[0])}" alt="${playlist.name}" class="card-image" loading="lazy">`;
} else {
imageHTML = `<img src="/assets/appicon.png" alt="${playlist.name}" class="card-image" loading="lazy">`;
}
}
const isCompact = cardSettings.isCompactAlbum();
const subtitle =
customSubtitle || `${playlist.tracks ? playlist.tracks.length : playlist.numberOfTracks || 0} tracks`;
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,
imageHTML: imageHTML,
actionButtonsHTML: `
<button class="edit-playlist-btn" data-action="edit-playlist" title="Edit Playlist">
${SVG_SQUARE_PEN(20)}
</button>
<button class="delete-playlist-btn" data-action="delete-playlist" title="Delete Playlist">
${SVG_BIN(20)}
</button>
`,
isCompact,
extraAttributes: 'draggable="true"',
extraClasses: 'user-playlist',
});
}
createAlbumCardHTML(album) {
const explicitBadge = hasExplicitContent(album) ? this.createExplicitBadge() : '';
const qualityBadge = createQualityBadgeHTML(album);
const isBlocked = contentBlockingSettings?.shouldHideAlbum(album);
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();
let artistName = '';
if (album.artist) {
artistName = typeof album.artist === 'string' ? album.artist : album.artist.name;
} else if (album.artists?.length) {
artistName = album.artists.map((a) => a.name).join(', ');
}
return this.createBaseCardHTML({
type: 'album',
id: album.id,
href: `/album/${album.id}`,
title: `${escapeHtml(album.title)} ${explicitBadge} ${qualityBadge}`,
subtitle: `${escapeHtml(artistName)}${yearDisplay}${typeLabel}`,
imageHTML: this.getCoverHTML(
album.cover,
escapeHtml(album.title),
'card-image',
'lazy',
album.videoCoverUrl
),
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact,
extraClasses: isBlocked ? 'blocked' : '',
extraAttributes: isBlocked
? `title="Blocked: ${contentBlockingSettings.isAlbumBlocked(album.id) ? 'Album blocked' : 'Artist blocked'}"`
: '',
});
}
createVideoCardHTML(video) {
const duration = formatTime(video.duration);
const artistName = getTrackArtists(video);
const videoCoverUrl = this.api.getVideoCoverUrl(video.imageId);
const cover = video.image || video.cover;
let imageHTML;
if (videoCoverUrl) {
imageHTML = `<img src="${videoCoverUrl}" alt="${escapeHtml(video.title)}" class="card-image" loading="lazy">`;
} else if (cover) {
imageHTML = this.getCoverHTML(cover, escapeHtml(video.title));
} else {
imageHTML = `<div class="card-image video-icon-placeholder" style="display: flex; align-items: center; justify-content: center; background: var(--secondary); aspect-ratio: 16/9; width: 100%;">${SVG_PLAY(48, { style: 'opacity: 0.7;' })}</div>`;
}
return `
<div class="card video-card" data-video-id="${video.id}" data-type="video" draggable="true">
<div class="card-image-container">
${imageHTML}
<div class="card-overlay">
<button class="card-play-btn" title="Play video">
${SVG_PLAY(24)}
</button>
</div>
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="video" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
<div class="video-duration-badge" style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.7); color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-weight: 500;">${duration}</div>
</div>
<div class="card-info">
<div class="card-title" title="${escapeHtml(video.title)}">${escapeHtml(video.title)}</div>
<div class="card-subtitle">${escapeHtml(artistName)}</div>
</div>
</div>
`;
}
createArtistCardHTML(artist) {
const isCompact = cardSettings.isCompactArtist();
const isBlocked = contentBlockingSettings?.shouldHideArtist(artist);
return this.createBaseCardHTML({
type: 'artist',
id: artist.id,
href: `/artist/${artist.id}`,
title: escapeHtml(artist.name),
subtitle: '',
imageHTML: `<img src="${this.api.getArtistPictureUrl(artist.picture)}" alt="${escapeHtml(artist.name)}" class="card-image" loading="lazy">`,
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact,
extraClasses: `artist${isBlocked ? ' blocked' : ''}`,
extraAttributes: isBlocked ? 'title="Blocked: Artist blocked"' : '',
});
}
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 class="skeleton skeleton-track-actions"></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 Array(count)
.fill(0)
.map(() => this.createSkeletonTrack(showCover))
.join('');
}
createSkeletonCards(count = 6, isArtist = false) {
return Array(count)
.fill(0)
.map(() => this.createSkeletonCard(isArtist))
.join('');
}
setupSearchClearButton(inputElement, clearBtnSelector = '.search-clear-btn') {
if (!inputElement) return;
const clearBtn = inputElement.parentElement?.querySelector(clearBtnSelector);
if (!clearBtn) return;
// Remove old listener if exists
const oldListener = clearBtn._clearListener;
if (oldListener) clearBtn.removeEventListener('click', oldListener);
// Toggle visibility based on input value
const toggleVisibility = () => {
clearBtn.style.display = inputElement.value.trim() ? 'flex' : 'none';
};
// Clear input on click
const clearListener = () => {
inputElement.value = '';
inputElement.dispatchEvent(new Event('input'));
inputElement.focus();
};
inputElement.addEventListener('input', toggleVisibility);
clearBtn._clearListener = clearListener;
clearBtn.addEventListener('click', clearListener);
}
setupTracklistSearch(
searchInputId = 'track-list-search-input',
tracklistContainerId = 'playlist-detail-tracklist'
) {
const searchInput = document.getElementById(searchInputId);
const tracklistContainer = document.getElementById(tracklistContainerId);
if (!searchInput || !tracklistContainer) return;
// Setup clear button
this.setupSearchClearButton(searchInput);
// Remove previous listener if exists
const oldListener = searchInput._searchListener;
if (oldListener) {
searchInput.removeEventListener('input', oldListener);
}
// Create new listener
const listener = () => {
const query = searchInput.value.toLowerCase().trim();
const trackItems = tracklistContainer.querySelectorAll('.track-item');
trackItems.forEach((item) => {
const trackData = trackDataStore.get(item);
if (!trackData) {
item.style.display = '';
return;
}
const title = (trackData.title || '').toLowerCase();
const artist = (trackData.artist?.name || trackData.artists?.[0]?.name || '').toLowerCase();
const album = (trackData.album?.title || '').toLowerCase();
const matches = title.includes(query) || artist.includes(query) || album.includes(query);
item.style.display = matches ? '' : 'none';
});
};
searchInput._searchListener = listener;
searchInput.addEventListener('input', listener);
}
renderListWithTracks(container, tracks, showCover, append = false, useTrackNumber = false) {
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, useTrackNumber))
.join('');
// Bind data to elements immediately using index, avoiding selector ambiguity
Array.from(tempDiv.children).forEach((element, index) => {
const track = tracks[index];
if (element && track) {
trackDataStore.set(element, track);
// Async update for like button
this.updateLikeState(element, track.type || 'track', track.id);
}
});
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
if (!append) container.innerHTML = '';
container.appendChild(fragment);
}
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 === 'white';
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');
}
updateFullscreenMetadata(track, nextTrack) {
if (!track) return;
const overlay = document.getElementById('fullscreen-cover-overlay');
const image = document.getElementById('fullscreen-cover-image');
const videoContainer = document.getElementById('fullscreen-video-container');
const title = document.getElementById('fullscreen-track-title');
const artist = document.getElementById('fullscreen-track-artist');
const nextTrackEl = document.getElementById('fullscreen-next-track');
const isRealVideo = track.type === 'video';
const visualizerContainer = document.getElementById('visualizer-container');
overlay.classList.toggle('is-video-mode', isRealVideo);
const toggleUiBtn = document.getElementById('toggle-ui-btn');
if (toggleUiBtn) {
toggleUiBtn.style.display = isRealVideo ? 'none' : 'flex';
}
if (isRealVideo) {
if (sidePanelManager.isActive('lyrics')) {
sidePanelManager.close();
}
if (videoContainer) {
videoContainer.style.display = 'flex';
const videoPlayer = document.getElementById('video-player');
if (videoPlayer && videoPlayer.parentElement !== videoContainer) {
videoContainer.appendChild(videoPlayer);
videoPlayer.style.display = 'block';
videoPlayer.style.width = '100%';
videoPlayer.style.height = '100%';
videoPlayer.style.objectFit = 'contain';
}
}
if (image) image.style.display = 'none';
if (visualizerContainer) visualizerContainer.style.display = 'none';
} else {
if (videoContainer) {
videoContainer.style.display = 'none';
const videoPlayer = document.getElementById('video-player');
if (videoPlayer && videoPlayer.parentElement === videoContainer) {
document.body.appendChild(videoPlayer);
videoPlayer.style.display = 'none';
}
}
if (image) image.style.display = 'block';
if (visualizerContainer) visualizerContainer.style.display = 'block';
const qualityBtn = document.getElementById('fs-quality-btn');
const qualityMenu = document.getElementById('fs-quality-menu');
if (qualityBtn) qualityBtn.style.display = 'none';
if (qualityMenu) qualityMenu.style.display = 'none';
const videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.album?.cover, '1280');
const fsLikeBtn = document.getElementById('fs-like-btn');
if (fsLikeBtn) {
this.updateLikeState(fsLikeBtn.parentElement, track.type || 'track', track.id);
}
const currentImage = document.getElementById('fullscreen-cover-image');
if (videoCoverUrl) {
if (currentImage.tagName === 'IMG') {
const video = document.createElement('video');
video.src = videoCoverUrl;
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.className = currentImage.className;
currentImage.replaceWith(video);
} else if (currentImage.src !== videoCoverUrl) {
currentImage.src = videoCoverUrl;
}
} else {
if (currentImage.tagName === 'VIDEO') {
const img = document.createElement('img');
img.src = coverUrl;
img.id = currentImage.id;
img.className = currentImage.className;
currentImage.replaceWith(img);
} else if (currentImage.src !== coverUrl) {
currentImage.src = coverUrl;
}
}
overlay.style.setProperty('--bg-image', `url('${this.api.getCoverUrl(track.album?.cover, '1280')}')`);
this.extractAndApplyColor(this.api.getCoverUrl(track.album?.cover, '80'));
}
const qualityBadge = createQualityBadgeHTML(track);
title.innerHTML = `${escapeHtml(track.title)} ${qualityBadge}`;
artist.textContent = getTrackArtists(track);
if (nextTrack) {
nextTrackEl.style.display = 'flex';
nextTrackEl.querySelector('.value').textContent = `${nextTrack.title}${getTrackArtists(nextTrack)}`;
} else {
nextTrackEl.style.display = 'none';
}
}
async showFullscreenCover(track, nextTrack, lyricsManager, activeElement) {
if (!track) return;
if (window.location.hash !== '#fullscreen') {
window.history.pushState({ fullscreen: true }, '', '#fullscreen');
}
const overlay = document.getElementById('fullscreen-cover-overlay');
const nextTrackEl = document.getElementById('fullscreen-next-track');
const lyricsToggleBtn = document.getElementById('toggle-fullscreen-lyrics-btn');
this.updateFullscreenMetadata(track, nextTrack);
if (nextTrack) {
nextTrackEl.classList.remove('animate-in');
void nextTrackEl.offsetWidth;
nextTrackEl.classList.add('animate-in');
} else {
nextTrackEl.classList.remove('animate-in');
}
if (lyricsManager && activeElement) {
lyricsToggleBtn.style.display = 'flex';
lyricsToggleBtn.classList.remove('active');
const toggleLyrics = () => {
openLyricsPanel(track, activeElement, lyricsManager);
lyricsToggleBtn.classList.toggle('active');
};
const newToggleBtn = lyricsToggleBtn.cloneNode(true);
lyricsToggleBtn.parentNode.replaceChild(newToggleBtn, lyricsToggleBtn);
newToggleBtn.addEventListener('click', toggleLyrics);
} else {
lyricsToggleBtn.style.display = 'none';
}
const playerBar = document.querySelector('.now-playing-bar');
if (playerBar) playerBar.style.display = 'none';
this.setupFullscreenControls();
overlay.style.display = 'flex';
const startVisualizer = () => {
if (!visualizerSettings.isEnabled()) {
if (this.visualizer) this.visualizer.stop();
return;
}
if (!this.visualizer && activeElement) {
const canvas = document.getElementById('visualizer-canvas');
if (canvas) {
this.visualizer = new Visualizer(canvas, activeElement);
}
}
if (this.visualizer) {
this.visualizer.start();
}
// Add visualizer-active class for enhanced drop shadow
overlay.classList.add('visualizer-active');
};
// Setup UI toggle button
this.setupUIToggleButton(overlay);
if (localStorage.getItem('epilepsy-warning-dismissed') === 'true') {
startVisualizer();
} else {
const modal = document.getElementById('epilepsy-warning-modal');
if (modal) {
modal.classList.add('active');
const acceptBtn = document.getElementById('epilepsy-accept-btn');
const cancelBtn = document.getElementById('epilepsy-cancel-btn');
acceptBtn.onclick = () => {
modal.classList.remove('active');
localStorage.setItem('epilepsy-warning-dismissed', 'true');
startVisualizer();
};
cancelBtn.onclick = () => {
modal.classList.remove('active');
this.closeFullscreenCover();
};
} else {
startVisualizer();
}
}
}
closeFullscreenCover() {
const overlay = document.getElementById('fullscreen-cover-overlay');
overlay.style.display = 'none';
overlay.classList.remove('visualizer-active', 'ui-hidden');
const playerBar = document.querySelector('.now-playing-bar');
if (playerBar) playerBar.style.removeProperty('display');
if (this.player?.currentTrack?.type === 'video') {
const coverContainer = document.querySelector('.now-playing-bar .track-info');
const videoPlayer = document.getElementById('video-player');
const imgCover = coverContainer?.querySelector('.cover:not(#audio-player):not(#video-player)');
if (videoPlayer && coverContainer) {
if (imgCover) imgCover.style.display = 'none';
videoPlayer.style.display = 'block';
videoPlayer.classList.add('cover', 'video-cover-mirror');
videoPlayer.style.width = '56px';
videoPlayer.style.height = '56px';
videoPlayer.style.borderRadius = 'var(--radius-sm)';
videoPlayer.style.objectFit = 'cover';
videoPlayer.style.gridArea = 'none';
if (videoPlayer.parentElement !== coverContainer) {
coverContainer.insertBefore(videoPlayer, coverContainer.firstChild);
}
}
}
if (this.fullscreenUpdateInterval) {
cancelAnimationFrame(this.fullscreenUpdateInterval);
this.fullscreenUpdateInterval = null;
}
if (this.visualizer) {
this.visualizer.stop();
}
// Clear UI toggle button timers
if (this.uiToggleMouseTimer) {
clearTimeout(this.uiToggleMouseTimer);
this.uiToggleMouseTimer = null;
}
}
setupUIToggleButton(overlay) {
const toggleBtn = document.getElementById('toggle-ui-btn');
if (!toggleBtn) return;
let isUIHidden = overlay.classList.contains('ui-hidden');
toggleBtn.classList.toggle('active', isUIHidden);
toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI';
// Show button
const showButton = () => {
toggleBtn.classList.add('visible');
};
// Hide button
const hideButton = () => {
toggleBtn.classList.remove('visible');
};
// Initial state: hide button if UI is hidden
if (isUIHidden) {
hideButton();
} else {
showButton();
}
const toggleUI = (e) => {
if (e) e.stopPropagation();
isUIHidden = !isUIHidden;
overlay.classList.toggle('ui-hidden', isUIHidden);
toggleBtn.classList.toggle('active', isUIHidden);
toggleBtn.title = isUIHidden ? 'Show UI' : 'Hide UI';
if (isUIHidden) {
hideButton();
} else {
showButton();
}
};
// Mouse move handler
const handleMouseMove = (e) => {
const rect = overlay.getBoundingClientRect();
const isNearTopRight = e.clientY < 100 && e.clientX > rect.width - 150;
if (isUIHidden) {
if (overlay.classList.contains('is-video-mode')) {
if (isNearTopRight) {
showButton();
} else {
hideButton();
}
} else if (isNearTopRight) {
showButton();
} else {
hideButton();
}
}
};
// Add event listeners
toggleBtn.addEventListener('click', toggleUI);
overlay.addEventListener('mousemove', handleMouseMove);
overlay.addEventListener('mouseleave', () => {
if (isUIHidden) {
hideButton();
}
});
// Store cleanup function
this.uiToggleCleanup = () => {
toggleBtn.removeEventListener('click', toggleUI);
overlay.removeEventListener('mousemove', handleMouseMove);
};
}
setupFullscreenControls() {
const playBtn = document.getElementById('fs-play-pause-btn');
const prevBtn = document.getElementById('fs-prev-btn');
const nextBtn = document.getElementById('fs-next-btn');
const shuffleBtn = document.getElementById('fs-shuffle-btn');
const repeatBtn = document.getElementById('fs-repeat-btn');
const progressBar = document.getElementById('fs-progress-bar');
const progressFill = document.getElementById('fs-progress-fill');
const currentTimeEl = document.getElementById('fs-current-time');
const totalDurationEl = document.getElementById('fs-total-duration');
const fsLikeBtn = document.getElementById('fs-like-btn');
const fsAddPlaylistBtn = document.getElementById('fs-add-playlist-btn');
const fsDownloadBtn = document.getElementById('fs-download-btn');
const fsCastBtn = document.getElementById('fs-cast-btn');
const fsQueueBtn = document.getElementById('fs-queue-btn');
const artistEl = document.getElementById('fullscreen-track-artist');
if (artistEl) {
artistEl.style.cursor = 'pointer';
artistEl.onclick = () => {
if (this.player.currentTrack && this.player.currentTrack.artist) {
this.closeFullscreenCover();
navigate(`/artist/${this.player.currentTrack.artist.id}`);
}
};
}
let lastPausedState = null;
const updatePlayBtn = () => {
const activeEl = this.player.activeElement;
const isPaused = activeEl.paused;
if (isPaused === lastPausedState) return;
lastPausedState = isPaused;
if (isPaused) {
playBtn.innerHTML = SVG_PLAY_LARGE(32);
} else {
playBtn.innerHTML = SVG_PAUSE_LARGE(32);
}
};
updatePlayBtn();
playBtn.onclick = () => {
this.player.handlePlayPause();
updatePlayBtn();
};
prevBtn.onclick = () => this.player.playPrev();
nextBtn.onclick = () => this.player.playNext();
shuffleBtn.onclick = () => {
this.player.toggleShuffle();
shuffleBtn.classList.toggle('active', this.player.shuffleActive);
};
repeatBtn.onclick = () => {
const mode = this.player.toggleRepeat();
repeatBtn.classList.toggle('active', mode !== 0);
if (mode === 2) {
repeatBtn.innerHTML = SVG_REPEAT_ONE(24);
} else {
repeatBtn.innerHTML = SVG_REPEAT(24);
}
};
// Progress bar with drag support
let isFsSeeking = false;
let wasFsPlaying = false;
let lastFsSeekPosition = 0;
const updateFsSeekUI = (position) => {
const activeEl = this.player.activeElement;
if (!isNaN(activeEl.duration)) {
progressFill.style.width = `${position * 100}%`;
if (currentTimeEl) {
currentTimeEl.textContent = formatTime(position * activeEl.duration);
}
}
};
progressBar.addEventListener('mousedown', (e) => {
const activeEl = this.player.activeElement;
isFsSeeking = true;
wasFsPlaying = !activeEl.paused;
if (wasFsPlaying) activeEl.pause();
const rect = progressBar.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
lastFsSeekPosition = pos;
updateFsSeekUI(pos);
});
progressBar.addEventListener(
'touchstart',
(e) => {
const activeEl = this.player.activeElement;
e.preventDefault();
isFsSeeking = true;
wasFsPlaying = !activeEl.paused;
if (wasFsPlaying) activeEl.pause();
const touch = e.touches[0];
const rect = progressBar.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
lastFsSeekPosition = pos;
updateFsSeekUI(pos);
},
{ passive: false }
);
document.addEventListener('mousemove', (e) => {
if (isFsSeeking) {
const rect = progressBar.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
lastFsSeekPosition = pos;
updateFsSeekUI(pos);
}
});
document.addEventListener(
'touchmove',
(e) => {
if (isFsSeeking) {
const touch = e.touches[0];
const rect = progressBar.getBoundingClientRect();
const pos = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
lastFsSeekPosition = pos;
updateFsSeekUI(pos);
}
},
{ passive: false }
);
document.addEventListener('mouseup', () => {
if (isFsSeeking) {
const activeEl = this.player.activeElement;
if (!isNaN(activeEl.duration)) {
activeEl.currentTime = lastFsSeekPosition * activeEl.duration;
if (wasFsPlaying) activeEl.play();
}
isFsSeeking = false;
}
});
document.addEventListener('touchend', () => {
if (isFsSeeking) {
const activeEl = this.player.activeElement;
if (!isNaN(activeEl.duration)) {
activeEl.currentTime = lastFsSeekPosition * activeEl.duration;
if (wasFsPlaying) activeEl.play();
}
isFsSeeking = false;
}
});
if (fsLikeBtn) {
fsLikeBtn.onclick = () => document.getElementById('now-playing-like-btn')?.click();
}
if (fsAddPlaylistBtn) {
fsAddPlaylistBtn.onclick = () => document.getElementById('now-playing-add-playlist-btn')?.click();
}
if (fsDownloadBtn) {
fsDownloadBtn.onclick = () => document.getElementById('download-current-btn')?.click();
}
if (fsCastBtn) {
fsCastBtn.onclick = () => document.getElementById('cast-btn')?.click();
}
if (fsQueueBtn) {
fsQueueBtn.onclick = () => {
document.getElementById('queue-btn')?.click();
};
}
shuffleBtn.classList.toggle('active', this.player.shuffleActive);
const mode = this.player.repeatMode;
repeatBtn.classList.toggle('active', mode !== 0);
if (mode === 2) {
repeatBtn.innerHTML = SVG_REPEAT_ONE(24);
}
// Fullscreen volume controls
const fsVolumeBtn = document.getElementById('fs-volume-btn');
const fsVolumeBar = document.getElementById('fs-volume-bar');
const fsVolumeFill = document.getElementById('fs-volume-fill');
if (fsVolumeBtn && fsVolumeBar && fsVolumeFill) {
const updateFsVolumeUI = () => {
const activeEl = this.player.activeElement;
const { muted } = activeEl;
const volume = this.player.userVolume;
fsVolumeBtn.innerHTML = muted || volume === 0 ? SVG_MUTE(20) : SVG_VOLUME(20);
fsVolumeBtn.classList.toggle('muted', muted || volume === 0);
const effectiveVolume = muted ? 0 : volume * 100;
fsVolumeFill.style.setProperty('--fs-volume-level', `${effectiveVolume}%`);
fsVolumeFill.style.width = `${effectiveVolume}%`;
};
fsVolumeBtn.onclick = () => {
const activeEl = this.player.activeElement;
activeEl.muted = !activeEl.muted;
localStorage.setItem('muted', activeEl.muted);
updateFsVolumeUI();
};
const handleFsVolumeWheel = (e) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.05 : 0.05;
const currentVolume = this.player.userVolume;
const newVolume = Math.max(0, Math.min(1, currentVolume + delta));
const activeEl = this.player.activeElement;
if (delta > 0 && activeEl.muted) {
activeEl.muted = false;
localStorage.setItem('muted', false);
}
this.player.setVolume(newVolume);
updateFsVolumeUI();
};
[fsVolumeBar, fsVolumeBtn].forEach((el) => {
if (el._fsVolumeWheelHandler) {
el.removeEventListener('wheel', el._fsVolumeWheelHandler);
}
el._fsVolumeWheelHandler = handleFsVolumeWheel;
el.addEventListener('wheel', handleFsVolumeWheel, { passive: false });
});
const setFsVolume = (e) => {
const rect = fsVolumeBar.getBoundingClientRect();
const position = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const newVolume = position;
this.player.setVolume(newVolume);
const activeEl = this.player.activeElement;
if (activeEl.muted && newVolume > 0) {
activeEl.muted = false;
localStorage.setItem('muted', false);
}
updateFsVolumeUI();
};
let isAdjustingFsVolume = false;
fsVolumeBar.addEventListener('mousedown', (e) => {
isAdjustingFsVolume = true;
setFsVolume(e);
});
fsVolumeBar.addEventListener(
'touchstart',
(e) => {
e.preventDefault();
isAdjustingFsVolume = true;
const touch = e.touches[0];
setFsVolume({ clientX: touch.clientX });
},
{ passive: false }
);
document.addEventListener('mousemove', (e) => {
if (isAdjustingFsVolume) {
setFsVolume(e);
}
});
document.addEventListener(
'touchmove',
(e) => {
if (isAdjustingFsVolume) {
const touch = e.touches[0];
setFsVolume({ clientX: touch.clientX });
}
},
{ passive: false }
);
document.addEventListener('mouseup', () => {
isAdjustingFsVolume = false;
});
document.addEventListener('touchend', () => {
isAdjustingFsVolume = false;
});
this.player.activeElement.addEventListener('volumechange', updateFsVolumeUI);
updateFsVolumeUI();
}
const update = () => {
if (document.getElementById('fullscreen-cover-overlay').style.display === 'none') return;
const activeEl = this.player.activeElement;
const duration = activeEl.duration || 0;
const current = activeEl.currentTime || 0;
if (duration > 0) {
// Only update progress if not currently seeking (user is dragging)
if (!isFsSeeking) {
const percent = (current / duration) * 100;
progressFill.style.width = `${percent}%`;
currentTimeEl.textContent = formatTime(current);
}
totalDurationEl.textContent = formatTime(duration);
}
updatePlayBtn();
this.fullscreenUpdateInterval = requestAnimationFrame(update);
};
if (this.fullscreenUpdateInterval) cancelAnimationFrame(this.fullscreenUpdateInterval);
this.fullscreenUpdateInterval = requestAnimationFrame(update);
}
showPage(pageId) {
this.currentPage = 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.pathname === `/${pageId}` || (pageId === 'home' && link.pathname === '/')
);
});
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();
}
const downloadsdisabled = true;
if (downloadsdisabled == true) {
if (pageId === 'download') {
const maintenanceModal = document.getElementById('maintenance-modal');
const maintenanceHomeBtn = document.getElementById('maintenance-home-btn');
if (maintenanceModal) {
maintenanceModal.classList.add('active');
if (maintenanceHomeBtn) {
maintenanceHomeBtn.onclick = () => {
maintenanceModal.classList.remove('active');
navigate('/');
};
}
}
} else {
const maintenanceModal = document.getElementById('maintenance-modal');
if (maintenanceModal) {
maintenanceModal.classList.remove('active');
}
}
}
if (pageId === 'settings') {
this.renderApiSettings();
const savedTabName = settingsUiState.getActiveTab();
const savedTab = document.querySelector(`.settings-tab[data-tab="${savedTabName}"]`);
if (savedTab) {
document.querySelectorAll('.settings-tab').forEach((t) => t.classList.remove('active'));
document.querySelectorAll('.settings-tab-content').forEach((c) => c.classList.remove('active'));
savedTab.classList.add('active');
document.getElementById(`settings-tab-${savedTabName}`)?.classList.add('active');
}
} else {
document.querySelectorAll('.settings-tab').forEach((t) => t.classList.remove('active'));
document.querySelectorAll('.settings-tab-content').forEach((c) => c.classList.remove('active'));
}
}
async renderLibraryPage() {
this.showPage('library');
const tracksContainer = document.getElementById('library-tracks-container');
const videosTabContent = document.getElementById('library-tab-videos');
const albumsContainer = document.getElementById('library-albums-container');
const artistsContainer = document.getElementById('library-artists-container');
const playlistsContainer = document.getElementById('library-playlists-container');
const localContainer = document.getElementById('library-local-container');
const foldersContainer = document.getElementById('my-folders-container');
const myPlaylistsContainer = document.getElementById('my-playlists-container');
const likedTracks = await db.getFavorites('track');
const shuffleBtn = document.getElementById('shuffle-liked-tracks-btn');
const downloadBtn = document.getElementById('download-liked-tracks-btn');
if (likedTracks.length) {
if (shuffleBtn) shuffleBtn.style.display = 'flex';
if (downloadBtn) downloadBtn.style.display = 'flex';
this.renderListWithTracks(tracksContainer, likedTracks, true);
} else {
if (shuffleBtn) shuffleBtn.style.display = 'none';
if (downloadBtn) downloadBtn.style.display = 'none';
tracksContainer.innerHTML = createPlaceholder('No liked tracks yet.');
}
const likedVideos = await db.getFavorites('video');
if (videosTabContent) {
const grid = videosTabContent.querySelector('.card-grid');
if (likedVideos.length) {
grid.innerHTML = likedVideos.map((v) => this.createVideoCardHTML(v)).join('');
likedVideos.forEach((video) => {
const el = grid.querySelector(`[data-video-id="${video.id}"]`);
if (el) {
trackDataStore.set(el, video);
this.updateLikeState(el, 'video', video.id);
el.addEventListener('click', (e) => {
if (e.target.closest('.card-play-btn') || e.target.closest('.card-image-container')) {
e.stopPropagation();
this.player.playVideo(video);
}
});
}
});
} else {
grid.innerHTML = createPlaceholder('No liked videos 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 folders = await db.getFolders();
if (foldersContainer) {
foldersContainer.innerHTML = folders.map((f) => this.createFolderCardHTML(f)).join('');
foldersContainer.style.display = folders.length ? 'grid' : 'none';
}
const myPlaylists = await db.getPlaylists();
const playlistsInFolders = new Set();
folders.forEach((folder) => {
if (folder.playlists) {
folder.playlists.forEach((id) => playlistsInFolders.add(id));
}
});
const visiblePlaylists = myPlaylists.filter((p) => !playlistsInFolders.has(p.id));
if (myPlaylistsContainer) {
if (visiblePlaylists.length) {
myPlaylistsContainer.innerHTML = visiblePlaylists
.map((p) => this.createUserPlaylistCardHTML(p))
.join('');
visiblePlaylists.forEach((playlist) => {
const el = myPlaylistsContainer.querySelector(`[data-user-playlist-id="${playlist.id}"]`);
if (el) {
trackDataStore.set(el, playlist);
}
});
} else {
if (folders.length === 0) {
myPlaylistsContainer.innerHTML = createPlaceholder('No playlists yet. Create your first playlist!');
} else {
myPlaylistsContainer.innerHTML = '';
}
}
}
// Render Local Files
if (localContainer) {
this.renderLocalFiles(localContainer);
}
}
async renderLocalFiles(container) {
if (!container) return;
const introDiv = document.getElementById('local-files-intro');
const headerDiv = document.getElementById('local-files-header');
const listContainer = document.getElementById('local-files-list');
const selectBtnText = document.getElementById('select-local-folder-text');
const handle = await db.getSetting('local_folder_handle');
if (handle) {
if (selectBtnText) selectBtnText.textContent = `Load "${handle.name}"`;
if (window.localFilesCache && window.localFilesCache.length > 0) {
if (introDiv) introDiv.style.display = 'none';
if (headerDiv) {
headerDiv.style.display = 'flex';
headerDiv.querySelector('h3').textContent = `Local Files (${window.localFilesCache.length})`;
}
if (listContainer) {
this.renderListWithTracks(listContainer, window.localFilesCache, true);
}
} else {
if (introDiv) introDiv.style.display = 'block';
if (headerDiv) headerDiv.style.display = 'none';
if (listContainer) listContainer.innerHTML = '';
}
} else {
if (selectBtnText) selectBtnText.textContent = 'Select Music Folder';
if (introDiv) introDiv.style.display = 'block';
if (headerDiv) headerDiv.style.display = 'none';
if (listContainer) listContainer.innerHTML = '';
}
}
async renderHomePage() {
if (this.renderLock) return;
this.renderLock = true;
try {
this.showPage('home');
this.setupHomeTabs();
const welcomeEl = document.getElementById('home-welcome');
const contentEl = document.getElementById('home-content');
const editorsPicksSectionEmpty = document.getElementById('home-editors-picks-section-empty');
const editorsPicksSection = document.getElementById('home-editors-picks-section');
const history = await db.getHistory();
const favorites = await db.getFavorites('track');
const playlists = await db.getPlaylists(true);
const hasActivity = history.length > 0 || favorites.length > 0 || playlists.length > 0;
// Handle Editor's Picks visibility based on settings
if (!homePageSettings.shouldShowEditorsPicks()) {
if (editorsPicksSectionEmpty) editorsPicksSectionEmpty.style.display = 'none';
if (editorsPicksSection) editorsPicksSection.style.display = 'none';
} else {
// Show empty-state section at top when no activity, hide the bottom one
if (editorsPicksSectionEmpty) editorsPicksSectionEmpty.style.display = hasActivity ? 'none' : '';
// Show bottom section when has activity, render it
if (editorsPicksSection) editorsPicksSection.style.display = hasActivity ? '' : 'none';
}
// Render editor's picks in the visible container
if (hasActivity) {
this.renderHomeEditorsPicks(false, 'home-editors-picks');
} else {
this.renderHomeEditorsPicks(false, 'home-editors-picks-empty');
}
if (!hasActivity) {
if (welcomeEl) welcomeEl.style.display = 'block';
if (contentEl) contentEl.style.display = 'none';
return;
}
if (welcomeEl) welcomeEl.style.display = 'none';
if (contentEl) contentEl.style.display = 'block';
const refreshSongsBtn = document.getElementById('refresh-songs-btn');
const refreshAlbumsBtn = document.getElementById('refresh-albums-btn');
const refreshArtistsBtn = document.getElementById('refresh-artists-btn');
const clearRecentBtn = document.getElementById('clear-recent-btn');
if (refreshSongsBtn) refreshSongsBtn.onclick = () => this.renderHomeSongs(true);
if (refreshAlbumsBtn) refreshAlbumsBtn.onclick = () => this.renderHomeAlbums(true);
if (refreshArtistsBtn) refreshArtistsBtn.onclick = () => this.renderHomeArtists(true);
if (clearRecentBtn)
clearRecentBtn.onclick = () => {
if (confirm('Clear recent activity?')) {
recentActivityManager.clear();
this.renderHomeRecent();
}
};
this.renderHomeRecent();
// Load dynamic sections in parallel with pre-fetched seeds
const seeds = await this.getSeeds();
await Promise.all([
this.renderHomeSongs(false, seeds),
this.renderHomeAlbums(false, seeds),
this.renderHomeArtists(false, seeds),
]);
} finally {
this.renderLock = false;
}
}
setupHomeTabs() {
const tabs = document.querySelectorAll('.home-tab');
if (tabs.length === 0) return;
if (tabs[0].dataset.initialized) return;
tabs.forEach((tab) => {
tab.dataset.initialized = 'true';
tab.addEventListener('click', () => {
document.querySelectorAll('.home-tab').forEach((t) => t.classList.remove('active'));
document.querySelectorAll('.home-view').forEach((v) => {
v.style.display = 'none';
v.classList.remove('active');
});
tab.classList.add('active');
const viewId = `home-view-${tab.dataset.tab}`;
const view = document.getElementById(viewId);
if (view) {
view.style.display = 'block';
view.classList.add('active');
}
if (tab.dataset.tab === 'explore') {
this.renderExplorePage();
}
});
});
}
async renderExplorePage() {
const container = document.getElementById('explore-grid');
if (!container) return;
if (container.children.length > 0) return;
container.classList.remove('card-grid');
container.innerHTML = `<div class="card-grid">${this.createSkeletonCards(12)}</div>`;
try {
const response = await fetch('https://hot.monochrome.tf/');
if (!response.ok) throw new Error('Failed to load explore data');
const data = await response.json();
container.innerHTML = '';
const GENRES = [
{ id: 'hip_hop', name: 'Hip-Hop' },
{ id: 'rnb', name: 'R&B / Soul' },
{ id: 'blues', name: 'Blues' },
{ id: 'classical', name: 'Classical' },
{ id: 'country', name: 'Country' },
{ id: 'dance_electronic', name: 'Dance & Electronic' },
{ id: 'americana', name: 'Folk / Americana' },
{ id: 'world', name: 'Global' },
{ id: 'gospel', name: 'Gospel / Christian' },
{ id: 'jazz', name: 'Jazz' },
{ id: 'kpop', name: 'K-Pop' },
{ id: 'kids', name: 'Kids' },
{ id: 'latin', name: 'Latin' },
{ id: 'metal', name: 'Metal' },
{ id: 'pop', name: 'Pop' },
{ id: 'reggae', name: 'Reggae / Dancehall' },
{ id: 'retro', name: 'Legacy' },
{ id: 'indierock', name: 'Rock / Indie' },
];
if (GENRES.length > 0) {
const genresSection = document.createElement('section');
genresSection.className = 'content-section';
genresSection.innerHTML = `<h2 class="section-title">Genres</h2>`;
const genresGrid = document.createElement('div');
genresGrid.style.display = 'flex';
genresGrid.style.flexWrap = 'wrap';
genresGrid.style.gap = '0.5rem';
genresGrid.innerHTML = GENRES.map(
(genre) => `
<div class="card genre-card" data-genre-id="${genre.id}" data-genre-name="${escapeHtml(genre.name)}" style="cursor: pointer; background: var(--secondary); padding: 0.6rem 1rem; border-radius: var(--radius); border: 1px solid var(--border);">
<h3 style="margin: 0; font-size: 0.875rem; font-weight: 600;">${escapeHtml(genre.name)}</h3>
</div>
`
).join('');
genresSection.appendChild(genresGrid);
container.appendChild(genresSection);
genresGrid.querySelectorAll('.genre-card').forEach((card) => {
card.addEventListener('click', () => {
this.renderGenrePage(card.dataset.genreId, card.dataset.genreName);
});
});
}
if (data.top_albums && data.top_albums.length > 0) {
this.renderExploreSection(container, 'Trending Albums', data.top_albums, 'album');
}
if (data.top_tracks && data.top_tracks.length > 0) {
this.renderExploreSection(container, 'Trending Tracks', data.top_tracks, 'track');
}
if (data.featured_playlists && data.featured_playlists.length > 0) {
this.renderExploreSection(container, 'Featured Playlists', data.featured_playlists, 'playlist');
}
if (data.sections && data.sections.length > 0) {
data.sections.forEach((section) => {
if (section.items && section.items.length > 0) {
let type = null;
if (section.type === 'ALBUM_LIST') type = 'album';
else if (section.type === 'TRACK_LIST') type = 'track';
else if (section.type === 'PLAYLIST_LIST') type = 'playlist';
if (type) {
this.renderExploreSection(container, section.title, section.items, type);
}
}
});
}
if (container.children.length === 0) {
container.innerHTML = createPlaceholder('No explore content available.');
}
} catch (e) {
console.error(e);
container.innerHTML = createPlaceholder('Failed to load explore content.');
}
}
renderExploreSection(container, title, items, type) {
const section = document.createElement('section');
section.className = 'content-section';
section.innerHTML = `<h2 class="section-title">${title}</h2>`;
if (type === 'track') {
const list = document.createElement('div');
list.className = 'track-list';
this.renderListWithTracks(list, items, true);
section.appendChild(list);
} else {
const grid = document.createElement('div');
grid.className = 'card-grid';
grid.innerHTML = items
.map((item) => {
if (type === 'album') return this.createAlbumCardHTML(item);
if (type === 'playlist') return this.createPlaylistCardHTML(item);
return '';
})
.join('');
items.forEach((item) => {
let selector;
if (type === 'album') selector = `[data-album-id="${item.id}"]`;
if (type === 'playlist') selector = `[data-playlist-id="${item.uuid}"]`;
if (selector) {
const el = grid.querySelector(selector);
if (el) {
trackDataStore.set(el, item);
if (type === 'album') this.updateLikeState(el, 'album', item.id);
if (type === 'playlist') this.updateLikeState(el, 'playlist', item.uuid);
}
}
});
section.appendChild(grid);
}
container.appendChild(section);
}
async renderGenrePage(genreId, genreName) {
const container = document.getElementById('explore-grid');
if (!container) return;
container.classList.remove('card-grid');
container.innerHTML = `
<div style="margin-bottom: 1.5rem; display: flex; align-items: center; gap: 1rem;">
<button class="btn-secondary explore-back-btn" style="display: flex; align-items: center; gap: 0.5rem;">
${SVG_LEFT_ARROW(20)}
Back
</button>
<h2 class="section-title" style="margin: 0;">${escapeHtml(genreName)}</h2>
</div>
<div class="card-grid">${this.createSkeletonCards(12)}</div>
`;
container.querySelector('.explore-back-btn').addEventListener('click', () => {
container.innerHTML = '';
this.renderExplorePage();
});
try {
const response = await fetch(`https://hot.monochrome.tf/explore/genre/?id=${genreId}`);
if (!response.ok) throw new Error('Failed to load genre data');
const data = await response.json();
const header = container.firstElementChild;
container.innerHTML = '';
container.appendChild(header);
const contentContainer = document.createElement('div');
container.appendChild(contentContainer);
if (data.sections && data.sections.length > 0) {
data.sections.forEach((section) => {
if (section.items && section.items.length > 0) {
let type = null;
if (section.type === 'ALBUM_LIST') type = 'album';
else if (section.type === 'TRACK_LIST') type = 'track';
else if (section.type === 'PLAYLIST_LIST') type = 'playlist';
if (type) {
this.renderExploreSection(contentContainer, section.title, section.items, type);
}
}
});
}
if (contentContainer.children.length === 0) {
contentContainer.innerHTML = createPlaceholder('No content found for this genre.');
}
} catch (e) {
console.error(e);
const header = container.firstElementChild;
container.innerHTML = '';
container.appendChild(header);
const errorDiv = document.createElement('div');
errorDiv.innerHTML = createPlaceholder('Failed to load genre content.');
container.appendChild(errorDiv);
}
}
async getSeeds() {
const history = await db.getHistory();
const favorites = await db.getFavorites('track');
const playlists = await db.getPlaylists(true);
const playlistTracks = playlists.flatMap((p) => p.tracks || []);
// Prioritize: Playlists > Favorites > History
// Take random samples from each to form seeds
const shuffle = (arr) => [...arr].sort(() => Math.random() - 0.5);
const seeds = [
...shuffle(playlistTracks).slice(0, 20),
...shuffle(favorites).slice(0, 20),
...shuffle(history).slice(0, 10),
];
return shuffle(seeds);
}
async renderHomeSongs(forceRefresh = false, providedSeeds = null) {
const songsContainer = document.getElementById('home-recommended-songs');
const section = songsContainer?.closest('.content-section');
if (!homePageSettings.shouldShowRecommendedSongs()) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = '';
if (songsContainer) {
if (forceRefresh || songsContainer.children.length === 0) {
songsContainer.innerHTML = this.createSkeletonTracks(10, true);
} else if (!songsContainer.querySelector('.skeleton')) {
return; // Already loaded
}
try {
const seeds = providedSeeds || (await this.getSeeds());
const [favorites, playlists, history] = await Promise.all([
db.getFavorites('track'),
db.getPlaylists(true),
db.getHistory(),
]);
const knownTrackIds = new Set([
...favorites.map((t) => t.id),
...playlists.flatMap((p) => (p.tracks || []).map((t) => t.id)),
...history.map((t) => t.id),
]);
const recommendedTracks = await this.api.getRecommendedTracksForPlaylist(seeds, 20, {
skipCache: forceRefresh,
knownTrackIds: knownTrackIds,
});
const filteredTracks = await this.filterUserContent(recommendedTracks, 'track');
this.lastRecommendedTracks = filteredTracks;
if (filteredTracks.length > 0) {
this.renderListWithTracks(songsContainer, filteredTracks, true);
} else {
songsContainer.innerHTML = createPlaceholder('No song recommendations found.');
}
} catch (e) {
console.error(e);
songsContainer.innerHTML = createPlaceholder('Failed to load song recommendations.');
}
}
}
async renderHomeAlbums(forceRefresh = false, providedSeeds = null, retryCount = 0) {
const albumsContainer = document.getElementById('home-recommended-albums');
const section = albumsContainer?.closest('.content-section');
if (!homePageSettings.shouldShowRecommendedAlbums()) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = '';
if (albumsContainer) {
if (forceRefresh || albumsContainer.children.length === 0) {
albumsContainer.innerHTML = this.createSkeletonCards(5);
} else if (!albumsContainer.querySelector('.skeleton') && !forceRefresh) {
return;
}
try {
const seeds = providedSeeds || (await this.getSeeds());
const albumSeed = seeds.find((t) => t.album && t.album.id);
if (albumSeed) {
const similarAlbums = await this.api.getSimilarAlbums(albumSeed.album.id);
const filteredAlbums = await this.filterUserContent(similarAlbums, 'album');
if (filteredAlbums.length > 0) {
albumsContainer.innerHTML = filteredAlbums
.slice(0, 12)
.map((a) => this.createAlbumCardHTML(a))
.join('');
filteredAlbums.slice(0, 12).forEach((a) => {
const el = albumsContainer.querySelector(`[data-album-id="${a.id}"]`);
if (el) {
trackDataStore.set(el, a);
this.updateLikeState(el, 'album', a.id);
}
});
} else if (retryCount < 2) {
await new Promise((resolve) => setTimeout(resolve, 1500));
return this.renderHomeAlbums(forceRefresh, null, retryCount + 1);
} else {
albumsContainer.innerHTML = `<div style="grid-column: 1/-1; padding: 2rem 0;">${createPlaceholder('Tell us more about what you like so we can recommend albums!')}</div>`;
}
} else if (retryCount < 2) {
await new Promise((resolve) => setTimeout(resolve, 1500));
return this.renderHomeAlbums(forceRefresh, null, retryCount + 1);
} else {
albumsContainer.innerHTML = `<div style="grid-column: 1/-1; padding: 2rem 0;">${createPlaceholder('Tell us more about what you like so we can recommend albums!')}</div>`;
}
} catch (e) {
console.error(e);
if (retryCount < 2) {
await new Promise((resolve) => setTimeout(resolve, 1500));
return this.renderHomeAlbums(forceRefresh, null, retryCount + 1);
}
albumsContainer.innerHTML = createPlaceholder('Failed to load album recommendations.');
}
}
}
createTrackCardHTML(track) {
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
const qualityBadge = createQualityBadgeHTML(track);
const isCompact = cardSettings.isCompactAlbum();
return this.createBaseCardHTML({
type: 'track',
id: track.id,
href: `/track/${track.id}`,
title: `${escapeHtml(getTrackTitle(track))} ${explicitBadge} ${qualityBadge}`,
subtitle: escapeHtml(getTrackArtists(track)),
imageHTML: this.getCoverHTML(
track.album?.cover,
escapeHtml(track.title),
'card-image',
'lazy',
track.videoUrl || track.album?.videoCoverUrl
),
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="track" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact,
});
}
async renderHomeEditorsPicks(forceRefresh = false, containerId = 'home-editors-picks') {
const picksContainer = document.getElementById(containerId);
if (picksContainer) {
if (forceRefresh) picksContainer.innerHTML = this.createSkeletonCards(6);
else if (picksContainer.children.length > 0 && !picksContainer.querySelector('.skeleton')) return;
try {
const response = await fetch('/editors-picks.json');
if (!response.ok) throw new Error("Failed to load editor's picks");
let items = await response.json();
if (!Array.isArray(items) || items.length === 0) {
picksContainer.innerHTML = createPlaceholder("No editor's picks available.");
return;
}
// Filter out blocked content
const { contentBlockingSettings } = await import('./storage.js');
items = items.filter((item) => {
if (item.type === 'track') {
return !contentBlockingSettings.shouldHideTrack(item);
} else if (item.type === 'album') {
return !contentBlockingSettings.shouldHideAlbum(item);
} else if (item.type === 'artist') {
return !contentBlockingSettings.shouldHideArtist(item);
}
return true;
});
// Shuffle items if enabled
if (homePageSettings.shouldShuffleEditorsPicks()) {
items = [...items].sort(() => Math.random() - 0.5);
}
// Use cached metadata or fetch details for each item
const cardsHTML = [];
const itemsToStore = [];
for (const item of items) {
try {
if (item.type === 'album') {
// Check if we have cached metadata
if (item.title && item.artist) {
// Use cached data directly
const album = {
id: item.id,
title: item.title,
artist: item.artist,
releaseDate: item.releaseDate,
cover: item.cover,
explicit: item.explicit,
audioQuality: item.audioQuality,
mediaMetadata: item.mediaMetadata,
type: 'ALBUM',
};
cardsHTML.push(this.createAlbumCardHTML(album));
itemsToStore.push({ el: null, data: album, type: 'album' });
} else {
// Fall back to API call for legacy format
const result = await this.api.getAlbum(item.id);
if (result && result.album) {
cardsHTML.push(this.createAlbumCardHTML(result.album));
itemsToStore.push({ el: null, data: result.album, type: 'album' });
}
}
} else if (item.type === 'artist') {
if (item.name && item.picture) {
// Use cached data directly
const artist = {
id: item.id,
name: item.name,
picture: item.picture,
};
cardsHTML.push(this.createArtistCardHTML(artist));
itemsToStore.push({ el: null, data: artist, type: 'artist' });
} else {
// Fall back to API call
const artist = await this.api.getArtist(item.id);
if (artist) {
cardsHTML.push(this.createArtistCardHTML(artist));
itemsToStore.push({ el: null, data: artist, type: 'artist' });
}
}
} else if (item.type === 'track') {
if (item.title && item.album) {
// Use cached data directly
const track = {
id: item.id,
title: item.title,
artist: item.artist,
album: item.album,
explicit: item.explicit,
audioQuality: item.audioQuality,
mediaMetadata: item.mediaMetadata,
duration: item.duration,
};
cardsHTML.push(this.createTrackCardHTML(track));
itemsToStore.push({ el: null, data: track, type: 'track' });
} else {
// Fall back to API call
const track = await this.api.getTrackMetadata(item.id);
if (track) {
cardsHTML.push(this.createTrackCardHTML(track));
itemsToStore.push({ el: null, data: track, type: 'track' });
}
}
} else if (item.type === 'user-playlist') {
if (item.id && item.name) {
const playlist = {
id: item.id,
name: item.name,
cover: item.cover,
tracks: item.tracks || [],
numberOfTracks: item.numberOfTracks || (item.tracks ? item.tracks.length : 0),
};
const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
} else {
const playlist = await syncManager.getPublicPlaylist(item.id);
if (playlist) {
const subtitle = item.username ? `by ${item.username}` : null;
cardsHTML.push(this.createUserPlaylistCardHTML(playlist, subtitle));
itemsToStore.push({ el: null, data: playlist, type: 'user-playlist' });
}
}
}
} catch (e) {
console.warn(`Failed to load ${item.type} ${item.id}:`, e);
}
}
if (cardsHTML.length > 0) {
picksContainer.innerHTML = cardsHTML.join('');
itemsToStore.forEach((item, _index) => {
const type = item.type;
const id = item.data.id;
const el = picksContainer.querySelector(`[data-${type}-id="${id}"]`);
if (el) {
trackDataStore.set(el, item.data);
this.updateLikeState(el, type, id);
}
});
} else {
picksContainer.innerHTML = createPlaceholder("No editor's picks available.");
}
} catch (e) {
console.error("Failed to load editor's picks:", e);
picksContainer.innerHTML = createPlaceholder("Failed to load editor's picks.");
}
}
}
async renderHomeArtists(forceRefresh = false, providedSeeds = null) {
const artistsContainer = document.getElementById('home-recommended-artists');
const section = artistsContainer?.closest('.content-section');
if (!homePageSettings.shouldShowRecommendedArtists()) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = '';
if (artistsContainer) {
if (forceRefresh || artistsContainer.children.length === 0) {
artistsContainer.innerHTML = this.createSkeletonCards(12, true);
} else if (!artistsContainer.querySelector('.skeleton')) {
return;
}
try {
const seeds = providedSeeds || (await this.getSeeds());
const artistSeed = seeds.find((t) => (t.artist && t.artist.id) || (t.artists && t.artists.length > 0));
const artistId = artistSeed ? artistSeed.artist?.id || artistSeed.artists?.[0]?.id : null;
if (artistId) {
const similarArtists = await this.api.getSimilarArtists(artistId);
const filteredArtists = await this.filterUserContent(similarArtists, 'artist');
if (filteredArtists.length > 0) {
artistsContainer.innerHTML = filteredArtists
.slice(0, 12)
.map((a) => this.createArtistCardHTML(a))
.join('');
filteredArtists.slice(0, 12).forEach((a) => {
const el = artistsContainer.querySelector(`[data-artist-id="${a.id}"]`);
if (el) {
trackDataStore.set(el, a);
this.updateLikeState(el, 'artist', a.id);
}
});
} else {
artistsContainer.innerHTML = createPlaceholder('No artist recommendations found.');
}
} else {
artistsContainer.innerHTML = createPlaceholder(
'Listen to more music to get artist recommendations.'
);
}
} catch (e) {
console.error(e);
artistsContainer.innerHTML = createPlaceholder('Failed to load artist recommendations.');
}
}
}
renderHomeRecent() {
const recentContainer = document.getElementById('home-recent-mixed');
const section = recentContainer?.closest('.content-section');
if (!homePageSettings.shouldShowJumpBackIn()) {
if (section) section.style.display = 'none';
return;
}
if (section) section.style.display = '';
if (recentContainer) {
const recents = recentActivityManager.getRecents();
const items = [];
if (recents.albums) items.push(...recents.albums.slice(0, 4).map((i) => ({ ...i, _kind: 'album' })));
if (recents.playlists)
items.push(...recents.playlists.slice(0, 4).map((i) => ({ ...i, _kind: 'playlist' })));
if (recents.mixes) items.push(...recents.mixes.slice(0, 4).map((i) => ({ ...i, _kind: 'mix' })));
items.sort(() => Math.random() - 0.5);
const displayItems = items.slice(0, 6);
if (displayItems.length > 0) {
recentContainer.innerHTML = displayItems
.map((item) => {
if (item._kind === 'album') return this.createAlbumCardHTML(item);
if (item._kind === 'playlist') {
if (item.isUserPlaylist) return this.createUserPlaylistCardHTML(item);
return this.createPlaylistCardHTML(item);
}
if (item._kind === 'mix') return this.createMixCardHTML(item);
return '';
})
.join('');
displayItems.forEach((item) => {
let selector = '';
if (item._kind === 'album') selector = `[data-album-id="${item.id}"]`;
else if (item._kind === 'playlist')
selector = item.isUserPlaylist
? `[data-user-playlist-id="${item.id}"]`
: `[data-playlist-id="${item.uuid}"]`;
else if (item._kind === 'mix') selector = `[data-mix-id="${item.id}"]`;
const el = recentContainer.querySelector(selector);
if (el) {
trackDataStore.set(el, item);
if (item._kind === 'album') this.updateLikeState(el, 'album', item.id);
if (item._kind === 'playlist' && !item.isUserPlaylist)
this.updateLikeState(el, 'playlist', item.uuid);
if (item._kind === 'mix') this.updateLikeState(el, 'mix', item.id);
}
});
} else {
recentContainer.innerHTML = createPlaceholder('No recent items yet...');
}
}
}
async filterUserContent(items, type) {
if (!items || items.length === 0) return [];
// Import blocking settings
const { contentBlockingSettings } = await import('./storage.js');
// First filter out blocked content
if (type === 'track') {
items = contentBlockingSettings.filterTracks(items);
} else if (type === 'album') {
items = contentBlockingSettings.filterAlbums(items);
} else if (type === 'artist') {
items = contentBlockingSettings.filterArtists(items);
}
const favorites = await db.getFavorites(type);
const favoriteIds = new Set(favorites.map((i) => i.id));
const likedTracks = await db.getFavorites('track');
const playlists = await db.getPlaylists(true);
const userTracksMap = new Map();
likedTracks.forEach((t) => userTracksMap.set(t.id, t));
playlists.forEach((p) => {
if (p.tracks) p.tracks.forEach((t) => userTracksMap.set(t.id, t));
});
if (type === 'track') {
return items.filter((item) => !userTracksMap.has(item.id));
}
if (type === 'album') {
const albumTrackCounts = new Map();
for (const track of userTracksMap.values()) {
if (track.album && track.album.id) {
const aid = track.album.id;
albumTrackCounts.set(aid, (albumTrackCounts.get(aid) || 0) + 1);
}
}
return items.filter((item) => {
if (favoriteIds.has(item.id)) return false;
const userCount = albumTrackCounts.get(item.id) || 0;
const total = item.numberOfTracks;
if (total && total > 0) {
if (userCount / total > 0.5) return false;
}
return true;
});
}
return items.filter((item) => !favoriteIds.has(item.id));
}
async setupHlsVideo(video, result, fallbackImg) {
if (!result) return;
const url = typeof result === 'string' ? result : result.videoUrl || result.hlsUrl;
if (!url) return;
if (url.endsWith('.m3u8')) {
const Hls = (await import('hls.js')).default;
if (Hls.isSupported()) {
const hls = new Hls();
video._hls = hls;
hls.loadSource(url);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
video.play().catch((e) => {
console.warn('Autoplay failed, muted play might be required:', e);
video.muted = true;
video.play().catch(() => {});
});
});
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.warn('HLS fatal error:', data.type);
video.replaceWith(fallbackImg);
hls.destroy();
}
});
} else if (video.canPlayType('application/vnd.apple.mpegurl')) {
// safari supports HLS natively
video.src = url;
} else {
video.replaceWith(fallbackImg);
}
} else {
// MP4
video.src = url;
video.play().catch((e) => {
console.warn('MP4 autoplay failed:', e);
video.muted = true;
video.play().catch(() => {});
});
}
video.onerror = async () => {
if (result.hlsUrl) {
// HLS fallback (for some reason alot of animated covers js dont work on MP4 lol)
await this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, fallbackImg);
} else {
video.replaceWith(fallbackImg);
}
};
}
async replaceVideoArtwork(container, type, id, result) {
const url = result.videoUrl || result.hlsUrl;
if (!url) return;
const card = container.querySelector(`[data-${type}-id="${id}"]`);
if (!card) return;
const img = card.querySelector('.card-image');
if (img && img.tagName !== 'VIDEO') {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.className = img.className;
video.id = img.id;
video.style.objectFit = 'cover';
video.poster = img.src;
video.onerror = async () => {
if (video.src === result.videoUrl && result.hlsUrl) {
await this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, img);
return;
}
video.replaceWith(img);
};
video.addEventListener(
'error',
async (e) => {
if (video.src === result.videoUrl && result.hlsUrl) {
await this.setupHlsVideo(video, { videoUrl: null, hlsUrl: result.hlsUrl }, img);
return;
}
console.warn('Video decoding error:', e);
video.replaceWith(img);
},
true
);
img.replaceWith(video);
await this.setupHlsVideo(video, result, img);
}
}
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 provider = this.api.getCurrentProvider();
const [tracksResult, videosResult, artistsResult, albumsResult, playlistsResult] = await Promise.all([
this.api.searchTracks(query, { signal, provider }),
this.api.searchVideos(query, { signal, provider }),
this.api.searchArtists(query, { signal, provider }),
this.api.searchAlbums(query, { signal, provider }),
this.api.searchPlaylists(query, { signal, provider }),
]);
let finalTracks = tracksResult.items;
let finalVideos = videosResult.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());
}
// Track search with results
const totalResults = finalTracks.length + finalArtists.length + finalAlbums.length + finalPlaylists.length;
trackSearch(query, totalResults);
if (finalTracks.length) {
this.renderListWithTracks(tracksContainer, finalTracks, true);
} else {
tracksContainer.innerHTML = createPlaceholder('No tracks found.');
}
const videosContainer = document.getElementById('search-videos-container');
if (videosContainer) {
videosContainer.innerHTML = finalVideos.length
? finalVideos.map((video) => this.createVideoCardHTML(video)).join('')
: createPlaceholder('No videos found.');
finalVideos.forEach((video) => {
const el = videosContainer.querySelector(`[data-video-id="${video.id}"]`);
if (el) {
trackDataStore.set(el, video);
el.addEventListener('click', (e) => {
if (e.target.closest('.card-play-btn') || e.target.closest('.card-image-container')) {
e.stopPropagation();
this.player.playVideo(video);
}
});
}
});
}
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;
}
}
renderSearchHistory() {
const historyEl = document.getElementById('search-history');
if (!historyEl) return;
const history = JSON.parse(localStorage.getItem('search-history') || '[]');
if (history.length === 0) {
historyEl.style.display = 'none';
return;
}
historyEl.innerHTML =
history
.map(
(query) => `
<div class="search-history-item" data-query="${escapeHtml(query)}">
${SVG_CLOCK(16)}
<span class="query-text">${escapeHtml(query)}</span>
<span class="delete-history-btn" title="Remove from history">
${SVG_CLOSE(14)}
</span>
</div>
`
)
.join('') +
`
<div class="search-history-clear-all" id="clear-search-history">
Clear all history
</div>
`;
historyEl.style.display = 'block';
historyEl.querySelectorAll('.search-history-item').forEach((item) => {
item.addEventListener('click', (e) => {
if (e.target.closest('.delete-history-btn')) {
e.stopPropagation();
this.removeFromSearchHistory(item.dataset.query);
return;
}
const query = item.dataset.query;
const searchInput = document.getElementById('search-input');
if (searchInput) {
searchInput.value = query;
searchInput.dispatchEvent(new Event('input'));
historyEl.style.display = 'none';
}
});
});
const clearBtn = document.getElementById('clear-search-history');
if (clearBtn) {
clearBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
localStorage.removeItem('search-history');
this.renderSearchHistory();
});
}
}
removeFromSearchHistory(query) {
let history = JSON.parse(localStorage.getItem('search-history') || '[]');
history = history.filter((q) => q !== query);
localStorage.setItem('search-history', JSON.stringify(history));
this.renderSearchHistory();
}
addToSearchHistory(query) {
if (!query || query.trim().length === 0) return;
let history = JSON.parse(localStorage.getItem('search-history') || '[]');
history = history.filter((q) => q !== query);
history.unshift(query);
history = history.slice(0, 10);
localStorage.setItem('search-history', JSON.stringify(history));
}
async renderAlbumPage(albumId, provider = null) {
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(20)}<span>Play Album</span>`;
const dlBtn = document.getElementById('download-album-btn');
if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD(20)}<span>Download Album</span>`;
const mixBtn = document.getElementById('album-mix-btn');
if (mixBtn) mixBtn.style.display = 'none';
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>
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div>
${this.createSkeletonTracks(10, false)}
`;
try {
const { album, tracks } = await this.api.getAlbum(albumId, provider);
this.currentAlbumId = albumId;
const videoCoverUrl = album.videoCoverUrl || null;
if (!videoCoverUrl && tracks.length > 0) {
const firstTrack = tracks[0];
this.api.getVideoArtwork(firstTrack.title, getTrackArtists(firstTrack)).then(async (result) => {
if (result && this.currentPage === 'album' && this.currentAlbumId === albumId) {
const url = result.videoUrl || result.hlsUrl;
if (!url) return;
album.videoCoverUrl = url;
const currentImageEl = document.getElementById('album-detail-image');
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.className = currentImageEl.className;
video.id = currentImageEl.id;
video.style.opacity = '1';
video.poster = currentImageEl.src;
await this.setupHlsVideo(video, result, currentImageEl);
currentImageEl.replaceWith(video);
}
}
});
}
const coverUrl = videoCoverUrl || this.api.getCoverUrl(album.cover);
if (videoCoverUrl) {
if (imageEl.tagName !== 'VIDEO') {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.className = imageEl.className;
video.id = imageEl.id;
await this.setupHlsVideo(video, videoCoverUrl, imageEl);
imageEl.replaceWith(video);
} else {
await this.setupHlsVideo(imageEl, videoCoverUrl, null);
}
} else {
if (imageEl.tagName === 'VIDEO') {
const img = document.createElement('img');
img.src = coverUrl;
img.className = imageEl.className;
img.id = imageEl.id;
imageEl.replaceWith(img);
} else {
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 <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>
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</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, true);
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);
}
// Store album data for menu button
const albumMenuBtn = document.getElementById('album-menu-btn');
if (albumMenuBtn) {
albumMenuBtn.dataset.id = album.id;
trackDataStore.set(albumMenuBtn, album);
}
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 = () => navigate(`/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(async (similar) => {
// Filter out blocked artists
const { contentBlockingSettings } = await import('./storage.js');
const filteredSimilar = contentBlockingSettings.filterArtists(similar || []);
if (filteredSimilar.length > 0 && similarArtistsContainer && similarArtistsSection) {
similarArtistsContainer.innerHTML = filteredSimilar
.map((a) => this.createArtistCardHTML(a))
.join('');
similarArtistsSection.style.display = 'block';
filteredSimilar.forEach((a) => {
const el = similarArtistsContainer.querySelector(`[data-artist-id="${a.id}"]`);
if (el) {
trackDataStore.set(el, a);
this.updateLikeState(el, 'artist', a.id);
}
});
}
})
.catch((e) => console.warn('Failed to load similar artists:', e));
// Similar Albums
this.api
.getSimilarAlbums(albumId)
.then(async (similar) => {
// Filter out blocked albums
const { contentBlockingSettings } = await import('./storage.js');
const filteredSimilar = contentBlockingSettings.filterAlbums(similar || []);
if (filteredSimilar.length > 0 && similarAlbumsContainer && similarAlbumsSection) {
similarAlbumsContainer.innerHTML = filteredSimilar
.map((a) => this.createAlbumCardHTML(a))
.join('');
similarAlbumsSection.style.display = 'block';
filteredSimilar.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, forceRefresh = false) {
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;
}
if (forceRefresh) {
recommendedContainer.innerHTML = this.createSkeletonTracks(5, true);
}
try {
let recommendedTracks = await this.api.getRecommendedTracksForPlaylist(tracks, 20, {
refresh: forceRefresh,
});
// Filter out blocked tracks
const { contentBlockingSettings } = await import('./storage.js');
recommendedTracks = contentBlockingSettings.filterTracks(recommendedTracks);
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 = SVG_MINUS(20);
addToPlaylistBtn.onclick = async (e) => {
e.stopPropagation();
const trackData = trackDataStore.get(item);
if (trackData) {
try {
const path = window.location.pathname;
const playlistMatch = path.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 = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div> `;
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, _provider = null) {
this.showPage('playlist');
// Reset search input for new playlist
const searchInput = document.getElementById('track-list-search-input');
if (searchInput) searchInput.value = '';
const imageEl = document.getElementById('playlist-detail-image');
const collageEl = document.getElementById('playlist-detail-collage');
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(20)}<span>Play</span>`;
const dlBtn = document.getElementById('download-playlist-btn');
if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD(20)}<span>Download</span>`;
const addPlaylistBtn = document.getElementById('add-playlist-to-playlist-btn');
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>
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div>
${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;
let currentSort = 'custom';
// 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 Pocketbase playlist
if (!playlistData) {
try {
playlistData = await syncManager.getPublicPlaylist(playlistId);
} catch (e) {
console.warn('Failed to check public pocketbase playlists:', e);
}
}
}
if (playlistData) {
// ... (rest of the logic)
if (addPlaylistBtn) addPlaylistBtn.style.display = 'none';
if (playlistData.cover) {
imageEl.src = playlistData.cover;
imageEl.style.display = 'block';
if (collageEl) collageEl.style.display = 'none';
this.setPageBackground(playlistData.cover);
this.extractAndApplyColor(playlistData.cover);
} else {
const tracksWithCovers = (playlistData.tracks || []).filter((t) => t.album && t.album.cover);
const uniqueCovers = [];
const seen = new Set();
for (const t of tracksWithCovers) {
if (!seen.has(t.album.cover)) {
seen.add(t.album.cover);
uniqueCovers.push(t.album.cover);
if (uniqueCovers.length >= 4) break;
}
}
if (uniqueCovers.length > 0 && collageEl) {
imageEl.style.display = 'none';
collageEl.style.display = 'grid';
collageEl.innerHTML = '';
const imagesToRender = [];
for (let i = 0; i < 4; i++) {
imagesToRender.push(uniqueCovers[i % uniqueCovers.length]);
}
imagesToRender.forEach((cover) => {
const img = document.createElement('img');
img.src = this.api.getCoverUrl(cover);
collageEl.appendChild(img);
});
} else {
imageEl.src = '/assets/appicon.png';
imageEl.style.display = 'block';
if (collageEl) collageEl.style.display = 'none';
}
this.setPageBackground(null);
this.resetVibrantColor();
}
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 || '';
const originalTracks = [...tracks];
const savedSort = localStorage.getItem(`playlist-sort-${playlistId}`);
currentSort = savedSort || 'custom';
let currentTracks = sortTracks(originalTracks, currentSort);
const renderTracks = () => {
// Re-fetch container each time because enableTrackReordering clones it
const container = document.getElementById('playlist-detail-tracklist');
container.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div>
`;
this.renderListWithTracks(container, currentTracks, true, true);
// Add remove buttons and enable reordering ONLY IF OWNED
if (ownedPlaylist) {
const trackItems = container.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 = SVG_BIN(20);
removeBtn.dataset.trackId = currentTracks[index].id;
removeBtn.dataset.type = currentTracks[index].type || 'track';
const menuBtn = actionsDiv.querySelector('.track-menu-btn');
actionsDiv.insertBefore(removeBtn, menuBtn);
});
// Always add is-editable class for owned playlists to fix layout
// This expands the grid columns to accommodate the remove button
container.classList.add('is-editable');
// Only enable drag-and-drop reordering in custom sort mode
if (currentSort === 'custom') {
this.enableTrackReordering(container, currentTracks, playlistId, syncManager);
}
} else {
container.classList.remove('is-editable');
}
};
const applySort = (sortType) => {
currentSort = sortType;
localStorage.setItem(`playlist-sort-${playlistId}`, sortType);
currentTracks = sortTracks(originalTracks, sortType);
renderTracks();
};
renderTracks();
// 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);
const refreshBtn = document.getElementById('refresh-recommended-songs-btn');
if (refreshBtn) {
refreshBtn.onclick = async () => {
const icon = refreshBtn.querySelector('svg');
if (icon) icon.style.animation = 'spin 1s linear infinite';
refreshBtn.disabled = true;
await this.loadRecommendedSongsForPlaylist(tracks, true);
if (icon) icon.style.animation = '';
refreshBtn.disabled = false;
};
}
}
// Render Actions (Sort, Shuffle, Edit, Delete, Share)
this.updatePlaylistHeaderActions(
playlistData,
!!ownedPlaylist,
currentTracks,
false,
applySort,
() => currentSort
);
playBtn.onclick = () => {
this.player.setQueue(currentTracks, 0);
this.player.playTrackFromQueue();
};
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`;
// Setup playlist search
this.setupTracklistSearch();
} else {
if (addPlaylistBtn) addPlaylistBtn.style.display = 'flex';
// 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 || '';
const originalTracks = [...tracks];
const savedSort = localStorage.getItem(`playlist-sort-${playlistId}`);
let currentSort = savedSort || 'custom';
let currentTracks = sortTracks(originalTracks, currentSort);
const renderTracks = () => {
tracklistContainer.innerHTML = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div>
`;
this.renderListWithTracks(tracklistContainer, currentTracks, true, true);
};
const applySort = (sortType) => {
currentSort = sortType;
localStorage.setItem(`playlist-sort-${playlistId}`, sortType);
currentTracks = sortTracks(originalTracks, sortType);
renderTracks();
};
renderTracks();
playBtn.onclick = () => {
this.player.setQueue(currentTracks, 0);
this.player.playTrackFromQueue();
};
// 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 + Sort + Share)
this.updatePlaylistHeaderActions(playlist, false, currentTracks, false, applySort, () => currentSort);
recentActivityManager.addPlaylist(playlist);
document.title = playlist.title || 'Artist Mix';
}
// Setup playlist search
this.setupTracklistSearch();
} catch (error) {
console.error('Failed to load playlist:', error);
tracklistContainer.innerHTML = createPlaceholder(`Could not load playlist details. ${error.message}`);
}
}
async renderFolderPage(folderId) {
this.showPage('folder');
const imageEl = document.getElementById('folder-detail-image');
const titleEl = document.getElementById('folder-detail-title');
const metaEl = document.getElementById('folder-detail-meta');
const container = document.getElementById('folder-detail-container');
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
titleEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
container.innerHTML = this.createSkeletonCards(4, false);
try {
const folder = await db.getFolder(folderId);
if (!folder) throw new Error('Folder not found');
imageEl.src = folder.cover || '/assets/folder.png';
imageEl.onerror = () => {
imageEl.src = '/assets/folder.png';
};
imageEl.style.backgroundColor = '';
titleEl.textContent = folder.name;
metaEl.textContent = `Created ${new Date(folder.createdAt).toLocaleDateString()}`;
this.setPageBackground(null);
this.resetVibrantColor();
if (folder.playlists?.length > 0) {
const playlistPromises = folder.playlists.map((id) => db.getPlaylist(id));
const playlists = (await Promise.all(playlistPromises)).filter(Boolean);
if (playlists.length > 0) {
container.innerHTML = playlists.map((p) => this.createUserPlaylistCardHTML(p)).join('');
playlists.forEach((playlist) => {
const el = container.querySelector(`[data-user-playlist-id="${playlist.id}"]`);
if (el) trackDataStore.set(el, playlist);
});
} else {
container.innerHTML = createPlaceholder(
'This folder is empty. Some playlists may have been deleted.'
);
}
} else {
container.innerHTML = createPlaceholder('This folder is empty. Drag a playlist here to add it.');
}
} catch (error) {
console.error('Failed to load folder:', error);
container.innerHTML = createPlaceholder('Folder not found.');
}
}
async renderMixPage(mixId, provider = null) {
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(20)}<span>Play</span>`;
const dlBtn = document.getElementById('download-mix-btn');
if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD(20)}<span>Download</span>`;
// Skeleton loading
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>
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div>
${this.createSkeletonTracks(10, true)}
`;
try {
const { mix, tracks } = await this.api.getMix(mixId, provider);
this.currentMixId = 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) {
const firstTrack = tracks[0];
let videoCoverUrl =
firstTrack.videoUrl || firstTrack.videoCoverUrl || firstTrack.album?.videoCoverUrl || null;
if (!videoCoverUrl && (firstTrack.album || firstTrack.type === 'video')) {
const fetchArtwork = () => {
this.api
.getVideoArtwork(firstTrack.title, getTrackArtists(firstTrack))
.then(async (result) => {
if (result && this.currentPage === 'mix' && this.currentMixId === mixId) {
const url = result.videoUrl || result.hlsUrl;
if (!url) return;
firstTrack.album = firstTrack.album || {};
firstTrack.album.videoCoverUrl = url;
const currentImageEl = document.getElementById('mix-detail-image');
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.className = currentImageEl.className;
video.id = currentImageEl.id;
video.style.opacity = '1';
video.poster = currentImageEl.src;
await this.setupHlsVideo(video, result, currentImageEl);
currentImageEl.replaceWith(video);
}
}
});
};
if (firstTrack.type === 'video') {
this.api
.getVideoStreamUrl(firstTrack.id)
.then((url) => {
if (url) {
firstTrack.videoUrl = url;
this.renderMixPage(mixId);
} else {
fetchArtwork();
}
})
.catch(fetchArtwork);
} else {
fetchArtwork();
}
}
const coverUrl = videoCoverUrl || this.api.getCoverUrl(firstTrack.album.cover);
if (videoCoverUrl) {
if (imageEl.tagName === 'IMG') {
const video = document.createElement('video');
video.src = videoCoverUrl;
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.className = imageEl.className;
video.id = imageEl.id;
imageEl.replaceWith(video);
} else {
imageEl.src = videoCoverUrl;
}
} else {
if (imageEl.tagName === 'VIDEO') {
const img = document.createElement('img');
img.src = coverUrl;
img.className = imageEl.className;
img.id = imageEl.id;
imageEl.replaceWith(img);
} else {
imageEl.src = coverUrl;
}
}
this.setPageBackground(coverUrl);
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 = `
<div class="track-list-header">
<span style="width: 40px; text-align: center;">#</span>
<span>Title</span>
<span class="duration-header">Duration</span>
<span style="display: flex; justify-content: flex-end; opacity: 0.8;">Menu</span>
</div>
`;
this.renderListWithTracks(tracklistContainer, tracks, true, 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, provider = null) {
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 socialsEl = document.getElementById('artist-detail-socials');
const bioEl = document.getElementById('artist-detail-bio');
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 inLibraryContainer = document.getElementById('artist-detail-in-library');
const inLibrarySection = document.getElementById('artist-section-in-library');
const dlBtn = document.getElementById('download-discography-btn');
if (dlBtn) dlBtn.innerHTML = `${SVG_DOWNLOAD(20)}<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>';
if (socialsEl) socialsEl.innerHTML = '';
if (bioEl) {
bioEl.style.display = 'none';
bioEl.textContent = '';
bioEl.classList.remove('expanded');
}
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';
const loadUnreleasedSection = document.getElementById('artist-section-load-unreleased');
if (loadUnreleasedSection) loadUnreleasedSection.style.display = 'none';
if (similarContainer) similarContainer.innerHTML = this.createSkeletonCards(6, true);
if (similarSection) similarSection.style.display = 'block';
if (inLibrarySection) inLibrarySection.style.display = 'none';
if (inLibraryContainer) {
inLibraryContainer.innerHTML = '';
inLibraryContainer.hidden = true;
}
// Reset chevron and toggle state
const chevronEl = document.getElementById('in-library-chevron');
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
const toggleBtn = document.getElementById('in-library-toggle');
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
try {
const artist = await this.api.getArtist(artistId, provider);
// Handle Biography
if (bioEl) {
// Pre-define regex patterns for better performance
const linkTypes = ['artist', 'album', 'track', 'playlist'];
const regexCache = {
wimp: linkTypes.reduce((acc, type) => {
acc[type] = new RegExp(`\\[wimpLink ${type}Id="([a-f\\d-]+)"\\](.*?)\\[\\/wimpLink\\]`, 'g');
return acc;
}, {}),
legacy: linkTypes.reduce((acc, type) => {
acc[type] = new RegExp(`\\[${type}:([a-f\\d-]+)\\](.*?)\\[\\/${type}\\]`, 'g');
return acc;
}, {}),
doubleBracket: /\[\[(.*?)\|(.*?)\]\]/g,
};
const parseBio = (text) => {
if (!text) return '';
let parsed = text;
linkTypes.forEach((type) => {
parsed = parsed.replace(
regexCache.wimp[type],
(_m, id, name) =>
`<span class="bio-link" data-type="${type}" data-id="${id}">${name}</span>`
);
parsed = parsed.replace(
regexCache.legacy[type],
(_m, id, name) =>
`<span class="bio-link" data-type="${type}" data-id="${id}">${name}</span>`
);
});
parsed = parsed.replace(
regexCache.doubleBracket,
(_m, name, id) => `<span class="bio-link" data-type="artist" data-id="${id}">${name}</span>`
);
return parsed.replace(/\n/g, '<br>');
};
// Helper to strip tags for clean preview
const stripBioTags = (text) => {
if (!text) return '';
let clean = text;
linkTypes.forEach((type) => {
// [wimpLink artistId="..."]Name[/wimpLink] -> Name
clean = clean.replace(regexCache.wimp[type], (_m, _id, name) => name);
// [artist:...]Name[/artist] -> Name
clean = clean.replace(regexCache.legacy[type], (_m, _id, name) => name);
});
// [[Name|ID]] -> Name
clean = clean.replace(regexCache.doubleBracket, (_m, name, _id) => name);
return clean;
};
const showBioModal = (bio) => {
const text = typeof bio === 'string' ? bio : bio.text;
const source = typeof bio === 'string' ? null : bio.source;
const modal = document.createElement('div');
modal.className = 'modal active bio-modal';
modal.style.zIndex = '9999'; // Ensure it's on top
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-content extra-wide" style="display: flex; flex-direction: column;">
<div class="modal-header" style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem;">
<h3 style="margin: 0;">Artist Biography</h3>
<button class="btn-close" style="background: none; border: none; font-size: 2rem; cursor: pointer; color: var(--foreground); padding: 0.2rem 0.5rem; line-height: 1;">&times;</button>
</div>
<div class="modal-body" style="max-height: 70vh; overflow-y: auto; line-height: 1.8; font-size: 1.1rem; padding-right: 1rem; color: var(--foreground); cursor: default;">
${parseBio(text)}
${source ? `<div class="bio-source">Source: ${source}</div>` : ''}
</div>
</div>
`;
document.body.appendChild(modal);
const close = (e) => {
if (e) {
e.preventDefault();
e.stopPropagation();
}
modal.remove();
};
modal.querySelector('.modal-overlay').onclick = close;
modal.querySelector('.btn-close').onclick = close;
// Ensure links are clickable by attaching the listener to the modal body
const modalBody = modal.querySelector('.modal-body');
modalBody.addEventListener(
'click',
(e) => {
const link = e.target.closest('.bio-link');
if (link) {
e.preventDefault();
e.stopPropagation();
const { type, id } = link.dataset;
if (type && id) {
modal.remove();
navigate(`/${type}/t/${id}`);
}
}
},
true
); // Use capture phase to ensure it's hit
};
const renderBioPreview = (bio) => {
const text = typeof bio === 'string' ? bio : bio.text;
if (text) {
// Use stripped text for preview to avoid broken tags/links
const cleanText = stripBioTags(text);
const isLong = cleanText.length > 200;
const previewText = isLong ? cleanText.substring(0, 200).trim() + '...' : cleanText;
bioEl.innerHTML = previewText.replace(/\n/g, '<br>');
bioEl.style.display = 'block';
bioEl.style.webkitLineClamp = 'unset';
bioEl.style.cursor = 'default';
bioEl.onclick = null;
if (isLong) {
bioEl.appendChild(document.createElement('br'));
const readMore = document.createElement('span');
readMore.className = 'bio-read-more';
readMore.textContent = 'Read More';
readMore.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
showBioModal(bio);
};
bioEl.appendChild(readMore);
}
} else {
bioEl.style.display = 'none';
}
};
if (artist.biography) {
renderBioPreview(artist.biography);
} else {
// Try to fetch biography asynchronously
this.api
.getArtistBiography(artistId, provider)
.then((bio) => {
if (bio) renderBioPreview(bio);
})
.catch(() => {
/* ignore */
});
}
}
// 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 = () => navigate(`/mix/${artist.mixes.ARTIST_MIX}`);
} else {
mixBtn.style.display = 'none';
}
}
// Similar Artists
if (similarContainer && similarSection) {
this.api
.getSimilarArtists(artistId)
.then(async (similar) => {
// Filter out blocked artists
const { contentBlockingSettings } = await import('./storage.js');
const filteredSimilar = contentBlockingSettings.filterArtists(similar || []);
if (filteredSimilar.length > 0) {
similarContainer.innerHTML = filteredSimilar
.map((a) => this.createArtistCardHTML(a))
.join('');
similarSection.style.display = 'block';
filteredSimilar.forEach((a) => {
const el = similarContainer.querySelector(`[data-artist-id="${a.id}"]`);
if (el) {
trackDataStore.set(el, a);
this.updateLikeState(el, 'artist', a.id);
}
});
} 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 = `
<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.api.getArtistSocials(artist.name).then((links) => {
if (socialsEl && links.length > 0) {
socialsEl.innerHTML = links.map((link) => this.createSocialLinkHTML(link)).join('');
}
});
this.renderListWithTracks(tracksContainer, artist.tracks, true);
// "In your library" section: find liked tracks and playlist tracks for this artist
if (inLibraryContainer && inLibrarySection) {
const artistNameLower = artist.name.toLowerCase();
const isTrackByArtist = (track) => {
if (track.artists && Array.isArray(track.artists)) {
return track.artists.some(
(a) =>
a &&
((artist.id && a.id === artist.id) ||
(a.name && a.name.toLowerCase() === artistNameLower))
);
}
if (track.artist) {
if (typeof track.artist === 'object') {
if (artist.id && track.artist.id === artist.id) return true;
if (track.artist.name && track.artist.name.toLowerCase() === artistNameLower) return true;
} else if (typeof track.artist === 'string') {
if (track.artist.toLowerCase() === artistNameLower) return true;
}
}
return false;
};
const refreshInLibrary = async () => {
try {
const seenIds = new Set();
const libraryTracks = [];
const trackSourceMap = new Map(); // trackId -> Array<{ label, href }>
const addSource = (trackId, source) => {
if (!trackSourceMap.has(trackId)) {
trackSourceMap.set(trackId, []);
}
trackSourceMap.get(trackId).push(source);
};
// Get liked tracks
const likedTracks = await db.getFavorites('track');
for (const track of likedTracks) {
if (isTrackByArtist(track)) {
if (!seenIds.has(track.id)) {
seenIds.add(track.id);
libraryTracks.push(track);
}
addSource(track.id, { label: 'Liked Tracks', href: '/library' });
}
}
// Get tracks from user playlists
const userPlaylists = await db.getPlaylists(true);
for (const playlist of userPlaylists) {
if (playlist.tracks && Array.isArray(playlist.tracks)) {
for (const track of playlist.tracks) {
if (isTrackByArtist(track)) {
if (!seenIds.has(track.id)) {
seenIds.add(track.id);
libraryTracks.push(track);
}
const label = playlist.name || playlist.title || 'Playlist';
addSource(track.id, {
label,
href: `/userplaylist/${playlist.id}`,
});
}
}
}
}
// Sort alphabetically by title
libraryTracks.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
if (libraryTracks.length > 0) {
inLibrarySection.style.display = 'block';
this.renderListWithTracks(inLibraryContainer, libraryTracks, true);
// Inject source labels into each track's .artist div
const trackElements = inLibraryContainer.querySelectorAll('.track-item');
trackElements.forEach((el, idx) => {
const track = libraryTracks[idx];
if (!track) return;
const sources = trackSourceMap.get(track.id);
if (!sources || sources.length === 0) return;
const artistDiv = el.querySelector('.track-item-details .artist');
if (!artistDiv) return;
// Extract artist name and year from existing content
const artistLinks = artistDiv.querySelectorAll('.artist-link');
const artistNames = Array.from(artistLinks)
.map((a) => a.textContent)
.join(', ');
const truncatedArtist =
artistNames.length > 15 ? artistNames.slice(0, 20) + '…' : artistNames;
// Extract year from text content (pattern: " • 2024")
const fullText = artistDiv.textContent;
const yearMatch = fullText.match(/\s•\s(\d{4})/);
const yearText = yearMatch ? `${yearMatch[1]}` : '';
// Build source content
const sourceSpan = document.createElement('span');
sourceSpan.className = 'library-source';
const labelSpan = document.createElement('span');
labelSpan.className = 'library-source-label';
labelSpan.textContent = '· Source:\u00a0';
const linkSpan = document.createElement('span');
linkSpan.className = 'library-source-link';
sourceSpan.style.cursor = 'pointer';
sourceSpan.appendChild(labelSpan);
sourceSpan.appendChild(linkSpan);
if (sources.length === 1) {
const srcLabel =
sources[0].label.length > 15
? sources[0].label.slice(0, 15) + '…'
: sources[0].label;
linkSpan.textContent = srcLabel;
sourceSpan.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
navigate(sources[0].href);
});
} else {
linkSpan.textContent = 'Multiple Playlists';
sourceSpan.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const modal = document.getElementById('goto-playlist-modal');
const list = document.getElementById('goto-playlist-list');
const cancelBtn = document.getElementById('goto-playlist-cancel');
const overlay = modal.querySelector('.modal-overlay');
list.innerHTML = '';
sources.forEach((s) => {
const option = document.createElement('div');
option.className = 'modal-option';
option.dataset.href = s.href;
const span = document.createElement('span');
span.textContent = s.label;
option.appendChild(span);
list.appendChild(option);
});
const closeModal = () => {
modal.classList.remove('active');
};
list.onclick = (ev) => {
const option = ev.target.closest('.modal-option');
if (!option) return;
const href = option.dataset.href;
closeModal();
if (href) navigate(href);
};
cancelBtn.onclick = closeModal;
overlay.onclick = closeModal;
modal.classList.add('active');
});
}
// Rebuild artist div with structured layout
artistDiv.innerHTML = '';
artistDiv.classList.add('library-artist-flex');
const artistNameSpan = document.createElement('span');
artistNameSpan.className = 'library-artist-name';
artistNameSpan.textContent = truncatedArtist;
const yearSpan = document.createElement('span');
yearSpan.className = 'library-year';
yearSpan.textContent = yearText;
artistDiv.appendChild(artistNameSpan);
artistDiv.appendChild(yearSpan);
artistDiv.appendChild(sourceSpan);
});
} else {
inLibrarySection.style.display = 'none';
}
} catch (err) {
console.warn('Failed to load library tracks for artist:', err);
inLibrarySection.style.display = 'none';
}
};
// Initial load
refreshInLibrary().then(() => {
inLibraryContainer.hidden = true;
});
// Setup chevron toggle (once)
const toggle = document.getElementById('in-library-toggle');
const chevron = document.getElementById('in-library-chevron');
if (toggle) {
toggle.onclick = () => {
const isOpen = !inLibraryContainer.hidden;
inLibraryContainer.hidden = isOpen;
toggle.setAttribute('aria-expanded', String(!isOpen));
if (chevron) {
chevron.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
}
};
}
// Real-time updates: refresh when favorites or playlists change
let refreshTimeout;
const debouncedRefresh = () => {
clearTimeout(refreshTimeout);
refreshTimeout = setTimeout(() => refreshInLibrary(), 300);
};
// Cleanup previous listeners before attaching new ones
const cleanupOnNav = () => {
window.removeEventListener('favorites-changed', debouncedRefresh);
window.removeEventListener('playlist-tracks-changed', debouncedRefresh);
window.removeEventListener('popstate', cleanupOnNav);
};
cleanupOnNav();
window.addEventListener('favorites-changed', debouncedRefresh);
window.addEventListener('playlist-tracks-changed', debouncedRefresh);
window.addEventListener('popstate', cleanupOnNav, { once: true });
}
// Update header like button
const artistLikeBtn = document.getElementById('like-artist-btn');
if (artistLikeBtn) {
const isLiked = await db.isFavorite('artist', artist.id);
artistLikeBtn.innerHTML = this.createHeartIcon(isLiked);
artistLikeBtn.classList.toggle('active', isLiked);
}
// 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';
artist.eps.forEach((album) => {
const el = epsContainer.querySelector(`[data-album-id="${album.id}"]`);
if (el) {
trackDataStore.set(el, album);
this.updateLikeState(el, 'album', album.id);
}
});
} 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);
}
});
const videosSection = document.getElementById('artist-section-videos');
const videosContainer = document.getElementById('artist-detail-videos');
if (videosSection && videosContainer) {
if (artist.videos && artist.videos.length > 0) {
videosContainer.innerHTML = artist.videos.map((video) => this.createVideoCardHTML(video)).join('');
videosSection.style.display = 'block';
artist.videos.forEach((video) => {
const el = videosContainer.querySelector(`[data-video-id="${video.id}"]`);
if (el) {
trackDataStore.set(el, video);
this.updateLikeState(el, 'track', video.id);
}
});
} else {
videosSection.style.display = 'none';
}
}
// Check for unreleased projects
const unreleasedSection = document.getElementById('artist-section-unreleased');
const unreleasedContainer = document.getElementById('artist-detail-unreleased');
const loadUnreleasedBtn = document.getElementById('load-unreleased-btn');
const loadUnreleasedSection = document.getElementById('artist-section-load-unreleased');
if (unreleasedSection && unreleasedContainer && loadUnreleasedBtn && loadUnreleasedSection) {
// Initially hide the unreleased section
unreleasedSection.style.display = 'none';
loadUnreleasedSection.style.display = 'none';
// Check if artist has unreleased projects
const trackerArtist = findTrackerArtistByName(artist.name);
if (trackerArtist) {
// Show the load button section
loadUnreleasedSection.style.display = 'block';
// Add click handler to load and display unreleased projects
loadUnreleasedBtn.onclick = async () => {
loadUnreleasedBtn.disabled = true;
loadUnreleasedBtn.textContent = 'Loading...';
try {
const unreleasedData = await getArtistUnreleasedProjects(artist.name);
if (unreleasedData && unreleasedData.eras.length > 0) {
const { artist: trackerArtistData, sheetId, eras } = unreleasedData;
unreleasedContainer.innerHTML = eras
.map((e) => {
let trackCount = 0;
if (e.data) {
Object.values(e.data).forEach((songs) => {
if (songs && songs.length) trackCount += songs.length;
});
}
return createProjectCardHTML(e, trackerArtistData, sheetId, trackCount);
})
.join('');
unreleasedSection.style.display = 'block';
loadUnreleasedBtn.style.display = 'none';
// Add click handlers
const player = this.player;
unreleasedContainer.querySelectorAll('.card').forEach((card) => {
const eraName = decodeURIComponent(card.dataset.trackerProjectId);
const era = eras.find((e) => e.name === eraName);
if (!era) return;
card.onclick = (e) => {
if (e.target.closest('.card-play-btn')) {
e.stopPropagation();
let eraTracks = [];
if (era.data) {
Object.values(era.data).forEach((songs) => {
if (songs && songs.length) {
songs.forEach((song) => {
const track = createTrackFromSong(
song,
era,
trackerArtistData.name,
eraTracks.length,
sheetId
);
eraTracks.push(track);
});
}
});
}
const availableTracks = eraTracks.filter((t) => !t.unavailable);
if (availableTracks.length > 0) {
player.setQueue(availableTracks, 0);
player.playTrackFromQueue();
}
} else if (e.target.closest('.card-menu-btn')) {
e.stopPropagation();
} else {
navigate(`/unreleased/${sheetId}/${encodeURIComponent(era.name)}`);
}
};
});
} else {
loadUnreleasedBtn.textContent = 'No unreleased projects';
}
} catch (error) {
console.error('Failed to load unreleased projects:', error);
loadUnreleasedBtn.textContent = 'Failed to load';
loadUnreleasedBtn.disabled = false;
}
};
}
}
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}`
);
}
}
createSocialLinkHTML(link) {
const url = link.url;
if (url.includes('tidal.com')) return '';
if (url.includes('qobuz.com')) return '';
let icon = SVG_GLOBE(24);
let title = 'Website';
if (url.includes('twitter.com') || url.includes('x.com')) {
icon = SVG_TWITTER(24);
title = 'Twitter';
} else if (url.includes('instagram.com')) {
icon = SVG_INSTAGRAM(24);
title = 'Instagram';
} else if (url.includes('facebook.com')) {
icon = SVG_FACEBOOK(24);
title = 'Facebook';
} else if (url.includes('youtube.com')) {
icon = SVG_YOUTUBE(24);
title = 'YouTube';
} else if (url.includes('spotify.com') || url.includes('open.spotify.com')) {
icon = SVG_LINK(24);
title = 'Spotify';
} else if (url.includes('soundcloud.com')) {
icon = SVG_SOUNDCLOUD(24);
title = 'SoundCloud';
} else if (url.includes('apple.com')) {
icon = SVG_APPLE(24);
title = 'Apple Music';
}
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="social-link" title="${title}">${icon}</a>`;
}
async renderRecentPage() {
this.showPage('recent');
const container = document.getElementById('recent-tracks-container');
const clearBtn = document.getElementById('clear-history-btn');
container.innerHTML = this.createSkeletonTracks(10, true);
try {
const history = await db.getHistory();
// Show/hide clear button based on whether there's history
if (clearBtn) {
clearBtn.style.display = history.length > 0 ? 'flex' : 'none';
}
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);
}
}
// Setup clear button handler
if (clearBtn) {
clearBtn.onclick = async () => {
if (confirm('Clear all recently played tracks? This cannot be undone.')) {
try {
await db.clearHistory();
await syncManager.clearHistory();
container.innerHTML = createPlaceholder("You haven't played any tracks yet.");
clearBtn.style.display = 'none';
} catch (err) {
console.error('Failed to clear history:', err);
alert('Failed to clear history');
}
}
};
}
} catch (error) {
console.error('Failed to load history:', error);
container.innerHTML = createPlaceholder('Failed to load history.');
if (clearBtn) clearBtn.style.display = 'none';
}
}
async renderUnreleasedPage() {
this.showPage('unreleased');
const container = document.getElementById('unreleased-content');
await renderUnreleasedTrackerPage(container);
}
async renderTrackerArtistPage(sheetId) {
this.showPage('tracker-artist');
const container = document.getElementById('tracker-artist-projects-container');
await renderTrackerArtistContent(sheetId, container);
}
async renderTrackerProjectPage(sheetId, projectName) {
this.showPage('album'); // Use album page template
const container = document.getElementById('album-detail-tracklist');
await renderTrackerProjectContent(sheetId, projectName, container, this);
}
async renderTrackerTrackPage(trackId) {
this.showPage('album'); // Use album page template
const container = document.getElementById('album-detail-tracklist');
await renderTrackerTrackContent(trackId, container, this);
}
updatePlaylistHeaderActions(playlist, isOwned, tracks, showShare = false, onSort = null, getCurrentSort = null) {
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',
'sort-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 = `${SVG_SHUFFLE(20)}<span>Shuffle</span>`;
shuffleBtn.onclick = () => {
const shuffledTracks = [...tracks].sort(() => Math.random() - 0.5);
this.player.setQueue(shuffledTracks, 0);
this.player.playTrackFromQueue();
};
// Sort button (always available if onSort is provided)
let sortBtn = null;
if (onSort) {
sortBtn = document.createElement('button');
sortBtn.id = 'sort-playlist-btn';
sortBtn.className = 'btn-secondary';
sortBtn.innerHTML = `${SVG_SORT(20)}<span>Sort</span>`;
sortBtn.onclick = (e) => {
e.stopPropagation();
const menu = document.getElementById('sort-menu');
// Show "Date Added" options only if tracks have addedAt
const hasAddedDate = tracks.some((t) => t.addedAt);
menu.querySelectorAll('.requires-added-date').forEach((opt) => {
opt.style.display = hasAddedDate ? '' : 'none';
});
// Highlight current sort option
const currentSortType = getCurrentSort ? getCurrentSort() : 'custom';
menu.querySelectorAll('li').forEach((opt) => {
opt.classList.toggle('sort-active', opt.dataset.sort === currentSortType);
});
const rect = sortBtn.getBoundingClientRect();
menu.style.top = `${rect.bottom + 5}px`;
menu.style.left = `${rect.left}px`;
menu.style.display = 'block';
const closeMenu = () => {
menu.style.display = 'none';
document.removeEventListener('click', closeMenu);
};
const handleSort = (ev) => {
const li = ev.target.closest('li');
if (li && li.dataset.sort) {
trackChangeSort(li.dataset.sort);
onSort(li.dataset.sort);
closeMenu();
}
};
menu.onclick = handleSort;
setTimeout(() => document.addEventListener('click', closeMenu), 0);
};
}
// Edit/Delete (Owned Only)
if (isOwned) {
const editBtn = document.createElement('button');
editBtn.id = 'edit-playlist-btn';
editBtn.className = 'btn-secondary';
editBtn.innerHTML = `${SVG_SQUARE_PEN(24)}<span>Edit</span>`;
fragment.appendChild(editBtn);
const deleteBtn = document.createElement('button');
deleteBtn.id = 'delete-playlist-btn';
deleteBtn.className = 'btn-secondary danger';
deleteBtn.innerHTML = `${SVG_BIN(24)}<span>Delete</span>`;
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 = `${SVG_SHARE(20)}<span>Share</span>`;
shareBtn.onclick = () => {
const url = getShareUrl(`/userplaylist/${playlist.id || playlist.uuid}`);
navigator.clipboard.writeText(url).then(() => alert('Link copied to clipboard!'));
};
fragment.appendChild(shareBtn);
}
// Insert buttons in the correct order: Play, Shuffle, Download, Sort, Like, Edit/Delete/Share
const dlBtn = actionsDiv.querySelector('#download-playlist-btn');
const likeBtn = actionsDiv.querySelector('#like-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);
// Insert Sort after Download, before Like
if (sortBtn && likeBtn) {
actionsDiv.insertBefore(sortBtn, likeBtn);
} else if (sortBtn) {
actionsDiv.appendChild(sortBtn);
}
// Append Edit/Delete/Share buttons after Like
while (fragment.firstChild) {
actionsDiv.appendChild(fragment.firstChild);
}
} else {
// If no Download button, just append everything
actionsDiv.appendChild(shuffleBtn);
if (sortBtn) actionsDiv.appendChild(sortBtn);
while (fragment.firstChild) {
actionsDiv.appendChild(fragment.firstChild);
}
}
}
enableTrackReordering(container, tracks, playlistId, syncManager) {
// Clone to remove old listeners
const newContainer = container.cloneNode(true);
if (container.parentNode) {
container.parentNode.replaceChild(newContainer, container);
}
container = newContainer;
let draggedElement = null;
let draggedIndex = -1;
let trackItems = Array.from(container.querySelectorAll('.track-item'));
trackItems.forEach((item, index) => {
// Re-bind data to cloned elements
if (tracks[index]) {
trackDataStore.set(item, tracks[index]);
}
item.draggable = true;
item.dataset.index = index;
});
const dragStart = (e) => {
draggedElement = e.target.closest('.track-item');
if (!draggedElement) return;
draggedIndex = parseInt(draggedElement.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 renderGroup = (instances, type) => {
if (!instances || instances.length === 0) return '';
const listHtml = instances
.map((instance, index) => {
const isObject = instance && typeof instance === 'object';
const instanceUrl = isObject ? instance.url || '' : String(instance || '');
const instanceName = isObject
? instance.name || instance.displayName || instance.id || instanceUrl
: instanceUrl;
const instanceVersion = isObject && instance.version ? String(instance.version) : '';
const isUser = isObject && instance.isUser;
const safeName = escapeHtml(instanceName || 'Unknown instance');
const safeUrl = escapeHtml(instanceUrl || '');
const safeVersion = escapeHtml(instanceVersion);
return `
<li data-index="${index}" data-type="${type}" data-url="${safeUrl}">
<div style="flex: 1; min-width: 0;">
<div class="instance-url">${safeName} ${isUser ? '<span style="font-size: 0.6rem; opacity: 0.7; background: var(--muted); padding: 1px 4px; border-radius: 3px; margin-left: 4px; vertical-align: middle;">U</span>' : ''}</div>
${safeUrl && safeUrl !== safeName ? `<div style="font-size: 0.8rem; color: var(--muted-foreground); margin-top: 0.15rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${safeUrl}</div>` : ''}
${safeVersion ? `<div style="font-size: 0.75rem; color: var(--muted-foreground); margin-top: 0.1rem;">v${safeVersion}</div>` : ''}
</div>
<div class="controls">
${
isUser
? `
<button class="delete-instance" title="Delete Instance">
${SVG_TRASH(16)}
</button>`
: ''
}
<button class="move-up" title="Move Up" ${index === 0 ? 'disabled' : ''}>
${SVG_MOVE_UP(16)}
</button>
<button class="move-down" title="Move Down" ${index === instances.length - 1 ? 'disabled' : ''}>
${SVG_MOVE_DOWN(16)}
</button>
</div>
</li>
`;
})
.join('');
return `
<li class="group-header" style="display: flex; justify-content: space-between; align-items: center; font-weight: bold; padding: 1rem 0 0.5rem; background: transparent; border: none;">
<span>${type === 'api' ? 'API Instances' : 'Streaming Instances'}</span>
<button class="add-instance" data-type="${type}" title="Add Custom Instance" style="background: var(--primary); color: var(--primary-foreground); border: none; padding: 0.25rem 0.5rem; border-radius: 4px; font-size: 0.75rem; cursor: pointer; pointer-events: auto;">
Add
</button>
</li>
${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`;
}
}
);
}
async renderTrackPage(trackId, provider = null) {
this.showPage('track');
document.body.classList.add('sidebar-collapsed');
const toggleBtn = document.getElementById('sidebar-toggle');
if (toggleBtn) {
toggleBtn.innerHTML = SVG_RIGHT_ARROW(20);
}
const imageEl = document.getElementById('track-detail-image');
const titleEl = document.getElementById('track-detail-title');
const artistEl = document.getElementById('track-detail-artist');
const albumEl = document.getElementById('track-detail-album');
const yearEl = document.getElementById('track-detail-year');
const albumSection = document.getElementById('track-album-section');
const albumTracksContainer = document.getElementById('track-detail-album-tracks');
const similarSection = document.getElementById('track-similar-section');
const playBtn = document.getElementById('play-track-btn');
const likeBtn = document.getElementById('like-track-btn');
imageEl.src = '';
imageEl.style.backgroundColor = 'var(--muted)';
titleEl.innerHTML = '<div class="skeleton" style="height: 48px; width: 300px; max-width: 90%;"></div>';
artistEl.innerHTML = '<div class="skeleton" style="height: 16px; width: 100px;"></div>';
albumEl.innerHTML = '';
yearEl.innerHTML = '';
albumTracksContainer.innerHTML = this.createSkeletonTracks(5, false);
albumSection.style.display = 'none';
similarSection.style.display = 'none';
if (!trackId || trackId === 'undefined' || trackId === 'null') {
titleEl.textContent = 'Invalid Track ID';
artistEl.innerHTML = '';
return;
}
try {
let track;
try {
const result = await this.api.getTrack(trackId, provider);
track = result.track;
} catch (e) {
console.warn('getTrack failed, trying getTrackMetadata', e);
track = await this.api.getTrackMetadata(trackId, provider);
}
this.currentTrackPageId = track.id;
let videoCoverUrl = track.videoUrl || track.videoCoverUrl || track.album?.videoCoverUrl || null;
if (!videoCoverUrl && (track.album || track.type === 'video')) {
const fetchArtwork = () => {
this.api.getVideoArtwork(track.title, getTrackArtists(track)).then(async (result) => {
if (result && this.currentPage === 'track' && this.currentTrackPageId === track.id) {
const url = result.videoUrl || result.hlsUrl;
if (!url) return;
track.album = track.album || {};
track.album.videoCoverUrl = url;
const currentImageEl = document.getElementById('track-detail-image');
if (currentImageEl && currentImageEl.tagName !== 'VIDEO') {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.className = currentImageEl.className;
video.id = currentImageEl.id;
video.style.opacity = '1';
video.poster = currentImageEl.src;
await this.setupHlsVideo(video, result, currentImageEl);
currentImageEl.replaceWith(video);
}
}
});
};
if (track.type === 'video') {
this.api
.getVideoStreamUrl(track.id)
.then((url) => {
if (url) {
track.videoUrl = url;
this.renderTrackPage(trackId, provider);
} else {
fetchArtwork();
}
})
.catch(fetchArtwork);
} else {
fetchArtwork();
}
}
const coverUrl = videoCoverUrl || this.api.getCoverUrl(track.image || track.cover || track.album?.cover);
if (videoCoverUrl) {
if (imageEl.tagName !== 'VIDEO') {
const video = document.createElement('video');
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
video.preload = 'auto';
video.className = imageEl.className;
video.id = imageEl.id;
await this.setupHlsVideo(video, videoCoverUrl, imageEl);
imageEl.replaceWith(video);
} else {
await this.setupHlsVideo(imageEl, videoCoverUrl, null);
}
} else {
if (imageEl.tagName === 'VIDEO') {
const img = document.createElement('img');
img.src = coverUrl;
img.className = imageEl.className;
img.id = imageEl.id;
imageEl.replaceWith(img);
} else {
imageEl.src = coverUrl;
}
}
imageEl.style.backgroundColor = '';
this.setPageBackground(coverUrl);
if (backgroundSettings.isEnabled() && track.album?.cover) {
this.extractAndApplyColor(this.api.getCoverUrl(track.album.cover, '80'));
}
const explicitBadge = hasExplicitContent(track) ? this.createExplicitBadge() : '';
const qualityBadge = createQualityBadgeHTML(track);
titleEl.innerHTML = `${escapeHtml(track.title)} ${explicitBadge} ${qualityBadge}`;
this.adjustTitleFontSize(titleEl, track.title);
artistEl.innerHTML = getTrackArtistsHTML(track);
if (track.album) {
albumEl.innerHTML = `<a href="/album/${track.album.id}">${escapeHtml(track.album.title)}</a>`;
}
if (track.album?.releaseDate) {
const date = new Date(track.album.releaseDate);
if (!isNaN(date.getTime())) {
yearEl.textContent = date.getFullYear();
}
}
playBtn.onclick = () => {
this.player.setQueue([track], 0);
this.player.playTrackFromQueue();
};
if (likeBtn) {
const isLiked = await db.isFavorite('track', track.id);
likeBtn.innerHTML = this.createHeartIcon(isLiked);
likeBtn.classList.toggle('active', isLiked);
}
if (track.album?.id) {
const { tracks } = await this.api.getAlbum(track.album.id);
if (tracks && tracks.length > 0) {
albumSection.style.display = 'block';
this.renderListWithTracks(albumTracksContainer, tracks, false);
}
}
document.title = `${track.title} - ${getTrackArtists(track)}`;
} catch (error) {
console.error('Failed to load track:', error);
titleEl.textContent = 'Track not found';
artistEl.innerHTML = '';
}
}
}