diff --git a/_locales/de/messages.json b/_locales/de/messages.json index f4618a0..cce5ec8 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -314,5 +314,18 @@ "searchOnPlex": { "message": "Auf Plex suchen" }, "jellyfinTitle": { "message": "Jellyfin-Inhalt" }, "noJellyfinContent": { "message": "Kein Jellyfin-Inhalt gefunden." }, - "noJellyfinContentSub": { "message": "Stelle sicher, dass du deinen Jellyfin-Server in den Einstellungen gescannt hast." } + "noJellyfinContentSub": { "message": "Stelle sicher, dass du deinen Jellyfin-Server in den Einstellungen gescannt hast." }, + "activityViewerTitle": { "message": "Server-Aktivitätsanzeige" }, + "activitySelectServer": { "message": "Wählen Sie einen Server aus" }, + "activityCheckBtn": { "message": "Aktualisieren" }, + "activityNoSessions": { "message": "Keine aktiven Sitzungen auf diesem Server." }, + "activitySessionUser": { "message": "Benutzer" }, + "activitySessionDevice": { "message": "Gerät" }, + "activitySessionContent": { "message": "Inhalt" }, + "activitySessionState": { "message": "Status" }, + "activitySessionIdentifier": { "message": "Client-Kennung" }, + "activityCopyID": { "message": "ID kopieren" }, + "activityError": { "message": "Serveraktivität konnte nicht abgerufen werden." }, + "activityCopied": { "message": "Kennung in die Zwischenablage kopiert!" }, + "activityCopyError": { "message": "Fehler beim Kopieren der Kennung." } } \ No newline at end of file diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 555b084..5d52025 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -314,5 +314,18 @@ "searchOnPlex": { "message": "Search on Plex" }, "jellyfinTitle": { "message": "Jellyfin Content" }, "noJellyfinContent": { "message": "No Jellyfin content found." }, - "noJellyfinContentSub": { "message": "Make sure you have scanned your Jellyfin server in the settings." } + "noJellyfinContentSub": { "message": "Make sure you have scanned your Jellyfin server in the settings." }, + "activityViewerTitle": { "message": "Server Activity Viewer" }, + "activitySelectServer": { "message": "Select a server" }, + "activityCheckBtn": { "message": "Refresh" }, + "activityNoSessions": { "message": "No active sessions on this server." }, + "activitySessionUser": { "message": "User" }, + "activitySessionDevice": { "message": "Device" }, + "activitySessionContent": { "message": "Content" }, + "activitySessionState": { "message": "State" }, + "activitySessionIdentifier": { "message": "Client Identifier" }, + "activityCopyID": { "message": "Copy ID" }, + "activityError": { "message": "Could not fetch server activity." }, + "activityCopied": { "message": "Identifier copied to clipboard!" }, + "activityCopyError": { "message": "Failed to copy identifier." } } \ No newline at end of file diff --git a/_locales/es/messages.json b/_locales/es/messages.json index f208a0e..b75e60b 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -169,7 +169,7 @@ "updatingView": { "message": "Actualizando la vista con los nuevos datos..." }, "confirmClearContent": { "message": "¿Estás seguro de que deseas borrar los datos de contenido locales (Películas, Series, Música, etc.)? Los Favoritos y Ajustes NO se borrarán." }, "trailerNotFound": { "message": "No se encontró tráiler para este título." }, - "confirmClearHistory": { "message": "¿Estás seguro de que deseas borrar todo tu historial de visualización? Esta acción no se puede deshacer." }, + "confirmClearHistory": { "message": "¿Estás seguro de que deseas borrar todo tu historial de visualización? Esta acción no se puede rehacer." }, "historyCleared": { "message": "Historial de visualización borrado." }, "historyItemDeleted": { "message": "Elemento borrado del historial." }, "errorGeneratingScript": { "message": "Primero genera un script para poder copiarlo." }, @@ -314,5 +314,18 @@ "searchOnPlex": { "message": "Buscar en Plex" }, "jellyfinTitle": { "message": "Contenido de Jellyfin" }, "noJellyfinContent": { "message": "No se encontró contenido de Jellyfin." }, - "noJellyfinContentSub": { "message": "Asegúrate de haber escaneado tu servidor Jellyfin en los ajustes." } + "noJellyfinContentSub": { "message": "Asegúrate de haber escaneado tu servidor Jellyfin en los ajustes." }, + "activityViewerTitle": { "message": "Visor de Actividad del Servidor" }, + "activitySelectServer": { "message": "Selecciona un servidor" }, + "activityCheckBtn": { "message": "Actualizar" }, + "activityNoSessions": { "message": "No hay sesiones activas en este servidor." }, + "activitySessionUser": { "message": "Usuario" }, + "activitySessionDevice": { "message": "Dispositivo" }, + "activitySessionContent": { "message": "Contenido" }, + "activitySessionState": { "message": "Estado" }, + "activitySessionIdentifier": { "message": "Identificador del Cliente" }, + "activityCopyID": { "message": "Copiar ID" }, + "activityError": { "message": "No se pudo obtener la actividad del servidor." }, + "activityCopied": { "message": "¡Identificador copiado al portapapeles!" }, + "activityCopyError": { "message": "Error al copiar el identificador." } } \ No newline at end of file diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index b4db336..fd40009 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -314,5 +314,18 @@ "searchOnPlex": { "message": "Rechercher sur Plex" }, "jellyfinTitle": { "message": "Contenu Jellyfin" }, "noJellyfinContent": { "message": "Aucun contenu Jellyfin trouvé." }, - "noJellyfinContentSub": { "message": "Assurez-vous d'avoir scanné votre serveur Jellyfin dans les paramètres." } + "noJellyfinContentSub": { "message": "Assurez-vous d'avoir scanné votre serveur Jellyfin dans les paramètres." }, + "activityViewerTitle": { "message": "Visualiseur d'Activité du Serveur" }, + "activitySelectServer": { "message": "Sélectionnez un serveur" }, + "activityCheckBtn": { "message": "Actualiser" }, + "activityNoSessions": { "message": "Aucune session active sur ce serveur." }, + "activitySessionUser": { "message": "Utilisateur" }, + "activitySessionDevice": { "message": "Appareil" }, + "activitySessionContent": { "message": "Contenu" }, + "activitySessionState": { "message": "État" }, + "activitySessionIdentifier": { "message": "Identifiant du Client" }, + "activityCopyID": { "message": "Copier l'ID" }, + "activityError": { "message": "Impossible de récupérer l'activité du serveur." }, + "activityCopied": { "message": "Identifiant copié dans le presse-papiers !" }, + "activityCopyError": { "message": "Échec de la copie de l'identifiant." } } \ No newline at end of file diff --git a/_locales/it/messages.json b/_locales/it/messages.json index 102444b..3c10de0 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -314,5 +314,18 @@ "searchOnPlex": { "message": "Cerca su Plex" }, "jellyfinTitle": { "message": "Contenuto Jellyfin" }, "noJellyfinContent": { "message": "Nessun contenuto Jellyfin trovato." }, - "noJellyfinContentSub": { "message": "Assicurati di aver scansionato il tuo server Jellyfin nelle impostazioni." } + "noJellyfinContentSub": { "message": "Assicurati di aver scansionato il tuo server Jellyfin nelle impostazioni." }, + "activityViewerTitle": { "message": "Visualizzatore Attività Server" }, + "activitySelectServer": { "message": "Seleziona un server" }, + "activityCheckBtn": { "message": "Aggiorna" }, + "activityNoSessions": { "message": "Nessuna sessione attiva su questo server." }, + "activitySessionUser": { "message": "Utente" }, + "activitySessionDevice": { "message": "Dispositivo" }, + "activitySessionContent": { "message": "Contenuto" }, + "activitySessionState": { "message": "Stato" }, + "activitySessionIdentifier": { "message": "Identificatore Client" }, + "activityCopyID": { "message": "Copia ID" }, + "activityError": { "message": "Impossibile recuperare l'attività del server." }, + "activityCopied": { "message": "Identificatore copiato negli appunti!" }, + "activityCopyError": { "message": "Copia dell'identificatore non riuscita." } } \ No newline at end of file diff --git a/_locales/pt/messages.json b/_locales/pt/messages.json index 35a6ef3..41d163b 100644 --- a/_locales/pt/messages.json +++ b/_locales/pt/messages.json @@ -304,8 +304,8 @@ "jellyfinFetchFailed": { "message": "Falha ao buscar bibliotecas: $message$", "placeholders": { "message": { "content": "$1" } } }, "jellyfinNoMediaLibraries": { "message": "Nenhuma biblioteca de filmes ou séries encontrada no Jellyfin." }, "jellyfinLibrariesFound": { "message": "$count$ biblioteca(s) de mídia encontrada(s).", "placeholders": { "count": { "content": "$1" } } }, - "jellyfinLibraryScanSuccess": { "message": "[Sucesso] Análise de '$libraryName concluída, $count$ títulos adicionados.", "placeholders": { "libraryName": { "content": "$1" }, "count": { "content": "$2" } } }, - "jellyfinLibraryScanFailed": { "message": "Falha ao analisar a biblioteca '$libraryName.", "placeholders": { "libraryName": { "content": "$1" } } }, + "jellyfinLibraryScanSuccess": { "message": "[Sucesso] Análise de '$libraryName' concluída, $count$ títulos adicionados.", "placeholders": { "libraryName": { "content": "$1" }, "count": { "content": "$2" } } }, + "jellyfinLibraryScanFailed": { "message": "Falha ao analisar a biblioteca '$libraryName'.", "placeholders": { "libraryName": { "content": "$1" } } }, "jellyfinScanSuccess": { "message": "Análise do Jellyfin concluída. Adicionados $movies$ filmes e $series$ séries.", "placeholders": { "movies": { "content": "$1" }, "series": { "content": "$2" } } }, "noJellyfinCredentials": { "message": "Credenciais do Jellyfin não configuradas." }, "notFoundOnJellyfin": { "message": "\"$query$\" não encontrado no Jellyfin.", "placeholders": { "query": { "content": "$1" } } }, @@ -314,5 +314,18 @@ "searchOnPlex": { "message": "Pesquisar no Plex" }, "jellyfinTitle": { "message": "Conteúdo do Jellyfin" }, "noJellyfinContent": { "message": "Nenhum conteúdo do Jellyfin encontrado." }, - "noJellyfinContentSub": { "message": "Certifique-se de que você analisou seu servidor Jellyfin nas configurações." } + "noJellyfinContentSub": { "message": "Certifique-se de que você analisou seu servidor Jellyfin nas configurações." }, + "activityViewerTitle": { "message": "Visualizador de Atividade do Servidor" }, + "activitySelectServer": { "message": "Selecione um servidor" }, + "activityCheckBtn": { "message": "Atualizar" }, + "activityNoSessions": { "message": "Nenhuma sessão ativa neste servidor." }, + "activitySessionUser": { "message": "Usuário" }, + "activitySessionDevice": { "message": "Dispositivo" }, + "activitySessionContent": { "message": "Conteúdo" }, + "activitySessionState": { "message": "Estado" }, + "activitySessionIdentifier": { "message": "Identificador do Cliente" }, + "activityCopyID": { "message": "Copiar ID" }, + "activityError": { "message": "Não foi possível buscar a atividade do servidor." }, + "activityCopied": { "message": "Identificador copiado para a área de transferência!" }, + "activityCopyError": { "message": "Falha ao copiar o identificador." } } \ No newline at end of file diff --git a/css/activity-viewer.css b/css/activity-viewer.css new file mode 100644 index 0000000..6c44e9f --- /dev/null +++ b/css/activity-viewer.css @@ -0,0 +1,68 @@ +#activityViewerModal .modal-body { + max-height: 70vh; + overflow-y: auto; +} + +.session-card { + display: flex; + gap: 1.5rem; + padding: 1.2rem; + background-color: var(--glass); + border: 1px solid var(--glass-border); + border-radius: var(--border-radius-md); + margin-bottom: 1rem; + transition: var(--transition); +} + +.session-card:hover { + background-color: rgba(255, 255, 255, 0.08); + border-color: var(--accent); +} + +.session-poster { + width: 80px; + height: 120px; + object-fit: cover; + border-radius: var(--border-radius-sm); + flex-shrink: 0; +} + +.session-info { + display: flex; + flex-direction: column; + flex-grow: 1; + gap: 1rem; +} + +.session-details p { + margin: 0 0 0.5rem 0; + font-size: 0.9rem; + color: var(--text-secondary); +} + +.session-details strong { + color: var(--text-primary); + font-weight: 600; +} + +.session-identifier { + margin-top: auto; +} + +.session-identifier label { + font-size: 0.8rem; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 0.25rem; +} + +.session-identifier .input-group .form-control { + background-color: var(--primary) !important; + font-family: monospace; + font-size: 0.85rem; +} + +#activity-results .empty-state { + padding: 2rem; + margin-top: 1rem; +} \ No newline at end of file diff --git a/css/footer.css b/css/footer.css index eaea68e..f7bd324 100644 --- a/css/footer.css +++ b/css/footer.css @@ -2,7 +2,7 @@ background: var(--secondary); padding: 1.5rem 2rem; border-top: 1px solid var(--glass-border); - margin-top: 4rem; + margin-top: 0; } .footer .container { diff --git a/css/hero.css b/css/hero.css index 123a14c..ce370d1 100644 --- a/css/hero.css +++ b/css/hero.css @@ -7,7 +7,7 @@ max-height: 800px; overflow: hidden; background-color: var(--primary); - margin-bottom: 3rem; + margin-bottom: 0; } .hero::before { @@ -17,7 +17,7 @@ left: 0; width: 100%; height: 100%; - background: linear-gradient(to top, var(--primary) 5%, rgba(10, 10, 15, 0.7) 40%, rgba(10, 10, 15, 0.2) 70%, transparent 100%), + background: linear-gradient(to top, var(--primary) 5%, rgba(0, 0, 0, 0.8) 40%, rgba(0, 0, 0, 0.4) 70%, transparent 100%), linear-gradient(to right, var(--primary) 10%, transparent 70%); z-index: 1; } diff --git a/css/main.css b/css/main.css index 87211ab..763e34e 100644 --- a/css/main.css +++ b/css/main.css @@ -9,4 +9,15 @@ @import url('footer.css'); @import url('overlays.css'); @import url('music-player.css'); -@import url('photos.css'); \ No newline at end of file +@import url('photos.css'); +@import url('activity-viewer.css'); + +/* Styles to manage hero loading state and content section visibility */ + +.hero.loading .hero-content { + opacity: 0; +} + +.hero:not(.loading) .hero-content { + opacity: 1; +} \ No newline at end of file diff --git a/css/music-player.css b/css/music-player.css index 714102a..a552fef 100644 --- a/css/music-player.css +++ b/css/music-player.css @@ -574,6 +574,39 @@ body.miniplayer-active #musicPlayerContainer { box-shadow: 0 0 15px rgba(0, 224, 255, 0.4); } +#closeMiniplayerBtn { + color: var(--text-secondary); +} + +#closeMiniplayerBtn:hover { + color: var(--accent); +} + +.fab-btn { + position: fixed; + bottom: 2rem; + right: 2rem; + width: 60px; + height: 60px; + border-radius: 50%; + background-color: var(--accent); + color: var(--primary); + border: none; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + cursor: pointer; + transition: transform 0.3s ease, box-shadow 0.3s ease; + z-index: 1030; +} + +.fab-btn:hover { + transform: scale(1.1); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); +} + .time-and-progress { display: flex; align-items: center; diff --git a/js/activityViewer.js b/js/activityViewer.js new file mode 100644 index 0000000..6a40eb0 --- /dev/null +++ b/js/activityViewer.js @@ -0,0 +1,155 @@ +import { state } from './state.js'; +import { getFromDB } from './db.js'; +import { fetchPlexSessions } from './api.js'; +import { showNotification, _ } from './utils.js'; + +export class ActivityViewer { + constructor(modalElement) { + this.modalElement = modalElement; + this.modal = new bootstrap.Modal(this.modalElement); + this.dom = {}; + this.isChecking = false; + + this.cacheDOM(); + this.bindEvents(); + } + + cacheDOM() { + this.dom.serverSelect = this.modalElement.querySelector('#activity-server-select'); + this.dom.checkBtn = this.modalElement.querySelector('#check-activity-btn'); + this.dom.loader = this.modalElement.querySelector('#activity-loader'); + this.dom.resultsContainer = this.modalElement.querySelector('#activity-results'); + } + + bindEvents() { + this.modalElement.addEventListener('show.bs.modal', () => this.onModalShow()); + this.dom.checkBtn.addEventListener('click', () => this.handleCheckActivity()); + this.dom.resultsContainer.addEventListener('click', (e) => { + if (e.target.classList.contains('copy-identifier-btn')) { + const identifier = e.target.dataset.identifier; + this.copyToClipboard(identifier, e.target); + } + }); + } + + async onModalShow() { + this.dom.resultsContainer.innerHTML = ''; + await this.populateServerSelect(); + } + + async populateServerSelect() { + this.dom.serverSelect.innerHTML = ``; + try { + const servers = await getFromDB('conexiones_locales'); + if (servers.length === 0) { + this.dom.serverSelect.innerHTML = ``; + this.dom.checkBtn.disabled = true; + return; + } + + this.dom.serverSelect.innerHTML = ''; + servers.forEach((server, index) => { + const option = document.createElement('option'); + option.value = index; + option.textContent = server.nombre || server.ip; + this.dom.serverSelect.appendChild(option); + }); + this.dom.checkBtn.disabled = false; + } catch (error) { + this.dom.serverSelect.innerHTML = ``; + this.dom.checkBtn.disabled = true; + } + } + + async handleCheckActivity() { + if (this.isChecking) return; + + const selectedIndex = this.dom.serverSelect.value; + if (selectedIndex === '') return; + + const servers = await getFromDB('conexiones_locales'); + const selectedServer = servers[selectedIndex]; + if (!selectedServer) return; + + this.isChecking = true; + this.dom.checkBtn.disabled = true; + this.dom.loader.style.display = 'block'; + this.dom.resultsContainer.innerHTML = ''; + + try { + const sessions = await fetchPlexSessions(selectedServer); + this.renderSessions(sessions, selectedServer); + } catch (error) { + this.dom.resultsContainer.innerHTML = `

