session, update playermusic

This commit is contained in:
Filipinos 2025-07-12 12:56:04 +02:00
parent e988ff15c8
commit 104d669ac9
19 changed files with 516 additions and 53 deletions

View File

@ -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." }
}

View File

@ -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." }
}

View File

@ -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." }
}

View File

@ -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." }
}

View File

@ -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." }
}

View File

@ -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." }
}

68
css/activity-viewer.css Normal file
View File

@ -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;
}

View File

@ -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 {

View File

@ -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;
}

View File

@ -9,4 +9,15 @@
@import url('footer.css');
@import url('overlays.css');
@import url('music-player.css');
@import url('photos.css');
@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;
}

View File

@ -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;

155
js/activityViewer.js Normal file
View File

@ -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 = `<option>${_('loading')}</option>`;
try {
const servers = await getFromDB('conexiones_locales');
if (servers.length === 0) {
this.dom.serverSelect.innerHTML = `<option>${_('noServersFound')}</option>`;
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 = `<option>${_('errorLoadingServers')}</option>`;
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 = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('activityError')}</p><p class="text-muted">${error.message}</p></div>`;
} 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 = `<div class="empty-state"><i class="fas fa-bed"></i><p class="lead">${_('activityNoSessions')}</p></div>`;
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 = `
<img src="${posterUrl}" class="session-poster" alt="Poster">
<div class="session-info">
<div class="session-details">
<p><strong>${_('activitySessionUser')}:</strong> ${session.User.title}</p>
<p><strong>${_('activitySessionDevice')}:</strong> ${session.Player.product} (${session.Player.title})</p>
<p><strong>${_('activitySessionContent')}:</strong> ${contentTitle}</p>
<p><strong>${_('activitySessionState')}:</strong> <i class="fas ${playerStateIcon} ${playerStateColor}"></i> ${session.Player.state}</p>
</div>
<div class="session-identifier">
<label>${_('activitySessionIdentifier')}:</label>
<div class="input-group">
<input type="text" class="form-control form-control-sm" value="${session.Player.machineIdentifier}" readonly>
<button class="btn btn-sm btn-outline-secondary copy-identifier-btn" data-identifier="${session.Player.machineIdentifier}" title="${_('activityCopyID')}">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
</div>
`;
return card;
}
copyToClipboard(text, button) {
if (!text) return;
navigator.clipboard.writeText(text).then(() => {
const originalIcon = button.innerHTML;
button.innerHTML = '<i class="fas fa-check"></i>';
showNotification(_('activityCopied'), 'success');
setTimeout(() => {
button.innerHTML = originalIcon;
}, 2000);
}).catch(err => {
showNotification(_('activityCopyError'), 'error');
});
}
show() {
this.modal.show();
}
}

View File

@ -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 {

View File

@ -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++;

View File

@ -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();

View File

@ -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 = '<i class="fas fa-play"></i>'; });
}
this.isPlaying = !this.isPlaying;
if (this.isPlaying) {
document.getElementById('fab-music-player').style.display = 'none';
}
}
playNext() {

View File

@ -44,6 +44,7 @@ export const state = {
isScanningPlex: false,
isScanningJellyfin: false,
musicPlayer: null,
activityViewer: null,
currentContentFetchController: null,
plexScanAbortController: null,
aceEditor: null,

View File

@ -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 = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('couldNotLoadContent')}</p></div>`;
}
@ -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 = '<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>';
if (state.favorites.length === 0) {
console.log('loadFavorites: No favorites found.');
grid.innerHTML = `<div class="empty-state"><i class="far fa-heart fa-3x mb-3"></i><p class="lead">${_('noFavorites')}</p></div>`;
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 = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('errorLoadingFavorites')}</p></div>`;
@ -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);

View File

@ -30,6 +30,9 @@
</div>
</div>
<div class="top-bar-right">
<button id="activity-viewer-btn" class="btn-icon" title="__MSG_activityViewerTitle__">
<i class="fas fa-desktop"></i>
</button>
<button id="openMusicPlayerDesktop" class="btn-icon" title="__MSG_openMusicPlayer__">
<i class="fas fa-music"></i>
</button>
@ -267,8 +270,33 @@
</div>
</div>
<button id="fab-music-player" class="fab-btn" style="display: none;" title="__MSG_openMusicPlayer__"><i class="fas fa-music"></i></button>
<div class="spinner" id="spinner"></div>
<div class="modal fade" id="activityViewerModal" tabindex="-1" aria-labelledby="activityViewerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="activityViewerModalLabel"><i class="fas fa-desktop me-2"></i>__MSG_activityViewerTitle__</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="__MSG_close__"></button>
</div>
<div class="modal-body">
<div class="d-flex gap-3 mb-4">
<select class="form-control filter-select flex-grow-1" id="activity-server-select"></select>
<button class="btn btn-primary" id="check-activity-btn"><i class="fas fa-sync-alt"></i></button>
</div>
<div id="activity-loader" style="display: none;" class="text-center py-4">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">__MSG_loading__</span>
</div>
</div>
<div id="activity-results"></div>
</div>
</div>
</div>
</div>
<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
@ -553,6 +581,7 @@
<button id="shuffleBtn" class="control-btn" title="__MSG_miniplayerShuffle__"><i class="fas fa-random"></i></button>
<button id="eqBtn" class="control-btn" title="__MSG_miniplayerEqualizer__"><i class="fas fa-sliders-h"></i></button>
<button id="openMusicPlayerFromMiniplayer" class="control-btn" title="__MSG_miniplayerOpenList__"><i class="fas fa-list"></i></button>
<button id="closeMiniplayerBtn" class="control-btn" title="__MSG_close__"><i class="fas fa-times"></i></button>
</div>
<audio id="audioPlayer"></audio>
@ -665,6 +694,8 @@
<script src="lib/chart.umd.min.js"></script>
<script src="js/i18n.js"></script>
<script type="module" src="js/main.js"></script>
<script type="module" src="js/activityViewer.js"></script>
</body>
</html>