bug fix audio musicplayer

This commit is contained in:
Filipinos 2025-07-04 09:36:40 +02:00
parent 3e176a5762
commit ebabbe8b2d
18 changed files with 252 additions and 352 deletions

109
README.md
View File

@ -1,70 +1,67 @@
# CinePlex: Your Plex on Steroids 🚀
## 🤔 What the Heck Is This? Prepare for Takeoff!
## 🤔 So, What the Heck Is This?
Let's be real. You love your Plex server. It's your own little corner of the digital universe, packed with your movies, shows, music, and photos. But... don't you ever feel like the interface could be... *more*? A little more ✨ **magic and sparkle** ✨?
Let's be real. You love your Plex server. It's your own little corner of the digital universe, packed with your movies, shows, and music. But... don't you ever feel like the interface could be... *more*? A little more ✨ **pizzazz** ✨?
Well, **CinePlex** is the answer to your digital prayers! It's like you bought your Plex a superhero cape and sent it to a futuristic spa. It's a modern, fast, and stunningly designed interface that sits on top of your Plex servers to give you a visually dazzling experience.
Well, **CinePlex** is the answer. It's like you bought your Plex a superhero cape and sent it to a futuristic spa. It's a modern, fast, and slick-looking interface that sits on top of your Plex servers to give you a visually stunning experience.
**In short:** CinePlex is not a streaming service. It's a *radical transformation* for the content **you already own**. It's your personal media universe, but with steroids and a lot more style!
**In short:** CinePlex is not a streaming service. It's a radical new *look* for the content **you already own**. It's your personal media universe, but cooler.
---
## 🌐 We Speak All Languages! (Well, Almost)
## 🚨 A Quick Heads-Up: ¡Hablamos Español!
Goodbye, language barrier! 👋 We've been working hard in the lab, and now CinePlex is a polyglot. Thanks to the magic of Chrome's `i18n` API, the extension is fully translated and ready to speak your language. Yes, you heard that right! No more excuses not to dive into your favorite content.
> Right now, the entire user interface is in **Spanish**.
**We currently support:** Spanish, English, German, and French. And we're adding more!
I'm currently in the lab, tinkering away to integrate Chrome's `i18n` API to bring a full **English** translation to the party. It's a top priority! So please bear with the Spanish for now, an English version is coming soon.
---
## ✨ The Feature Loot Drop (What Makes It Awesome!)
## ✨ The Feature Loot Drop (What Makes It Awesome)
Get ready to be amazed by everything CinePlex has under the hood. This isn't just an extension; it's an experience!
* **🎬 Pimped-Out Interface:** Forget boring UIs. We use TheMovieDB's API to bring you high-res posters, spectacular backdrops, gripping synopses, ratings, cast info... all the juicy movie gossip you crave!
* **🗣️ Multilingual Maestro:** Hola! Bonjour! Hallo! CinePlex now speaks your language with full i18n support. No more getting lost in translation your media, your language!
* **📡 Psychic Plex Scanner:** You give it your Plex tokens, and CinePlex goes into full detective mode. It scans your servers, figures out what you *actually* have, and jots it down in its secret notebook (a local IndexedDB database in your browser). It's like having a personal librarian for your media!
* **🎬 Pimped-Out Interface:** Forget boring UIs. We use TheMovieDB's API to bring you high-res posters, backdrops, synopses, ratings, cast info... all the juicy movie gossip you crave.
* **📡 Psychic Plex Scanner:** You give it your Plex tokens, and CinePlex goes into full detective mode. It scans your servers, figures out what you *actually* have, and jots it down in its secret notebook (a local IndexedDB database in your browser).
* **✅ "Got It" Badge of Honor:** See a movie you want to watch? CinePlex will let you know if you already have it on your server with a neat "Local" badge. No more blind searching!
* **🎶 Music Jukebox 2077:** It's not all about movies. We've built a full-fledged music player that connects directly to your Plex music library. Browse artists, listen to albums, and rock out with a **graphic equalizer and audio visualizer**! Your personal party, guaranteed!
* **📊 The Nerd Stats Panel:** Ever wondered how many 80s movies you have? Or what your most common genre is? Dive into the statistics panel and get a full breakdown of your media library with amazing charts. Unleash your inner nerd!
* **🖼️ Your Personal Photo Gallery:** Connect to your Plex photo libraries and browse your albums and pictures with a beautiful, integrated lightbox viewer. Relive your memories in style!
* **📜 The Scroll of Power (M3U):** For the power users. Found that series you wanted to watch? Generate an `.m3u` file with all the episode links with a single click. Power in your hands!
* **🔥 Stream Straight to Your Server:** This is where it gets wild. Configure a simple PHP script on your server, and you can send streams from CinePlex directly to your M3U playlist file with a single click. We even give you a **PHP script generator** to make it foolproof!
* **❤️ Favorites & Goldfish Memory:** Save your favorite movies and shows. Plus, we've got a "History" section so you can remember what you were watching last night before you fell asleep on the couch. Never lose track again!
* **🧠 AI-Powered Recommendations:** Based on your viewing history and favorites, CinePlex will suggest new content you might love. It's like having a personal movie critic living in your browser.
* **🔧 Customization Tuning Shop:** Don't like the dark theme? Switch to light mode! Don't want the giant hero banner? Hide it! Add your own TMDB API key. You're the boss, this is your extension!
* **🎶 Music Jukebox 2077:** It's not all about movies. We've built a full-fledged music player that connects directly to your Plex music library. Browse artists, listen to albums, and rock out with a **graphic equalizer and audio visualizer**!
* **📊 The Nerd Stats Panel:** Ever wondered how many 80s movies you have? Or what your most common genre is? Dive into the statistics panel and get a full breakdown of your media library with fancy charts.
* **🖼️ Your Personal Photo Gallery:** Connect to your Plex photo libraries and browse your albums and pictures with a beautiful, integrated lightbox viewer.
* **📜 The Scroll of Power (M3U):** For the power users. Found that series you wanted to watch? Generate an `.m3u` file with all the episode links with a single click.
* **🔥 Stream Straight to Your Server:** This is where it gets crazy. Configure a simple PHP script on your server, and you can send streams from CinePlex directly to your M3U playlist file with one click. We even give you a **PHP script generator** to make it foolproof!
* **❤️ Favorites & Goldfish Memory:** Save your favorite movies and shows. Plus, we've got a "History" section so you can remember what you were watching last night before you fell asleep on the couch.
* **🔧 Customization Tuning Shop:** Don't like the dark theme? Switch to light mode! Don't want the giant hero banner? Hide it! Add your own TMDB API key. You're the boss.
* **💾 Data Hoarder's Dream:** Import and export your entire local CinePlex database. Move your settings and scanned data between computers with ease.
---
## 🛠️ Installation and First Steps: Liftoff in 3, 2, 1...!
## 🛠️ Installation & First-Time Setup
Get ready for adventure! Getting this beast up and running is easier than finding popcorn at the movies. Follow these simple steps and you'll be navigating your Plex like a Starship captain.
Getting this beast up and running is easy. Just follow these steps.
### 1. Installing the Extension: The First Quantum Leap!
### 1. Installing the Extension
Since we're not yet on the Chrome Web Store (but we will be, oh yes!), you'll have to load it as an "unpacked" extension. Think of it as an exclusive beta launch just for you.
Since this isn't on the Chrome Web Store, you'll have to load it as an "unpacked" extension.
1. **Download this repository:** Click the green "Code" button and select "Download ZIP". Unzip the file somewhere you'll remember (perhaps your "Documents" folder or a secret place where you keep your digital treasures!).
2. **Open Chrome Extensions:** In a new tab, type `chrome://extensions` and press Enter. Welcome to the control center of your browser superpowers!
3. **Enable Developer Mode:** Find the "Developer mode" toggle in the top-right corner and switch it **ON**. It's like turning on your browser's turbo mode!
1. **Download this repository:** Click the green "Code" button and select "Download ZIP". Unzip the file somewhere you'll remember (like your `Documents` folder).
2. **Open Chrome Extensions:** Open a new tab and go to `chrome://extensions`.
3. **Enable Developer Mode:** Find the "Developer mode" toggle in the top-right corner and switch it **ON**.
4. **Load the Extension:** You'll see some new buttons appear. Click on **"Load unpacked"**.
5. **Select the Folder:** A file browser will open. Navigate to the folder where you unzipped the repository and select it. Don't pick the wrong folder, or the universe might collapse! (Well, maybe not, but it won't work).
6. **Mission Ready!** You should now see the CinePlex card in your extensions list. Congratulations, you've installed your first piece of space technology!
5. **Select the Folder:** A file browser will open. Navigate to the folder where you unzipped the repository and select it.
6. **Done!** You should now see the CinePlex card in your extensions list.
### 2. Initial Setup: Your Personal Operations Base!
### 2. First-Time Setup (The Fun Part!)
When you first open CinePlex, it's like a newly built spaceship: impressive, but it needs fuel and coordinates. Let's bring it to life!
When you first open CinePlex, it's a blank slate. It's like a cool new apartment, but you need to bring your furniture. Let's do that.
1. **Find Your Plex Token: The Master Key to the Universe!** This is the MOST important step. You need your `X-Plex-Token` to let CinePlex talk to your Plex server. The easiest way is to follow the official Plex guide: [Finding an Authentication Token / X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/). Don't share this key with anyone, it's yours and yours alone!
1. **Find Your Plex Token:** This is the most important step. You need to get your `X-Plex-Token` to let CinePlex talk to your server. The easiest way is to follow the official Plex guide: [Finding an Authentication Token / X-Plex-Token](https://support.plex.tv/articles/204059436-finding-an-authentication-token-x-plex-token/).
2. **Open CinePlex Settings: The Control Panel!**
2. **Open CinePlex Settings:**
* Click the CinePlex icon in your browser's toolbar to open the application in a new tab.
* Click the **cogwheel icon (⚙️)** in the top-right corner to open the Settings modal. This is where the magic happens!
* Click the **cogwheel icon (⚙️)** in the top-right corner to open the Settings modal.
3. **Add Your Token: Injecting the Fuel!**
3. **Add Your Token:**
* Go to the **Plex** tab.
* You'll see a code editor. Paste your `X-Plex-Token` inside the square brackets `[]`. If you have more than one (how lucky!), separate them with commas. It should look something like this:
* You'll see a code editor. Paste your `X-Plex-Token` inside the square brackets `[]`. If you have more than one, separate them with commas. It should look like this:
```json
{
"tokens": [
@ -73,34 +70,24 @@ When you first open CinePlex, it's like a newly built spaceship: impressive, but
]
}
```
* Click the **"Save Tokens"** button. You've secured the connection!
* Click the **"Guardar Tokens"** (Save Tokens) button.
4. **Start Your First Scan: The Great Exploration!**
* Still in the Plex tab, check the boxes for the content you want to scan (e.g., Movies, Series, Music, Photos).
* Click the big blue **"Start Scan"** button. It's time for CinePlex to discover all your treasures!
* A console will appear at the bottom of the main page, showing you the scanner's progress. Be patient, the first scan can take a few minutes if you have a gigantic library. Rome wasn't built in a day, and your Plex library won't be scanned in a second either!
4. **Run Your First Scan:**
* Still in the Plex tab, check the boxes for the content you want to scan (e.g., Películas, Series).
* Click the big blue **"Iniciar Escaneo"** (Start Scan) button.
* A console will appear at the bottom of the main page, showing you the scanner's progress. Be patient, the first scan can take a few minutes if you have a large library.
5. **Enjoy!** Once the scan is complete, the app will automatically refresh. Go back to the main view and start exploring your newly supercharged Plex interface! The galaxy of your content awaits!
5. **Enjoy!** Once the scan is complete, the app will automatically refresh. Go back to the main view and start exploring your newly supercharged Plex interface!
---
## 💡 Pro-Tip: Setting Up the "Add Stream" Feature - The Magic Button!
## 💡 Pro-Tip: Setting Up the "Add Stream" Feature
Want to take your experience to the next level and use the "Add Stream" button? It's like having a magic wand for your content!
Want to use the "Add Stream" button? It's awesome.
1. In the CinePlex settings, go to the **"PHP Generator"** tab.
2. Configure the options (like the filename, save path, and security key) and click **"Generate Script"**. Watch as the magic of code materializes!
3. Copy the generated PHP code. It's your secret recipe!
4. Save that code as a `.php` file (e.g., `playlist.php`) and upload it to a web server you control. Your own corner on the web for your streams!
5. Go to the **"General"** tab in settings and paste the public URL to your new script in the **"Stream Server URL"** field. Connecting the dots!
6. Save, and you're ready! Now you can add streams with a single click. It's like teleporting your content wherever you need it!
---
## 🔒 Permissions: What Does CinePlex Need?
CinePlex is designed to be as non-intrusive as possible, but it does need a few permissions to work its magic:
* **`storage`**: This allows CinePlex to store your Plex tokens, scanned library data, and your personalized settings directly in your browser's local storage. All your data stays on your machine, safe and sound!
* **`notifications`**: Used to send you helpful notifications, for example, when a scan is complete or if there's an important update.
* **`host_permissions` for `https://*.plex.tv/*`**: This is crucial! It allows CinePlex to communicate directly with your Plex servers to fetch your library information. Without this, CinePlex wouldn't be able to see your awesome media collection.
1. In the CinePlex settings, go to the **"Generador PHP"** tab.
2. Configure the options (like the filename) and click **"Generar Script"**.
3. Copy the generated PHP code.
4. Save that code as a `.php` file (e.g., `playlist.php`) and upload it to a web server you control.
5. Go to the **"General"** tab in settings and paste the public URL to your new script in the **"URL del Servidor para Añadir Streams"** field.
6. Save, and you're ready to add streams with a single click!

View File

@ -256,8 +256,8 @@
"writer": {"message": "Autor:"},
"viewOnImdb": {"message": "Auf IMDb ansehen"},
"watchTrailer": {"message": "Trailer ansehen"},
"addToFavorites": {"message": "Zu Favoriten hinzufügen"},
"removeFromFavorites": {"message": "Aus Favoriten entfernen"},
"addToFavorites": {"message": "Favorit"},
"removeFromFavorites": {"message": "Entfernen"},
"notAvailable": {"message": "Nicht verfügbar"},
"mainCast": {"message": "Hauptbesetzung"},
"seasonsAndEpisodes": {"message": "Staffeln & Episoden"},

View File

@ -256,8 +256,8 @@
"writer": {"message": "Writer:"},
"viewOnImdb": {"message": "View on IMDb"},
"watchTrailer": {"message": "Watch Trailer"},
"addToFavorites": {"message": "Add to Favorites"},
"removeFromFavorites": {"message": "Remove from Favorites"},
"addToFavorites": {"message": "Favorite"},
"removeFromFavorites": {"message": "Unfavorite"},
"notAvailable": {"message": "Not Available"},
"mainCast": {"message": "Main Cast"},
"seasonsAndEpisodes": {"message": "Seasons & Episodes"},

View File

@ -117,7 +117,7 @@
"miniplayerOpenList": { "message": "Abrir lista" },
"eqTitle": { "message": "Ecualizador Gráfico" },
"eqPresetsLabel": { "message": "Presets" },
"eqPresetFlat": { "message": "Plano" },
"eqPresetFlat": { "message": "Plano (Flat)" },
"eqPresetRock": { "message": "Rock" },
"eqPresetPop": { "message": "Pop" },
"eqPresetJazz": { "message": "Jazz" },
@ -256,8 +256,8 @@
"writer": {"message": "Escritor:"},
"viewOnImdb": {"message": "Ver en IMDb"},
"watchTrailer": {"message": "Ver Tráiler"},
"addToFavorites": {"message": "Añadir a favoritos"},
"removeFromFavorites": {"message": "Quitar de favoritos"},
"addToFavorites": {"message": "Favorito"},
"removeFromFavorites": {"message": "Quitar Fav."},
"notAvailable": {"message": "No disponible"},
"mainCast": {"message": "Reparto Principal"},
"seasonsAndEpisodes": {"message": "Temporadas y Episodios"},

View File

@ -257,7 +257,7 @@
"viewOnImdb": {"message": "Voir sur IMDb"},
"watchTrailer": {"message": "Voir la bande-annonce"},
"addToFavorites": {"message": "Ajouter aux favoris"},
"removeFromFavorites": {"message": "Supprimer des favoris"},
"removeFromFavorites": {"message": "Retirer des favoris"},
"notAvailable": {"message": "Non disponible"},
"mainCast": {"message": "Distribution Principale"},
"seasonsAndEpisodes": {"message": "Saisons & Épisodes"},

View File

@ -149,7 +149,7 @@
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%231f2937'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");
}
.filter-select option, .form-control option {
.filter-select option {
background: var(--primary);
color: var(--text-primary);
border: none;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 466 KiB

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 KiB

After

Width:  |  Height:  |  Size: 477 KiB

View File

@ -84,17 +84,25 @@ export async function getMusicUrlsFromPlex(token, protocolo, ip, puerto, artista
return tracks;
} catch (error) {
console.error("Error in getMusicUrlsFromPlex:", error);
throw error;
}
}
export async function fetchAllStreamsFromPlex(busqueda, tipoContenido) {
if (!busqueda || !tipoContenido) return { success: false, streams: [], message: _('invalidStreamInfo') };
if (!state.db) return { success: false, streams: [], message: _('dbUnavailableForStreams') };
async function searchAndProcessServer(server, busqueda, tipoContenido) {
const plexSearchType = tipoContenido === 'movie' ? '1' : '2';
const servers = await getFromDB('conexiones_locales');
if (!servers || servers.length === 0) return { success: false, streams: [], message: _('noPlexServersForStreams') };
const searchTasks = servers.map(async (server) => {
const { ip, puerto, token, protocolo = 'http', nombre: serverName = 'Servidor Desconocido' } = server;
if (!ip || !puerto || !token) return [];
const plexSearchType = tipoContenido === 'movie' ? '1' : '2';
const searchUrl = `${protocolo}://${ip}:${puerto}/search?type=${plexSearchType}&query=${encodeURIComponent(busqueda)}&X-Plex-Token=${token}`;
let serverStreams = [];
try {
const response = await fetchWithTimeout(searchUrl, { headers: { 'Accept': 'application/xml' } });
@ -106,16 +114,6 @@ async function searchAndProcessServer(server, busqueda, tipoContenido) {
if (xml.querySelector('parsererror')) return [];
if (tipoContenido === 'movie') {
return processMovieResults(xml, busqueda, protocolo, ip, puerto, token);
} else {
return await processShowResults(xml, busqueda, protocolo, ip, puerto, token);
}
} catch (error) {
return [];
}
}
function processMovieResults(xml, busqueda, protocolo, ip, puerto, token) {
const videos = Array.from(xml.querySelectorAll("Video"));
let videosToProcess = videos;
const exactMatch = videos.find(v => v.getAttribute('title')?.toLowerCase() === busqueda.toLowerCase());
@ -123,26 +121,23 @@ function processMovieResults(xml, busqueda, protocolo, ip, puerto, token) {
videosToProcess = [exactMatch];
}
return videosToProcess.map(video => {
videosToProcess.forEach(video => {
const part = video.querySelector("Part");
if (!part || !part.getAttribute("key")) return null;
if (part && part.getAttribute("key")) {
const movieTitle = video.getAttribute("title") || busqueda;
const movieYear = video.getAttribute("year");
const streamUrl = `${protocolo}://${ip}:${puerto}${part.getAttribute("key")}?X-Plex-Token=${token}`;
const extinfName = `${movieTitle}${movieYear ? ` (${movieYear})` : ''}`;
const logoUrl = video.getAttribute("thumb") ? `${protocolo}://${ip}:${puerto}${video.getAttribute("thumb")}?X-Plex-Token=${token}` : '';
const groupTitle = extinfName.replace(/"/g, "'");
return {
serverStreams.push({
url: streamUrl,
title: extinfName,
extinf: `#EXTINF:-1 tvg-name="${extinfName.replace(/"/g, "'")}" tvg-logo="${logoUrl}" group-title="${groupTitle}",${extinfName}`
};
}).filter(Boolean);
});
}
async function processShowResults(xml, busqueda, protocolo, ip, puerto, token) {
});
} else {
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) {
@ -151,22 +146,17 @@ async function processShowResults(xml, busqueda, protocolo, ip, puerto, token) {
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 = new DOMParser().parseFromString(leavesData, "text/xml");
const leavesXml = parser.parseFromString(leavesData, "text/xml");
if (!leavesXml.querySelector('parsererror')) {
return processShowEpisodes(leavesXml, busqueda, protocolo, ip, puerto, token);
}
}
}
return [];
}
const episodes = Array.from(leavesXml.querySelectorAll("Video"));
function processShowEpisodes(xml, busqueda, protocolo, ip, puerto, token) {
const episodes = Array.from(xml.querySelectorAll("Video"));
episodes.sort((a,b) => {
const seasonA = parseInt(a.getAttribute("parentIndex") || 0, 10);
const seasonB = parseInt(b.getAttribute("parentIndex") || 0, 10);
@ -176,12 +166,9 @@ function processShowEpisodes(xml, busqueda, protocolo, ip, puerto, token) {
return episodeA - episodeB;
});
return episodes.map(episode => {
episodes.forEach(episode => {
const part = episode.querySelector("Part");
if (!part || !part.getAttribute("key")) return null;
const serieTitulo = episode.getAttribute("grandparentTitle") || busqueda;
const serieYear = episode.getAttribute("parentYear");
if (part && part.getAttribute("key")) {
const seasonNum = episode.getAttribute("parentIndex") || 'S';
const episodeNum = episode.getAttribute("index") || 'E';
const episodeTitle = episode.getAttribute("title") || 'Episodio';
@ -190,23 +177,23 @@ function processShowEpisodes(xml, busqueda, protocolo, ip, puerto, token) {
const extinfName = `${serieTitulo} T${seasonNum}E${episodeNum} ${episodeTitle}`;
const logoUrl = episode.getAttribute("grandparentThumb") || episode.getAttribute("parentThumb") || episode.getAttribute("thumb");
const fullLogoUrl = logoUrl ? `${protocolo}://${ip}:${puerto}${logoUrl}?X-Plex-Token=${token}` : '';
return {
serverStreams.push({
url: streamUrl,
title: extinfName,
extinf: `#EXTINF:-1 tvg-name="${extinfName.replace(/"/g, "'")}" tvg-logo="${fullLogoUrl}" group-title="${groupTitle}",${extinfName}`
};
}).filter(Boolean);
});
}
export async function fetchAllStreamsFromPlex(busqueda, tipoContenido) {
if (!busqueda || !tipoContenido) return { success: false, streams: [], message: _('invalidStreamInfo') };
if (!state.db) return { success: false, streams: [], message: _('dbUnavailableForStreams') };
const servers = await getFromDB('conexiones_locales');
if (!servers || servers.length === 0) return { success: false, streams: [], message: _('noPlexServersForStreams') };
const searchTasks = servers.map(server => searchAndProcessServer(server, busqueda, tipoContenido));
});
}
}
}
}
return serverStreams;
} catch (error) {
console.warn(`Error buscando streams en ${serverName}:`, error.message);
return [];
}
});
const results = await Promise.allSettled(searchTasks);
const allFoundStreams = results

View File

@ -1,20 +0,0 @@
export const API_URLS = {
TMDB_BASE: 'https://api.themoviedb.org/3',
TMDB_IMAGE_BASE: 'https://image.tmdb.org/t/p',
PLEX_TV: 'https://plex.tv/api/resources',
YOUTUBE_EMBED: 'https://www.youtube.com/embed/',
IMDB_TITLE: 'https://www.imdb.com/title/'
};
export const CONFIG = {
DEFAULT_API_KEY: '4e44d9029b1270a757cddc766a1bcb63',
DB_NAME: 'PlexDB',
DB_VERSION: 6
};
export const STORAGE_KEYS = {
USER_HISTORY: 'cineplex_userHistory',
USER_PREFERENCES: 'cineplex_userPreferences',
FAVORITES: 'cineplex_favorites',
RECOMMENDATIONS_CACHE: 'cineplex_recommendations'
};

View File

@ -55,6 +55,7 @@ export function initDB() {
window.location.reload();
};
state.db.onerror = event => {
console.error(`Database error: ${event.target.errorCode}`);
reject(event.target.error);
};
resolve();
@ -113,7 +114,7 @@ export function addItemsToStore(storeName, items) {
if (item !== undefined && item !== null) {
const request = store.put(item);
request.onsuccess = () => successCount++;
request.onerror = (e) => {};
request.onerror = (e) => console.warn(`Error adding/updating item in ${storeName}:`, e.target.error, item);
}
});

View File

@ -54,6 +54,7 @@ export class Equalizer {
this.drawVisualizer();
return true;
} catch (e) {
console.error("Error initializing Web Audio API:", e);
return false;
}
}

View File

@ -5,6 +5,7 @@ function localizeHtmlPage() {
try {
return p1 ? chrome.i18n.getMessage(p1) : match;
} catch (e) {
console.warn(`Could not find message for: ${p1}`);
return match;
}
}
@ -33,7 +34,6 @@ function localizeHtmlPage() {
document.documentElement.lang = chrome.i18n.getUILanguage().split('-')[0];
document.title = document.title.replace(i18nRegex, replaceMsg);
document.body.classList.add('loaded');
}
document.addEventListener('DOMContentLoaded', localizeHtmlPage);

View File

@ -19,6 +19,7 @@ async function loadSettings() {
state.settings.apiKey = config.defaultApiKey;
}
} catch (error) {
console.error("Could not load settings from DB, using defaults.", error);
state.settings.language = chrome.i18n.getUILanguage().split('-')[0];
}
}
@ -46,6 +47,7 @@ document.addEventListener('DOMContentLoaded', async () => {
initializeThirdPartyLibs();
} catch (error) {
console.error("Fatal Initialization failed:", error);
showNotification(_("fatalInitError"), "error");
document.getElementById('main-container').innerHTML = `<div class="container text-center mt-5 pt-5"><h1 class="text-danger">${_("fatalInitError")}</h1><p>${_("fatalInitErrorSub")}</p></div>`;
}

201
js/ui.js
View File

@ -5,27 +5,6 @@ import { getFromDB, addItemsToStore } from './db.js';
let charts = {};
function createElement(tag, options = {}, children = []) {
const el = document.createElement(tag);
Object.entries(options).forEach(([key, value]) => {
if (key === 'dataset') {
Object.entries(value).forEach(([dataKey, dataValue]) => {
el.dataset[dataKey] = dataValue;
});
} else {
el[key] = value;
}
});
children.forEach(child => {
if (typeof child === 'string') {
el.appendChild(document.createTextNode(child));
} else if (child) {
el.appendChild(child);
}
});
return el;
}
export async function loadInitialContent() {
await Promise.all([loadGenres(), loadYears()]);
await Promise.all([loadContent(), initializeHeroSection()]);
@ -248,29 +227,31 @@ function updateSortOptions() {
async function loadGenres() {
const type = state.currentParams.contentType;
const select = document.getElementById('genre-filter');
select.innerHTML = '';
select.appendChild(createElement('option', { value: '', textContent: _('loadingGenres') }));
select.innerHTML = `<option value="">${_('loadingGenres')}</option>`;
try {
const data = await fetchTMDB(`genre/${type}/list`);
select.innerHTML = '';
select.appendChild(createElement('option', { value: '', textContent: _('allGenres') }));
select.innerHTML = `<option value="">${_('allGenres')}</option>`;
data.genres.forEach(genre => {
select.appendChild(createElement('option', { value: genre.id, textContent: genre.name }));
const option = document.createElement('option');
option.value = genre.id;
option.textContent = genre.name;
select.appendChild(option);
});
select.value = state.currentParams.genre || "";
} catch (error) {
select.innerHTML = '';
select.appendChild(createElement('option', { value: '', textContent: _('errorLoadingGenres') }));
select.innerHTML = `<option value="">${_('errorLoadingGenres')}</option>`;
}
}
function loadYears() {
const select = document.getElementById('year-filter');
select.innerHTML = '';
select.appendChild(createElement('option', { value: '', textContent: _('allYears') }));
select.innerHTML = `<option value="">${_('allYears')}</option>`;
const currentYear = new Date().getFullYear();
for (let year = currentYear; year >= 1900; year--) {
select.appendChild(createElement('option', { value: year, textContent: year }));
const option = document.createElement('option');
option.value = year;
option.textContent = year;
select.appendChild(option);
}
select.value = state.currentParams.year || "";
}
@ -287,14 +268,11 @@ export async function loadContent(append = false) {
const loadMoreButton = document.getElementById('load-more');
if (!append) {
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'col-12 text-center mt-5' }, [createElement('div', { className: 'spinner', style: 'position: static; margin: auto; display: block;' })]));
grid.innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>';
loadMoreButton.style.display = 'none';
} else {
loadMoreButton.disabled = true;
loadMoreButton.innerHTML = '';
loadMoreButton.appendChild(createElement('span', { className: 'spinner-border spinner-border-sm', role: 'status', "aria-hidden": 'true' }));
loadMoreButton.appendChild(document.createTextNode(` ${_('loading')}`));
loadMoreButton.innerHTML = `<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> ${_('loading')}`;
}
try {
@ -316,13 +294,7 @@ export async function loadContent(append = false) {
} catch (error) {
if (error.name !== 'AbortError') {
if (!append) {
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'empty-state' }, [
createElement('i', { className: 'fas fa-exclamation-triangle' }),
createElement('p', { textContent: _('couldNotLoadContent') })
]));
}
if (!append) grid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('couldNotLoadContent')}</p></div>`;
}
} finally {
state.isLoading = false;
@ -355,11 +327,7 @@ function renderGrid(items, append = false) {
const emptyStateTarget = document.getElementById('recommendations-grid') || grid;
const emptyIcon = state.currentView === 'recommendations' ? 'fa-user-astronaut' : 'fa-film';
const emptyText = state.currentView === 'recommendations' ? _('noRecommendations') : _('noContentFound');
emptyStateTarget.innerHTML = '';
emptyStateTarget.appendChild(createElement('div', { className: 'empty-state' }, [
createElement('i', { className: `fas ${emptyIcon} fa-3x mb-3` }),
createElement('p', { className: 'lead', textContent: emptyText })
]));
emptyStateTarget.innerHTML = `<div class="empty-state"><i class="fas ${emptyIcon} fa-3x mb-3"></i><p class="lead">${emptyText}</p></div>`;
}
document.getElementById('load-more').style.display = 'none';
return;
@ -380,7 +348,10 @@ function renderGrid(items, append = false) {
const voteAvg = item.vote_average ? item.vote_average.toFixed(1) : 'N/A';
const ratingClass = voteAvg >= 7.5 ? 'rating-good' : (voteAvg >= 5.0 ? 'rating-ok' : 'rating-bad');
const card = createElement('div', { className: 'item-card', dataset: { id: item.id, type: itemType } });
const card = document.createElement('div');
card.className = `item-card`;
card.dataset.id = item.id;
card.dataset.type = itemType;
card.innerHTML = `
<div class="item-poster" style="background-image: url('${posterPath}')"></div>
${voteAvg >= 7.8 ? '<span class="badge top-badge">TOP</span>' : ''}
@ -470,8 +441,7 @@ export async function showItemDetails(itemId, contentType) {
if (mainView.style.display !== 'none') state.lastScrollPosition = window.scrollY;
document.body.classList.add('details-view-active');
detailsContent.innerHTML = '';
detailsContent.appendChild(createElement('div', { className: 'text-center py-5' }, [createElement('div', { className: 'spinner', style: 'display: block; margin: auto; position: static;' })]));
detailsContent.innerHTML = '<div class="text-center py-5"><div class="spinner" style="display: block; margin: auto; position: static;"></div></div>';
detailsView.classList.add('active');
@ -538,11 +508,7 @@ export async function showItemDetails(itemId, contentType) {
await renderItemDetails(item);
} catch (error) {
detailsContent.innerHTML = '';
detailsContent.appendChild(createElement('div', { className: 'alert alert-danger mx-3 my-5 text-center' }, [
createElement('h4', { textContent: _('errorLoadingDetails') }),
createElement('p', { textContent: error.message })
]));
detailsContent.innerHTML = `<div class="alert alert-danger mx-3 my-5 text-center"><h4>${_('errorLoadingDetails')}</h4><p>${error.message}</p></div>`;
} finally {
state.isLoading = false;
}
@ -569,7 +535,6 @@ async function renderItemDetails(item) {
const director = crew.find(c => c.job === 'Director');
const writer = crew.find(c => c.job === 'Screenplay' || c.job === 'Writer' || c.job === 'Story');
detailsContent.innerHTML = '';
detailsContent.innerHTML = `
${backdropPath ? `<div class="details-backdrop-container"><img src="${backdropPath}" class="details-backdrop-img"><div class="details-backdrop-overlay"></div></div>` : ''}
<div class="item-details-container">
@ -745,15 +710,10 @@ function updateFavoriteButtonVisuals(itemId, itemType, isFavorite) {
export async function loadFavorites() {
const grid = document.getElementById('content-grid');
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'col-12 text-center mt-5' }, [createElement('div', { className: 'spinner', style: 'position: static; margin: auto; display: block;' })]));
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) {
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'empty-state' }, [
createElement('i', { className: 'far fa-heart fa-3x mb-3' }),
createElement('p', { className: 'lead', textContent: _('noFavorites') })
]));
grid.innerHTML = `<div class="empty-state"><i class="far fa-heart fa-3x mb-3"></i><p class="lead">${_('noFavorites')}</p></div>`;
return;
}
@ -762,11 +722,7 @@ export async function loadFavorites() {
const favoriteItems = (await Promise.all(favoritePromises)).filter(item => item !== null);
renderGrid(favoriteItems, false);
} catch (error) {
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'empty-state' }, [
createElement('i', { className: 'fas fa-exclamation-triangle' }),
createElement('p', { textContent: _('errorLoadingFavorites') })
]));
grid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('errorLoadingFavorites')}</p></div>`;
}
}
@ -774,11 +730,7 @@ export function displayHistory() {
const listContainer = document.getElementById('history-list');
listContainer.innerHTML = "";
if (state.userHistory.length === 0) {
listContainer.appendChild(createElement('div', { className: 'empty-state' }, [
createElement('i', { className: 'fas fa-history fa-3x mb-3' }),
createElement('p', { className: 'lead', textContent: _('historyEmpty') }),
createElement('p', { className: 'text-muted', textContent: _('historyEmptySub') })
]));
listContainer.innerHTML = `<div class="empty-state"><i class="fas fa-history fa-3x mb-3"></i><p class="lead">${_('historyEmpty')}</p><p class="text-muted">${_('historyEmptySub')}</p></div>`;
document.getElementById('clear-history-btn').style.display = 'none';
return;
}
@ -788,7 +740,11 @@ export function displayHistory() {
[...state.userHistory].sort((a,b) => b.timestamp - a.timestamp).forEach(item => {
const posterUrl = item.poster ? `https://image.tmdb.org/t/p/w92${item.poster}` : 'img/no-poster.png';
const isAvailable = !!buscarContenidoLocal(item.title, item.type);
const historyItem = createElement('div', { className: 'history-item', dataset: { id: item.id, type: item.type, title: item.title } });
const historyItem = document.createElement('div');
historyItem.className = 'history-item';
historyItem.dataset.id = item.id;
historyItem.dataset.type = item.type;
historyItem.dataset.title = item.title;
historyItem.innerHTML = `
<div class="history-main-content">
<img src="${posterUrl}" class="history-poster info-btn">
@ -856,8 +812,7 @@ export async function getTrailerKey(id, type) {
export async function loadRecommendations() {
const grid = document.getElementById('recommendations-grid');
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'col-12 text-center mt-5' }, [createElement('div', { className: 'spinner', style: 'position: static; margin: auto; display: block;' })]));
grid.innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="position: static; margin: auto; display: block;"></div></div>';
const cachedRecs = sessionStorage.getItem('cineplex_recommendations');
if (cachedRecs) {
@ -889,19 +844,14 @@ export async function loadRecommendations() {
renderGrid([]);
}
} catch(error) {
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'empty-state' }, [
createElement('i', { className: 'fas fa-exclamation-triangle' }),
createElement('p', { textContent: _('errorGeneratingRecommendations') })
]));
grid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('errorGeneratingRecommendations')}</p></div>`;
}
}
async function populateStatsTokenFilter() {
const select = document.getElementById('stats-token-filter');
const currentValue = select.value;
select.innerHTML = '';
select.appendChild(createElement('option', { value: 'all', textContent: _('statsAllTokens') }));
select.innerHTML = `<option value="all">${_('statsAllTokens')}</option>`;
try {
const tokensData = await getFromDB('tokens');
@ -916,7 +866,10 @@ async function populateStatsTokenFilter() {
});
primaryTokens.forEach(token => {
select.appendChild(createElement('option', { value: token, textContent: tokenNames[token] || `Token...${token.slice(-4)}` }));
const option = document.createElement('option');
option.value = token;
option.textContent = tokenNames[token] || `Token...${token.slice(-4)}`;
select.appendChild(option);
});
if (currentValue) {
@ -924,8 +877,7 @@ async function populateStatsTokenFilter() {
}
} catch(e) {
select.innerHTML = '';
select.appendChild(createElement('option', { value: 'all', textContent: _('errorLoadingTokens') }));
select.innerHTML = `<option value="all">${_('errorLoadingTokens')}</option>`;
}
}
@ -998,11 +950,7 @@ export async function generateStatistics() {
});
} catch (error) {
loader.innerHTML = '';
loader.appendChild(createElement('div', { className: 'empty-state' }, [
createElement('i', { className: 'fas fa-exclamation-triangle' }),
createElement('p', { textContent: _('errorGeneratingStats') })
]));
loader.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('errorGeneratingStats')}</p></div>`;
}
}
@ -1017,15 +965,12 @@ function updateTokenDetailsCard(selectedToken, allConnections) {
const associatedServers = allConnections.filter(c => c.tokenPrincipal === selectedToken);
serverList.innerHTML = '';
if (associatedServers.length === 0) {
serverList.appendChild(createElement('li', { textContent: _('noServersForToken') }));
serverList.innerHTML = `<li>${_('noServersForToken')}</li>`;
} else {
associatedServers.forEach(s => {
const li = createElement('li');
li.innerHTML = `<strong>${s.nombre}:</strong> <code>${s.token.slice(0, 5)}...${s.token.slice(-5)}</code>`;
serverList.appendChild(li);
});
serverList.innerHTML = associatedServers.map(s =>
`<li><strong>${s.nombre}:</strong> <code>${s.token.slice(0, 5)}...${s.token.slice(-5)}</code></li>`
).join('');
}
card.style.display = 'block';
}
@ -1147,8 +1092,7 @@ export async function searchByActor(actorId, actorName) {
updateActiveNav(state.currentParams.contentType);
const grid = document.getElementById('content-grid');
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'col-12 text-center mt-5' }, [createElement('div', { className: 'spinner', style: 'display: block; margin: auto; position: static;' })]));
grid.innerHTML = '<div class="col-12 text-center mt-5"><div class="spinner" style="display: block; margin: auto; position: static;"></div></div>';
document.querySelector('.filters').style.display = 'none';
try {
@ -1157,11 +1101,7 @@ export async function searchByActor(actorId, actorName) {
renderGrid(data.results, false);
document.getElementById('load-more').style.display = (data.page < data.total_pages) ? 'block' : 'none';
} catch (error) {
grid.innerHTML = '';
grid.appendChild(createElement('div', { className: 'empty-state' }, [
createElement('i', { className: 'fas fa-exclamation-triangle' }),
createElement('p', { textContent: _('errorLoadingActorContent', actorName) })
]));
grid.innerHTML = `<div class="empty-state"><i class="fas fa-exclamation-triangle"></i><p>${_('errorLoadingActorContent', actorName)}</p></div>`;
}
}
@ -1251,6 +1191,7 @@ export async function initializeHeroSection() {
setInterval(() => changeHeroSlide(false), 12000);
} catch (error) {
console.error("Error initializing hero section:", error);
heroSection.style.display = 'none';
}
}
@ -1572,13 +1513,13 @@ if (!isset($data['streams']) || !is_array($data['streams']) || empty($data['stre
sendResponse(false, 'Error: El JSON debe contener un array "streams" no vacío.');
}
$save_dir = SAVE_DIRECTORY !== '' ? rtrim(SAVE_DIRECTORY, '/\\') : __DIR__;
$save_dir = SAVE_DIRECTORY !== '' ? rtrim(SAVE_DIRECTORY, '/\\\\') : __DIR__;
if (!is_dir($save_dir) || !is_writable($save_dir)) {
sendResponse(false, 'Error del servidor: El directorio de destino no existe o no tiene permisos de escritura.', '', 500);
}
$safe_filename = preg_replace('/[^\\w\\s._-]/u', '', basename(FILENAME));
$safe_filename = preg_replace('/[^\\w\\s._-]/', '', basename(FILENAME));
$safe_filename = preg_replace('/\\s+/', '_', $safe_filename);
$target_path = $save_dir . DIRECTORY_SEPARATOR . $safe_filename;
@ -1587,12 +1528,12 @@ $content_to_write = "";
if (FILE_ACTION_APPEND) {
$file_exists = file_exists($target_path);
if (!$file_exists) {
$content_to_write .= "#EXTM3U\n";
$content_to_write .= "#EXTM3U\\n";
}
foreach ($data['streams'] as $stream) {
if (isset($stream['extinf'], $stream['url'])) {
$content_to_write .= trim($stream['extinf']) . "\n";
$content_to_write .= trim($stream['url']) . "\n";
$content_to_write .= trim($stream['extinf']) . "\\n";
$content_to_write .= trim($stream['url']) . "\\n";
}
}
if (file_put_contents($target_path, $content_to_write, FILE_APPEND | LOCK_EX) !== false) {
@ -1601,11 +1542,11 @@ if (FILE_ACTION_APPEND) {
sendResponse(false, 'Error del servidor: No se pudo añadir contenido al archivo.', '', 500);
}
} else { // Overwrite mode
$content_to_write = "#EXTM3U\n";
$content_to_write = "#EXTM3U\\n";
foreach ($data['streams'] as $stream) {
if (isset($stream['extinf'], $stream['url'])) {
$content_to_write .= trim($stream['extinf']) . "\n";
$content_to_write .= trim($stream['url']) . "\n";
$content_to_write .= trim($stream['extinf']) . "\\n";
$content_to_write .= trim($stream['url']) . "\\n";
}
}
if (file_put_contents($target_path, $content_to_write, LOCK_EX) !== false) {
@ -1636,14 +1577,12 @@ if (FILE_ACTION_APPEND) {
export function initPhotosView() {
const select = document.getElementById('photos-token-select');
select.innerHTML = '';
select.appendChild(createElement('option', { value: '', textContent: _('loading') }));
select.innerHTML = `<option value="">${_('loading')}</option>`;
const photoServers = [...new Map(state.localPhotos.map(item => [item.tokenPrincipal, item])).values()];
if (photoServers.length === 0) {
select.innerHTML = '';
select.appendChild(createElement('option', { value: '', textContent: _('noPhotoServers') }));
select.innerHTML = `<option value="">${_('noPhotoServers')}</option>`;
document.getElementById('photos-empty-state').style.display = 'block';
document.getElementById('photos-grid').innerHTML = '';
return;
@ -1651,10 +1590,12 @@ export function initPhotosView() {
document.getElementById('photos-empty-state').style.display = 'none';
select.innerHTML = '';
select.appendChild(createElement('option', { value: '', textContent: _('selectServer') }));
select.innerHTML = `<option value="">${_('selectServer')}</option>`;
photoServers.forEach(server => {
select.appendChild(createElement('option', { value: server.tokenPrincipal, textContent: server.serverName || `Servidor ${server.tokenPrincipal.slice(-4)}` }));
const option = document.createElement('option');
option.value = server.tokenPrincipal;
option.textContent = server.serverName || `Servidor ${server.tokenPrincipal.slice(-4)}`;
select.appendChild(option);
});
if (state.currentPhotoToken && photoServers.some(s => s.tokenPrincipal === state.currentPhotoToken)) {
@ -1812,8 +1753,11 @@ function renderPhotoBreadcrumb() {
const breadcrumb = document.getElementById('photos-breadcrumb');
breadcrumb.innerHTML = '';
const rootItem = createElement('li', { className: 'breadcrumb-item' });
const rootLink = createElement('a', { href: '#', innerHTML: `<i class="fas fa-home"></i> ${_('photosBreadcrumbHome')}` });
const rootItem = document.createElement('li');
rootItem.className = 'breadcrumb-item';
const rootLink = document.createElement('a');
rootLink.href = '#';
rootLink.innerHTML = `<i class="fas fa-home"></i> ${_('photosBreadcrumbHome')}`;
rootLink.onclick = (e) => {
e.preventDefault();
handlePhotoTokenChange();
@ -1822,15 +1766,20 @@ function renderPhotoBreadcrumb() {
breadcrumb.appendChild(rootItem);
state.photoStack.forEach((item, index) => {
const divider = createElement('li', { className: 'breadcrumb-divider', innerHTML: '<i class="fas fa-chevron-right"></i>' });
const divider = document.createElement('li');
divider.className = 'breadcrumb-divider';
divider.innerHTML = '<i class="fas fa-chevron-right"></i>';
breadcrumb.appendChild(divider);
const breadcrumbItem = createElement('li', { className: 'breadcrumb-item' });
const breadcrumbItem = document.createElement('li');
breadcrumbItem.className = 'breadcrumb-item';
if (index === state.photoStack.length - 1) {
breadcrumbItem.classList.add('active');
breadcrumbItem.textContent = item.title;
} else {
const link = createElement('a', { href: '#', textContent: item.title });
const link = document.createElement('a');
link.href = '#';
link.textContent = item.title;
link.onclick = async (e) => {
e.preventDefault();
state.photoStack = state.photoStack.slice(0, index + 1);

View File

@ -93,17 +93,18 @@ export function logToConsole(message) {
logEntry.className = 'console-log-entry';
const time = new Date().toLocaleTimeString(_('appLocaleCode'), { hour12: false });
const timeSpan = document.createElement('span');
timeSpan.className = 'log-time';
timeSpan.textContent = `[${time}]`;
const timeSpan = `<span class="log-time">[${time}]</span>`;
const messageSpan = `<span class="log-message">${message.replace(/</g, "<").replace(/>/g, ">")}</span>`;
const messageSpan = document.createElement('span');
messageSpan.className = 'log-message';
messageSpan.textContent = message;
if (message.toLowerCase().includes("error")) {
logEntry.classList.add('log-error');
} else if (message.toLowerCase().includes("advertencia") || message.toLowerCase().includes("warning") || message.toLowerCase().includes("pendiente")) {
logEntry.classList.add('log-warning');
} else if (message.toLowerCase().includes("success") || message.toLowerCase().includes("finalizado") || message.toLowerCase().includes("éxito") || message.toLowerCase().includes("limpiadas")) {
logEntry.classList.add('log-success');
}
logEntry.appendChild(timeSpan);
logEntry.appendChild(document.createTextNode(' '));
logEntry.appendChild(messageSpan);
logEntry.innerHTML = `${timeSpan} ${messageSpan}`;
consoleOutput.appendChild(logEntry);
consoleOutput.scrollTop = consoleOutput.scrollHeight;
}

View File

@ -14,7 +14,8 @@
"notifications"
],
"host_permissions": [
"https://*.plex.tv/*"
"https://*.plex.tv/*",
"*://*:*/*"
],
"background": {
"service_worker": "js/background.js",

View File

@ -13,15 +13,6 @@
<link rel="stylesheet" href="css/main.css">
<link rel="stylesheet" href="css/equalizer.css">
<link rel="stylesheet" href="css/photos.css">
<style>
body {
opacity: 0;
transition: opacity 0.5s ease-in-out;
}
body.loaded {
opacity: 1;
}
</style>
</head>
<body>