kv-music/js/profile.js

966 lines
40 KiB
JavaScript

import { syncManager } from './accounts/pocketbase.js';
import { authManager } from './accounts/auth.js';
import { navigate } from './router.js';
import { MusicAPI } from './music-api.js';
import { apiSettings } from './storage.js';
import { debounce, escapeHtml } from './utils.js';
import { Player } from './player.js';
// objects execution february 29th 2027
const profilePage = document.getElementById('page-profile');
const editProfileModal = document.getElementById('edit-profile-modal');
const editProfileBtn = document.getElementById('profile-edit-btn');
const viewMyProfileBtn = document.getElementById('view-my-profile-btn');
const editUsername = document.getElementById('edit-profile-username');
const editDisplayName = document.getElementById('edit-profile-display-name');
const editAvatar = document.getElementById('edit-profile-avatar');
const editBanner = document.getElementById('edit-profile-banner');
const editStatusSearch = document.getElementById('edit-profile-status-search');
const editStatusJson = document.getElementById('edit-profile-status-json');
const statusSearchResults = document.getElementById('status-search-results');
const statusPreview = document.getElementById('status-preview');
const clearStatusBtn = document.getElementById('clear-status-btn');
const editFavoriteAlbumsList = document.getElementById('edit-favorite-albums-list');
const editFavoriteAlbumsSearch = document.getElementById('edit-favorite-albums-search');
const editFavoriteAlbumsResults = document.getElementById('edit-favorite-albums-results');
const editAbout = document.getElementById('edit-profile-about');
const editWebsite = document.getElementById('edit-profile-website');
const editLastfm = document.getElementById('edit-profile-lastfm');
const privacyPlaylists = document.getElementById('privacy-playlists-toggle');
const privacyLastfm = document.getElementById('privacy-lastfm-toggle');
const saveProfileBtn = document.getElementById('edit-profile-save');
const cancelProfileBtn = document.getElementById('edit-profile-cancel');
const usernameError = document.getElementById('username-error');
let currentFavoriteAlbums = [];
const api = new MusicAPI(apiSettings);
async function uploadImage(file) {
try {
const fileNameWithoutSpace = file.name.replace(/\s/g, '_');
const response = await fetch(`https://worker.uploads.monochrome.qzz.io/${fileNameWithoutSpace}`, {
method: 'PUT',
headers: {
'x-api-key': 'if_youre_reading_this_fuck_off',
'Content-Type': file.type || 'application/octet-stream',
},
body: file,
});
if (!response.ok) {
if (response.status === 413) throw new Error('File exceeds 10MB');
throw new Error(`Upload failed: ${response.status}`);
}
return `https://images.monochrome.qzz.io/${await response.text()}`;
} catch (error) {
console.error('Upload error:', error);
throw error;
}
}
function setupImageUploadControl(idPrefix) {
const urlInput = document.getElementById(idPrefix);
const fileInput = document.getElementById(idPrefix + '-file');
const uploadBtn = document.getElementById(idPrefix + '-upload-btn');
const toggleBtn = document.getElementById(idPrefix + '-toggle-btn');
const statusEl = document.getElementById(idPrefix + '-upload-status');
if (!urlInput || !fileInput || !uploadBtn || !toggleBtn || !statusEl) return () => {};
let useUrl = false;
function updateUI() {
if (useUrl) {
uploadBtn.style.display = 'none';
urlInput.style.display = 'block';
toggleBtn.textContent = 'Upload';
} else {
uploadBtn.style.display = 'flex';
urlInput.style.display = 'none';
toggleBtn.textContent = 'or URL';
}
}
toggleBtn.addEventListener('click', () => {
useUrl = !useUrl;
updateUI();
});
uploadBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', async (e) => {
const file = e.target.files[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
statusEl.style.display = 'block';
statusEl.textContent = 'Uploading...';
statusEl.style.color = 'var(--muted-foreground)';
uploadBtn.disabled = true;
try {
const url = await uploadImage(file);
urlInput.value = url;
statusEl.textContent = 'Done!';
statusEl.style.color = '#10b981';
setTimeout(() => {
statusEl.style.display = 'none';
}, 2000);
} catch {
statusEl.textContent = 'Failed - try URL';
statusEl.style.color = '#ef4444';
} finally {
uploadBtn.disabled = false;
fileInput.value = '';
}
});
return (currentUrl) => {
urlInput.value = currentUrl || '';
useUrl = !!currentUrl;
updateUI();
statusEl.style.display = 'none';
};
}
const resetAvatarControl = setupImageUploadControl('edit-profile-avatar');
const resetBannerControl = setupImageUploadControl('edit-profile-banner');
export async function loadProfile(username) {
document.querySelectorAll('.page').forEach((p) => p.classList.remove('active'));
profilePage.classList.add('active');
document.getElementById('profile-banner').style.backgroundImage = '';
document.getElementById('profile-avatar').src = '/assets/appicon.png';
document.getElementById('profile-display-name').textContent = 'Loading...';
document.getElementById('profile-username').textContent = '@' + username;
document.getElementById('profile-status').style.display = 'none';
document.getElementById('profile-about').textContent = '';
document.getElementById('profile-website').style.display = 'none';
document.getElementById('profile-lastfm').style.display = 'none';
document.getElementById('profile-playlists-container').innerHTML = '';
const favAlbumsSection = document.getElementById('profile-favorite-albums-section');
const favAlbumsContainer = document.getElementById('profile-favorite-albums-container');
if (favAlbumsSection) favAlbumsSection.style.display = 'none';
if (favAlbumsContainer) favAlbumsContainer.innerHTML = '';
const recentSection = document.getElementById('profile-recent-scrobbles-section');
const recentContainer = document.getElementById('profile-recent-scrobbles-container');
if (recentSection) recentSection.style.display = 'none';
if (recentContainer) recentContainer.innerHTML = '';
const topArtistsSection = document.getElementById('profile-top-artists-section');
const topArtistsContainer = document.getElementById('profile-top-artists-container');
const topAlbumsSection = document.getElementById('profile-top-albums-section');
const topAlbumsContainer = document.getElementById('profile-top-albums-container');
const topTracksSection = document.getElementById('profile-top-tracks-section');
const topTracksContainer = document.getElementById('profile-top-tracks-container');
if (topArtistsSection) topArtistsSection.style.display = 'none';
if (topArtistsContainer) topArtistsContainer.innerHTML = '';
if (topAlbumsSection) topAlbumsSection.style.display = 'none';
if (topAlbumsContainer) topAlbumsContainer.innerHTML = '';
if (topTracksSection) topTracksSection.style.display = 'none';
if (topTracksContainer) topTracksContainer.innerHTML = '';
editProfileBtn.style.display = 'none';
const profile = await syncManager.getProfile(username);
if (!profile) {
document.getElementById('profile-display-name').textContent = 'User not found';
return;
}
document.getElementById('profile-display-name').textContent = profile.display_name || username;
if (profile.banner) document.getElementById('profile-banner').style.backgroundImage = `url('${profile.banner}')`;
if (profile.avatar_url) document.getElementById('profile-avatar').src = profile.avatar_url;
if (profile.status) {
const statusEl = document.getElementById('profile-status');
try {
const statusObj = JSON.parse(profile.status);
statusEl.innerHTML = `
<span style="opacity: 0.7; margin-right: 0.25rem;">Listening to:</span>
<img src="${statusObj.image}" style="width: 20px; height: 20px; border-radius: 2px; vertical-align: middle; margin-right: 0.5rem;">
<a href="${statusObj.link}" class="status-link" style="color: inherit; text-decoration: none; font-weight: 500;">${statusObj.text}</a>
`;
statusEl.querySelector('.status-link').onclick = (e) => {
e.preventDefault();
navigate(statusObj.link);
};
} catch {
statusEl.textContent = `Listening to: ${profile.status}`;
}
statusEl.style.display = 'inline-flex';
}
if (profile.about) {
document.getElementById('profile-about').textContent = profile.about;
}
if (profile.website) {
const webEl = document.getElementById('profile-website');
webEl.href = profile.website;
webEl.style.display = 'inline-block';
}
if (profile.favorite_albums && profile.favorite_albums.length > 0) {
if (favAlbumsSection && favAlbumsContainer) {
favAlbumsSection.style.display = 'block';
favAlbumsContainer.innerHTML = profile.favorite_albums
.map((album) => {
const image = api.getCoverUrl(album.cover);
return `
<div class="favorite-album-item" style="display: flex; gap: 1rem; margin-bottom: 1rem; background: var(--card); padding: 1rem; border-radius: var(--radius); border: 1px solid var(--border);">
<div class="card" style="width: 120px; flex-shrink: 0; padding: 0; border: none; background: transparent; cursor: pointer;" onclick="window.location.hash='/album/${album.id}'">
<div class="card-image-wrapper" style="margin-bottom: 0.5rem;">
<img src="${image}" class="card-image" loading="lazy" style="border-radius: var(--radius);">
</div>
<div class="card-info">
<div class="card-title" style="font-size: 0.9rem;">${escapeHtml(album.title)}</div>
<div class="card-subtitle" style="font-size: 0.8rem;">${escapeHtml(album.artist)}</div>
</div>
</div>
<div class="favorite-album-description" style="flex: 1; display: flex; flex-direction: column;">
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; color: var(--muted-foreground); text-transform: uppercase; letter-spacing: 0.05em;">Why it's a favorite</h4>
<p style="margin: 0; line-height: 1.6; white-space: pre-wrap;">${escapeHtml(album.description || '')}</p>
</div>
</div>
`;
})
.join('');
}
}
if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') {
const lfmEl = document.getElementById('profile-lastfm');
lfmEl.href = `https://last.fm/user/${profile.lastfm_username}`;
lfmEl.style.display = 'inline-block';
}
if (profile.lastfm_username && profile.privacy?.lastfm !== 'private') {
fetchLastFmRecentTracks(profile.lastfm_username).then(async (tracks) => {
if (tracks.length > 0) {
recentSection.style.display = 'block';
recentContainer.innerHTML = tracks
.map((track, index) => {
const isNowPlaying = track['@attr']?.nowplaying === 'true';
let image = getLastFmImage(track.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
track._imgId = `scrobble-img-${index}`;
track._needsCover = !hasImage;
let dateDisplay = '';
if (isNowPlaying) dateDisplay = 'Scrobbling now';
else if (track.date) {
const date = new Date(track.date.uts * 1000);
dateDisplay =
date.toLocaleDateString() +
' ' +
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
return `
<div class="track-item lastfm-track" data-title="${escapeHtml(track.name)}" data-artist="${escapeHtml(track.artist?.['#text'] || track.artist?.name || '')}" style="grid-template-columns: 40px 1fr auto; cursor: pointer;">
<img id="${track._imgId}" src="${image}" class="track-item-cover" style="width: 40px; height: 40px; border-radius: 4px;" loading="lazy" onerror="this.src='/assets/appicon.png'">
<div class="track-item-info">
<div class="track-item-details">
<div class="title">${track.name}</div>
<div class="artist">${track.artist?.['#text'] || track.artist?.name || track.artist || 'Unknown Artist'}</div>
</div>
</div>
<div class="track-item-duration" style="font-size: 0.8rem; min-width: auto;">${dateDisplay}</div>
</div>
`;
})
.join('');
recentContainer.querySelectorAll('.track-item').forEach((item) => {
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
});
for (const track of tracks) {
if (track._needsCover) {
fetchFallbackCover(track.name, track.artist?.['#text'] || track.artist?.name, track._imgId);
}
}
}
});
fetchLastFmTopArtists(profile.lastfm_username).then(async (artists) => {
if (artists.length > 0 && topArtistsSection && topArtistsContainer) {
topArtistsSection.style.display = 'block';
topArtistsContainer.innerHTML = artists
.map((artist, index) => {
let image = getLastFmImage(artist.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
const imgId = `top-artist-img-${index}`;
artist._imgId = imgId;
artist._needsCover = !hasImage;
return `
<div class="card artist lastfm-card" data-name="${escapeHtml(artist.name)}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img id="${imgId}" src="${image}" class="card-image" loading="lazy" onerror="this.src='/assets/appicon.png'">
</div>
<div class="card-info">
<div class="card-title">${artist.name}</div>
<div class="card-subtitle">${parseInt(artist.playcount).toLocaleString()} plays</div>
</div>
</div>
`;
})
.join('');
topArtistsContainer.querySelectorAll('.card').forEach((card) => {
card.addEventListener('click', () => handleArtistClick(card.dataset.name));
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
});
for (const artist of artists) {
if (artist._needsCover) {
fetchFallbackArtistImage(artist.name, artist._imgId);
}
}
}
});
fetchLastFmTopAlbums(profile.lastfm_username).then(async (albums) => {
if (albums.length > 0 && topAlbumsSection && topAlbumsContainer) {
topAlbumsSection.style.display = 'block';
topAlbumsContainer.innerHTML = albums
.map((album, index) => {
let image = getLastFmImage(album.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
const imgId = `top-album-img-${index}`;
album._imgId = imgId;
album._needsCover = !hasImage;
const artistName =
album.artist?.name ||
album.artist?.['#text'] ||
(typeof album.artist === 'string' ? album.artist : 'Unknown Artist');
album._artistName = artistName;
return `
<div class="card lastfm-card" data-name="${escapeHtml(album.name)}" data-artist="${escapeHtml(artistName)}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img id="${imgId}" src="${image}" class="card-image" loading="lazy" onerror="this.src='/assets/appicon.png'">
</div>
<div class="card-info">
<div class="card-title">${album.name}</div>
<div class="card-subtitle">${artistName}</div>
</div>
</div>
`;
})
.join('');
topAlbumsContainer.querySelectorAll('.card').forEach((card) => {
card.addEventListener('click', () => handleAlbumClick(card.dataset.name, card.dataset.artist));
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
});
for (const album of albums) {
if (album._needsCover) {
fetchFallbackAlbumCover(album.name, album._artistName, album._imgId);
}
}
}
});
fetchLastFmTopTracks(profile.lastfm_username).then(async (tracks) => {
if (tracks.length > 0 && topTracksSection && topTracksContainer) {
topTracksSection.style.display = 'block';
topTracksContainer.innerHTML = tracks
.map((track, index) => {
let image = getLastFmImage(track.image);
const hasImage = !!image;
if (!image) image = '/assets/appicon.png';
const imgId = `top-track-img-${index}`;
track._imgId = imgId;
track._needsCover = !hasImage;
const artistName =
track.artist?.name ||
track.artist?.['#text'] ||
(typeof track.artist === 'string' ? track.artist : 'Unknown Artist');
track._artistName = artistName;
return `
<div class="track-item lastfm-track" data-title="${escapeHtml(track.name)}" data-artist="${escapeHtml(artistName)}" style="grid-template-columns: 40px 1fr auto; cursor: pointer;">
<img id="${imgId}" src="${image}" class="track-item-cover" style="width: 40px; height: 40px; border-radius: 4px;" loading="lazy" onerror="this.src='/assets/appicon.png'">
<div class="track-item-info">
<div class="track-item-details">
<div class="title">${track.name}</div>
<div class="artist">${artistName}</div>
</div>
</div>
<div class="track-item-duration" style="font-size: 0.8rem; min-width: auto;">${parseInt(track.playcount).toLocaleString()} plays</div>
</div>
`;
})
.join('');
topTracksContainer.querySelectorAll('.track-item').forEach((item) => {
item.addEventListener('click', () => handleTrackClick(item.dataset.title, item.dataset.artist));
item.addEventListener('contextmenu', (e) => {
e.preventDefault();
return false;
});
});
for (const track of tracks) {
if (track._needsCover) {
fetchFallbackCover(track.name, track._artistName, track._imgId);
}
}
}
});
}
const currentUser = await syncManager.getUserData();
const isOwner = currentUser && currentUser.profile && currentUser.profile.username === username;
if (isOwner) {
editProfileBtn.style.display = 'inline-flex';
}
if (profile.privacy?.playlists !== 'private' || isOwner) {
const container = document.getElementById('profile-playlists-container');
const playlists = profile.user_playlists || {};
Object.values(playlists).forEach((playlist) => {
if (!playlist.isPublic && !isOwner) return;
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="card-image-wrapper">
<img src="${playlist.cover || '/assets/appicon.png'}" class="card-image" loading="lazy" alt="${playlist.name}">
</div>
<div class="card-info">
<div class="card-title">${playlist.name}</div>
<div class="card-subtitle">${playlist.numberOfTracks || 0} tracks</div>
</div>
`;
card.onclick = () => {
window.location.hash = `/userplaylist/${playlist.id}`;
};
container.appendChild(card);
});
if (container.children.length === 0) {
container.innerHTML =
'<p style="color: var(--muted-foreground); grid-column: 1/-1; text-align: center;">No public playlists.</p>';
}
}
}
export function openEditProfile() {
syncManager.getUserData().then((data) => {
if (!data || !data.profile) return;
const p = data.profile;
editUsername.value = p.username || '';
editDisplayName.value = p.display_name || '';
resetAvatarControl(p.avatar_url);
resetBannerControl(p.banner);
editStatusJson.value = p.status || '';
editStatusSearch.value = '';
if (p.status) {
try {
const statusObj = JSON.parse(p.status);
showStatusPreview(statusObj);
} catch {
if (p.status.trim()) {
editStatusSearch.value = p.status;
hideStatusPreview();
}
}
} else {
hideStatusPreview();
}
currentFavoriteAlbums = p.favorite_albums || [];
renderEditFavoriteAlbums();
editFavoriteAlbumsSearch.value = '';
editFavoriteAlbumsResults.style.display = 'none';
editAbout.value = p.about || '';
editWebsite.value = p.website || '';
editLastfm.value = p.lastfm_username || '';
privacyPlaylists.checked = p.privacy?.playlists !== 'private';
privacyLastfm.checked = p.privacy?.lastfm !== 'private';
editProfileModal.classList.add('active');
});
}
async function saveProfile() {
const newUsername = editUsername.value.trim();
if (!newUsername) {
usernameError.textContent = 'Username cannot be empty';
usernameError.style.display = 'block';
return;
}
const currentUser = await syncManager.getUserData();
if (currentUser.profile.username !== newUsername) {
const taken = await syncManager.isUsernameTaken(newUsername);
if (taken) {
usernameError.textContent = 'Username is already taken';
usernameError.style.display = 'block';
return;
}
}
usernameError.style.display = 'none';
saveProfileBtn.disabled = true;
saveProfileBtn.textContent = 'Saving...';
const data = {
username: newUsername,
display_name: editDisplayName.value.trim(),
avatar_url: editAvatar.value.trim(),
banner: editBanner.value.trim(),
status: editStatusJson.value.trim() || (editStatusSearch.value.trim() ? editStatusSearch.value.trim() : ''),
about: editAbout.value.trim(),
website: editWebsite.value.trim(),
favorite_albums: currentFavoriteAlbums,
lastfm_username: editLastfm.value.trim(),
privacy: {
playlists: privacyPlaylists.checked ? 'public' : 'private',
lastfm: privacyLastfm.checked ? 'public' : 'private',
},
};
try {
await syncManager.updateProfile(data);
editProfileModal.classList.remove('active');
loadProfile(newUsername);
if (window.location.pathname.includes('/user/@')) {
window.history.replaceState(null, '', `/user/@${newUsername}`);
}
} catch (e) {
alert('Failed to save profile. See console.');
console.error(e);
} finally {
saveProfileBtn.disabled = false;
saveProfileBtn.textContent = 'Save Profile';
}
}
editProfileBtn.addEventListener('click', openEditProfile);
cancelProfileBtn.addEventListener('click', () => editProfileModal.classList.remove('active'));
saveProfileBtn.addEventListener('click', saveProfile);
viewMyProfileBtn.addEventListener('click', async () => {
const data = await syncManager.getUserData();
if (data && data.profile && data.profile.username) {
navigate(`/user/@${data.profile.username}`);
} else {
openEditProfile();
}
});
authManager.onAuthStateChanged((user) => {
viewMyProfileBtn.style.display = user ? 'inline-block' : 'none';
});
function showStatusPreview(data) {
document.getElementById('status-preview-img').src = data.image;
document.getElementById('status-preview-title').textContent = data.title;
document.getElementById('status-preview-subtitle').textContent = data.subtitle;
statusPreview.style.display = 'flex';
editStatusSearch.style.display = 'none';
}
function hideStatusPreview() {
statusPreview.style.display = 'none';
editStatusSearch.style.display = 'block';
editStatusJson.value = '';
}
clearStatusBtn.addEventListener('click', () => {
hideStatusPreview();
editStatusSearch.value = '';
editStatusSearch.focus();
});
const performStatusSearch = debounce(async (query) => {
if (!query) {
statusSearchResults.style.display = 'none';
return;
}
try {
const [tracks, albums] = await Promise.all([
api.searchTracks(query, { limit: 3 }),
api.searchAlbums(query, { limit: 3 }),
]);
statusSearchResults.innerHTML = '';
const createItem = (item, type) => {
const div = document.createElement('div');
div.className = 'search-result-item';
const title = item.title;
const subtitle =
type === 'track' ? item.artist?.name || 'Unknown Artist' : item.artist?.name || 'Unknown Artist';
const image = api.getCoverUrl(item.album?.cover || item.cover);
div.innerHTML = `
<img src="${image}">
<div class="search-result-info">
<div class="search-result-title">${title}</div>
<div class="search-result-subtitle">${type === 'track' ? 'Song' : 'Album'}${subtitle}</div>
</div>
`;
div.onclick = () => {
const data = {
type: type,
id: item.id,
text: `${title} - ${subtitle}`,
title: title,
subtitle: subtitle,
image: image,
link: `/${type}/${item.id}`,
};
editStatusJson.value = JSON.stringify(data);
showStatusPreview(data);
statusSearchResults.style.display = 'none';
};
return div;
};
tracks.items.forEach((t) => statusSearchResults.appendChild(createItem(t, 'track')));
albums.items.forEach((a) => statusSearchResults.appendChild(createItem(a, 'album')));
statusSearchResults.style.display = tracks.items.length || albums.items.length ? 'block' : 'none';
} catch (e) {
console.error('Status search failed', e);
}
}, 300);
editStatusSearch.addEventListener('input', (e) => performStatusSearch(e.target.value.trim()));
document.addEventListener('click', (e) => {
if (!e.target.closest('.status-picker-container')) {
statusSearchResults.style.display = 'none';
}
});
function renderEditFavoriteAlbums() {
editFavoriteAlbumsList.innerHTML = currentFavoriteAlbums
.map(
(album, index) => `
<div class="edit-favorite-album-item" style="background: var(--secondary); padding: 0.5rem; border-radius: var(--radius); border: 1px solid var(--border);">
<div style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<img src="${api.getCoverUrl(album.cover)}" style="width: 40px; height: 40px; border-radius: 4px; object-fit: cover;">
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(album.title)}</div>
<div style="font-size: 0.8rem; color: var(--muted-foreground);">${escapeHtml(album.artist)}</div>
</div>
<button class="btn-icon remove-album-btn" data-index="${index}" style="color: var(--danger);">&times;</button>
</div>
<textarea class="template-input album-description-input" data-index="${index}" placeholder="Why is this a favorite?" style="min-height: 60px; font-size: 0.85rem; resize: vertical;">${escapeHtml(album.description || '')}</textarea>
</div>
`
)
.join('');
editFavoriteAlbumsList.querySelectorAll('.remove-album-btn').forEach((btn) => {
btn.onclick = () => {
const idx = parseInt(btn.dataset.index);
currentFavoriteAlbums.splice(idx, 1);
renderEditFavoriteAlbums();
};
});
editFavoriteAlbumsList.querySelectorAll('.album-description-input').forEach((input) => {
input.oninput = () => {
const idx = parseInt(input.dataset.index);
currentFavoriteAlbums[idx].description = input.value;
};
});
if (currentFavoriteAlbums.length >= 5) {
editFavoriteAlbumsSearch.disabled = true;
editFavoriteAlbumsSearch.placeholder = 'Max 5 albums reached';
} else {
editFavoriteAlbumsSearch.disabled = false;
editFavoriteAlbumsSearch.placeholder = 'Search for an album...';
}
}
const performFavoriteAlbumSearch = debounce(async (query) => {
if (!query || currentFavoriteAlbums.length >= 5) {
editFavoriteAlbumsResults.style.display = 'none';
return;
}
try {
const results = await api.searchAlbums(query, { limit: 5 });
editFavoriteAlbumsResults.innerHTML = '';
if (results.items.length === 0) {
editFavoriteAlbumsResults.style.display = 'none';
return;
}
results.items.forEach((album) => {
const div = document.createElement('div');
div.className = 'search-result-item';
const image = api.getCoverUrl(album.cover);
div.innerHTML = `
<img src="${image}">
<div class="search-result-info">
<div class="search-result-title">${album.title}</div>
<div class="search-result-subtitle">${album.artist?.name || 'Unknown Artist'}</div>
</div>
`;
div.onclick = () => {
currentFavoriteAlbums.push({
id: album.id,
title: album.title,
artist: album.artist?.name || 'Unknown Artist',
cover: album.cover,
description: '',
});
renderEditFavoriteAlbums();
editFavoriteAlbumsSearch.value = '';
editFavoriteAlbumsResults.style.display = 'none';
};
editFavoriteAlbumsResults.appendChild(div);
});
editFavoriteAlbumsResults.style.display = 'block';
} catch (e) {
console.error('Album search failed', e);
}
}, 300);
editFavoriteAlbumsSearch.addEventListener('input', (e) => performFavoriteAlbumSearch(e.target.value.trim()));
function getLastFmImage(images) {
if (!images) return null;
const imgArray = Array.isArray(images) ? images : [images];
const sizes = ['extralarge', 'large', 'medium', 'small'];
const placeholders = ['2a96cbd8b46e442fc41c2b86b821562f', 'c6f59c1e5e7240a4c0d427abd71f3dbb'];
const isValidUrl = (url) => {
if (!url) return false;
return !placeholders.some((ph) => url.includes(ph));
};
for (const size of sizes) {
const img = imgArray.find((i) => i.size === size);
if (img && img['#text'] && isValidUrl(img['#text'])) return img['#text'];
}
const anyImg = imgArray.find((i) => i['#text'] && isValidUrl(i['#text']));
if (anyImg) return anyImg['#text'];
return null;
}
async function handleArtistClick(name) {
try {
const results = await api.searchArtists(name, { limit: 1 });
if (results.items.length > 0) {
navigate(`/artist/${results.items[0].id}`);
} else {
alert('Artist not found in library');
}
} catch (e) {
console.error(e);
}
}
async function handleAlbumClick(name, artist) {
try {
const query = `${name} ${artist}`;
const results = await api.searchAlbums(query, { limit: 1 });
if (results.items.length > 0) {
navigate(`/album/${results.items[0].id}`);
} else {
alert('Album not found in library');
}
} catch (e) {
console.error(e);
}
}
async function handleTrackClick(title, artist) {
try {
const query = `${title} ${artist}`;
const results = await api.searchTracks(query, { limit: 1 });
if (results.items.length > 0) {
const track = results.items[0];
if (Player.instance) {
Player.instance.setQueue([track], 0);
Player.instance.playTrackFromQueue();
}
} else {
alert('Track not found');
}
} catch (e) {
console.error(e);
}
}
async function fetchFallbackCover(title, artist, imgId) {
try {
const query = `${title} ${artist}`;
await new Promise((r) => setTimeout(r, 100));
const results = await api.searchTracks(query, { limit: 5 });
let foundCover = false;
if (results.items && results.items.length > 0) {
const found = results.items.find((item) => item.album?.cover);
if (found) {
const newUrl = api.getCoverUrl(found.album.cover);
const imgEl = document.getElementById(imgId);
if (imgEl) {
imgEl.src = newUrl;
foundCover = true;
}
}
}
if (!foundCover) {
await fetchFallbackArtistImage(artist, imgId);
}
} catch {
await fetchFallbackArtistImage(artist, imgId);
}
}
async function fetchFallbackAlbumCover(title, artist, imgId) {
try {
const query = `${title} ${artist}`;
await new Promise((r) => setTimeout(r, 100));
const results = await api.searchAlbums(query, { limit: 5 });
let foundCover = false;
if (results.items && results.items.length > 0) {
const found = results.items.find((item) => item.cover);
if (found) {
const newUrl = api.getCoverUrl(found.cover);
const imgEl = document.getElementById(imgId);
if (imgEl) {
imgEl.src = newUrl;
foundCover = true;
}
}
}
if (!foundCover) {
await fetchFallbackArtistImage(artist, imgId);
}
} catch {
await fetchFallbackArtistImage(artist, imgId);
}
}
async function fetchFallbackArtistImage(artistName, imgId) {
try {
await new Promise((r) => setTimeout(r, 100));
const results = await api.searchArtists(artistName, { limit: 3 });
if (results.items && results.items.length > 0) {
const found = results.items.find((item) => item.picture);
if (found) {
const newUrl = api.getArtistPictureUrl(found.picture);
const imgEl = document.getElementById(imgId);
if (imgEl) imgEl.src = newUrl;
}
}
} catch {
// Silently ignore errors
}
}
async function fetchLastFmRecentTracks(username) {
const apiKey = '85214f5abbc730e78770f27784b9bdf7';
const url = `https://ws.audioscrobbler.com/2.0/?method=user.getrecenttracks&user=${encodeURIComponent(username)}&api_key=${apiKey}&format=json&limit=5`;
try {
const res = await fetch(url);
const data = await res.json();
const tracks = data.recenttracks?.track;
if (!tracks) return [];
return Array.isArray(tracks) ? tracks : [tracks];
} catch (e) {
console.error('Failed to fetch Last.fm recent tracks', e);
return [];
}
}
async function fetchLastFmTopArtists(username) {
const apiKey = '85214f5abbc730e78770f27784b9bdf7';
const url = `https://ws.audioscrobbler.com/2.0/?method=user.gettopartists&user=${encodeURIComponent(username)}&api_key=${apiKey}&format=json&limit=6`;
try {
const res = await fetch(url);
const data = await res.json();
return data.topartists?.artist || [];
} catch (e) {
console.error('Failed to fetch Last.fm top artists', e);
return [];
}
}
async function fetchLastFmTopAlbums(username) {
const apiKey = '85214f5abbc730e78770f27784b9bdf7';
const url = `https://ws.audioscrobbler.com/2.0/?method=user.gettopalbums&user=${encodeURIComponent(username)}&api_key=${apiKey}&format=json&limit=6`;
try {
const res = await fetch(url);
const data = await res.json();
return data.topalbums?.album || [];
} catch (e) {
console.error('Failed to fetch Last.fm top albums', e);
return [];
}
}
async function fetchLastFmTopTracks(username) {
const apiKey = '85214f5abbc730e78770f27784b9bdf7';
const url = `https://ws.audioscrobbler.com/2.0/?method=user.gettoptracks&user=${encodeURIComponent(username)}&api_key=${apiKey}&format=json&limit=5`;
try {
const res = await fetch(url);
const data = await res.json();
return data.toptracks?.track || [];
} catch (e) {
console.error('Failed to fetch Last.fm top tracks', e);
return [];
}
}