Jellyfin music integration in MusicPlayer

This commit is contained in:
Filipinos 2025-07-28 00:18:53 +02:00
parent 2a8c3611a4
commit e614cb387a
5 changed files with 4148 additions and 99 deletions

File diff suppressed because it is too large Load Diff

View File

@ -95,7 +95,8 @@ export async function getMusicUrlsFromPlex(token, protocolo, ip, puerto, artista
year: track.getAttribute("parentYear") || track.getAttribute("year"), year: track.getAttribute("parentYear") || track.getAttribute("year"),
genre: Array.from(track.querySelectorAll("Genre")).map(g => g.getAttribute('tag')).join(', ') || '', genre: Array.from(track.querySelectorAll("Genre")).map(g => g.getAttribute('tag')).join(', ') || '',
index: parseInt(track.getAttribute("index") || 0, 10), index: parseInt(track.getAttribute("index") || 0, 10),
albumIndex: parseInt(track.getAttribute("parentIndex") || 0, 10) albumIndex: parseInt(track.getAttribute("parentIndex") || 0, 10),
trackIndex: parseInt(track.getAttribute("index") || 0, 10)
}; };
}).filter(track => track !== null); }).filter(track => track !== null);
@ -114,6 +115,62 @@ export async function getMusicUrlsFromPlex(token, protocolo, ip, puerto, artista
} }
} }
export async function getMusicUrlsFromJellyfin(serverUrl, userId, token, artistId) {
try {
const albumsUrl = `${serverUrl}/Users/${userId}/Items?ParentId=${artistId}&IncludeItemTypes=MusicAlbum&Recursive=true&Fields=ImageTags`;
const albumsResponse = await fetch(albumsUrl, { headers: { 'X-Emby-Token': token } });
if (!albumsResponse.ok) throw new Error(`Failed to fetch albums: ${albumsResponse.status}`);
const albumsData = await albumsResponse.json();
let allTracks = [];
for (const album of albumsData.Items) {
const songsUrl = `${serverUrl}/Users/${userId}/Items?ParentId=${album.Id}&IncludeItemTypes=Audio&Recursive=true&Fields=MediaSources,ImageTags`;
const songsResponse = await fetch(songsUrl, { headers: { 'X-Emby-Token': token } });
if (!songsResponse.ok) continue;
const songsData = await songsResponse.json();
const albumTracks = songsData.Items.map(track => {
const source = track.MediaSources?.[0];
if (!source) return null;
const streamUrl = `${serverUrl}/Audio/${track.Id}/stream.${source.Container || 'mp3'}?api_key=${token}&static=true`;
const coverUrl = album.ImageTags?.Primary ? `${serverUrl}/Items/${album.Id}/Images/Primary?tag=${album.ImageTags.Primary}` : 'img/no-poster.png';
return {
url: streamUrl,
titulo: track.Name || 'Pista desconocida',
album: album.Name || 'Álbum desconocido',
artista: track.AlbumArtist || 'Artista desconocido',
cover: coverUrl,
extension: source.Container || "mp3",
id: track.Id,
artistId: artistId,
year: album.ProductionYear,
genre: track.Genres?.join(', ') || '',
index: track.IndexNumber || 0,
albumIndex: album.IndexNumber || 0,
trackIndex: track.IndexNumber || 0
};
}).filter(track => track !== null);
allTracks.push(...albumTracks);
}
allTracks.sort((a, b) => {
if (a.albumIndex !== b.albumIndex) {
return a.albumIndex - b.albumIndex;
}
return a.index - b.index;
});
return allTracks;
} catch (error) {
console.error("Error in getMusicUrlsFromJellyfin:", error);
throw error;
}
}
export async function fetchAllStreamsFromPlex(busqueda, tipoContenido) { export async function fetchAllStreamsFromPlex(busqueda, tipoContenido) {
if (!busqueda || !tipoContenido) return { success: false, streams: [], message: _('invalidStreamInfo') }; if (!busqueda || !tipoContenido) return { success: false, streams: [], message: _('invalidStreamInfo') };
if (!state.db) return { success: false, streams: [], message: _('dbUnavailableForStreams') }; if (!state.db) return { success: false, streams: [], message: _('dbUnavailableForStreams') };

View File

@ -1,5 +1,5 @@
import { state } from './state.js'; import { state } from './state.js';
import { addItemsToStore, clearStore } from './db.js'; import { addItemsToStore, clearStore, getFromDB } from './db.js';
import { showNotification, _, emitirEventoActualizacion } from './utils.js'; import { showNotification, _, emitirEventoActualizacion } from './utils.js';
async function authenticateJellyfin(url, username, password) { async function authenticateJellyfin(url, username, password) {
@ -50,28 +50,34 @@ export async function fetchLibraryViews(url, userId, apiKey) {
export async function fetchItemsFromLibrary(url, userId, apiKey, library) { export async function fetchItemsFromLibrary(url, userId, apiKey, library) {
const itemsUrl = `${url}/Users/${userId}/Items?ParentId=${library.Id}&recursive=true&fields=ProductionYear,RunTimeTicks,SeriesName,ParentIndexNumber,ImageTags&includeItemTypes=Movie,Series`; const isMusic = library.CollectionType === 'music';
const itemTypes = isMusic ? 'MusicArtist' : 'Movie,Series';
const fields = isMusic ? 'ImageTags' : 'ProductionYear,RunTimeTicks,SeriesName,ParentIndexNumber,ImageTags';
const itemsUrl = `${url}/Users/${userId}/Items?ParentId=${library.Id}&recursive=true&fields=${fields}&includeItemTypes=${itemTypes}`;
try { try {
const response = await fetch(itemsUrl, { const response = await fetch(itemsUrl, {
headers: { headers: { 'X-Emby-Token': apiKey }
'X-Emby-Token': apiKey
}
}); });
if (!response.ok) throw new Error(`Error ${response.status}`); if (!response.ok) throw new Error(`Error ${response.status}`);
const data = await response.json(); const data = await response.json();
const items = data.Items.map(item => ({ const items = data.Items.map(item => {
id: item.Id, const baseItem = {
title: item.Name, id: item.Id,
year: item.ProductionYear, title: item.Name,
type: item.Type, type: item.Type,
duration: item.RunTimeTicks, thumb: item.ImageTags?.Primary ? `${url}/Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}` : ''
seriesTitle: item.SeriesName, };
seasonNumber: item.ParentIndexNumber, if (!isMusic) {
thumb: item.ImageTags?.Primary ? `${url}/Items/${item.Id}/Images/Primary?tag=${item.ImageTags.Primary}` : '' baseItem.year = item.ProductionYear;
})); baseItem.duration = item.RunTimeTicks;
baseItem.seriesTitle = item.SeriesName;
baseItem.seasonNumber = item.ParentIndexNumber;
}
return baseItem;
});
return { success: true, items, libraryName: library.Name, libraryId: library.Id }; return { success: true, items, libraryName: library.Name, libraryId: library.Id };
@ -135,7 +141,7 @@ export async function startJellyfinScan() {
return; return;
} }
const mediaLibraries = viewsResult.views.filter(v => v.CollectionType === 'movies' || v.CollectionType === 'tvshows'); const mediaLibraries = viewsResult.views.filter(v => v.CollectionType === 'movies' || v.CollectionType === 'tvshows' || v.CollectionType === 'music');
if (mediaLibraries.length === 0) { if (mediaLibraries.length === 0) {
statusDiv.innerHTML += `<div class="text-warning">${_('jellyfinNoMediaLibraries')}</div>`; statusDiv.innerHTML += `<div class="text-warning">${_('jellyfinNoMediaLibraries')}</div>`;
@ -149,34 +155,71 @@ export async function startJellyfinScan() {
await clearStore('jellyfin_movies'); await clearStore('jellyfin_movies');
await clearStore('jellyfin_series'); await clearStore('jellyfin_series');
const allArtists = await getFromDB('artists') || [];
const plexArtists = allArtists.filter(a => !a.isJellyfin);
await clearStore('artists');
if (plexArtists.length > 0) {
await addItemsToStore('artists', plexArtists);
}
let totalMovies = 0; let totalMovies = 0;
let totalSeries = 0; let totalSeries = 0;
let totalMusic = 0;
const scanPromises = mediaLibraries.map(library => const scanPromises = mediaLibraries.map(library =>
fetchItemsFromLibrary(url, authResult.userId, authResult.token, library) fetchItemsFromLibrary(url, authResult.userId, authResult.token, library)
); );
const results = await Promise.allSettled(scanPromises); const results = await Promise.allSettled(scanPromises);
const urlObject = new URL(url);
for (const result of results) { for (const result of results) {
if (result.status === 'fulfilled' && result.value.success) { if (result.status === 'fulfilled' && result.value.success) {
const library = mediaLibraries.find(lib => lib.Id === result.value.libraryId); const library = mediaLibraries.find(lib => lib.Id === result.value.libraryId);
if (library) { if (library) {
const storeName = library.CollectionType === 'movies' ? 'jellyfin_movies' : 'jellyfin_series'; const collectionType = library.CollectionType;
const dbEntry = { let storeName;
serverUrl: url, if (collectionType === 'movies') storeName = 'jellyfin_movies';
libraryId: library.Id, else if (collectionType === 'tvshows') storeName = 'jellyfin_series';
libraryName: library.Name, else if (collectionType === 'music') storeName = 'artists';
titulos: result.value.items,
}; if (storeName) {
await addItemsToStore(storeName, [dbEntry]); const titulos = result.value.items;
if (storeName === 'jellyfin_movies') { if (storeName === 'artists') {
totalMovies += result.value.items.length; const jellyfinArtists = {
} else { ip: urlObject.hostname,
totalSeries += result.value.items.length; puerto: urlObject.port || (urlObject.protocol === 'https:' ? '443' : '80'),
token: authResult.token,
protocolo: urlObject.protocol.replace(':', ''),
serverName: `Jellyfin - ${library.Name}`,
titulos: titulos.map(t => ({...t, isJellyfin: true, userId: authResult.userId, serverUrl: url})),
tokenPrincipal: authResult.token,
isJellyfin: true,
userId: authResult.userId,
serverUrl: url
};
if (titulos.length > 0) {
await addItemsToStore(storeName, [jellyfinArtists]);
}
} else {
const dbEntry = {
serverUrl: url,
libraryId: library.Id,
libraryName: library.Name,
titulos: titulos,
};
if (titulos.length > 0) {
await addItemsToStore(storeName, [dbEntry]);
}
}
if (collectionType === 'movies') totalMovies += titulos.length;
else if (collectionType === 'tvshows') totalSeries += titulos.length;
else if (collectionType === 'music') totalMusic += titulos.length;
statusDiv.innerHTML += `<div class="text-success-secondary">${_('jellyfinLibraryScanSuccess', [library.Name, String(titulos.length)])}</div>`;
} }
statusDiv.innerHTML += `<div class="text-success-secondary">${_('jellyfinLibraryScanSuccess', [library.Name, String(result.value.items.length)])}</div>`;
} }
} else { } else {
const libraryName = result.reason?.libraryName || result.value?.libraryName || 'Unknown'; const libraryName = result.reason?.libraryName || result.value?.libraryName || 'Unknown';
@ -196,7 +239,7 @@ export async function startJellyfinScan() {
await addItemsToStore('jellyfin_settings', [newSettings]); await addItemsToStore('jellyfin_settings', [newSettings]);
state.jellyfinSettings = newSettings; state.jellyfinSettings = newSettings;
const message = _('jellyfinScanSuccess', [String(totalMovies), String(totalSeries)]); const message = `Scan finished. Found ${totalMovies} movies, ${totalSeries} series, and ${totalMusic} music artists.`;
statusDiv.innerHTML += `<div class="text-success mt-2">${message}</div>`; statusDiv.innerHTML += `<div class="text-success mt-2">${message}</div>`;
showNotification(message, 'success'); showNotification(message, 'success');

View File

@ -1,6 +1,7 @@
import { getFromDB } from './db.js'; import { getFromDB } from './db.js';
import { debounce, showNotification, _ } from './utils.js'; import { debounce, showNotification, _ } from './utils.js';
import { getMusicUrlsFromPlex } from './api.js'; import { getMusicUrlsFromPlex, getMusicUrlsFromJellyfin } from './api.js';
import { state } from './state.js';
export class MusicPlayer { export class MusicPlayer {
constructor() { constructor() {
@ -264,7 +265,13 @@ export class MusicPlayer {
const artistCard = document.querySelector(`.artist-card[data-id='${wasArtistId}']`); const artistCard = document.querySelector(`.artist-card[data-id='${wasArtistId}']`);
if (artistCard) { if (artistCard) {
try { try {
const canciones = await getMusicUrlsFromPlex(artistCard.dataset.token, artistCard.dataset.protocolo, artistCard.dataset.ip, artistCard.dataset.puerto, wasArtistId); const { token, protocolo, ip, puerto, id: artistaId, isjellyfin, serverurl, userid } = artistCard.dataset;
let canciones;
if (isjellyfin === 'true') {
canciones = await getMusicUrlsFromJellyfin(serverurl, userid, token, artistaId);
} else {
canciones = await getMusicUrlsFromPlex(token, protocolo, ip, puerto, artistaId);
}
this.handleSongsLoaded(canciones, wasArtistId); this.handleSongsLoaded(canciones, wasArtistId);
this.markCurrentSong(); this.markCurrentSong();
@ -333,12 +340,25 @@ export class MusicPlayer {
if (servidor && Array.isArray(servidor.titulos)) { if (servidor && Array.isArray(servidor.titulos)) {
servidor.titulos.forEach(artista => { servidor.titulos.forEach(artista => {
if (artista && typeof artista.id !== 'undefined' && artista.id !== null && !artistMap.has(artista.id)) { if (artista && typeof artista.id !== 'undefined' && artista.id !== null && !artistMap.has(artista.id)) {
artistMap.set(artista.id, { const artistEntry = {
id: artista.id, title: artista.title || 'Artista Desconocido', id: artista.id,
token: servidor.token, ip: servidor.ip, puerto: servidor.puerto, title: artista.title || 'Artista Desconocido',
protocolo: servidor.protocolo, serverName: servidor.serverName || servidor.ip || 'Servidor Desconocido', thumb: artista.thumb,
thumb: artista.thumb isJellyfin: servidor.isJellyfin || false,
}); serverName: servidor.serverName || 'Servidor Desconocido',
};
if (servidor.isJellyfin) {
artistEntry.serverUrl = servidor.serverUrl;
artistEntry.userId = servidor.userId;
artistEntry.token = servidor.token;
} else {
artistEntry.token = servidor.token;
artistEntry.ip = servidor.ip;
artistEntry.puerto = servidor.puerto;
artistEntry.protocolo = servidor.protocolo;
}
artistMap.set(artista.id, artistEntry);
} }
}); });
} }
@ -367,9 +387,14 @@ export class MusicPlayer {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'artist-card'; card.className = 'artist-card';
const thumbUrl = artista.thumb let thumbUrl = null;
? `${artista.protocolo}://${artista.ip}:${artista.puerto}${artista.thumb}?X-Plex-Token=${artista.token}` if (artista.thumb) {
: null; if (artista.isJellyfin) {
thumbUrl = artista.thumb;
} else {
thumbUrl = `${artista.protocolo}://${artista.ip}:${artista.puerto}${artista.thumb}?X-Plex-Token=${artista.token}`;
}
}
card.innerHTML = ` card.innerHTML = `
<div class="artist-thumb-wrapper"> <div class="artist-thumb-wrapper">
@ -380,12 +405,21 @@ export class MusicPlayer {
<div class="artist-card-title">${artista.title}</div> <div class="artist-card-title">${artista.title}</div>
`; `;
card.dataset.id = artista.id; card.dataset.id = artista.id;
card.dataset.token = artista.token;
card.dataset.ip = artista.ip;
card.dataset.puerto = artista.puerto;
card.dataset.protocolo = artista.protocolo;
card.dataset.thumb = artista.thumb || ''; card.dataset.thumb = artista.thumb || '';
card.dataset.isjellyfin = artista.isJellyfin;
card.title = `${artista.title} en ${artista.serverName || 'Servidor Desconocido'}`; card.title = `${artista.title} en ${artista.serverName || 'Servidor Desconocido'}`;
if (artista.isJellyfin) {
card.dataset.serverurl = artista.serverUrl;
card.dataset.userid = artista.userId;
card.dataset.token = artista.token;
} else {
card.dataset.token = artista.token;
card.dataset.ip = artista.ip;
card.dataset.puerto = artista.puerto;
card.dataset.protocolo = artista.protocolo;
}
artistGrid.appendChild(card); artistGrid.appendChild(card);
} }
}); });
@ -432,12 +466,19 @@ export class MusicPlayer {
async loadArtistSongs(selectedCard) { async loadArtistSongs(selectedCard) {
if (!this.isReady) return; if (!this.isReady) return;
const { token, protocolo, ip, puerto, id: artistaId, thumb } = selectedCard.dataset; const { id: artistaId, thumb, isjellyfin, serverurl, userid, token, protocolo, ip, puerto } = selectedCard.dataset;
this.currentArtist = selectedCard.querySelector('.artist-card-title').textContent; this.currentArtist = selectedCard.querySelector('.artist-card-title').textContent;
this.currentArtistId = artistaId; this.currentArtistId = artistaId;
if (!artistaId) return; if (!artistaId) return;
const thumbUrl = thumb ? `${protocolo}://${ip}:${puerto}${thumb}?X-Plex-Token=${token}` : 'img/no-profile.png'; let thumbUrl = 'img/no-profile.png';
if (thumb) {
if (isjellyfin === 'true') {
thumbUrl = thumb;
} else {
thumbUrl = `${protocolo}://${ip}:${puerto}${thumb}?X-Plex-Token=${token}`;
}
}
document.getElementById('artist-header-thumb').src = thumbUrl; document.getElementById('artist-header-thumb').src = thumbUrl;
document.getElementById('artist-header-title').textContent = this.currentArtist; document.getElementById('artist-header-title').textContent = this.currentArtist;
@ -449,7 +490,12 @@ export class MusicPlayer {
document.getElementById('listaCanciones').innerHTML = ''; document.getElementById('listaCanciones').innerHTML = '';
try { try {
let canciones = await getMusicUrlsFromPlex(token, protocolo, ip, puerto, artistaId); let canciones;
if (isjellyfin === 'true') {
canciones = await getMusicUrlsFromJellyfin(serverurl, userid, token, artistaId);
} else {
canciones = await getMusicUrlsFromPlex(token, protocolo, ip, puerto, artistaId);
}
this.handleSongsLoaded(canciones, artistaId); this.handleSongsLoaded(canciones, artistaId);
} catch (error) { } catch (error) {
showNotification(_('errorFetchingArtistSongs'), "error"); showNotification(_('errorFetchingArtistSongs'), "error");
@ -789,7 +835,14 @@ export class MusicPlayer {
filteredArtists.forEach((artista) => { filteredArtists.forEach((artista) => {
const card = document.createElement('div'); const card = document.createElement('div');
card.className = 'artist-card'; card.className = 'artist-card';
const thumbUrl = artista.thumb ? `${artista.protocolo}://${artista.ip}:${artista.puerto}${artista.thumb}?X-Plex-Token=${artista.token}` : null; let thumbUrl = null;
if (artista.thumb) {
if (artista.isJellyfin) {
thumbUrl = artista.thumb;
} else {
thumbUrl = `${artista.protocolo}://${artista.ip}:${artista.puerto}${artista.thumb}?X-Plex-Token=${artista.token}`;
}
}
card.innerHTML = ` card.innerHTML = `
<div class="artist-thumb-wrapper"> <div class="artist-thumb-wrapper">
${thumbUrl ? `<img src="${thumbUrl}" class="artist-thumb" alt="${artista.title}" loading="lazy">` : '<i class="fas fa-user-music artist-thumb-placeholder"></i>'} ${thumbUrl ? `<img src="${thumbUrl}" class="artist-thumb" alt="${artista.title}" loading="lazy">` : '<i class="fas fa-user-music artist-thumb-placeholder"></i>'}
@ -797,12 +850,20 @@ export class MusicPlayer {
<div class="artist-card-title">${artista.title}</div> <div class="artist-card-title">${artista.title}</div>
`; `;
card.dataset.id = artista.id; card.dataset.id = artista.id;
card.dataset.token = artista.token;
card.dataset.ip = artista.ip;
card.dataset.puerto = artista.puerto;
card.dataset.protocolo = artista.protocolo;
card.dataset.thumb = artista.thumb || ''; card.dataset.thumb = artista.thumb || '';
card.dataset.isjellyfin = artista.isJellyfin;
card.title = `${artista.title} en ${artista.serverName || 'Servidor Desconocido'}`; card.title = `${artista.title} en ${artista.serverName || 'Servidor Desconocido'}`;
if (artista.isJellyfin) {
card.dataset.serverurl = artista.serverUrl;
card.dataset.userid = artista.userId;
card.dataset.token = artista.token;
} else {
card.dataset.token = artista.token;
card.dataset.ip = artista.ip;
card.dataset.puerto = artista.puerto;
card.dataset.protocolo = artista.protocolo;
}
artistGrid.appendChild(card); artistGrid.appendChild(card);
}); });
} }

View File

@ -4,6 +4,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="__MSG_appDescription__">
<title>__MSG_appName__ - __MSG_appTagline__</title> <title>__MSG_appName__ - __MSG_appTagline__</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
@ -11,9 +12,6 @@
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&family=Orbitron:wght@500;600;700&display=swap" href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&family=Orbitron:wght@500;600;700&display=swap"
rel="stylesheet"> rel="stylesheet">
<link rel="stylesheet" href="css/main.css"> <link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/equalizer.css">
<link rel="stylesheet" href="css/photos.css">
<link rel="stylesheet" href="css/providers.css">
</head> </head>
<body class="unlocalized"> <body class="unlocalized">
@ -22,7 +20,7 @@
<header class="top-bar"> <header class="top-bar">
<div class="top-bar-left"> <div class="top-bar-left">
<button id="sidebar-toggle" class="btn-icon" aria-label="__MSG_toggleNavigation__"><i class="fas fa-bars"></i></button> <button id="sidebar-toggle" class="btn-icon" aria-label="__MSG_toggleNavigation__"><i class="fas fa-bars"></i></button>
<a class="navbar-brand logo" href="#" id="reset-view-btn">__MSG_appName__</a> <a class="navbar-brand logo" href="#" id="reset-view-btn" role="button">__MSG_appName__</a>
</div> </div>
<div class="top-bar-center"> <div class="top-bar-center">
<div class="search-bar"> <div class="search-bar">
@ -43,22 +41,22 @@
</div> </div>
</header> </header>
<nav class="sidebar-nav" id="sidebar-nav"> <nav class="sidebar-nav" id="sidebar-nav" role="navigation">
<ul class="sidebar-menu"> <ul class="sidebar-menu">
<li><a class="nav-link active" href="#" id="nav-movies"><i class="fas fa-film"></i><span>__MSG_navMovies__</span></a></li> <li><a class="nav-link active" href="#" id="nav-movies" role="button"><i class="fas fa-film"></i><span>__MSG_navMovies__</span></a></li>
<li><a class="nav-link" href="#" id="nav-series"><i class="fas fa-tv"></i><span>__MSG_navSeries__</span></a></li> <li><a class="nav-link" href="#" id="nav-series" role="button"><i class="fas fa-tv"></i><span>__MSG_navSeries__</span></a></li>
<li><a class="nav-link" href="#" id="nav-providers"><i class="fas fa-broadcast-tower"></i><span>__MSG_navProviders__</span></a></li> <li><a class="nav-link" href="#" id="nav-providers" role="button"><i class="fas fa-broadcast-tower"></i><span>__MSG_navProviders__</span></a></li>
<li><a class="nav-link" href="#" id="nav-photos"><i class="fas fa-images"></i><span>__MSG_navPhotos__</span></a></li> <li><a class="nav-link" href="#" id="nav-photos" role="button"><i class="fas fa-images"></i><span>__MSG_navPhotos__</span></a></li>
<li><a class="nav-link" href="#" id="nav-stats"><i class="fas fa-chart-pie"></i><span>__MSG_navStats__</span></a></li> <li><a class="nav-link" href="#" id="nav-stats" role="button"><i class="fas fa-chart-pie"></i><span>__MSG_navStats__</span></a></li>
<li><a class="nav-link" href="#" id="nav-favorites"><i class="fas fa-heart"></i><span>__MSG_navFavorites__</span></a></li> <li><a class="nav-link" href="#" id="nav-favorites" role="button"><i class="fas fa-heart"></i><span>__MSG_navFavorites__</span></a></li>
<li><a class="nav-link" href="#" id="nav-history"><i class="fas fa-history"></i><span>__MSG_navHistory__</span></a></li> <li><a class="nav-link" href="#" id="nav-history" role="button"><i class="fas fa-history"></i><span>__MSG_navHistory__</span></a></li>
<li><a class="nav-link" href="#" id="nav-recommendations"><i class="fas fa-magic"></i><span>__MSG_navRecommendations__</span></a></li> <li><a class="nav-link" href="#" id="nav-recommendations" role="button"><i class="fas fa-magic"></i><span>__MSG_navRecommendations__</span></a></li>
<li><a class="nav-link d-lg-none" href="#" id="openMusicPlayerMobile"><i class="fas fa-music"></i><span>__MSG_navMusic__</span></a></li> <li><a class="nav-link d-lg-none" href="#" id="openMusicPlayerMobile" role="button"><i class="fas fa-music"></i><span>__MSG_navMusic__</span></a></li>
<li><a class="nav-link" href="#" id="nav-m3u-generator"><i class="fas fa-list-ul"></i><span>__MSG_navM3uGenerator__</span></a></li> <li><a class="nav-link" href="#" id="nav-m3u-generator" role="button"><i class="fas fa-list-ul"></i><span>__MSG_navM3uGenerator__</span></a></li>
</ul> </ul>
</nav> </nav>
<div id="main-container"> <div id="main-container" role="main">
<div id="main-view"> <div id="main-view">
<section class="hero" id="hero-section"> <section class="hero" id="hero-section">
<div class="hero-background-container"> <div class="hero-background-container">
@ -287,15 +285,15 @@
<footer class="footer"> <footer class="footer">
<div class="container"> <div class="container">
<div class="footer-content"> <div class="footer-content">
<a class="footer-logo-link" href="#" id="footer-logo-btn"> <a class="footer-logo-link" href="#" id="footer-logo-btn" role="button">
<span class="footer-logo-text">__MSG_appName__</span> <span class="footer-logo-text">__MSG_appName__</span>
</a> </a>
<div class="footer-links"> <div class="footer-links">
<a href="#" class="footer-link" id="footer-movies">__MSG_navMovies__</a> <a href="#" class="footer-link" id="footer-movies" role="button">__MSG_navMovies__</a>
<a href="#" class="footer-link" id="footer-series">__MSG_navSeries__</a> <a href="#" class="footer-link" id="footer-series" role="button">__MSG_navSeries__</a>
<a href="#" class="footer-link" id="footer-providers">__MSG_navProviders__</a> <a href="#" class="footer-link" id="footer-providers" role="button">__MSG_navProviders__</a>
<a href="#" class="footer-link" id="footer-stats">__MSG_navStats__</a> <a href="#" class="footer-link" id="footer-stats" role="button">__MSG_navStats__</a>
<a href="#" class="footer-link" id="footer-favorites">__MSG_navFavorites__</a> <a href="#" class="footer-link" id="footer-favorites" role="button">__MSG_navFavorites__</a>
</div> </div>
<p class="footer-credit">__MSG_footerCredit__</p> <p class="footer-credit">__MSG_footerCredit__</p>
</div> </div>
@ -308,7 +306,7 @@
<div id="item-details-content"></div> <div id="item-details-content"></div>
</section> </section>
<div id="video-lightbox" class="lightbox"> <div id="video-lightbox" class="lightbox" role="dialog" aria-modal="true">
<div class="lightbox-content"> <div class="lightbox-content">
<button class="lightbox-close" aria-label="__MSG_closeTrailer__"><i class="fas fa-times"></i></button> <button class="lightbox-close" aria-label="__MSG_closeTrailer__"><i class="fas fa-times"></i></button>
<div class="video-container"><iframe id="video-iframe" frameborder="0" allow="autoplay; encrypted-media" <div class="video-container"><iframe id="video-iframe" frameborder="0" allow="autoplay; encrypted-media"
@ -316,7 +314,7 @@
</div> </div>
</div> </div>
<div id="photo-lightbox" style="display: none;"> <div id="photo-lightbox" style="display: none;" role="dialog" aria-modal="true">
<div class="photo-lightbox-container"> <div class="photo-lightbox-container">
<button class="photo-lightbox-btn" id="photo-lightbox-close" aria-label="__MSG_close__"><i class="fas fa-times"></i></button> <button class="photo-lightbox-btn" id="photo-lightbox-close" aria-label="__MSG_close__"><i class="fas fa-times"></i></button>
<button class="photo-lightbox-btn" id="photo-lightbox-prev" aria-label="__MSG_previous__"><i class="fas fa-chevron-left"></i></button> <button class="photo-lightbox-btn" id="photo-lightbox-prev" aria-label="__MSG_previous__"><i class="fas fa-chevron-left"></i></button>
@ -337,7 +335,7 @@
<div class="spinner" id="spinner"></div> <div class="spinner" id="spinner"></div>
<div class="modal fade" id="activityViewerModal" tabindex="-1" aria-labelledby="activityViewerModalLabel" aria-hidden="true"> <div class="modal fade" id="activityViewerModal" tabindex="-1" aria-labelledby="activityViewerModalLabel" aria-hidden="true" role="dialog" aria-modal="true">
<div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -360,7 +358,7 @@
</div> </div>
</div> </div>
<div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true"> <div class="modal fade" id="settingsModal" tabindex="-1" aria-labelledby="settingsModalLabel" aria-hidden="true" role="dialog" aria-modal="true">
<div class="modal-dialog modal-dialog-centered modal-lg"> <div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -736,7 +734,7 @@
</div> </div>
</div> </div>
<div class="modal fade" id="infoModal" tabindex="-1" aria-labelledby="infoModalLabel" aria-hidden="true"> <div class="modal fade" id="infoModal" tabindex="-1" aria-labelledby="infoModalLabel" aria-hidden="true" role="dialog" aria-modal="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
@ -768,4 +766,4 @@
</body> </body>
</html> </html>