${_('activityError')}

${error.message}

`; + } finally { + this.isChecking = false; + this.dom.checkBtn.disabled = false; + this.dom.loader.style.display = 'none'; + } + } + + renderSessions(sessions, server) { + if (sessions.length === 0) { + this.dom.resultsContainer.innerHTML = `

${_('activityNoSessions')}

`; + return; + } + + const fragment = document.createDocumentFragment(); + sessions.forEach(session => { + const card = this.createSessionCard(session, server); + fragment.appendChild(card); + }); + this.dom.resultsContainer.appendChild(fragment); + } + + createSessionCard(session, server) { + const card = document.createElement('div'); + card.className = 'session-card'; + + const posterUrl = session.thumb ? `${server.protocolo}://${server.ip}:${server.puerto}${session.thumb}?X-Plex-Token=${server.token}` : 'img/no-poster.png'; + + const contentTitle = session.grandparentTitle ? `${session.grandparentTitle} - ${session.title}` : session.title; + const playerStateIcon = session.Player.state === 'playing' ? 'fa-play' : 'fa-pause'; + const playerStateColor = session.Player.state === 'playing' ? 'text-success' : 'text-warning'; + + card.innerHTML = ` + Poster +
+
+

${_('activitySessionUser')}: ${session.User.title}

+

