514 lines
19 KiB
JavaScript
514 lines
19 KiB
JavaScript
|
import { state } from './state.js';
|
||
|
import { debounce, _, showNotification } from './utils.js';
|
||
|
import { fetchArtistGenresFromLastFM } from './api.js';
|
||
|
import { getFromDB, addItemsToStore } from './db.js';
|
||
|
|
||
|
let musicSection, genreGrid, artistGrid, songListView, searchInput, serverFilter, loadMoreBtn, backBtn, genreSearchInput;
|
||
|
let isInitialized = false;
|
||
|
let isClassifying = false;
|
||
|
let currentPage = 0;
|
||
|
const ARTISTS_PER_PAGE = 20;
|
||
|
|
||
|
let currentMusicView = 'genres';
|
||
|
let selectedGenre = null;
|
||
|
let genreScrollPosition = 0;
|
||
|
|
||
|
export function initMusicView() {
|
||
|
if (typeof chrome === 'undefined' || !chrome.storage) {
|
||
|
console.warn('Running outside of Chrome extension, skipping initMusicView.');
|
||
|
return;
|
||
|
}
|
||
|
musicSection = document.getElementById('music-section');
|
||
|
if (!musicSection) return;
|
||
|
|
||
|
if (state.musicPlayer && state.musicPlayer.isReady) {
|
||
|
setupMusicView();
|
||
|
} else {
|
||
|
const initialGrid = document.getElementById('music-section-genre-grid');
|
||
|
if (initialGrid) initialGrid.innerHTML = `<div class="col-12 text-center mt-5"><div class="spinner"></div><p class="mt-2">${_('loading')}</p></div>`;
|
||
|
|
||
|
const checkReady = setInterval(() => {
|
||
|
if (state.musicPlayer && state.musicPlayer.isReady) {
|
||
|
clearInterval(checkReady);
|
||
|
setupMusicView();
|
||
|
}
|
||
|
}, 150);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async function setupMusicView() {
|
||
|
genreGrid = document.getElementById('music-section-genre-grid');
|
||
|
artistGrid = document.getElementById('music-section-artist-grid');
|
||
|
songListView = document.getElementById('music-section-song-list');
|
||
|
searchInput = document.getElementById('music-section-search-input');
|
||
|
serverFilter = document.getElementById('music-server-filter');
|
||
|
loadMoreBtn = document.getElementById('music-load-more-btn');
|
||
|
backBtn = document.getElementById('music-back-btn');
|
||
|
genreSearchInput = document.getElementById('genre-search-input');
|
||
|
|
||
|
if (!genreGrid || !artistGrid || !songListView || !searchInput || !serverFilter || !loadMoreBtn || !backBtn || !genreSearchInput) return;
|
||
|
|
||
|
if (!isInitialized) {
|
||
|
serverFilter.addEventListener('change', () => {
|
||
|
currentPage = 0;
|
||
|
renderArtists();
|
||
|
});
|
||
|
searchInput.addEventListener('input', debounce(() => {
|
||
|
currentPage = 0;
|
||
|
renderArtists();
|
||
|
}, 300));
|
||
|
genreSearchInput.addEventListener('input', debounce(() => {
|
||
|
renderGenres();
|
||
|
}, 300));
|
||
|
loadMoreBtn.addEventListener('click', () => {
|
||
|
currentPage++;
|
||
|
renderArtists(true);
|
||
|
});
|
||
|
backBtn.addEventListener('click', navigateBack);
|
||
|
genreGrid.addEventListener('click', handleGenreClick);
|
||
|
artistGrid.addEventListener('click', handleArtistClick);
|
||
|
isInitialized = true;
|
||
|
}
|
||
|
|
||
|
if (isClassifying) {
|
||
|
const overlay = document.getElementById('music-classification-overlay');
|
||
|
overlay.style.display = 'flex';
|
||
|
} else {
|
||
|
await checkAndClassifyArtists();
|
||
|
}
|
||
|
|
||
|
currentMusicView = 'genres';
|
||
|
selectedGenre = null;
|
||
|
await renderMusicView();
|
||
|
}
|
||
|
|
||
|
async function checkAndClassifyArtists() {
|
||
|
if (isClassifying) {
|
||
|
const overlay = document.getElementById('music-classification-overlay');
|
||
|
overlay.style.display = 'flex';
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const allServers = await getFromDB('artists');
|
||
|
let artistsToClassify = [];
|
||
|
|
||
|
allServers.forEach(server => {
|
||
|
if (server.titulos) {
|
||
|
server.titulos.forEach(artist => {
|
||
|
if (!artist.hasOwnProperty('genres')) {
|
||
|
artistsToClassify.push(artist);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
});
|
||
|
|
||
|
if (artistsToClassify.length > 0) {
|
||
|
isClassifying = true;
|
||
|
const overlay = document.getElementById('music-classification-overlay');
|
||
|
overlay.style.display = 'flex';
|
||
|
const totalArtists = artistsToClassify.length;
|
||
|
|
||
|
try {
|
||
|
for (let i = 0; i < artistsToClassify.length; i += 5) {
|
||
|
const batch = artistsToClassify.slice(i, i + 5);
|
||
|
const promises = batch.map(artist =>
|
||
|
fetchArtistGenresFromLastFM(artist.title).then(genres => ({ artist, genres }))
|
||
|
);
|
||
|
|
||
|
await Promise.all(promises.map(p => p.then(({ artist, genres }) => {
|
||
|
artist.genres = genres.length > 0 ? genres : ['Varios'];
|
||
|
})));
|
||
|
|
||
|
const processedCount = Math.min(i + batch.length, totalArtists);
|
||
|
const percentage = Math.round((processedCount / totalArtists) * 100);
|
||
|
const nextArtist = artistsToClassify[processedCount];
|
||
|
|
||
|
updateClassificationProgress(percentage, processedCount, totalArtists, nextArtist ? nextArtist.title : null);
|
||
|
}
|
||
|
|
||
|
await addItemsToStore('artists', allServers);
|
||
|
await state.musicPlayer.loadMusicData();
|
||
|
showNotification('Clasificación de música completada.', 'success');
|
||
|
|
||
|
} catch (error) {
|
||
|
showNotification('Ocurrió un error durante la clasificación de artistas.', 'error');
|
||
|
} finally {
|
||
|
isClassifying = false;
|
||
|
gsap.to(overlay, {
|
||
|
autoAlpha: 0,
|
||
|
duration: 0.5,
|
||
|
onComplete: () => {
|
||
|
overlay.style.display = 'none';
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
function updateClassificationProgress(percentage, processedCount, totalArtists, nextArtistTitle) {
|
||
|
const progressFill = document.getElementById('classification-progress-fill');
|
||
|
const percentageText = document.getElementById('classification-percentage');
|
||
|
const progressDetails = document.getElementById('classification-progress-details');
|
||
|
const statusText = document.getElementById('classification-status-text');
|
||
|
|
||
|
if (progressFill) progressFill.style.width = `${percentage}%`;
|
||
|
if (percentageText) percentageText.textContent = `${percentage}%`;
|
||
|
if (progressDetails) progressDetails.textContent = `${processedCount} / ${totalArtists} artistas`;
|
||
|
|
||
|
if (statusText) {
|
||
|
if (nextArtistTitle) {
|
||
|
statusText.textContent = `Analizando: ${nextArtistTitle}...`;
|
||
|
} else {
|
||
|
statusText.textContent = `Finalizando...`;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
|
||
|
async function renderMusicView() {
|
||
|
genreGrid.style.display = 'none';
|
||
|
artistGrid.style.display = 'none';
|
||
|
songListView.style.display = 'none';
|
||
|
serverFilter.style.display = 'none';
|
||
|
searchInput.parentElement.style.display = 'none';
|
||
|
genreSearchInput.parentElement.style.display = 'none';
|
||
|
backBtn.style.display = 'none';
|
||
|
loadMoreBtn.style.display = 'none';
|
||
|
|
||
|
switch (currentMusicView) {
|
||
|
case 'genres':
|
||
|
genreSearchInput.parentElement.style.display = 'flex';
|
||
|
await renderGenres();
|
||
|
break;
|
||
|
case 'artists':
|
||
|
backBtn.style.display = 'inline-flex';
|
||
|
serverFilter.style.display = 'block';
|
||
|
searchInput.parentElement.style.display = 'flex';
|
||
|
renderArtists();
|
||
|
break;
|
||
|
case 'songs':
|
||
|
break;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async function renderGenres() {
|
||
|
currentMusicView = 'genres';
|
||
|
genreGrid.style.display = 'grid';
|
||
|
genreGrid.innerHTML = '';
|
||
|
const allArtists = state.musicPlayer._generateFullArtistListForToken('all');
|
||
|
|
||
|
const genreSet = new Set();
|
||
|
allArtists.forEach(artist => {
|
||
|
if (artist.genres && artist.genres.length > 0) {
|
||
|
artist.genres.forEach(g => genreSet.add(g));
|
||
|
}
|
||
|
});
|
||
|
|
||
|
let sortedGenres = Array.from(genreSet).sort((a, b) => a.localeCompare(b));
|
||
|
|
||
|
const filter = genreSearchInput.value.toLowerCase();
|
||
|
if (filter) {
|
||
|
sortedGenres = sortedGenres.filter(genre => genre.toLowerCase().includes(filter));
|
||
|
}
|
||
|
|
||
|
if (sortedGenres.length === 0) {
|
||
|
genreGrid.innerHTML = `<div class="empty-state"><i class="fas fa-microphone-slash"></i><p>${_('noArtistsFound')}</p></div>`;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const fragment = document.createDocumentFragment();
|
||
|
sortedGenres.forEach(genre => {
|
||
|
const card = document.createElement('div');
|
||
|
card.className = 'genre-card';
|
||
|
card.textContent = genre;
|
||
|
card.dataset.genre = genre;
|
||
|
fragment.appendChild(card);
|
||
|
});
|
||
|
genreGrid.appendChild(fragment);
|
||
|
}
|
||
|
|
||
|
function handleGenreClick(e) {
|
||
|
const card = e.target.closest('.genre-card');
|
||
|
if (card && card.dataset.genre) {
|
||
|
genreScrollPosition = window.scrollY;
|
||
|
window.scrollTo(0, 0);
|
||
|
selectedGenre = card.dataset.genre;
|
||
|
currentMusicView = 'artists';
|
||
|
currentPage = 0;
|
||
|
populateServerFilter();
|
||
|
renderMusicView();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async function navigateBack() {
|
||
|
if (currentMusicView === 'songs') {
|
||
|
currentMusicView = 'artists';
|
||
|
await renderMusicView();
|
||
|
} else if (currentMusicView === 'artists') {
|
||
|
selectedGenre = null;
|
||
|
currentMusicView = 'genres';
|
||
|
await renderMusicView();
|
||
|
requestAnimationFrame(() => {
|
||
|
window.scrollTo(0, genreScrollPosition);
|
||
|
});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function populateServerFilter() {
|
||
|
if (!serverFilter || !state.musicPlayer) return;
|
||
|
|
||
|
const currentVal = serverFilter.value;
|
||
|
serverFilter.innerHTML = `<option value="all">${_('musicAllServers')}</option>`;
|
||
|
|
||
|
state.musicPlayer.tokens.forEach(token => {
|
||
|
const option = document.createElement('option');
|
||
|
option.value = token.value;
|
||
|
option.textContent = token.name;
|
||
|
serverFilter.appendChild(option);
|
||
|
});
|
||
|
|
||
|
if (currentVal) serverFilter.value = currentVal;
|
||
|
}
|
||
|
|
||
|
function renderArtists(append = false) {
|
||
|
if (!artistGrid || !state.musicPlayer) return;
|
||
|
artistGrid.style.display = 'grid';
|
||
|
|
||
|
const musicPlayer = state.musicPlayer;
|
||
|
const selectedServer = serverFilter.value;
|
||
|
const filter = searchInput.value.toLowerCase();
|
||
|
|
||
|
let allArtists = musicPlayer._generateFullArtistListForToken(selectedServer);
|
||
|
|
||
|
if (selectedGenre) {
|
||
|
allArtists = allArtists.filter(artist => artist.genres && artist.genres.map(g => g.toLowerCase()).includes(selectedGenre.toLowerCase()));
|
||
|
}
|
||
|
|
||
|
const filteredArtists = filter
|
||
|
? allArtists.filter(artist => artist.title.toLowerCase().includes(filter))
|
||
|
: allArtists;
|
||
|
|
||
|
if (!append) {
|
||
|
artistGrid.innerHTML = '';
|
||
|
currentPage = 0;
|
||
|
}
|
||
|
|
||
|
if (filteredArtists.length === 0 && !append) {
|
||
|
artistGrid.innerHTML = `<div class="empty-state" style="grid-column: 1 / -1;"><i class="fas fa-microphone-slash"></i><p>${_('noArtistsFound')}</p></div>`;
|
||
|
loadMoreBtn.style.display = 'none';
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const startIndex = currentPage * ARTISTS_PER_PAGE;
|
||
|
const endIndex = startIndex + ARTISTS_PER_PAGE;
|
||
|
const artistsToRender = filteredArtists.slice(startIndex, endIndex);
|
||
|
|
||
|
const fragment = document.createDocumentFragment();
|
||
|
artistsToRender.forEach(artist => {
|
||
|
const card = createMusicCard(artist.title, artist.serverName, artist.thumb, artist.id, artist.isJellyfin, artist.token, artist.protocolo, artist.ip, artist.puerto);
|
||
|
fragment.appendChild(card);
|
||
|
});
|
||
|
artistGrid.appendChild(fragment);
|
||
|
|
||
|
if (endIndex < filteredArtists.length) {
|
||
|
loadMoreBtn.style.display = 'block';
|
||
|
} else {
|
||
|
loadMoreBtn.style.display = 'none';
|
||
|
}
|
||
|
|
||
|
if (musicPlayer) {
|
||
|
musicPlayer.markCurrentArtist();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async function handleArtistClick(e) {
|
||
|
const card = e.target.closest('.artist-card-spotify');
|
||
|
if (card && card.dataset.id) {
|
||
|
currentMusicView = 'songs';
|
||
|
const artistId = card.dataset.id;
|
||
|
const musicPlayer = state.musicPlayer;
|
||
|
if (musicPlayer) {
|
||
|
const fullList = musicPlayer._generateFullArtistListForToken(serverFilter.value);
|
||
|
const artistData = fullList.find(a => a.id == artistId);
|
||
|
if (artistData) {
|
||
|
const songs = await musicPlayer.getArtistSongs(artistData);
|
||
|
renderSongList(artistData, songs);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
async function renderSongList(artistData, songs) {
|
||
|
artistGrid.style.display = 'none';
|
||
|
genreGrid.style.display = 'none';
|
||
|
searchInput.parentElement.style.display = 'none';
|
||
|
serverFilter.style.display = 'none';
|
||
|
loadMoreBtn.style.display = 'none';
|
||
|
backBtn.style.display = 'none';
|
||
|
songListView.style.display = 'block';
|
||
|
songListView.innerHTML = '';
|
||
|
|
||
|
let thumbUrl = 'img/no-profile.png';
|
||
|
if (artistData.thumb) {
|
||
|
if (artistData.isJellyfin) {
|
||
|
thumbUrl = artistData.thumb;
|
||
|
} else {
|
||
|
thumbUrl = `${artistData.protocolo}://${artistData.ip}:${artistData.puerto}${artistData.thumb}?X-Plex-Token=${artistData.token}`;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const header = document.createElement('div');
|
||
|
header.className = 'song-list-header-spotify';
|
||
|
header.innerHTML = `
|
||
|
<button id="backToArtistsViewBtn" class="btn-icon"><i class="fas fa-arrow-left"></i></button>
|
||
|
<img src="${thumbUrl}" class="artist-header-thumb-spotify" crossorigin="anonymous" onerror="this.onerror=null;this.src='img/no-profile.png';">
|
||
|
<div class="artist-header-info-spotify">
|
||
|
<span class="artist-header-type">${_('artist')}</span>
|
||
|
<h1>${artistData.title}</h1>
|
||
|
</div>
|
||
|
`;
|
||
|
songListView.appendChild(header);
|
||
|
|
||
|
const img = header.querySelector('.artist-header-thumb-spotify');
|
||
|
img.onload = () => {
|
||
|
if(img.src.includes('no-profile.png')) return;
|
||
|
const canvas = document.createElement('canvas');
|
||
|
const ctx = canvas.getContext('2d');
|
||
|
canvas.width = img.width;
|
||
|
canvas.height = img.height;
|
||
|
ctx.drawImage(img, 0, 0);
|
||
|
const data = ctx.getImageData(0, 0, 1, 1).data;
|
||
|
const color = `rgba(${data[0]}, ${data[1]}, ${data[2]}, 0.5)`;
|
||
|
const darkColor = `rgba(${Math.round(data[0] * 0.3)}, ${Math.round(data[1] * 0.3)}, ${Math.round(data[2] * 0.3)}, 0.8)`;
|
||
|
header.style.background = `linear-gradient(135deg, ${color} 0%, ${darkColor} 100%)`;
|
||
|
};
|
||
|
|
||
|
const songListContainer = document.createElement('div');
|
||
|
songListContainer.className = 'song-list-container-spotify';
|
||
|
songListView.appendChild(songListContainer);
|
||
|
|
||
|
songListContainer.addEventListener('click', e => {
|
||
|
const row = e.target.closest('.song-grid-row');
|
||
|
if(row) {
|
||
|
const songId = row.dataset.id;
|
||
|
const originalIndex = songs.findIndex(s => s.id === songId);
|
||
|
if(originalIndex !== -1) {
|
||
|
state.musicPlayer.cancionesActuales = songs;
|
||
|
state.musicPlayer.playSong(originalIndex);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
const albumPlayBtn = e.target.closest('.album-play-btn');
|
||
|
if(albumPlayBtn) {
|
||
|
const albumTitle = albumPlayBtn.dataset.album;
|
||
|
const albumSongs = songs.filter(s => s.album === albumTitle);
|
||
|
if(albumSongs.length > 0) {
|
||
|
state.musicPlayer.cancionesActuales = albumSongs;
|
||
|
state.musicPlayer.playSong(0);
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|
||
|
if (songs.length === 0) {
|
||
|
songListContainer.innerHTML = `<div class="empty-state"><i class="fas fa-music"></i><p>${_('noSongsFound')}</p></div>`;
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const albums = songs.reduce((acc, song) => {
|
||
|
const albumTitle = song.album || 'Unknown Album';
|
||
|
if (!acc[albumTitle]) {
|
||
|
acc[albumTitle] = {
|
||
|
cover: song.cover,
|
||
|
songs: []
|
||
|
};
|
||
|
}
|
||
|
acc[albumTitle].songs.push(song);
|
||
|
return acc;
|
||
|
}, {});
|
||
|
|
||
|
for (const albumTitle in albums) {
|
||
|
const album = albums[albumTitle];
|
||
|
const albumContainer = document.createElement('div');
|
||
|
albumContainer.className = 'album-group-container-spotify';
|
||
|
|
||
|
albumContainer.innerHTML = `
|
||
|
<div class="album-group-header-spotify">
|
||
|
<img src="${album.cover || 'img/no-poster.png'}" class="album-group-cover-art-spotify" onerror="this.onerror=null;this.src='img/no-poster.png';">
|
||
|
<div class="album-info-spotify">
|
||
|
<h3 class="album-group-title-spotify">${albumTitle}</h3>
|
||
|
<span class="album-track-count">${album.songs.length} ${_('tracks')}</span>
|
||
|
</div>
|
||
|
<button class="album-play-btn" data-album="${albumTitle}">
|
||
|
<i class="fas fa-play"></i>
|
||
|
</button>
|
||
|
</div>
|
||
|
<div class="song-list-grid">
|
||
|
<div class="song-list-grid-header">
|
||
|
<div>#</div>
|
||
|
<div>${_('infoModalFieldTitle')}</div>
|
||
|
<div class="song-duration-header"><i class="far fa-clock"></i></div>
|
||
|
</div>
|
||
|
</div>
|
||
|
`;
|
||
|
|
||
|
const songsGrid = albumContainer.querySelector('.song-list-grid');
|
||
|
album.songs.forEach(song => {
|
||
|
const songRow = document.createElement('div');
|
||
|
songRow.className = 'song-grid-row';
|
||
|
songRow.dataset.id = song.id;
|
||
|
|
||
|
const originalIndex = songs.findIndex(s => s.id === song.id);
|
||
|
|
||
|
songRow.innerHTML = `
|
||
|
<div class="song-index">
|
||
|
<span class="track-number">${song.index || originalIndex + 1}</span>
|
||
|
<i class="fas fa-play play-icon-spotify"></i>
|
||
|
<div class="playing-indicator"><div></div><div></div><div></div></div>
|
||
|
</div>
|
||
|
<div class="song-title-artist">
|
||
|
<span class="song-title-spotify">${song.titulo}</span>
|
||
|
</div>
|
||
|
<div class="song-duration">${song.duration ? state.musicPlayer.formatTime(song.duration / 1000) : '--:--'}</div>
|
||
|
`;
|
||
|
|
||
|
songsGrid.appendChild(songRow);
|
||
|
});
|
||
|
songListContainer.appendChild(albumContainer);
|
||
|
}
|
||
|
|
||
|
document.getElementById('backToArtistsViewBtn').addEventListener('click', navigateBack);
|
||
|
|
||
|
if (state.musicPlayer) {
|
||
|
state.musicPlayer.markCurrentSong();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
function createMusicCard(title, subtitle, imageUrl, artistId, isJellyfin, token, protocolo, ip, puerto) {
|
||
|
const card = document.createElement('div');
|
||
|
card.className = 'artist-card-spotify';
|
||
|
card.dataset.id = artistId;
|
||
|
|
||
|
let thumbUrl = 'img/no-profile.png';
|
||
|
if (imageUrl) {
|
||
|
if (isJellyfin === 'true' || isJellyfin === true) {
|
||
|
thumbUrl = imageUrl;
|
||
|
} else {
|
||
|
thumbUrl = `${protocolo}://${ip}:${puerto}${imageUrl}?X-Plex-Token=${token}`;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
card.innerHTML = `
|
||
|
<div class="artist-card-img-container">
|
||
|
<img src="${thumbUrl}" class="artist-card-img" alt="${title}" loading="lazy" onerror="this.onerror=null;this.src='img/no-profile.png';">
|
||
|
<div class="artist-card-play-btn">
|
||
|
<i class="fas fa-play"></i>
|
||
|
</div>
|
||
|
</div>
|
||
|
<div class="artist-card-info">
|
||
|
<p class="artist-card-title-spotify">${title}</p>
|
||
|
<p class="artist-card-subtitle">${_('artist')}</p>
|
||
|
</div>
|
||
|
`;
|
||
|
return card;
|
||
|
}
|