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 = `
+
+
+
+
${_('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 = ``;
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 @@
+
+
+
+
@@ -553,6 +581,7 @@
+
@@ -665,6 +694,8 @@
+
+