${_('activitySessionDevice')}: ${session.Player.product} (${session.Player.title})

+

${_('activitySessionContent')}: ${contentTitle}

+

${_('activitySessionState')}: ${session.Player.state}

+
+
+ +
+ + +
+
+
+ `; + return card; + } + + copyToClipboard(text, button) { + if (!text) return; + navigator.clipboard.writeText(text).then(() => { + const originalIcon = button.innerHTML; + button.innerHTML = ''; + showNotification(_('activityCopied'), 'success'); + setTimeout(() => { + button.innerHTML = originalIcon; + }, 2000); + }).catch(err => { + showNotification(_('activityCopyError'), 'error'); + }); + } + + show() { + this.modal.show(); + } +} \ No newline at end of file diff --git a/js/api.js b/js/api.js index 480b93c..bb98790 100644 --- a/js/api.js +++ b/js/api.js @@ -12,13 +12,13 @@ export async function fetchTMDB(endpoint, signal) { 'fr': 'fr-FR', 'de': 'de-DE', 'it': 'it-IT', - 'pt': 'pt-BR' + 'pt': 'pt-BR' }; if (langMap[state.settings.language]) { tmdbLang = langMap[state.settings.language]; } - + const separator = endpoint.includes('?') ? '&' : '?'; const url = `https://api.themoviedb.org/3/${endpoint}${separator}language=${tmdbLang}&api_key=${state.settings.apiKey}`; const response = await fetch(url, { signal }); @@ -29,12 +29,23 @@ export async function fetchTMDB(endpoint, signal) { return response.json(); } +export async function fetchPlexSessions(server) { + const { protocolo, ip, puerto, token } = server; + const url = `${protocolo}://${ip}:${puerto}/status/sessions?X-Plex-Token=${token}`; + const response = await fetchWithTimeout(url, { headers: { 'Accept': 'application/json' } }, 8000); + if (!response.ok) { + throw new Error(`Error ${response.status}`); + } + const data = await response.json(); + return data.MediaContainer.Metadata || []; +} + export async function getMusicUrlsFromPlex(token, protocolo, ip, puerto, artistaId) { const url = `${protocolo}://${ip}:${puerto}/library/metadata/${artistaId}/allLeaves?X-Plex-Token=${token}`; try { const response = await fetchWithTimeout(url, {}, 15000); if (!response.ok) throw new Error(`Failed to fetch tracks: ${response.status}`); - + const data = await response.text(); const parser = new DOMParser(); const xmlDoc = parser.parseFromString(data, "text/xml"); @@ -46,11 +57,11 @@ export async function getMusicUrlsFromPlex(token, protocolo, ip, puerto, artista const fileKey = part.getAttribute("key"); const fileUrl = `${protocolo}://${ip}:${puerto}${fileKey}?X-Plex-Token=${token}`; - + const thumb = track.getAttribute("thumb"); const parentThumb = track.getAttribute("parentThumb"); const grandparentThumb = track.getAttribute("grandparentThumb"); - + let coverUrl = 'img/no-poster.png'; if (thumb) { coverUrl = `${protocolo}://${ip}:${puerto}${thumb}?X-Plex-Token=${token}`; @@ -75,7 +86,7 @@ export async function getMusicUrlsFromPlex(token, protocolo, ip, puerto, artista albumIndex: parseInt(track.getAttribute("parentIndex") || 0, 10) }; }).filter(track => track !== null); - + tracks.sort((a, b) => { if (a.albumIndex !== b.albumIndex) { return a.albumIndex - b.albumIndex; @@ -143,26 +154,26 @@ export async function fetchAllStreamsFromPlex(busqueda, tipoContenido) { const directories = Array.from(xml.querySelectorAll('Directory[type="show"]')); let directoryToProcess = directories.find(d => d.getAttribute('title')?.toLowerCase() === busqueda.toLowerCase()); if (!directoryToProcess && directories.length > 0) { - directoryToProcess = directories[0]; + directoryToProcess = directories[0]; } - + if (directoryToProcess && directoryToProcess.getAttribute("ratingKey")) { const serieKey = directoryToProcess.getAttribute("ratingKey"); const serieTitulo = directoryToProcess.getAttribute("title") || busqueda; const serieYear = directoryToProcess.getAttribute("year"); const leavesUrl = `${protocolo}://${ip}:${puerto}/library/metadata/${serieKey}/allLeaves?X-Plex-Token=${token}`; - + const leavesResponse = await fetchWithTimeout(leavesUrl, { headers: { 'Accept': 'application/xml' } }); if (leavesResponse.ok) { const leavesData = await leavesResponse.text(); const leavesXml = parser.parseFromString(leavesData, "text/xml"); if (!leavesXml.querySelector('parsererror')) { const episodes = Array.from(leavesXml.querySelectorAll("Video")); - - episodes.sort((a,b) => { + + episodes.sort((a, b) => { const seasonA = parseInt(a.getAttribute("parentIndex") || 0, 10); const seasonB = parseInt(b.getAttribute("parentIndex") || 0, 10); - if(seasonA !== seasonB) return seasonA - seasonB; + if (seasonA !== seasonB) return seasonA - seasonB; const episodeA = parseInt(a.getAttribute("index") || 0, 10); const episodeB = parseInt(b.getAttribute("index") || 0, 10); return episodeA - episodeB; @@ -212,7 +223,7 @@ export async function fetchAllStreamsFromPlex(busqueda, tipoContenido) { } if (tipoContenido === 'movie') { - uniqueStreams.sort((a, b) => (a.title || '').localeCompare(b.title || '')); + uniqueStreams.sort((a, b) => (a.title || '').localeCompare(b.title || '')); } if (uniqueStreams.length > 0) { @@ -224,22 +235,22 @@ export async function fetchAllStreamsFromPlex(busqueda, tipoContenido) { export async function fetchAllStreamsFromJellyfin(busqueda, tipoContenido) { if (!busqueda || !tipoContenido) return { success: false, streams: [], message: _('invalidStreamInfo') }; - + const { url, userId, apiKey } = state.jellyfinSettings; if (!url || !userId || !apiKey) return { success: false, streams: [], message: _('noJellyfinCredentials') }; const jellyfinSearchType = tipoContenido === 'movie' ? 'Movie' : 'Series'; const searchUrl = `${url}/Users/${userId}/Items?searchTerm=${encodeURIComponent(busqueda)}&IncludeItemTypes=${jellyfinSearchType}&Recursive=true`; - + try { const response = await fetch(searchUrl, { headers: { 'X-Emby-Token': apiKey } }); if (!response.ok) throw new Error(`Error buscando en Jellyfin: ${response.status}`); const searchData = await response.json(); - + if (!searchData.Items || searchData.Items.length === 0) { return { success: false, streams: [], message: _('notFoundOnJellyfin', busqueda) }; } - + const item = searchData.Items.find(i => i.Name.toLowerCase() === busqueda.toLowerCase()) || searchData.Items[0]; const itemId = item.Id; const itemName = item.Name; @@ -264,8 +275,8 @@ export async function fetchAllStreamsFromJellyfin(busqueda, tipoContenido) { const episodesResponse = await fetch(episodesUrl, { headers: { 'X-Emby-Token': apiKey } }); if (!episodesResponse.ok) throw new Error(`Error obteniendo episodios: ${episodesResponse.status}`); const episodesData = await episodesResponse.json(); - - const sortedEpisodes = episodesData.Items.sort((a,b) => { + + const sortedEpisodes = episodesData.Items.sort((a, b) => { if (a.ParentIndexNumber !== b.ParentIndexNumber) return (a.ParentIndexNumber || 0) - (b.ParentIndexNumber || 0); return (a.IndexNumber || 0) - (b.IndexNumber || 0); }); @@ -277,7 +288,7 @@ export async function fetchAllStreamsFromJellyfin(busqueda, tipoContenido) { const episodeTitle = ep.Name || 'Episodio'; const groupTitle = `${itemName} - Temporada ${seasonNum}`.replace(/"/g, "'"); const extinfName = `${itemName} T${seasonNum}E${episodeNum} ${episodeTitle}`; - + streams.push({ url: streamUrl, title: extinfName, @@ -315,10 +326,10 @@ export async function fetchAllAvailableStreams(title, type) { errorMessages.push(`${sourceName}: ${result.reason.message}`); } }); - + const uniqueStreamsMap = new Map(allStreams.map(stream => [stream.url, stream])); const uniqueStreams = Array.from(uniqueStreamsMap.values()); - + if (uniqueStreams.length > 0) { return { success: true, streams: uniqueStreams, message: `Found ${uniqueStreams.length} streams.` }; } else { diff --git a/js/eventListeners.js b/js/eventListeners.js index 8c005c5..0b3eaa4 100644 --- a/js/eventListeners.js +++ b/js/eventListeners.js @@ -8,7 +8,7 @@ import { Equalizer } from './equalizer.js'; async function handleDatabaseUpdate() { showNotification(_('updatingView'), "info", 2000); - await loadLocalContent(); + await loadLocalContent(); switch(state.currentView) { case 'stats': @@ -56,6 +56,8 @@ export function setupEventListeners() { document.getElementById('footer-stats').addEventListener('click', (e) => { e.preventDefault(); switchView('stats'); }); document.getElementById('footer-favorites').addEventListener('click', (e) => { e.preventDefault(); switchView('favorites'); }); + document.getElementById('activity-viewer-btn').addEventListener('click', () => state.activityViewer.show()); + document.getElementById('load-more').addEventListener('click', () => { if (!state.isLoading) { state.currentPage++; diff --git a/js/main.js b/js/main.js index e5e6422..d74d4c3 100644 --- a/js/main.js +++ b/js/main.js @@ -2,6 +2,7 @@ import { state } from './state.js'; import { config } from './config.js'; import { initDB, getFromDB } from './db.js'; import { MusicPlayer } from './musicPlayer.js'; +import { ActivityViewer } from './activityViewer.js'; import { setupEventListeners } from './eventListeners.js'; import { loadInitialContent, initializeFavorites, initializeUserData, loadLocalContent, applyTheme, applyHeroVisibility } from './ui.js'; import { showNotification, _ } from './utils.js'; @@ -42,6 +43,8 @@ document.addEventListener('DOMContentLoaded', async () => { state.musicPlayer = new MusicPlayer(); state.musicPlayer.setDB(state.db); + state.activityViewer = new ActivityViewer(document.getElementById('activityViewerModal')); + initializeFavorites(); initializeUserData(); diff --git a/js/musicPlayer.js b/js/musicPlayer.js index 1ce8232..e8bf6d7 100644 --- a/js/musicPlayer.js +++ b/js/musicPlayer.js @@ -79,6 +79,8 @@ export class MusicPlayer { btn.addEventListener('click', () => this.togglePlayerVisibility()); }); document.getElementById('closeSideNavBtn').addEventListener('click', () => this.hidePlayer()); + document.getElementById('closeMiniplayerBtn').addEventListener('click', () => this.closeMiniplayer()); + document.getElementById('fab-music-player').addEventListener('click', () => this.openMiniplayer()); document.getElementById('searchArtist').addEventListener("input", debounce(() => this.filterArtists(), 300)); document.getElementById('searchSong').addEventListener("input", debounce(() => this.filterSongs(), 300)); document.getElementById('backBtn').addEventListener('click', () => this.showArtistList()); @@ -211,6 +213,29 @@ export class MusicPlayer { this.isPlayerVisible = false; } + closeMiniplayer() { + const miniplayer = document.getElementById('miniplayer'); + gsap.to(miniplayer, { y: '110%', duration: 0.5, ease: 'power3.in', onComplete: () => { + miniplayer.style.display = 'none'; + document.body.classList.remove('miniplayer-active'); + if (this.isPlaying) { + document.getElementById('fab-music-player').style.display = 'flex'; + gsap.fromTo('#fab-music-player', { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: 'back.out(1.7)' }); + } + }}); + } + + openMiniplayer() { + const miniplayer = document.getElementById('miniplayer'); + const fab = document.getElementById('fab-music-player'); + gsap.to(fab, { scale: 0, opacity: 0, duration: 0.3, ease: 'back.in(1.7)', onComplete: () => { + fab.style.display = 'none'; + miniplayer.style.display = 'grid'; + document.body.classList.add('miniplayer-active'); + gsap.fromTo(miniplayer, { y: '110%' }, { y: '0%', duration: 0.5, ease: 'power3.out' }); + }}); + } + async handleDatabaseUpdate() { if (!this.isReady) await this.asyncInitialize(); if (!this.isReady) return; @@ -549,6 +574,7 @@ export class MusicPlayer { if (playIconElement) { playIconElement.className = 'fas fa-play play-icon'; } + document.getElementById('fab-music-player').style.display = 'none'; }).catch((error) => { this.handleAudioError(_('playbackError')); if (playIconElement) { @@ -573,6 +599,9 @@ export class MusicPlayer { .catch(err => { this.isPlaying = false; btn.innerHTML = ''; }); } this.isPlaying = !this.isPlaying; + if (this.isPlaying) { + document.getElementById('fab-music-player').style.display = 'none'; + } } playNext() { diff --git a/js/state.js b/js/state.js index 5ffb200..f0a9eba 100644 --- a/js/state.js +++ b/js/state.js @@ -44,6 +44,7 @@ export const state = { isScanningPlex: false, isScanningJellyfin: false, musicPlayer: null, + activityViewer: null, currentContentFetchController: null, plexScanAbortController: null, aceEditor: null, diff --git a/js/ui.js b/js/ui.js index a096121..c7cb622 100644 --- a/js/ui.js +++ b/js/ui.js @@ -54,17 +54,31 @@ export function resetView() { if (state.isLoading) return; const heroSection = document.getElementById('hero-section'); - if (heroSection) { - heroSection.style.display = 'flex'; - if (state.settings.showHero) { - initializeHeroSection(); - } - } - + const mainContent = document.getElementById('main-content'); const contentSection = document.getElementById('content-section'); + + // Hide all main content sections + if (mainContent) { + mainContent.style.display = 'none'; + } if (contentSection) { contentSection.style.display = 'none'; } + document.getElementById('stats-section').style.display = 'none'; + document.getElementById('history-section').style.display = 'none'; + document.getElementById('recommendations-section').style.display = 'none'; + document.getElementById('photos-section').style.display = 'none'; + + // Show hero if enabled + if (heroSection) { + if (state.settings.showHero) { + heroSection.style.display = 'flex'; + heroSection.classList.add('loading'); // Add loading class to hero + initializeHeroSection(); + } else { + heroSection.style.display = 'none'; + } + } state.currentView = 'home'; updateActiveNav('home'); @@ -72,11 +86,22 @@ export function resetView() { } export function switchView(viewType) { + console.log(`switchView called with viewType: ${viewType}`); if (state.isLoading) return; const heroSection = document.getElementById('hero-section'); + const mainContent = document.querySelector('.main-content'); + if (heroSection) { heroSection.style.display = 'none'; + if (state.heroIntervalId) { + clearInterval(state.heroIntervalId); + state.heroIntervalId = null; + } + } + + if (mainContent) { + mainContent.style.display = 'block'; // Ensure main content is visible when switching views } const sidebar = document.getElementById('sidebar-nav'); @@ -85,7 +110,6 @@ export function switchView(viewType) { document.getElementById('main-container').classList.remove('sidebar-open'); } - const mainContent = document.querySelector('.main-content'); const topBarHeight = document.querySelector('.top-bar')?.offsetHeight || 60; const targetScrollTop = mainContent ? mainContent.offsetTop - topBarHeight : 0; @@ -116,8 +140,11 @@ export function switchView(viewType) { switch(viewType) { case 'movies': + console.log('switchView: case movies'); case 'series': + console.log('switchView: case series'); case 'search': + console.log('switchView: case search'); document.getElementById('content-section').style.display = 'block'; filters.style.display = 'flex'; if (viewType !== 'search') { @@ -128,19 +155,25 @@ export function switchView(viewType) { } break; case 'favorites': + console.log('switchView: case favorites'); document.getElementById('content-section').style.display = 'block'; break; case 'history': + console.log('switchView: case history'); document.getElementById('history-section').style.display = 'block'; break; case 'recommendations': + console.log('switchView: case recommendations'); document.getElementById('recommendations-section').style.display = 'block'; break; case 'stats': + console.log('switchView: case stats'); document.getElementById('stats-section').style.display = 'block'; + console.log('switchView: Showing stats-section'); document.getElementById('stats-filters').style.display = 'flex'; break; case 'photos': + document.getElementById('photos-section').style.display = 'block'; break; } @@ -293,6 +326,7 @@ function loadYears() { } export async function loadContent(append = false) { + console.log(`loadContent called with append: ${append}, currentView: ${state.currentView}, contentType: ${state.currentParams.contentType}`); if (state.currentContentFetchController) state.currentContentFetchController.abort(); state.currentContentFetchController = new AbortController(); const signal = state.currentContentFetchController.signal; @@ -324,11 +358,13 @@ export async function loadContent(append = false) { } const data = await fetchTMDB(endpoint, signal); + console.log('loadContent: Data fetched successfully', data); renderGrid(data.results, append); loadMoreButton.style.display = (data.page < data.total_pages) ? 'block' : 'none'; if (!append) setupScrollEffects(); } catch (error) { + console.error('loadContent: Error fetching content', error); if (error.name !== 'AbortError') { if (!append) grid.innerHTML = `

${_('couldNotLoadContent')}

`; } @@ -759,10 +795,12 @@ function updateFavoriteButtonVisuals(itemId, itemType, isFavorite) { } export async function loadFavorites() { + console.log('loadFavorites called'); const grid = document.getElementById('content-grid'); grid.innerHTML = '
'; if (state.favorites.length === 0) { + console.log('loadFavorites: No favorites found.'); grid.innerHTML = `

${_('noFavorites')}

`; return; } @@ -770,6 +808,7 @@ export async function loadFavorites() { try { const favoritePromises = state.favorites.map(fav => fetchTMDB(`${fav.type}/${fav.id}`).catch(()=>null)); const favoriteItems = (await Promise.all(favoritePromises)).filter(item => item !== null); + console.log('loadFavorites: Data received for rendering', favoriteItems); renderGrid(favoriteItems, false); } catch (error) { grid.innerHTML = `

${_('errorLoadingFavorites')}

`; @@ -1196,16 +1235,17 @@ export async function initializeHeroSection() { if (isFirst) { gsap.set(currentBg, { backgroundImage: `url(${nextImage.src})` }); - gsap.to(currentBg, { autoAlpha: 1, duration: 1.5, ease: 'power2.out' }); - gsap.to(content, { autoAlpha: 1, duration: 1, delay: 0.5 }); + gsap.to(currentBg, { autoAlpha: 1, duration: 2.5, ease: 'power2.out' }); + gsap.to(content, { autoAlpha: 1, duration: 1.2, delay: 0.8, ease: 'power3.out' }); gsap.fromTo(currentBg, { scale: 1.15, transformOrigin: 'center center' }, { scale: 1, duration: 12, ease: 'none' }); + heroSection.classList.remove('loading'); // Remove loading class after first slide is ready } else { const tl = gsap.timeline({ onComplete: () => { - gsap.set(currentBg, { autoAlpha: 0 }); const temp = currentBg; currentBg = nextBg; nextBg = temp; + gsap.set(nextBg, { autoAlpha: 0 }); // Reset nextBg opacity for next transition } }); @@ -1215,10 +1255,11 @@ export async function initializeHeroSection() { stagger: 0.08, duration: 0.6, ease: 'power3.in' - }); + }, 0); // Start content fade out at the beginning of the timeline - gsap.set(nextBg, { backgroundImage: `url(${nextImage.src})` }); - tl.to(nextBg, { autoAlpha: 1, duration: 1.5, ease: 'power2.inOut' }, '-=0.5'); + gsap.set(nextBg, { backgroundImage: `url(${nextImage.src})`, autoAlpha: 0 }); // Set new image and hide it + tl.to(currentBg, { autoAlpha: 0, duration: 2.5, ease: 'power2.inOut' }, 0); // Fade out current background + tl.to(nextBg, { autoAlpha: 1, duration: 2.5, ease: 'power2.inOut' }, 0); // Fade in new background simultaneously gsap.fromTo(nextBg, { scale: 1.15, transformOrigin: 'center center' }, { scale: 1, duration: 12, ease: 'none' }); @@ -1229,9 +1270,9 @@ export async function initializeHeroSection() { y: 0, autoAlpha: 1, stagger: 0.1, - duration: 0.8, + duration: 1.2, ease: 'power3.out' - }, '>-1'); + }, '>-0.8'); // Start content fade in slightly before background transition ends } }; } @@ -1240,7 +1281,7 @@ export async function initializeHeroSection() { gsap.set([bg1, bg2], { autoAlpha: 0 }); changeHeroSlide(true); - setInterval(() => changeHeroSlide(false), 12000); + state.heroIntervalId = setInterval(() => changeHeroSlide(false), 12000); } catch (error) { console.error("Error initializing hero section:", error); diff --git a/plex.html b/plex.html index a21b47b..1d1746b 100644 --- a/plex.html +++ b/plex.html @@ -30,6 +30,9 @@
+ @@ -267,8 +270,33 @@
+ +
+ +