Add CinePlex
This commit is contained in:
commit
3e176a5762
9
LICENSE
Normal file
9
LICENSE
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Filipinos
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
106
README.md
Normal file
106
README.md
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
# CinePlex: Your Plex on Steroids 🚀
|
||||||
|
|
||||||
|
## 🤔 What the Heck Is This? Prepare for Takeoff!
|
||||||
|
|
||||||
|
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** ✨?
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**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!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 We Speak All Languages! (Well, Almost)
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
**We currently support:** Spanish, English, German, and French. And we're adding more!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 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!
|
||||||
|
* **✅ "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!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Installation and First Steps: Liftoff in 3, 2, 1...!
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 1. Installing the Extension: The First Quantum Leap!
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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!
|
||||||
|
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!
|
||||||
|
|
||||||
|
### 2. Initial Setup: Your Personal Operations Base!
|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|
2. **Open CinePlex Settings: The Control Panel!**
|
||||||
|
* 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!
|
||||||
|
|
||||||
|
3. **Add Your Token: Injecting the Fuel!**
|
||||||
|
* 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:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tokens": [
|
||||||
|
"YourPlexTokenGoesHere_abc123",
|
||||||
|
"AnotherTokenIfYouHaveOne_def456"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* Click the **"Save Tokens"** button. You've secured the connection!
|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 Pro-Tip: Setting Up the "Add Stream" Feature - The Magic Button!
|
||||||
|
|
||||||
|
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!
|
||||||
|
|
||||||
|
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.
|
288
_locales/de/messages.json
Normal file
288
_locales/de/messages.json
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
{
|
||||||
|
"appName": { "message": "CinePlex" },
|
||||||
|
"appDescription": { "message": "Sucht auf Plex-Servern nach Inhalten und zeigt sie in der Benutzeroberfläche an" },
|
||||||
|
"appTagline": { "message": "Filme, Serien & Musik" },
|
||||||
|
"appLocaleCode": { "message": "de-DE" },
|
||||||
|
"toggleNavigation": { "message": "Navigation umschalten" },
|
||||||
|
"searchPlaceholder": { "message": "Suche nach Filmen oder Serien..." },
|
||||||
|
"openMusicPlayer": { "message": "Musik-Player öffnen" },
|
||||||
|
"settings": { "message": "Einstellungen" },
|
||||||
|
"navMovies": { "message": "Filme" },
|
||||||
|
"navSeries": { "message": "Serien" },
|
||||||
|
"navPhotos": { "message": "Fotos" },
|
||||||
|
"navStats": { "message": "Statistiken" },
|
||||||
|
"navFavorites": { "message": "Favoriten" },
|
||||||
|
"navHistory": { "message": "Verlauf" },
|
||||||
|
"navRecommendations": { "message": "Empfehlungen" },
|
||||||
|
"navMusic": { "message": "Musik" },
|
||||||
|
"heroWelcome": { "message": "Willkommen bei CinePlex" },
|
||||||
|
"heroSubtitle": { "message": "Entdecke Tausende von Filmen und Serien." },
|
||||||
|
"addStream": { "message": "Stream hinzufügen" },
|
||||||
|
"moreInfo": { "message": "Mehr Infos" },
|
||||||
|
"popularMovies": { "message": "Beliebte Filme" },
|
||||||
|
"allGenres": { "message": "Alle Genres" },
|
||||||
|
"allYears": { "message": "Alle Jahre" },
|
||||||
|
"sortPopular": { "message": "Am beliebtesten" },
|
||||||
|
"sortTopRated": { "message": "Am besten bewertet" },
|
||||||
|
"sortRecent": { "message": "Neueste" },
|
||||||
|
"loadMore": { "message": "Mehr laden" },
|
||||||
|
"photosBreadcrumbHome": { "message": "Alben" },
|
||||||
|
"selectServer": { "message": "Einen Server auswählen" },
|
||||||
|
"loading": { "message": "Lädt..." },
|
||||||
|
"photosEmptyState": { "message": "Keine Alben oder Fotos gefunden." },
|
||||||
|
"photosEmptyStateSub": { "message": "Bitte wähle einen Server aus oder stelle sicher, dass du eine Fotobibliothek in Plex hast." },
|
||||||
|
"statsTitle": { "message": "Bibliotheksstatistiken" },
|
||||||
|
"statsAllTokens": { "message": "Alle Tokens" },
|
||||||
|
"statsAnalyzing": { "message": "Analysiere deine Bibliothek..." },
|
||||||
|
"statsActiveTokens": { "message": "Aktive Tokens" },
|
||||||
|
"statsServersFound": { "message": "Gefundene Server" },
|
||||||
|
"statsUniqueMovies": { "message": "Einzigartige Filme" },
|
||||||
|
"statsUniqueSeries": { "message": "Einzigartige Serien" },
|
||||||
|
"statsUniqueArtists": { "message": "Einzigartige Künstler" },
|
||||||
|
"statsTokenServers": { "message": "Server des Tokens" },
|
||||||
|
"statsChartMoviesByGenre": { "message": "Inhalt nach Genre (Filme)" },
|
||||||
|
"statsChartSeriesByGenre": { "message": "Inhalt nach Genre (Serien)" },
|
||||||
|
"statsChartByDecade": { "message": "Inhalt nach Jahrzehnt" },
|
||||||
|
"recommendationsTitle": { "message": "Empfehlungen für dich" },
|
||||||
|
"historyTitle": { "message": "Wiedergabeverlauf" },
|
||||||
|
"clearHistory": { "message": "Alles löschen" },
|
||||||
|
"consoleTitle": { "message": "Plex Scan-Konsole" },
|
||||||
|
"footerCredit": { "message": "Eine Oberfläche für dein Plex-Universum." },
|
||||||
|
"backButton": { "message": "Zurück" },
|
||||||
|
"closeTrailer": { "message": "Trailer schließen" },
|
||||||
|
"close": { "message": "Schließen" },
|
||||||
|
"photoViewer": { "message": "Fotoanzeige" },
|
||||||
|
"previous": { "message": "Zurück" },
|
||||||
|
"next": { "message": "Weiter" },
|
||||||
|
"notificationTemplateText": { "message": "Benachrichtigung" },
|
||||||
|
"settingsTitleFull": { "message": "Einstellungen und Konfiguration" },
|
||||||
|
"settingsTabGeneral": { "message": "Allgemein" },
|
||||||
|
"settingsTabPlex": { "message": "Plex" },
|
||||||
|
"settingsTabPhpGen": { "message": "PHP-Generator" },
|
||||||
|
"settingsTabData": { "message": "Daten" },
|
||||||
|
"settingsApiServer": { "message": "API- und Serverkonfiguration" },
|
||||||
|
"settingsTmdbApiLabel": { "message": "TMDB API-Schlüssel (Optional)" },
|
||||||
|
"settingsTmdbApiPlaceholder": { "message": "Verwendet den Standardschlüssel, wenn leer gelassen" },
|
||||||
|
"settingsTmdbLangLabel": { "message": "Sprache für TMDB & UI" },
|
||||||
|
"settingsPhpUrlLabel": { "message": "Server-URL zum Hinzufügen von Streams" },
|
||||||
|
"settingsPhpUrlPlaceholder": { "message": "https://dein-server.com/pfad/zum/script.php" },
|
||||||
|
"settingsInterface": { "message": "Oberfläche" },
|
||||||
|
"settingsLightTheme": { "message": "Heller Modus" },
|
||||||
|
"settingsShowHero": { "message": "Willkommensbereich \"Hero\" anzeigen" },
|
||||||
|
"settingsScanContent": { "message": "Inhaltsscan" },
|
||||||
|
"settingsScanDesc": { "message": "Wähle aus, was gescannt werden soll, und drücke die Taste." },
|
||||||
|
"settingsScanMovies": { "message": "Filme" },
|
||||||
|
"settingsScanShows": { "message": "Serien" },
|
||||||
|
"settingsScanArtists": { "message": "Musik" },
|
||||||
|
"settingsScanPhotos": { "message": "Fotos" },
|
||||||
|
"settingsSelectAll": { "message": "Alles auswählen" },
|
||||||
|
"settingsStartScan": { "message": "Scan starten" },
|
||||||
|
"settingsPlexTokens": { "message": "Plex-Tokens" },
|
||||||
|
"settingsPlexTokensDesc": { "message": "Bearbeite die Liste der Plex-Tokens (JSON-Format)." },
|
||||||
|
"settingsSaveTokens": { "message": "Tokens speichern" },
|
||||||
|
"settingsPhpGenTitle": { "message": "PHP-Server-Skript-Generator" },
|
||||||
|
"settingsPhpFileOptions": { "message": "Dateioptionen" },
|
||||||
|
"settingsPhpSavePathLabel": { "message": "Speicherpfad auf dem Server" },
|
||||||
|
"settingsPhpSavePathPlaceholder": { "message": "Bsp: /var/www/html/listen (leer für denselben Ordner)" },
|
||||||
|
"settingsPhpFilenameLabel": { "message": "Dateiname" },
|
||||||
|
"settingsPhpFileAction": { "message": "Dateiaktion" },
|
||||||
|
"settingsPhpActionAppend": { "message": "An das Ende der Datei anhängen (kumulativ)" },
|
||||||
|
"settingsPhpActionOverwrite": { "message": "Datei überschreiben (neu beginnen)" },
|
||||||
|
"settingsPhpSecurity": { "message": "Sicherheit (Optional)" },
|
||||||
|
"settingsPhpUseSecretKey": { "message": "Geheimschlüssel verwenden (Empfohlen)" },
|
||||||
|
"settingsPhpSecretKeyPlaceholder": { "message": "Gib einen sicheren Geheimschlüssel ein" },
|
||||||
|
"settingsPhpGeneratedCode": { "message": "Generierter Code" },
|
||||||
|
"settingsPhpGeneratedPlaceholder": { "message": "Der generierte PHP-Code wird hier angezeigt." },
|
||||||
|
"settingsGenerateScript": { "message": "Skript generieren" },
|
||||||
|
"settingsCopyScript": { "message": "Skript kopieren" },
|
||||||
|
"settingsDataManagement": { "message": "Lokale Datenbankverwaltung" },
|
||||||
|
"settingsImportDb": { "message": "DB aus Datei importieren" },
|
||||||
|
"settingsExportDb": { "message": "DB in Datei exportieren" },
|
||||||
|
"settingsClearContent": { "message": "Lokale Inhaltsdaten löschen" },
|
||||||
|
"settingsClearContentDesc": { "message": "Diese Aktion löscht Filme, Serien und Musik aus der lokalen Datenbank, hat aber keine Auswirkungen auf deine Favoriten oder Einstellungen." },
|
||||||
|
"settingsClose": { "message": "Schließen" },
|
||||||
|
"settingsSave": { "message": "Einstellungen speichern" },
|
||||||
|
"musicSidenavTitle": { "message": "Plex Musik" },
|
||||||
|
"musicAllServers": { "message": "Alle Server" },
|
||||||
|
"musicSearchArtistPlaceholder": { "message": "Künstler suchen..." },
|
||||||
|
"musicSearchDiscographyPlaceholder": { "message": "In Diskografie suchen..." },
|
||||||
|
"musicNothingPlaying": { "message": "Nichts wird abgespielt" },
|
||||||
|
"musicSelectSong": { "message": "Wähle ein Lied" },
|
||||||
|
"musicToStart": { "message": "um die Wiedergabe zu starten" },
|
||||||
|
"miniplayerDownloadSong": { "message": "Lied herunterladen" },
|
||||||
|
"miniplayerDownloadAlbum": { "message": "M3U-Album herunterladen" },
|
||||||
|
"miniplayerVolume": { "message": "Lautstärke" },
|
||||||
|
"miniplayerShuffle": { "message": "Zufall" },
|
||||||
|
"miniplayerEqualizer": { "message": "Equalizer" },
|
||||||
|
"miniplayerOpenList": { "message": "Liste öffnen" },
|
||||||
|
"eqTitle": { "message": "Grafischer Equalizer" },
|
||||||
|
"eqPresetsLabel": { "message": "Voreinstellungen" },
|
||||||
|
"eqPresetFlat": { "message": "Flach" },
|
||||||
|
"eqPresetRock": { "message": "Rock" },
|
||||||
|
"eqPresetPop": { "message": "Pop" },
|
||||||
|
"eqPresetJazz": { "message": "Jazz" },
|
||||||
|
"eqPresetClassical": { "message": "Klassik" },
|
||||||
|
"eqPresetBassBoost": { "message": "Bass-Boost" },
|
||||||
|
"eqPreampLabel": { "message": "Vorverstärker" },
|
||||||
|
"infoModalTitle": { "message": "Information" },
|
||||||
|
"infoModalFieldTitle": { "message": "Titel:" },
|
||||||
|
"infoModalFieldArtist": { "message": "Künstler:" },
|
||||||
|
"infoModalFieldAlbum": { "message": "Album:" },
|
||||||
|
"infoModalFieldSong": { "message": "Lied:" },
|
||||||
|
"infoModalFieldYear": { "message": "Jahr:" },
|
||||||
|
"infoModalFieldGenre": { "message": "Genre:" },
|
||||||
|
"lang_en": { "message": "Englisch" },
|
||||||
|
"lang_es": { "message": "Spanisch" },
|
||||||
|
"lang_fr": { "message": "Französisch" },
|
||||||
|
"lang_de": { "message": "Deutsch" },
|
||||||
|
"essentialFeaturesNotSupported": { "message": "Dein Browser unterstützt wesentliche Funktionen nicht." },
|
||||||
|
"dbAccessError": { "message": "Fehler beim Zugriff auf die lokale Datenbank." },
|
||||||
|
"dbUpdateNeeded": { "message": "Die Datenbank muss aktualisiert werden, bitte lade die Seite neu." },
|
||||||
|
"dbBlocked": { "message": "Bitte schließe andere Tabs dieser Anwendung, um fortzufahren." },
|
||||||
|
"deletingContentData": { "message": "Lokale Inhaltsdaten werden gelöscht..." },
|
||||||
|
"noContentDataToDelete": { "message": "Keine Inhaltsdaten zum Löschen." },
|
||||||
|
"contentDataDeleted": { "message": "Inhaltsdaten aus IndexedDB gelöscht." },
|
||||||
|
"errorDeletingData": { "message": "Fehler beim Löschen der Daten: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"aceEditorNotAvailable": { "message": "Texteditor nicht verfügbar." },
|
||||||
|
"errorLoadingTokens": { "message": "Fehler beim Laden der Tokens in den Editor." },
|
||||||
|
"errorLoadingTokensMessage": { "message": "Fehler beim Laden der Tokens: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"aceEditorNotAvailableToSave": { "message": "Editor zum Speichern nicht verfügbar." },
|
||||||
|
"invalidJsonFormat": { "message": "Ungültiges JSON-Format. Muss { \"tokens\": [...] } sein" },
|
||||||
|
"tokensSaved": { "message": "Tokens erfolgreich gespeichert." },
|
||||||
|
"errorSavingTokens": { "message": "Fehler beim Speichern der Tokens: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"dbNotAvailable": { "message": "IndexedDB ist nicht verfügbar." },
|
||||||
|
"dbExported": { "message": "Datenbank erfolgreich exportiert." },
|
||||||
|
"errorExportingDb": { "message": "Fehler beim Exportieren der Datenbank: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"invalidJsonFile": { "message": "Die Datei enthält kein gültiges JSON-Objekt." },
|
||||||
|
"noDataToImport": { "message": "Die Datei enthält keine Daten für die aktuellen DB-Abschnitte." },
|
||||||
|
"dbImported": { "message": "Datenbank erfolgreich importiert." },
|
||||||
|
"errorImportingDb": { "message": "Fehler beim Importieren der Datenbank: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"updatingView": { "message": "Ansicht wird mit neuen Daten aktualisiert..." },
|
||||||
|
"confirmClearContent": { "message": "Bist du sicher, dass du die lokalen Inhaltsdaten (Filme, Serien, Musik usw.) löschen möchtest? Favoriten und Einstellungen werden NICHT gelöscht." },
|
||||||
|
"trailerNotFound": { "message": "Für diesen Titel wurde kein Trailer gefunden." },
|
||||||
|
"confirmClearHistory": { "message": "Bist du sicher, dass du deinen gesamten Wiedergabeverlauf löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden." },
|
||||||
|
"historyCleared": { "message": "Wiedergabeverlauf gelöscht." },
|
||||||
|
"historyItemDeleted": { "message": "Eintrag aus dem Verlauf gelöscht." },
|
||||||
|
"errorGeneratingScript": { "message": "Generiere zuerst ein Skript, um es kopieren zu können." },
|
||||||
|
"scriptCopied": { "message": "PHP-Skript in die Zwischenablage kopiert." },
|
||||||
|
"errorCopyingScript": { "message": "Fehler beim Kopieren des Skripts." },
|
||||||
|
"scriptGenerated": { "message": "PHP-Skript generiert." },
|
||||||
|
"errorLoadingAlbum": { "message": "Fehler beim Laden des Albums: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"noPhotoServerSelected": { "message": "Fehler: Es wurde kein Fotoserver ausgewählt." },
|
||||||
|
"loadingGenres": { "message": "Lade Genres..." },
|
||||||
|
"errorLoadingGenres": { "message": "Fehler beim Laden" },
|
||||||
|
"noContentFound": { "message": "Keine Ergebnisse gefunden." },
|
||||||
|
"couldNotLoadContent": { "message": "Inhalt konnte nicht geladen werden." },
|
||||||
|
"noFavorites": { "message": "Du hast noch keine Favoriten." },
|
||||||
|
"errorLoadingFavorites": { "message": "Fehler beim Laden der Favoriten." },
|
||||||
|
"historyEmpty": { "message": "Dein Verlauf ist leer." },
|
||||||
|
"historyEmptySub": { "message": "Durchsuche und schaue Inhalte an, damit sie hier erscheinen." },
|
||||||
|
"errorGeneratingRecommendations": { "message": "Fehler beim Erstellen von Empfehlungen." },
|
||||||
|
"noRecommendations": { "message": "Wir müssen dich besser kennenlernen, um dir Empfehlungen geben zu können!" },
|
||||||
|
"errorGeneratingStats": { "message": "Fehler beim Erstellen von Statistiken." },
|
||||||
|
"noServersForToken": { "message": "Keine zugehörigen Server für dieses Token gefunden." },
|
||||||
|
"searchingActorContent": { "message": "Suche nach Inhalten von $actorName$", "placeholders": { "actorName": { "content": "$1" } } },
|
||||||
|
"errorLoadingActorContent": { "message": "Inhalt für $actorName$ konnte nicht geladen werden.", "placeholders": { "actorName": { "content": "$1" } } },
|
||||||
|
"errorAddingStream": { "message": "Fehler beim Hinzufügen von Stream(s): $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"phpUrlNotConfigured": { "message": "Die PHP-Server-URL ist nicht konfiguriert. Bitte richte sie in den Einstellungen ein." },
|
||||||
|
"searchingStreams": { "message": "Suche nach Streams für \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"sendingStreams": { "message": "Sende $count$ Stream(s) an den Server...", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"streamAddedSuccess": { "message": "Stream(s) erfolgreich hinzugefügt." },
|
||||||
|
"generatingM3U": { "message": "Generiere M3U für \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"m3uDownloaded": { "message": "M3U für \"$title$\" heruntergeladen.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"errorGeneratingM3U": { "message": "Fehler beim Generieren der M3U: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"settingsSavedSuccess": { "message": "Einstellungen erfolgreich gespeichert." },
|
||||||
|
"errorSavingSettings": { "message": "Fehler beim Speichern der Einstellungen in der Datenbank." },
|
||||||
|
"languageChangeReload": { "message": "Sprache geändert. Die Anwendung wird jetzt neu geladen." },
|
||||||
|
"addedToFavorites": { "message": "Zu Favoriten hinzugefügt." },
|
||||||
|
"removedFromFavorites": { "message": "Aus Favoriten entfernt." },
|
||||||
|
"plexScanInProgress": { "message": "Plex-Scan läuft bereits." },
|
||||||
|
"plexScanStarting": { "message": "Starte Plex-Scan..." },
|
||||||
|
"noPlexTokens": { "message": "Keine Plex-Tokens konfiguriert." },
|
||||||
|
"clearingSections": { "message": "Lösche Abschnitte: $sections$", "placeholders": { "sections": { "content": "$1" } } },
|
||||||
|
"sectionsCleared": { "message": "Abschnitte gelöscht." },
|
||||||
|
"tokenFoundServers": { "message": "Token $token$... hat $count$ Server gefunden.", "placeholders": { "token": { "content": "$1" }, "count": { "content": "$2" } } },
|
||||||
|
"errorProcessingToken": { "message": "Fehler bei der Verarbeitung von Token $token$...: $message$", "placeholders": { "token": { "content": "$1" }, "message": { "content": "$2" } } },
|
||||||
|
"initialScanPhaseComplete": { "message": "Initiale Scan-Phase abgeschlossen." },
|
||||||
|
"retryPhaseFinished": { "message": "Wiederholungsphase abgeschlossen." },
|
||||||
|
"plexScanFinished": { "message": "Scan abgeschlossen. Aktualisiere Inhalte..." },
|
||||||
|
"plexScanFatalError": { "message": "FATALER FEHLER: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"errorDuringScan": { "message": "Fehler während des Scans: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"scanCancelled": { "message": "Scan vom Benutzer abgebrochen." },
|
||||||
|
"scanCancelledInfo": { "message": "Scan abgebrochen." },
|
||||||
|
"retyingSection": { "message": "Wiederhole Abschnitt \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"retrySuccess": { "message": "[ERFOLG] Wiederholung von \"$title$\" abgeschlossen.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"retryError": { "message": "[FINALER FEHLER] Wiederholung für \"$title$\" fehlgeschlagen: $message$", "placeholders": { "title": { "content": "$1" }, "message": { "content": "$2" } } },
|
||||||
|
"noRetriesPending": { "message": "Keine ausstehenden Wiederholungen." },
|
||||||
|
"startingRetryPhase": { "message": "Starte Wiederholungsphase für $count$ Abschnitte...", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"retryPhaseCancelled": { "message": "Wiederholungsphase abgebrochen." },
|
||||||
|
"errorInitializingMusicPlayer": { "message": "Fehler beim Initialisieren des Musik-Players." },
|
||||||
|
"criticalErrorLoadingMusic": { "message": "Kritischer Fehler beim Laden der Musikdaten." },
|
||||||
|
"errorLoadingArtists": { "message": "Fehler beim Laden der Künstler." },
|
||||||
|
"dbUnavailableError": { "message": "Fehler: Datenbank nicht verfügbar." },
|
||||||
|
"updatingMusicData": { "message": "Aktualisiere Musikdaten..." },
|
||||||
|
"musicDataUpdated": { "message": "Musikdaten aktualisiert." },
|
||||||
|
"errorFetchingArtistSongs": { "message": "Fehler beim Abrufen der Lieder des Künstlers." },
|
||||||
|
"errorLoadingSongs": { "message": "Fehler beim Laden der Lieder." },
|
||||||
|
"noArtistsFound": { "message": "Keine Künstler gefunden." },
|
||||||
|
"artistsCounter": { "message": "$start$-$end$ von $total$", "placeholders": { "start": { "content": "$1" }, "end": { "content": "$2" }, "total": { "content": "$3" } } },
|
||||||
|
"artistsCounterSingle": { "message": "$total$ Künstler", "placeholders": { "total": { "content": "$1" } } },
|
||||||
|
"artistsCounterLoading": { "message": "Lädt..." },
|
||||||
|
"noSongsFound": { "message": "Keine Lieder gefunden." },
|
||||||
|
"shuffleOn": { "message": "Zufallsmodus aktiviert." },
|
||||||
|
"shuffleOff": { "message": "Zufallsmodus deaktiviert." },
|
||||||
|
"downloadingSong": { "message": "Starte Download von \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"songDownloaded": { "message": "\"$title$\" heruntergeladen.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"errorDownloadingSong": { "message": "Fehler beim Herunterladen von \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"generatingAlbumM3U": { "message": "Generiere M3U für \"$artist$\"...", "placeholders": { "artist": { "content": "$1" } } },
|
||||||
|
"albumM3UGenerated": { "message": "M3U für Album \"$artist$\" generiert.", "placeholders": { "artist": { "content": "$1" } } },
|
||||||
|
"playbackError": { "message": "Wiedergabefehler" },
|
||||||
|
"errorLabel": { "message": "Fehler" },
|
||||||
|
"reloadingPage": { "message": "Seite wird neu geladen..." },
|
||||||
|
"viewed": { "message": "Gesehen" },
|
||||||
|
"local": { "message": "Lokal" },
|
||||||
|
"topRatedSort": {"message": "Bestbewertet"},
|
||||||
|
"recentSort": {"message": "Neueste"},
|
||||||
|
"popularSort": {"message": "Beliebte"},
|
||||||
|
"moviesSectionTitle": {"message": "Filme"},
|
||||||
|
"seriesSectionTitle": {"message": "Serien"},
|
||||||
|
"searchResultsFor": {"message": "Ergebnisse für \"$query$\"", "placeholders": {"query": {"content": "$1"}}},
|
||||||
|
"contentFrom": {"message": "Inhalt von $actor$", "placeholders": {"actor": {"content": "$1"}}},
|
||||||
|
"explore": {"message": "Entdecken"},
|
||||||
|
"noGenre": {"message": "Ohne Kategorie"},
|
||||||
|
"synopsis": {"message": "Zusammenfassung"},
|
||||||
|
"noSynopsis": {"message": "Keine Zusammenfassung verfügbar."},
|
||||||
|
"director": {"message": "Regisseur:"},
|
||||||
|
"writer": {"message": "Autor:"},
|
||||||
|
"viewOnImdb": {"message": "Auf IMDb ansehen"},
|
||||||
|
"watchTrailer": {"message": "Trailer ansehen"},
|
||||||
|
"addToFavorites": {"message": "Zu Favoriten hinzufügen"},
|
||||||
|
"removeFromFavorites": {"message": "Aus Favoriten entfernen"},
|
||||||
|
"notAvailable": {"message": "Nicht verfügbar"},
|
||||||
|
"mainCast": {"message": "Hauptbesetzung"},
|
||||||
|
"seasonsAndEpisodes": {"message": "Staffeln & Episoden"},
|
||||||
|
"similarContent": {"message": "Ähnlicher Inhalt"},
|
||||||
|
"episodesCount": {"message": "$count$ Episoden", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"seasonsCount": {"message": "$count$ Staffeln", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"runtimeMinutes": {"message": "$count$ Min.", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"noTrailerFound": {"message": "Für diesen Titel wurde kein Trailer gefunden."},
|
||||||
|
"fatalInitError": {"message": "Fataler Initialisierungsfehler"},
|
||||||
|
"fatalInitErrorSub": {"message": "Die Anwendung konnte nicht geladen werden."},
|
||||||
|
"invalidStreamInfo": {"message": "Ungültige Information."},
|
||||||
|
"dbUnavailableForStreams": {"message": "Lokale Datenbank nicht verfügbar."},
|
||||||
|
"noPlexServersForStreams": {"message": "Keine Plex-Server."},
|
||||||
|
"notFoundOnServers": {"message": "\"$query$\" auf Servern nicht gefunden.", "placeholders": {"query": {"content": "$1"}}},
|
||||||
|
"relativeTime_justNow": { "message": "Gerade eben" },
|
||||||
|
"relativeTime_minutesAgo": { "message": "Vor $count$ Minuten", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"relativeTime_hoursAgo": { "message": "Vor $count$ Stunden", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"relativeTime_yesterday": { "message": "Gestern" },
|
||||||
|
"relativeTime_daysAgo": { "message": "Vor $count$ Tagen", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"errorLoadingDetails": { "message": "Fehler beim Laden der Details" },
|
||||||
|
"errorLoadingLocalContent": { "message": "Fehler beim Laden lokaler Inhalte." },
|
||||||
|
"errorServerResponse": { "message": "Nicht erfolgreiche Antwort vom Server." },
|
||||||
|
"errorPlexApi": { "message": "Fehler $status$ von der Plex-API.", "placeholders": { "status": { "content": "$1" } } },
|
||||||
|
"errorParsingPlexXml": { "message": "Fehler beim Parsen von Plex-XML." },
|
||||||
|
"untitled": { "message": "Ohne Titel" },
|
||||||
|
"itemCount": { "message": "$count$ Elemente", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"noPhotoServers": { "message": "Keine Foto-Server" }
|
||||||
|
}
|
288
_locales/en/messages.json
Normal file
288
_locales/en/messages.json
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
{
|
||||||
|
"appName": { "message": "CinePlex" },
|
||||||
|
"appDescription": { "message": "Scans Plex servers to find content and displays it in the interface" },
|
||||||
|
"appTagline": { "message": "Movies, Series & Music" },
|
||||||
|
"appLocaleCode": { "message": "en-US" },
|
||||||
|
"toggleNavigation": { "message": "Toggle Navigation" },
|
||||||
|
"searchPlaceholder": { "message": "Search for movies or series..." },
|
||||||
|
"openMusicPlayer": { "message": "Open Music Player" },
|
||||||
|
"settings": { "message": "Settings" },
|
||||||
|
"navMovies": { "message": "Movies" },
|
||||||
|
"navSeries": { "message": "Series" },
|
||||||
|
"navPhotos": { "message": "Photos" },
|
||||||
|
"navStats": { "message": "Statistics" },
|
||||||
|
"navFavorites": { "message": "Favorites" },
|
||||||
|
"navHistory": { "message": "History" },
|
||||||
|
"navRecommendations": { "message": "Recommendations" },
|
||||||
|
"navMusic": { "message": "Music" },
|
||||||
|
"heroWelcome": { "message": "Welcome to CinePlex" },
|
||||||
|
"heroSubtitle": { "message": "Explore thousands of movies and series." },
|
||||||
|
"addStream": { "message": "Add Stream" },
|
||||||
|
"moreInfo": { "message": "More Info" },
|
||||||
|
"popularMovies": { "message": "Popular Movies" },
|
||||||
|
"allGenres": { "message": "All Genres" },
|
||||||
|
"allYears": { "message": "All Years" },
|
||||||
|
"sortPopular": { "message": "Most Popular" },
|
||||||
|
"sortTopRated": { "message": "Top Rated" },
|
||||||
|
"sortRecent": { "message": "Most Recent" },
|
||||||
|
"loadMore": { "message": "Load More" },
|
||||||
|
"photosBreadcrumbHome": { "message": "Albums" },
|
||||||
|
"selectServer": { "message": "Select a server" },
|
||||||
|
"loading": { "message": "Loading..." },
|
||||||
|
"photosEmptyState": { "message": "No albums or photos found." },
|
||||||
|
"photosEmptyStateSub": { "message": "Please select a server or make sure you have a photo library in Plex." },
|
||||||
|
"statsTitle": { "message": "Library Statistics" },
|
||||||
|
"statsAllTokens": { "message": "All Tokens" },
|
||||||
|
"statsAnalyzing": { "message": "Analyzing your library..." },
|
||||||
|
"statsActiveTokens": { "message": "Active Tokens" },
|
||||||
|
"statsServersFound": { "message": "Servers Found" },
|
||||||
|
"statsUniqueMovies": { "message": "Unique Movies" },
|
||||||
|
"statsUniqueSeries": { "message": "Unique Series" },
|
||||||
|
"statsUniqueArtists": { "message": "Unique Artists" },
|
||||||
|
"statsTokenServers": { "message": "Token's Servers" },
|
||||||
|
"statsChartMoviesByGenre": { "message": "Content by Genre (Movies)" },
|
||||||
|
"statsChartSeriesByGenre": { "message": "Content by Genre (Series)" },
|
||||||
|
"statsChartByDecade": { "message": "Content by Decade" },
|
||||||
|
"recommendationsTitle": { "message": "Recommendations For You" },
|
||||||
|
"historyTitle": { "message": "Viewing History" },
|
||||||
|
"clearHistory": { "message": "Clear All" },
|
||||||
|
"consoleTitle": { "message": "Plex Scan Console" },
|
||||||
|
"footerCredit": { "message": "An interface for your Plex universe." },
|
||||||
|
"backButton": { "message": "Back" },
|
||||||
|
"closeTrailer": { "message": "Close trailer" },
|
||||||
|
"close": { "message": "Close" },
|
||||||
|
"photoViewer": { "message": "Photo Viewer" },
|
||||||
|
"previous": { "message": "Previous" },
|
||||||
|
"next": { "message": "Next" },
|
||||||
|
"notificationTemplateText": { "message": "Notification" },
|
||||||
|
"settingsTitleFull": { "message": "Settings and Configuration" },
|
||||||
|
"settingsTabGeneral": { "message": "General" },
|
||||||
|
"settingsTabPlex": { "message": "Plex" },
|
||||||
|
"settingsTabPhpGen": { "message": "PHP Generator" },
|
||||||
|
"settingsTabData": { "message": "Data" },
|
||||||
|
"settingsApiServer": { "message": "API and Server Configuration" },
|
||||||
|
"settingsTmdbApiLabel": { "message": "TMDB API Key (Optional)" },
|
||||||
|
"settingsTmdbApiPlaceholder": { "message": "Will use default key if left blank" },
|
||||||
|
"settingsTmdbLangLabel": { "message": "Language for TMDB & UI" },
|
||||||
|
"settingsPhpUrlLabel": { "message": "Server URL for Adding Streams" },
|
||||||
|
"settingsPhpUrlPlaceholder": { "message": "https://your-server.com/path/to/script.php" },
|
||||||
|
"settingsInterface": { "message": "Interface" },
|
||||||
|
"settingsLightTheme": { "message": "Light Mode" },
|
||||||
|
"settingsShowHero": { "message": "Show 'Hero' welcome section" },
|
||||||
|
"settingsScanContent": { "message": "Content Scanning" },
|
||||||
|
"settingsScanDesc": { "message": "Select what to scan and press the button." },
|
||||||
|
"settingsScanMovies": { "message": "Movies" },
|
||||||
|
"settingsScanShows": { "message": "Series" },
|
||||||
|
"settingsScanArtists": { "message": "Music" },
|
||||||
|
"settingsScanPhotos": { "message": "Photos" },
|
||||||
|
"settingsSelectAll": { "message": "Select All" },
|
||||||
|
"settingsStartScan": { "message": "Start Scan" },
|
||||||
|
"settingsPlexTokens": { "message": "Plex Tokens" },
|
||||||
|
"settingsPlexTokensDesc": { "message": "Edit the list of Plex tokens (JSON format)." },
|
||||||
|
"settingsSaveTokens": { "message": "Save Tokens" },
|
||||||
|
"settingsPhpGenTitle": { "message": "PHP Server Script Generator" },
|
||||||
|
"settingsPhpFileOptions": { "message": "File Options" },
|
||||||
|
"settingsPhpSavePathLabel": { "message": "Save Path on Server" },
|
||||||
|
"settingsPhpSavePathPlaceholder": { "message": "Ex: /var/www/html/lists (blank for same folder)" },
|
||||||
|
"settingsPhpFilenameLabel": { "message": "Filename" },
|
||||||
|
"settingsPhpFileAction": { "message": "File Action" },
|
||||||
|
"settingsPhpActionAppend": { "message": "Append to the end of the file (cumulative)" },
|
||||||
|
"settingsPhpActionOverwrite": { "message": "Overwrite the file (start fresh)" },
|
||||||
|
"settingsPhpSecurity": { "message": "Security (Optional)" },
|
||||||
|
"settingsPhpUseSecretKey": { "message": "Use secret key (Recommended)" },
|
||||||
|
"settingsPhpSecretKeyPlaceholder": { "message": "Enter a secure secret key" },
|
||||||
|
"settingsPhpGeneratedCode": { "message": "Generated Code" },
|
||||||
|
"settingsPhpGeneratedPlaceholder": { "message": "The generated PHP code will appear here." },
|
||||||
|
"settingsGenerateScript": { "message": "Generate Script" },
|
||||||
|
"settingsCopyScript": { "message": "Copy Script" },
|
||||||
|
"settingsDataManagement": { "message": "Local Database Management" },
|
||||||
|
"settingsImportDb": { "message": "Import DB from File" },
|
||||||
|
"settingsExportDb": { "message": "Export DB to File" },
|
||||||
|
"settingsClearContent": { "message": "Clear Local Content Data" },
|
||||||
|
"settingsClearContentDesc": { "message": "This action will delete movies, series, and music from the local database, but will not affect your favorites or settings." },
|
||||||
|
"settingsClose": { "message": "Close" },
|
||||||
|
"settingsSave": { "message": "Save Settings" },
|
||||||
|
"musicSidenavTitle": { "message": "Plex Music" },
|
||||||
|
"musicAllServers": { "message": "All Servers" },
|
||||||
|
"musicSearchArtistPlaceholder": { "message": "Search artist..." },
|
||||||
|
"musicSearchDiscographyPlaceholder": { "message": "Search in discography..." },
|
||||||
|
"musicNothingPlaying": { "message": "Nothing playing" },
|
||||||
|
"musicSelectSong": { "message": "Select a song" },
|
||||||
|
"musicToStart": { "message": "to start playing" },
|
||||||
|
"miniplayerDownloadSong": { "message": "Download song" },
|
||||||
|
"miniplayerDownloadAlbum": { "message": "Download M3U album" },
|
||||||
|
"miniplayerVolume": { "message": "Volume" },
|
||||||
|
"miniplayerShuffle": { "message": "Shuffle" },
|
||||||
|
"miniplayerEqualizer": { "message": "Equalizer" },
|
||||||
|
"miniplayerOpenList": { "message": "Open list" },
|
||||||
|
"eqTitle": { "message": "Graphic Equalizer" },
|
||||||
|
"eqPresetsLabel": { "message": "Presets" },
|
||||||
|
"eqPresetFlat": { "message": "Flat" },
|
||||||
|
"eqPresetRock": { "message": "Rock" },
|
||||||
|
"eqPresetPop": { "message": "Pop" },
|
||||||
|
"eqPresetJazz": { "message": "Jazz" },
|
||||||
|
"eqPresetClassical": { "message": "Classical" },
|
||||||
|
"eqPresetBassBoost": { "message": "Bass Boost" },
|
||||||
|
"eqPreampLabel": { "message": "Pre-Amp" },
|
||||||
|
"infoModalTitle": { "message": "Information" },
|
||||||
|
"infoModalFieldTitle": { "message": "Title:" },
|
||||||
|
"infoModalFieldArtist": { "message": "Artist:" },
|
||||||
|
"infoModalFieldAlbum": { "message": "Album:" },
|
||||||
|
"infoModalFieldSong": { "message": "Song:" },
|
||||||
|
"infoModalFieldYear": { "message": "Year:" },
|
||||||
|
"infoModalFieldGenre": { "message": "Genre:" },
|
||||||
|
"lang_en": { "message": "English" },
|
||||||
|
"lang_es": { "message": "Spanish" },
|
||||||
|
"lang_fr": { "message": "French" },
|
||||||
|
"lang_de": { "message": "German" },
|
||||||
|
"essentialFeaturesNotSupported": { "message": "Your browser does not support essential features." },
|
||||||
|
"dbAccessError": { "message": "Error accessing the local database." },
|
||||||
|
"dbUpdateNeeded": { "message": "The database needs to be updated, please reload the page." },
|
||||||
|
"dbBlocked": { "message": "Please close other tabs of this application to continue." },
|
||||||
|
"deletingContentData": { "message": "Deleting local content data..." },
|
||||||
|
"noContentDataToDelete": { "message": "No content data to delete." },
|
||||||
|
"contentDataDeleted": { "message": "Content data deleted from IndexedDB." },
|
||||||
|
"errorDeletingData": { "message": "Error deleting data: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"aceEditorNotAvailable": { "message": "Text editor not available." },
|
||||||
|
"errorLoadingTokens": { "message": "Error loading tokens to editor." },
|
||||||
|
"errorLoadingTokensMessage": { "message": "Error loading tokens: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"aceEditorNotAvailableToSave": { "message": "Editor not available for saving." },
|
||||||
|
"invalidJsonFormat": { "message": "Invalid JSON format. Must be { \"tokens\": [...] }" },
|
||||||
|
"tokensSaved": { "message": "Tokens saved successfully." },
|
||||||
|
"errorSavingTokens": { "message": "Error saving tokens: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"dbNotAvailable": { "message": "IndexedDB is not available." },
|
||||||
|
"dbExported": { "message": "Database exported successfully." },
|
||||||
|
"errorExportingDb": { "message": "Error exporting database: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"invalidJsonFile": { "message": "The file does not contain a valid JSON object." },
|
||||||
|
"noDataToImport": { "message": "The file does not contain data for the current DB sections." },
|
||||||
|
"dbImported": { "message": "Database imported successfully." },
|
||||||
|
"errorImportingDb": { "message": "Error importing database: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"updatingView": { "message": "Updating view with new data..." },
|
||||||
|
"confirmClearContent": { "message": "Are you sure you want to delete local content data (Movies, Series, Music, etc.)? Favorites and Settings will NOT be deleted." },
|
||||||
|
"trailerNotFound": { "message": "No trailer found for this title." },
|
||||||
|
"confirmClearHistory": { "message": "Are you sure you want to delete your entire viewing history? This action cannot be undone." },
|
||||||
|
"historyCleared": { "message": "Viewing history cleared." },
|
||||||
|
"historyItemDeleted": { "message": "Item deleted from history." },
|
||||||
|
"errorGeneratingScript": { "message": "First, generate a script to be able to copy it." },
|
||||||
|
"scriptCopied": { "message": "PHP script copied to clipboard." },
|
||||||
|
"errorCopyingScript": { "message": "Error copying script." },
|
||||||
|
"scriptGenerated": { "message": "PHP script generated." },
|
||||||
|
"errorLoadingAlbum": { "message": "Error loading album: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"noPhotoServerSelected": { "message": "Error: No photo server has been selected." },
|
||||||
|
"loadingGenres": { "message": "Loading genres..." },
|
||||||
|
"errorLoadingGenres": { "message": "Error loading" },
|
||||||
|
"noContentFound": { "message": "No results found." },
|
||||||
|
"couldNotLoadContent": { "message": "Could not load content." },
|
||||||
|
"noFavorites": { "message": "You don't have any favorites yet." },
|
||||||
|
"errorLoadingFavorites": { "message": "Error loading favorites." },
|
||||||
|
"historyEmpty": { "message": "Your history is empty." },
|
||||||
|
"historyEmptySub": { "message": "Browse and watch content for it to appear here." },
|
||||||
|
"errorGeneratingRecommendations": { "message": "Error generating recommendations." },
|
||||||
|
"noRecommendations": { "message": "We need to know you better to give you recommendations!" },
|
||||||
|
"errorGeneratingStats": { "message": "Error generating statistics." },
|
||||||
|
"noServersForToken": { "message": "No associated servers found for this token." },
|
||||||
|
"searchingActorContent": { "message": "Searching for content from $actorName$", "placeholders": { "actorName": { "content": "$1" } } },
|
||||||
|
"errorLoadingActorContent": { "message": "Could not load content for $actorName$.", "placeholders": { "actorName": { "content": "$1" } } },
|
||||||
|
"errorAddingStream": { "message": "Error adding stream(s): $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"phpUrlNotConfigured": { "message": "The PHP server URL is not configured. Please set it up in Settings." },
|
||||||
|
"searchingStreams": { "message": "Searching for streams for \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"sendingStreams": { "message": "Sending $count$ stream(s) to the server...", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"streamAddedSuccess": { "message": "Stream(s) added successfully." },
|
||||||
|
"generatingM3U": { "message": "Generating M3U for \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"m3uDownloaded": { "message": "M3U for \"$title$\" downloaded.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"errorGeneratingM3U": { "message": "Error generating M3U: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"settingsSavedSuccess": { "message": "Settings saved successfully." },
|
||||||
|
"errorSavingSettings": { "message": "Error saving settings to the database." },
|
||||||
|
"languageChangeReload": { "message": "Language changed. The application will now reload." },
|
||||||
|
"addedToFavorites": { "message": "Added to favorites." },
|
||||||
|
"removedFromFavorites": { "message": "Removed from favorites." },
|
||||||
|
"plexScanInProgress": { "message": "Plex scan is already in progress." },
|
||||||
|
"plexScanStarting": { "message": "Starting Plex scan..." },
|
||||||
|
"noPlexTokens": { "message": "No Plex tokens configured." },
|
||||||
|
"clearingSections": { "message": "Clearing sections: $sections$", "placeholders": { "sections": { "content": "$1" } } },
|
||||||
|
"sectionsCleared": { "message": "Sections cleared." },
|
||||||
|
"tokenFoundServers": { "message": "Token $token$... found $count$ servers.", "placeholders": { "token": { "content": "$1" }, "count": { "content": "$2" } } },
|
||||||
|
"errorProcessingToken": { "message": "Error processing token $token$...: $message$", "placeholders": { "token": { "content": "$1" }, "message": { "content": "$2" } } },
|
||||||
|
"initialScanPhaseComplete": { "message": "Initial scan phase completed." },
|
||||||
|
"retryPhaseFinished": { "message": "Retry phase finished." },
|
||||||
|
"plexScanFinished": { "message": "Plex scan finished. Updating content..." },
|
||||||
|
"plexScanFatalError": { "message": "FATAL ERROR: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"errorDuringScan": { "message": "Error during scan: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"scanCancelled": { "message": "Scan cancelled by user." },
|
||||||
|
"scanCancelledInfo": { "message": "Scan cancelled." },
|
||||||
|
"retyingSection": { "message": "Retrying section \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"retrySuccess": { "message": "[SUCCESS] Retry of \"$title$\" completed.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"retryError": { "message": "[FINAL ERROR] Retry failed for \"$title$\": $message$", "placeholders": { "title": { "content": "$1" }, "message": { "content": "$2" } } },
|
||||||
|
"noRetriesPending": { "message": "No pending retries." },
|
||||||
|
"startingRetryPhase": { "message": "Starting retry phase for $count$ sections...", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"retryPhaseCancelled": { "message": "Retry phase cancelled." },
|
||||||
|
"errorInitializingMusicPlayer": { "message": "Error initializing music player." },
|
||||||
|
"criticalErrorLoadingMusic": { "message": "Critical error loading music data." },
|
||||||
|
"errorLoadingArtists": { "message": "Error loading artists." },
|
||||||
|
"dbUnavailableError": { "message": "Error: Database not available." },
|
||||||
|
"updatingMusicData": { "message": "Updating music data..." },
|
||||||
|
"musicDataUpdated": { "message": "Music data updated." },
|
||||||
|
"errorFetchingArtistSongs": { "message": "Error getting artist's songs." },
|
||||||
|
"errorLoadingSongs": { "message": "Error loading songs." },
|
||||||
|
"noArtistsFound": { "message": "No artists found." },
|
||||||
|
"artistsCounter": { "message": "$start$-$end$ of $total$", "placeholders": { "start": { "content": "$1" }, "end": { "content": "$2" }, "total": { "content": "$3" } } },
|
||||||
|
"artistsCounterSingle": { "message": "$total$ Artists", "placeholders": { "total": { "content": "$1" } } },
|
||||||
|
"artistsCounterLoading": { "message": "Loading..." },
|
||||||
|
"noSongsFound": { "message": "No songs found." },
|
||||||
|
"shuffleOn": { "message": "Shuffle mode enabled." },
|
||||||
|
"shuffleOff": { "message": "Shuffle mode disabled." },
|
||||||
|
"downloadingSong": { "message": "Starting download of \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"songDownloaded": { "message": "\"$title$\" downloaded.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"errorDownloadingSong": { "message": "Error downloading \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"generatingAlbumM3U": { "message": "Generating M3U for \"$artist$\"...", "placeholders": { "artist": { "content": "$1" } } },
|
||||||
|
"albumM3UGenerated": { "message": "M3U for album \"$artist$\" generated.", "placeholders": { "artist": { "content": "$1" } } },
|
||||||
|
"playbackError": { "message": "Playback error" },
|
||||||
|
"errorLabel": { "message": "Error" },
|
||||||
|
"reloadingPage": { "message": "Reloading page..." },
|
||||||
|
"viewed": { "message": "Viewed" },
|
||||||
|
"local": { "message": "Local" },
|
||||||
|
"topRatedSort": {"message": "Top Rated"},
|
||||||
|
"recentSort": {"message": "Recent"},
|
||||||
|
"popularSort": {"message": "Popular"},
|
||||||
|
"moviesSectionTitle": {"message": "Movies"},
|
||||||
|
"seriesSectionTitle": {"message": "Series"},
|
||||||
|
"searchResultsFor": {"message": "Results for \"$query$\"", "placeholders": {"query": {"content": "$1"}}},
|
||||||
|
"contentFrom": {"message": "Content from $actor$", "placeholders": {"actor": {"content": "$1"}}},
|
||||||
|
"explore": {"message": "Explore"},
|
||||||
|
"noGenre": {"message": "Uncategorized"},
|
||||||
|
"synopsis": {"message": "Synopsis"},
|
||||||
|
"noSynopsis": {"message": "No synopsis available."},
|
||||||
|
"director": {"message": "Director:"},
|
||||||
|
"writer": {"message": "Writer:"},
|
||||||
|
"viewOnImdb": {"message": "View on IMDb"},
|
||||||
|
"watchTrailer": {"message": "Watch Trailer"},
|
||||||
|
"addToFavorites": {"message": "Add to Favorites"},
|
||||||
|
"removeFromFavorites": {"message": "Remove from Favorites"},
|
||||||
|
"notAvailable": {"message": "Not Available"},
|
||||||
|
"mainCast": {"message": "Main Cast"},
|
||||||
|
"seasonsAndEpisodes": {"message": "Seasons & Episodes"},
|
||||||
|
"similarContent": {"message": "Similar Content"},
|
||||||
|
"episodesCount": {"message": "$count$ Episodes", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"seasonsCount": {"message": "$count$ Seasons", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"runtimeMinutes": {"message": "$count$ min", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"noTrailerFound": {"message": "No trailer was found for this title."},
|
||||||
|
"fatalInitError": {"message": "Fatal Initialization Error"},
|
||||||
|
"fatalInitErrorSub": {"message": "The application could not be loaded."},
|
||||||
|
"invalidStreamInfo": {"message": "Invalid information."},
|
||||||
|
"dbUnavailableForStreams": {"message": "Local database not available."},
|
||||||
|
"noPlexServersForStreams": {"message": "No Plex servers."},
|
||||||
|
"notFoundOnServers": {"message": "\"$query$\" not found on servers.", "placeholders": {"query": {"content": "$1"}}},
|
||||||
|
"relativeTime_justNow": { "message": "Just now" },
|
||||||
|
"relativeTime_minutesAgo": { "message": "$count$ minutes ago", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"relativeTime_hoursAgo": { "message": "$count$ hours ago", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"relativeTime_yesterday": { "message": "Yesterday" },
|
||||||
|
"relativeTime_daysAgo": { "message": "$count$ days ago", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"errorLoadingDetails": { "message": "Error Loading Details" },
|
||||||
|
"errorLoadingLocalContent": { "message": "Error loading local content." },
|
||||||
|
"errorServerResponse": { "message": "Unsuccessful response from server." },
|
||||||
|
"errorPlexApi": { "message": "Error $status$ from Plex API.", "placeholders": { "status": { "content": "$1" } } },
|
||||||
|
"errorParsingPlexXml": { "message": "Error parsing Plex XML." },
|
||||||
|
"untitled": { "message": "Untitled" },
|
||||||
|
"itemCount": { "message": "$count$ items", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"noPhotoServers": { "message": "No photo servers" }
|
||||||
|
}
|
288
_locales/es/messages.json
Normal file
288
_locales/es/messages.json
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
{
|
||||||
|
"appName": { "message": "CinePlex" },
|
||||||
|
"appDescription": { "message": "Escanea servidores de Plex para encontrar contenido y lo muestra en la interfaz" },
|
||||||
|
"appTagline": { "message": "Películas, Series y Música" },
|
||||||
|
"appLocaleCode": { "message": "es-ES" },
|
||||||
|
"toggleNavigation": { "message": "Alternar Navegación" },
|
||||||
|
"searchPlaceholder": { "message": "Buscar películas o series..." },
|
||||||
|
"openMusicPlayer": { "message": "Abrir Reproductor de Música" },
|
||||||
|
"settings": { "message": "Ajustes" },
|
||||||
|
"navMovies": { "message": "Películas" },
|
||||||
|
"navSeries": { "message": "Series" },
|
||||||
|
"navPhotos": { "message": "Fotos" },
|
||||||
|
"navStats": { "message": "Estadísticas" },
|
||||||
|
"navFavorites": { "message": "Favoritos" },
|
||||||
|
"navHistory": { "message": "Historial" },
|
||||||
|
"navRecommendations": { "message": "Recomendaciones" },
|
||||||
|
"navMusic": { "message": "Música" },
|
||||||
|
"heroWelcome": { "message": "Bienvenido a CinePlex" },
|
||||||
|
"heroSubtitle": { "message": "Explora miles de películas y series." },
|
||||||
|
"addStream": { "message": "Añadir Stream" },
|
||||||
|
"moreInfo": { "message": "Más info" },
|
||||||
|
"popularMovies": { "message": "Películas Populares" },
|
||||||
|
"allGenres": { "message": "Todos los géneros" },
|
||||||
|
"allYears": { "message": "Todos los años" },
|
||||||
|
"sortPopular": { "message": "Más populares" },
|
||||||
|
"sortTopRated": { "message": "Mejor valoradas" },
|
||||||
|
"sortRecent": { "message": "Más recientes" },
|
||||||
|
"loadMore": { "message": "Cargar más" },
|
||||||
|
"photosBreadcrumbHome": { "message": "Álbumes" },
|
||||||
|
"selectServer": { "message": "Selecciona un servidor" },
|
||||||
|
"loading": { "message": "Cargando..." },
|
||||||
|
"photosEmptyState": { "message": "No se encontraron álbumes o fotos." },
|
||||||
|
"photosEmptyStateSub": { "message": "Por favor, selecciona un servidor o asegúrate de tener una librería de fotos en Plex." },
|
||||||
|
"statsTitle": { "message": "Estadísticas de la Librería" },
|
||||||
|
"statsAllTokens": { "message": "Todos los Tokens" },
|
||||||
|
"statsAnalyzing": { "message": "Analizando tu librería..." },
|
||||||
|
"statsActiveTokens": { "message": "Tokens Activos" },
|
||||||
|
"statsServersFound": { "message": "Servidores Encontrados" },
|
||||||
|
"statsUniqueMovies": { "message": "Películas Únicas" },
|
||||||
|
"statsUniqueSeries": { "message": "Series Únicas" },
|
||||||
|
"statsUniqueArtists": { "message": "Artistas Únicos" },
|
||||||
|
"statsTokenServers": { "message": "Servidores del Token" },
|
||||||
|
"statsChartMoviesByGenre": { "message": "Contenido por Género (Películas)" },
|
||||||
|
"statsChartSeriesByGenre": { "message": "Contenido por Género (Series)" },
|
||||||
|
"statsChartByDecade": { "message": "Contenido por Década" },
|
||||||
|
"recommendationsTitle": { "message": "Recomendaciones para ti" },
|
||||||
|
"historyTitle": { "message": "Historial de Visualización" },
|
||||||
|
"clearHistory": { "message": "Borrar Todo" },
|
||||||
|
"consoleTitle": { "message": "Consola de Escaneo Plex" },
|
||||||
|
"footerCredit": { "message": "Una interfaz para tu universo Plex." },
|
||||||
|
"backButton": { "message": "Atrás" },
|
||||||
|
"closeTrailer": { "message": "Cerrar tráiler" },
|
||||||
|
"close": { "message": "Cerrar" },
|
||||||
|
"photoViewer": { "message": "Visor de fotos" },
|
||||||
|
"previous": { "message": "Anterior" },
|
||||||
|
"next": { "message": "Siguiente" },
|
||||||
|
"notificationTemplateText": { "message": "Notificación" },
|
||||||
|
"settingsTitleFull": { "message": "Ajustes y Configuración" },
|
||||||
|
"settingsTabGeneral": { "message": "General" },
|
||||||
|
"settingsTabPlex": { "message": "Plex" },
|
||||||
|
"settingsTabPhpGen": { "message": "Generador PHP" },
|
||||||
|
"settingsTabData": { "message": "Datos" },
|
||||||
|
"settingsApiServer": { "message": "Configuración de API y Servidor" },
|
||||||
|
"settingsTmdbApiLabel": { "message": "Clave de API de TMDB (Opcional)" },
|
||||||
|
"settingsTmdbApiPlaceholder": { "message": "Usará la clave por defecto si se deja en blanco" },
|
||||||
|
"settingsTmdbLangLabel": { "message": "Idioma para TMDB y UI" },
|
||||||
|
"settingsPhpUrlLabel": { "message": "URL del Servidor para Añadir Streams" },
|
||||||
|
"settingsPhpUrlPlaceholder": { "message": "https://tu-servidor.com/ruta/al/script.php" },
|
||||||
|
"settingsInterface": { "message": "Interfaz" },
|
||||||
|
"settingsLightTheme": { "message": "Modo Claro" },
|
||||||
|
"settingsShowHero": { "message": "Mostrar sección de bienvenida \"Hero\"" },
|
||||||
|
"settingsScanContent": { "message": "Escaneo de Contenido" },
|
||||||
|
"settingsScanDesc": { "message": "Selecciona qué escanear y pulsa el botón." },
|
||||||
|
"settingsScanMovies": { "message": "Películas" },
|
||||||
|
"settingsScanShows": { "message": "Series" },
|
||||||
|
"settingsScanArtists": { "message": "Música" },
|
||||||
|
"settingsScanPhotos": { "message": "Fotos" },
|
||||||
|
"settingsSelectAll": { "message": "Seleccionar Todo" },
|
||||||
|
"settingsStartScan": { "message": "Iniciar Escaneo" },
|
||||||
|
"settingsPlexTokens": { "message": "Tokens de Plex" },
|
||||||
|
"settingsPlexTokensDesc": { "message": "Edita la lista de tokens de Plex (formato JSON)." },
|
||||||
|
"settingsSaveTokens": { "message": "Guardar Tokens" },
|
||||||
|
"settingsPhpGenTitle": { "message": "Generador de Script PHP para el Servidor" },
|
||||||
|
"settingsPhpFileOptions": { "message": "Opciones del Archivo" },
|
||||||
|
"settingsPhpSavePathLabel": { "message": "Ruta de Guardado en Servidor" },
|
||||||
|
"settingsPhpSavePathPlaceholder": { "message": "Ej: /var/www/html/listas (en blanco para la misma carpeta)" },
|
||||||
|
"settingsPhpFilenameLabel": { "message": "Nombre del Archivo" },
|
||||||
|
"settingsPhpFileAction": { "message": "Acción sobre el Archivo" },
|
||||||
|
"settingsPhpActionAppend": { "message": "Añadir al final del archivo (acumulativo)" },
|
||||||
|
"settingsPhpActionOverwrite": { "message": "Sobrescribir el archivo (empezar de nuevo)" },
|
||||||
|
"settingsPhpSecurity": { "message": "Seguridad (Opcional)" },
|
||||||
|
"settingsPhpUseSecretKey": { "message": "Usar clave secreta (Recomendado)" },
|
||||||
|
"settingsPhpSecretKeyPlaceholder": { "message": "Introduce una clave secreta segura" },
|
||||||
|
"settingsPhpGeneratedCode": { "message": "Código Generado" },
|
||||||
|
"settingsPhpGeneratedPlaceholder": { "message": "El código PHP generado aparecerá aquí." },
|
||||||
|
"settingsGenerateScript": { "message": "Generar Script" },
|
||||||
|
"settingsCopyScript": { "message": "Copiar Script" },
|
||||||
|
"settingsDataManagement": { "message": "Gestión de la Base de Datos Local" },
|
||||||
|
"settingsImportDb": { "message": "Importar BD desde Archivo" },
|
||||||
|
"settingsExportDb": { "message": "Exportar BD a Archivo" },
|
||||||
|
"settingsClearContent": { "message": "Borrar Datos de Contenido Local" },
|
||||||
|
"settingsClearContentDesc": { "message": "Esta acción borrará películas, series y música de la base de datos local, pero no afectará a tus favoritos o ajustes." },
|
||||||
|
"settingsClose": { "message": "Cerrar" },
|
||||||
|
"settingsSave": { "message": "Guardar Ajustes" },
|
||||||
|
"musicSidenavTitle": { "message": "Plex Música" },
|
||||||
|
"musicAllServers": { "message": "Todos los Servidores" },
|
||||||
|
"musicSearchArtistPlaceholder": { "message": "Buscar artista..." },
|
||||||
|
"musicSearchDiscographyPlaceholder": { "message": "Buscar en la discografía..." },
|
||||||
|
"musicNothingPlaying": { "message": "Nada en reproducción" },
|
||||||
|
"musicSelectSong": { "message": "Selecciona una canción" },
|
||||||
|
"musicToStart": { "message": "para empezar a reproducir" },
|
||||||
|
"miniplayerDownloadSong": { "message": "Descargar canción" },
|
||||||
|
"miniplayerDownloadAlbum": { "message": "Descargar álbum M3U" },
|
||||||
|
"miniplayerVolume": { "message": "Volumen" },
|
||||||
|
"miniplayerShuffle": { "message": "Aleatorio" },
|
||||||
|
"miniplayerEqualizer": { "message": "Ecualizador" },
|
||||||
|
"miniplayerOpenList": { "message": "Abrir lista" },
|
||||||
|
"eqTitle": { "message": "Ecualizador Gráfico" },
|
||||||
|
"eqPresetsLabel": { "message": "Presets" },
|
||||||
|
"eqPresetFlat": { "message": "Plano" },
|
||||||
|
"eqPresetRock": { "message": "Rock" },
|
||||||
|
"eqPresetPop": { "message": "Pop" },
|
||||||
|
"eqPresetJazz": { "message": "Jazz" },
|
||||||
|
"eqPresetClassical": { "message": "Clásico" },
|
||||||
|
"eqPresetBassBoost": { "message": "Refuerzo de Graves" },
|
||||||
|
"eqPreampLabel": { "message": "Pre-Amp" },
|
||||||
|
"infoModalTitle": { "message": "Información" },
|
||||||
|
"infoModalFieldTitle": { "message": "Título:" },
|
||||||
|
"infoModalFieldArtist": { "message": "Artista:" },
|
||||||
|
"infoModalFieldAlbum": { "message": "Álbum:" },
|
||||||
|
"infoModalFieldSong": { "message": "Canción:" },
|
||||||
|
"infoModalFieldYear": { "message": "Año:" },
|
||||||
|
"infoModalFieldGenre": { "message": "Género:" },
|
||||||
|
"lang_en": { "message": "Inglés" },
|
||||||
|
"lang_es": { "message": "Español" },
|
||||||
|
"lang_fr": { "message": "Francés" },
|
||||||
|
"lang_de": { "message": "Alemán" },
|
||||||
|
"essentialFeaturesNotSupported": { "message": "Tu navegador no soporta funciones esenciales." },
|
||||||
|
"dbAccessError": { "message": "Error al acceder a la base de datos local." },
|
||||||
|
"dbUpdateNeeded": { "message": "La base de datos necesita actualizarse, por favor recarga la página." },
|
||||||
|
"dbBlocked": { "message": "Por favor, cierra otras pestañas de esta aplicación para continuar." },
|
||||||
|
"deletingContentData": { "message": "Borrando datos de contenido locales..." },
|
||||||
|
"noContentDataToDelete": { "message": "No hay datos de contenido que borrar." },
|
||||||
|
"contentDataDeleted": { "message": "Datos de contenido borrados de IndexedDB." },
|
||||||
|
"errorDeletingData": { "message": "Error al borrar datos: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"aceEditorNotAvailable": { "message": "Editor de texto no disponible." },
|
||||||
|
"errorLoadingTokens": { "message": "Error al cargar tokens para editar." },
|
||||||
|
"errorLoadingTokensMessage": { "message": "Error al cargar tokens: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"aceEditorNotAvailableToSave": { "message": "Editor no disponible para guardar." },
|
||||||
|
"invalidJsonFormat": { "message": "Formato JSON inválido. Debe ser { \"tokens\": [...] }" },
|
||||||
|
"tokensSaved": { "message": "Tokens guardados correctamente." },
|
||||||
|
"errorSavingTokens": { "message": "Error al guardar tokens: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"dbNotAvailable": { "message": "IndexedDB no está disponible." },
|
||||||
|
"dbExported": { "message": "Base de datos exportada con éxito." },
|
||||||
|
"errorExportingDb": { "message": "Error al exportar la base de datos: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"invalidJsonFile": { "message": "El archivo no contiene un objeto JSON válido." },
|
||||||
|
"noDataToImport": { "message": "El archivo no contiene datos para las secciones de la BD actual." },
|
||||||
|
"dbImported": { "message": "Base de datos importada correctamente." },
|
||||||
|
"errorImportingDb": { "message": "Error al importar la base de datos: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"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." },
|
||||||
|
"historyCleared": { "message": "Historial de visualización borrado." },
|
||||||
|
"historyItemDeleted": { "message": "Elemento borrado del historial." },
|
||||||
|
"errorGeneratingScript": { "message": "Primero genera un script para poder copiarlo." },
|
||||||
|
"scriptCopied": { "message": "Script PHP copiado al portapapeles." },
|
||||||
|
"errorCopyingScript": { "message": "Error al copiar el script." },
|
||||||
|
"scriptGenerated": { "message": "Script PHP generado." },
|
||||||
|
"errorLoadingAlbum": { "message": "Error al cargar álbum: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"noPhotoServerSelected": { "message": "Error: No se ha seleccionado un servidor de fotos." },
|
||||||
|
"loadingGenres": { "message": "Cargando géneros..." },
|
||||||
|
"errorLoadingGenres": { "message": "Error al cargar" },
|
||||||
|
"noContentFound": { "message": "No se encontraron resultados." },
|
||||||
|
"couldNotLoadContent": { "message": "No se pudo cargar el contenido." },
|
||||||
|
"noFavorites": { "message": "Aún no tienes favoritos." },
|
||||||
|
"errorLoadingFavorites": { "message": "Error al cargar favoritos." },
|
||||||
|
"historyEmpty": { "message": "Tu historial está vacío." },
|
||||||
|
"historyEmptySub": { "message": "Explora y mira contenido para que aparezca aquí." },
|
||||||
|
"errorGeneratingRecommendations": { "message": "Error al generar recomendaciones." },
|
||||||
|
"noRecommendations": { "message": "¡Necesitamos conocerte mejor para darte recomendaciones!" },
|
||||||
|
"errorGeneratingStats": { "message": "Error al generar estadísticas." },
|
||||||
|
"noServersForToken": { "message": "No se encontraron servidores asociados para este token." },
|
||||||
|
"searchingActorContent": { "message": "Buscando contenido de $actorName$", "placeholders": { "actorName": { "content": "$1" } } },
|
||||||
|
"errorLoadingActorContent": { "message": "No se pudo cargar el contenido para $actorName$.", "placeholders": { "actorName": { "content": "$1" } } },
|
||||||
|
"errorAddingStream": { "message": "Error al añadir stream(s): $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"phpUrlNotConfigured": { "message": "La URL del servidor PHP no está configurada. Por favor, configúrala en Ajustes." },
|
||||||
|
"searchingStreams": { "message": "Buscando streams para \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"sendingStreams": { "message": "Enviando $count$ stream(s) al servidor...", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"streamAddedSuccess": { "message": "Stream(s) añadido(s) con éxito." },
|
||||||
|
"generatingM3U": { "message": "Generando M3U para \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"m3uDownloaded": { "message": "M3U para \"$title$\" descargado.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"errorGeneratingM3U": { "message": "Error al generar M3U: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"settingsSavedSuccess": { "message": "Ajustes guardados correctamente." },
|
||||||
|
"errorSavingSettings": { "message": "Error al guardar los ajustes en la base de datos." },
|
||||||
|
"languageChangeReload": { "message": "Idioma cambiado. La aplicación se recargará ahora." },
|
||||||
|
"addedToFavorites": { "message": "Añadido a favoritos." },
|
||||||
|
"removedFromFavorites": { "message": "Eliminado de favoritos." },
|
||||||
|
"plexScanInProgress": { "message": "El escaneo Plex ya está en curso." },
|
||||||
|
"plexScanStarting": { "message": "Iniciando escaneo Plex..." },
|
||||||
|
"noPlexTokens": { "message": "No hay tokens de Plex configurados." },
|
||||||
|
"clearingSections": { "message": "Limpiando secciones: $sections$", "placeholders": { "sections": { "content": "$1" } } },
|
||||||
|
"sectionsCleared": { "message": "Secciones limpiadas." },
|
||||||
|
"tokenFoundServers": { "message": "Token $token$... encontró $count$ servidores.", "placeholders": { "token": { "content": "$1" }, "count": { "content": "$2" } } },
|
||||||
|
"errorProcessingToken": { "message": "Error procesando token $token$...: $message$", "placeholders": { "token": { "content": "$1" }, "message": { "content": "$2" } } },
|
||||||
|
"initialScanPhaseComplete": { "message": "Fase de escaneo inicial finalizada." },
|
||||||
|
"retryPhaseFinished": { "message": "Fase de reintentos finalizada." },
|
||||||
|
"plexScanFinished": { "message": "Escaneo finalizado. Actualizando contenido..." },
|
||||||
|
"plexScanFatalError": { "message": "ERROR FATAL: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"errorDuringScan": { "message": "Error durante el escaneo: $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"scanCancelled": { "message": "Escaneo cancelado por el usuario." },
|
||||||
|
"scanCancelledInfo": { "message": "Escaneo cancelado." },
|
||||||
|
"retyingSection": { "message": "Reintentando sección \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"retrySuccess": { "message": "[ÉXITO] Reintento de \"$title$\" completado.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"retryError": { "message": "[ERROR FINAL] Falló el reintento para \"$title$\": $message$", "placeholders": { "title": { "content": "$1" }, "message": { "content": "$2" } } },
|
||||||
|
"noRetriesPending": { "message": "No hay reintentos pendientes." },
|
||||||
|
"startingRetryPhase": { "message": "Iniciando fase de reintentos para $count$ secciones...", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"retryPhaseCancelled": { "message": "Fase de reintentos cancelada." },
|
||||||
|
"errorInitializingMusicPlayer": { "message": "Error inicializando el reproductor de música." },
|
||||||
|
"criticalErrorLoadingMusic": { "message": "Error crítico al cargar datos de música." },
|
||||||
|
"errorLoadingArtists": { "message": "Error al cargar artistas." },
|
||||||
|
"dbUnavailableError": { "message": "Error: Base de datos no disponible." },
|
||||||
|
"updatingMusicData": { "message": "Actualizando datos de música..." },
|
||||||
|
"musicDataUpdated": { "message": "Datos de música actualizados." },
|
||||||
|
"errorFetchingArtistSongs": { "message": "Error al obtener canciones del artista." },
|
||||||
|
"errorLoadingSongs": { "message": "Error cargando canciones." },
|
||||||
|
"noArtistsFound": { "message": "No se encontraron artistas." },
|
||||||
|
"artistsCounter": { "message": "$start$-$end$ de $total$", "placeholders": { "start": { "content": "$1" }, "end": { "content": "$2" }, "total": { "content": "$3" } } },
|
||||||
|
"artistsCounterSingle": { "message": "$total$ Artistas", "placeholders": { "total": { "content": "$1" } } },
|
||||||
|
"artistsCounterLoading": { "message": "Cargando..." },
|
||||||
|
"noSongsFound": { "message": "No se encontraron canciones." },
|
||||||
|
"shuffleOn": { "message": "Modo aleatorio activado." },
|
||||||
|
"shuffleOff": { "message": "Modo aleatorio desactivado." },
|
||||||
|
"downloadingSong": { "message": "Iniciando descarga de \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"songDownloaded": { "message": "\"$title$\" descargado.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"errorDownloadingSong": { "message": "Error al descargar \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"generatingAlbumM3U": { "message": "Generando M3U para \"$artist$\"...", "placeholders": { "artist": { "content": "$1" } } },
|
||||||
|
"albumM3UGenerated": { "message": "M3U del álbum \"$artist$\" generado.", "placeholders": { "artist": { "content": "$1" } } },
|
||||||
|
"playbackError": { "message": "Error de reproducción" },
|
||||||
|
"errorLabel": { "message": "Error" },
|
||||||
|
"reloadingPage": { "message": "Recargando la página..." },
|
||||||
|
"viewed": { "message": "Visto" },
|
||||||
|
"local": { "message": "Local" },
|
||||||
|
"topRatedSort": {"message": "Mejor Valoradas"},
|
||||||
|
"recentSort": {"message": "Recientes"},
|
||||||
|
"popularSort": {"message": "Populares"},
|
||||||
|
"moviesSectionTitle": {"message": "Películas"},
|
||||||
|
"seriesSectionTitle": {"message": "Series"},
|
||||||
|
"searchResultsFor": {"message": "Resultados para \"$query$\"", "placeholders": {"query": {"content": "$1"}}},
|
||||||
|
"contentFrom": {"message": "Contenido de $actor$", "placeholders": {"actor": {"content": "$1"}}},
|
||||||
|
"explore": {"message": "Explorar"},
|
||||||
|
"noGenre": {"message": "Sin Género"},
|
||||||
|
"synopsis": {"message": "Sinopsis"},
|
||||||
|
"noSynopsis": {"message": "No hay sinopsis disponible."},
|
||||||
|
"director": {"message": "Director:"},
|
||||||
|
"writer": {"message": "Escritor:"},
|
||||||
|
"viewOnImdb": {"message": "Ver en IMDb"},
|
||||||
|
"watchTrailer": {"message": "Ver Tráiler"},
|
||||||
|
"addToFavorites": {"message": "Añadir a favoritos"},
|
||||||
|
"removeFromFavorites": {"message": "Quitar de favoritos"},
|
||||||
|
"notAvailable": {"message": "No disponible"},
|
||||||
|
"mainCast": {"message": "Reparto Principal"},
|
||||||
|
"seasonsAndEpisodes": {"message": "Temporadas y Episodios"},
|
||||||
|
"similarContent": {"message": "Contenido Similar"},
|
||||||
|
"episodesCount": {"message": "$count$ Episodios", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"seasonsCount": {"message": "$count$ Temporadas", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"runtimeMinutes": {"message": "$count$ min", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"noTrailerFound": {"message": "No se encontró tráiler para este título."},
|
||||||
|
"fatalInitError": {"message": "Error de Inicialización"},
|
||||||
|
"fatalInitErrorSub": {"message": "No se pudo cargar la aplicación."},
|
||||||
|
"invalidStreamInfo": {"message": "Información inválida."},
|
||||||
|
"dbUnavailableForStreams": {"message": "Base de datos local no disponible."},
|
||||||
|
"noPlexServersForStreams": {"message": "No hay servidores Plex."},
|
||||||
|
"notFoundOnServers": {"message": "No se encontró \"$query$\" en los servidores.", "placeholders": {"query": {"content": "$1"}}},
|
||||||
|
"relativeTime_justNow": { "message": "Hace un momento" },
|
||||||
|
"relativeTime_minutesAgo": { "message": "Hace $count$ minutos", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"relativeTime_hoursAgo": { "message": "Hace $count$ horas", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"relativeTime_yesterday": { "message": "Ayer" },
|
||||||
|
"relativeTime_daysAgo": { "message": "Hace $count$ días", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"errorLoadingDetails": { "message": "Error al Cargar Detalles" },
|
||||||
|
"errorLoadingLocalContent": { "message": "Error al cargar contenido local." },
|
||||||
|
"errorServerResponse": { "message": "Respuesta no exitosa del servidor." },
|
||||||
|
"errorPlexApi": { "message": "Error $status$ de la API de Plex.", "placeholders": { "status": { "content": "$1" } } },
|
||||||
|
"errorParsingPlexXml": { "message": "Error al parsear XML de Plex." },
|
||||||
|
"untitled": { "message": "Sin título" },
|
||||||
|
"itemCount": { "message": "$count$ elementos", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"noPhotoServers": { "message": "No hay servidores de fotos" }
|
||||||
|
}
|
288
_locales/fr/messages.json
Normal file
288
_locales/fr/messages.json
Normal file
@ -0,0 +1,288 @@
|
|||||||
|
{
|
||||||
|
"appName": { "message": "CinePlex" },
|
||||||
|
"appDescription": { "message": "Scanne les serveurs Plex pour trouver du contenu et l'affiche dans l'interface" },
|
||||||
|
"appTagline": { "message": "Films, Séries & Musique" },
|
||||||
|
"appLocaleCode": { "message": "fr-FR" },
|
||||||
|
"toggleNavigation": { "message": "Basculer la Navigation" },
|
||||||
|
"searchPlaceholder": { "message": "Rechercher des films ou des séries..." },
|
||||||
|
"openMusicPlayer": { "message": "Ouvrir le Lecteur de Musique" },
|
||||||
|
"settings": { "message": "Paramètres" },
|
||||||
|
"navMovies": { "message": "Films" },
|
||||||
|
"navSeries": { "message": "Séries" },
|
||||||
|
"navPhotos": { "message": "Photos" },
|
||||||
|
"navStats": { "message": "Statistiques" },
|
||||||
|
"navFavorites": { "message": "Favoris" },
|
||||||
|
"navHistory": { "message": "Historique" },
|
||||||
|
"navRecommendations": { "message": "Recommandations" },
|
||||||
|
"navMusic": { "message": "Musique" },
|
||||||
|
"heroWelcome": { "message": "Bienvenue sur CinePlex" },
|
||||||
|
"heroSubtitle": { "message": "Explorez des milliers de films et de séries." },
|
||||||
|
"addStream": { "message": "Ajouter le flux" },
|
||||||
|
"moreInfo": { "message": "Plus d'infos" },
|
||||||
|
"popularMovies": { "message": "Films Populaires" },
|
||||||
|
"allGenres": { "message": "Tous les genres" },
|
||||||
|
"allYears": { "message": "Toutes les années" },
|
||||||
|
"sortPopular": { "message": "Les plus populaires" },
|
||||||
|
"sortTopRated": { "message": "Les mieux notés" },
|
||||||
|
"sortRecent": { "message": "Les plus récents" },
|
||||||
|
"loadMore": { "message": "Charger plus" },
|
||||||
|
"photosBreadcrumbHome": { "message": "Albums" },
|
||||||
|
"selectServer": { "message": "Sélectionnez un serveur" },
|
||||||
|
"loading": { "message": "Chargement..." },
|
||||||
|
"photosEmptyState": { "message": "Aucun album ou photo trouvé." },
|
||||||
|
"photosEmptyStateSub": { "message": "Veuillez sélectionner un serveur ou vous assurer d'avoir une bibliothèque de photos dans Plex." },
|
||||||
|
"statsTitle": { "message": "Statistiques de la Bibliothèque" },
|
||||||
|
"statsAllTokens": { "message": "Tous les Tokens" },
|
||||||
|
"statsAnalyzing": { "message": "Analyse de votre bibliothèque..." },
|
||||||
|
"statsActiveTokens": { "message": "Tokens Actifs" },
|
||||||
|
"statsServersFound": { "message": "Serveurs Trouvés" },
|
||||||
|
"statsUniqueMovies": { "message": "Films Uniques" },
|
||||||
|
"statsUniqueSeries": { "message": "Séries Uniques" },
|
||||||
|
"statsUniqueArtists": { "message": "Artistes Uniques" },
|
||||||
|
"statsTokenServers": { "message": "Serveurs du Token" },
|
||||||
|
"statsChartMoviesByGenre": { "message": "Contenu par Genre (Films)" },
|
||||||
|
"statsChartSeriesByGenre": { "message": "Contenu par Genre (Séries)" },
|
||||||
|
"statsChartByDecade": { "message": "Contenu par Décennie" },
|
||||||
|
"recommendationsTitle": { "message": "Recommandations pour vous" },
|
||||||
|
"historyTitle": { "message": "Historique de Visionnage" },
|
||||||
|
"clearHistory": { "message": "Tout effacer" },
|
||||||
|
"consoleTitle": { "message": "Console de Scan Plex" },
|
||||||
|
"footerCredit": { "message": "Une interface pour votre univers Plex." },
|
||||||
|
"backButton": { "message": "Retour" },
|
||||||
|
"closeTrailer": { "message": "Fermer la bande-annonce" },
|
||||||
|
"close": { "message": "Fermer" },
|
||||||
|
"photoViewer": { "message": "Visionneuse de photos" },
|
||||||
|
"previous": { "message": "Précédent" },
|
||||||
|
"next": { "message": "Suivant" },
|
||||||
|
"notificationTemplateText": { "message": "Notification" },
|
||||||
|
"settingsTitleFull": { "message": "Paramètres et Configuration" },
|
||||||
|
"settingsTabGeneral": { "message": "Général" },
|
||||||
|
"settingsTabPlex": { "message": "Plex" },
|
||||||
|
"settingsTabPhpGen": { "message": "Générateur PHP" },
|
||||||
|
"settingsTabData": { "message": "Données" },
|
||||||
|
"settingsApiServer": { "message": "Configuration API et Serveur" },
|
||||||
|
"settingsTmdbApiLabel": { "message": "Clé API de TMDB (Optionnel)" },
|
||||||
|
"settingsTmdbApiPlaceholder": { "message": "Utilisera la clé par défaut si laissé vide" },
|
||||||
|
"settingsTmdbLangLabel": { "message": "Langue pour TMDB & UI" },
|
||||||
|
"settingsPhpUrlLabel": { "message": "URL du Serveur pour Ajout de Flux" },
|
||||||
|
"settingsPhpUrlPlaceholder": { "message": "https://votre-serveur.com/chemin/vers/script.php" },
|
||||||
|
"settingsInterface": { "message": "Interface" },
|
||||||
|
"settingsLightTheme": { "message": "Mode Clair" },
|
||||||
|
"settingsShowHero": { "message": "Afficher la section d'accueil \"Hero\"" },
|
||||||
|
"settingsScanContent": { "message": "Scan de Contenu" },
|
||||||
|
"settingsScanDesc": { "message": "Sélectionnez ce que vous voulez scanner et appuyez sur le bouton." },
|
||||||
|
"settingsScanMovies": { "message": "Films" },
|
||||||
|
"settingsScanShows": { "message": "Séries" },
|
||||||
|
"settingsScanArtists": { "message": "Musique" },
|
||||||
|
"settingsScanPhotos": { "message": "Photos" },
|
||||||
|
"settingsSelectAll": { "message": "Tout sélectionner" },
|
||||||
|
"settingsStartScan": { "message": "Démarrer le Scan" },
|
||||||
|
"settingsPlexTokens": { "message": "Tokens Plex" },
|
||||||
|
"settingsPlexTokensDesc": { "message": "Modifiez la liste des tokens Plex (format JSON)." },
|
||||||
|
"settingsSaveTokens": { "message": "Sauvegarder les Tokens" },
|
||||||
|
"settingsPhpGenTitle": { "message": "Générateur de Script PHP pour Serveur" },
|
||||||
|
"settingsPhpFileOptions": { "message": "Options du Fichier" },
|
||||||
|
"settingsPhpSavePathLabel": { "message": "Chemin de Sauvegarde sur le Serveur" },
|
||||||
|
"settingsPhpSavePathPlaceholder": { "message": "Ex: /var/www/html/listes (vide pour le même dossier)" },
|
||||||
|
"settingsPhpFilenameLabel": { "message": "Nom du Fichier" },
|
||||||
|
"settingsPhpFileAction": { "message": "Action sur le Fichier" },
|
||||||
|
"settingsPhpActionAppend": { "message": "Ajouter à la fin du fichier (cumulatif)" },
|
||||||
|
"settingsPhpActionOverwrite": { "message": "Écraser le fichier (repartir à zéro)" },
|
||||||
|
"settingsPhpSecurity": { "message": "Sécurité (Optionnel)" },
|
||||||
|
"settingsPhpUseSecretKey": { "message": "Utiliser une clé secrète (Recommandé)" },
|
||||||
|
"settingsPhpSecretKeyPlaceholder": { "message": "Entrez une clé secrète sécurisée" },
|
||||||
|
"settingsPhpGeneratedCode": { "message": "Code Généré" },
|
||||||
|
"settingsPhpGeneratedPlaceholder": { "message": "Le code PHP généré apparaîtra ici." },
|
||||||
|
"settingsGenerateScript": { "message": "Générer le Script" },
|
||||||
|
"settingsCopyScript": { "message": "Copier le Script" },
|
||||||
|
"settingsDataManagement": { "message": "Gestion de la Base de Données Locale" },
|
||||||
|
"settingsImportDb": { "message": "Importer BD depuis un Fichier" },
|
||||||
|
"settingsExportDb": { "message": "Exporter BD vers un Fichier" },
|
||||||
|
"settingsClearContent": { "message": "Effacer les Données de Contenu Locales" },
|
||||||
|
"settingsClearContentDesc": { "message": "Cette action supprimera les films, séries et musiques de la base de données locale, mais n'affectera pas vos favoris ou paramètres." },
|
||||||
|
"settingsClose": { "message": "Fermer" },
|
||||||
|
"settingsSave": { "message": "Sauvegarder les Paramètres" },
|
||||||
|
"musicSidenavTitle": { "message": "Plex Musique" },
|
||||||
|
"musicAllServers": { "message": "Tous les Serveurs" },
|
||||||
|
"musicSearchArtistPlaceholder": { "message": "Rechercher un artiste..." },
|
||||||
|
"musicSearchDiscographyPlaceholder": { "message": "Rechercher dans la discographie..." },
|
||||||
|
"musicNothingPlaying": { "message": "Aucune lecture en cours" },
|
||||||
|
"musicSelectSong": { "message": "Sélectionnez une chanson" },
|
||||||
|
"musicToStart": { "message": "pour commencer la lecture" },
|
||||||
|
"miniplayerDownloadSong": { "message": "Télécharger la chanson" },
|
||||||
|
"miniplayerDownloadAlbum": { "message": "Télécharger l'album M3U" },
|
||||||
|
"miniplayerVolume": { "message": "Volume" },
|
||||||
|
"miniplayerShuffle": { "message": "Aléatoire" },
|
||||||
|
"miniplayerEqualizer": { "message": "Égaliseur" },
|
||||||
|
"miniplayerOpenList": { "message": "Ouvrir la liste" },
|
||||||
|
"eqTitle": { "message": "Égaliseur Graphique" },
|
||||||
|
"eqPresetsLabel": { "message": "Préréglages" },
|
||||||
|
"eqPresetFlat": { "message": "Plat" },
|
||||||
|
"eqPresetRock": { "message": "Rock" },
|
||||||
|
"eqPresetPop": { "message": "Pop" },
|
||||||
|
"eqPresetJazz": { "message": "Jazz" },
|
||||||
|
"eqPresetClassical": { "message": "Classique" },
|
||||||
|
"eqPresetBassBoost": { "message": "Renforcement des Basses" },
|
||||||
|
"eqPreampLabel": { "message": "Pré-Amp" },
|
||||||
|
"infoModalTitle": { "message": "Information" },
|
||||||
|
"infoModalFieldTitle": { "message": "Titre:" },
|
||||||
|
"infoModalFieldArtist": { "message": "Artiste:" },
|
||||||
|
"infoModalFieldAlbum": { "message": "Album:" },
|
||||||
|
"infoModalFieldSong": { "message": "Chanson:" },
|
||||||
|
"infoModalFieldYear": { "message": "Année:" },
|
||||||
|
"infoModalFieldGenre": { "message": "Genre:" },
|
||||||
|
"lang_en": { "message": "Anglais" },
|
||||||
|
"lang_es": { "message": "Espagnol" },
|
||||||
|
"lang_fr": { "message": "Français" },
|
||||||
|
"lang_de": { "message": "Allemand" },
|
||||||
|
"essentialFeaturesNotSupported": { "message": "Votre navigateur ne prend pas en charge les fonctionnalités essentielles." },
|
||||||
|
"dbAccessError": { "message": "Erreur d'accès à la base de données locale." },
|
||||||
|
"dbUpdateNeeded": { "message": "La base de données doit être mise à jour, veuillez recharger la page." },
|
||||||
|
"dbBlocked": { "message": "Veuillez fermer les autres onglets de cette application pour continuer." },
|
||||||
|
"deletingContentData": { "message": "Suppression des données de contenu locales..." },
|
||||||
|
"noContentDataToDelete": { "message": "Aucune donnée de contenu à supprimer." },
|
||||||
|
"contentDataDeleted": { "message": "Données de contenu supprimées de IndexedDB." },
|
||||||
|
"errorDeletingData": { "message": "Erreur lors de la suppression des données : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"aceEditorNotAvailable": { "message": "Éditeur de texte non disponible." },
|
||||||
|
"errorLoadingTokens": { "message": "Erreur lors du chargement des tokens pour l'édition." },
|
||||||
|
"errorLoadingTokensMessage": { "message": "Erreur de chargement des tokens : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"aceEditorNotAvailableToSave": { "message": "Éditeur non disponible pour la sauvegarde." },
|
||||||
|
"invalidJsonFormat": { "message": "Format JSON invalide. Doit être { \"tokens\": [...] }" },
|
||||||
|
"tokensSaved": { "message": "Tokens sauvegardés avec succès." },
|
||||||
|
"errorSavingTokens": { "message": "Erreur lors de la sauvegarde des tokens : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"dbNotAvailable": { "message": "IndexedDB n'est pas disponible." },
|
||||||
|
"dbExported": { "message": "Base de données exportée avec succès." },
|
||||||
|
"errorExportingDb": { "message": "Erreur lors de l'exportation de la base de données : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"invalidJsonFile": { "message": "Le fichier ne contient pas un objet JSON valide." },
|
||||||
|
"noDataToImport": { "message": "Le fichier ne contient pas de données pour les sections actuelles de la BD." },
|
||||||
|
"dbImported": { "message": "Base de données importée avec succès." },
|
||||||
|
"errorImportingDb": { "message": "Erreur lors de l'importation de la base de données : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"updatingView": { "message": "Mise à jour de la vue avec les nouvelles données..." },
|
||||||
|
"confirmClearContent": { "message": "Êtes-vous sûr de vouloir supprimer les données de contenu locales (Films, Séries, Musique, etc.) ? Les Favoris et les Paramètres ne seront PAS supprimés." },
|
||||||
|
"trailerNotFound": { "message": "Aucune bande-annonce trouvée pour ce titre." },
|
||||||
|
"confirmClearHistory": { "message": "Êtes-vous sûr de vouloir effacer tout votre historique de visionnage ? Cette action est irréversible." },
|
||||||
|
"historyCleared": { "message": "Historique de visionnage effacé." },
|
||||||
|
"historyItemDeleted": { "message": "Élément supprimé de l'historique." },
|
||||||
|
"errorGeneratingScript": { "message": "Générez d'abord un script avant de pouvoir le copier." },
|
||||||
|
"scriptCopied": { "message": "Script PHP copié dans le presse-papiers." },
|
||||||
|
"errorCopyingScript": { "message": "Erreur lors de la copie du script." },
|
||||||
|
"scriptGenerated": { "message": "Script PHP généré." },
|
||||||
|
"errorLoadingAlbum": { "message": "Erreur lors du chargement de l'album : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"noPhotoServerSelected": { "message": "Erreur : Aucun serveur de photos n'a été sélectionné." },
|
||||||
|
"loadingGenres": { "message": "Chargement des genres..." },
|
||||||
|
"errorLoadingGenres": { "message": "Erreur de chargement" },
|
||||||
|
"noContentFound": { "message": "Aucun résultat trouvé." },
|
||||||
|
"couldNotLoadContent": { "message": "Impossible de charger le contenu." },
|
||||||
|
"noFavorites": { "message": "Vous n'avez pas encore de favoris." },
|
||||||
|
"errorLoadingFavorites": { "message": "Erreur lors du chargement des favoris." },
|
||||||
|
"historyEmpty": { "message": "Votre historique est vide." },
|
||||||
|
"historyEmptySub": { "message": "Parcourez et regardez du contenu pour qu'il apparaisse ici." },
|
||||||
|
"errorGeneratingRecommendations": { "message": "Erreur lors de la génération des recommandations." },
|
||||||
|
"noRecommendations": { "message": "Nous avons besoin de mieux vous connaître pour vous faire des recommandations !" },
|
||||||
|
"errorGeneratingStats": { "message": "Erreur lors de la génération des statistiques." },
|
||||||
|
"noServersForToken": { "message": "Aucun serveur associé trouvé pour ce token." },
|
||||||
|
"searchingActorContent": { "message": "Recherche de contenu de $actorName$", "placeholders": { "actorName": { "content": "$1" } } },
|
||||||
|
"errorLoadingActorContent": { "message": "Impossible de charger le contenu pour $actorName$.", "placeholders": { "actorName": { "content": "$1" } } },
|
||||||
|
"errorAddingStream": { "message": "Erreur lors de l'ajout du ou des flux : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"phpUrlNotConfigured": { "message": "L'URL du serveur PHP n'est pas configurée. Veuillez la configurer dans les Paramètres." },
|
||||||
|
"searchingStreams": { "message": "Recherche de flux pour \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"sendingStreams": { "message": "Envoi de $count$ flux au serveur...", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"streamAddedSuccess": { "message": "Flux ajouté(s) avec succès." },
|
||||||
|
"generatingM3U": { "message": "Génération du M3U pour \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"m3uDownloaded": { "message": "M3U pour \"$title$\" téléchargé.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"errorGeneratingM3U": { "message": "Erreur lors de la génération du M3U : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"settingsSavedSuccess": { "message": "Paramètres sauvegardés avec succès." },
|
||||||
|
"errorSavingSettings": { "message": "Erreur lors de la sauvegarde des paramètres dans la base de données." },
|
||||||
|
"languageChangeReload": { "message": "Langue modifiée. L'application va maintenant se recharger." },
|
||||||
|
"addedToFavorites": { "message": "Ajouté aux favoris." },
|
||||||
|
"removedFromFavorites": { "message": "Retiré des favoris." },
|
||||||
|
"plexScanInProgress": { "message": "Le scan Plex est déjà en cours." },
|
||||||
|
"plexScanStarting": { "message": "Démarrage du scan Plex..." },
|
||||||
|
"noPlexTokens": { "message": "Aucun token Plex configuré." },
|
||||||
|
"clearingSections": { "message": "Nettoyage des sections : $sections$", "placeholders": { "sections": { "content": "$1" } } },
|
||||||
|
"sectionsCleared": { "message": "Sections nettoyées." },
|
||||||
|
"tokenFoundServers": { "message": "Le token $token$... a trouvé $count$ serveurs.", "placeholders": { "token": { "content": "$1" }, "count": { "content": "$2" } } },
|
||||||
|
"errorProcessingToken": { "message": "Erreur lors du traitement du token $token$... : $message$", "placeholders": { "token": { "content": "$1" }, "message": { "content": "$2" } } },
|
||||||
|
"initialScanPhaseComplete": { "message": "Phase de scan initiale terminée." },
|
||||||
|
"retryPhaseFinished": { "message": "Phase de relance terminée." },
|
||||||
|
"plexScanFinished": { "message": "Scan terminé. Mise à jour du contenu..." },
|
||||||
|
"plexScanFatalError": { "message": "ERREUR FATALE : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"errorDuringScan": { "message": "Erreur pendant le scan : $message$", "placeholders": { "message": { "content": "$1" } } },
|
||||||
|
"scanCancelled": { "message": "Scan annulé par l'utilisateur." },
|
||||||
|
"scanCancelledInfo": { "message": "Scan annulé." },
|
||||||
|
"retyingSection": { "message": "Nouvelle tentative pour la section \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"retrySuccess": { "message": "[SUCCÈS] Relance de \"$title$\" terminée.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"retryError": { "message": "[ERREUR FINALE] Échec de la nouvelle tentative pour \"$title$\" : $message$", "placeholders": { "title": { "content": "$1" }, "message": { "content": "$2" } } },
|
||||||
|
"noRetriesPending": { "message": "Aucune relance en attente." },
|
||||||
|
"startingRetryPhase": { "message": "Démarrage de la phase de relance pour $count$ sections...", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"retryPhaseCancelled": { "message": "Phase de relance annulée." },
|
||||||
|
"errorInitializingMusicPlayer": { "message": "Erreur lors de l'initialisation du lecteur de musique." },
|
||||||
|
"criticalErrorLoadingMusic": { "message": "Erreur critique lors du chargement des données musicales." },
|
||||||
|
"errorLoadingArtists": { "message": "Erreur lors du chargement des artistes." },
|
||||||
|
"dbUnavailableError": { "message": "Erreur : Base de données non disponible." },
|
||||||
|
"updatingMusicData": { "message": "Mise à jour des données musicales..." },
|
||||||
|
"musicDataUpdated": { "message": "Données musicales mises à jour." },
|
||||||
|
"errorFetchingArtistSongs": { "message": "Erreur lors de la récupération des chansons de l'artiste." },
|
||||||
|
"errorLoadingSongs": { "message": "Erreur lors du chargement des chansons." },
|
||||||
|
"noArtistsFound": { "message": "Aucun artiste trouvé." },
|
||||||
|
"artistsCounter": { "message": "$start$-$end$ sur $total$", "placeholders": { "start": { "content": "$1" }, "end": { "content": "$2" }, "total": { "content": "$3" } } },
|
||||||
|
"artistsCounterSingle": { "message": "$total$ Artistes", "placeholders": { "total": { "content": "$1" } } },
|
||||||
|
"artistsCounterLoading": { "message": "Chargement..." },
|
||||||
|
"noSongsFound": { "message": "Aucune chanson trouvée." },
|
||||||
|
"shuffleOn": { "message": "Mode aléatoire activé." },
|
||||||
|
"shuffleOff": { "message": "Mode aléatoire désactivé." },
|
||||||
|
"downloadingSong": { "message": "Début du téléchargement de \"$title$\"...", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"songDownloaded": { "message": "\"$title$\" téléchargé.", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"errorDownloadingSong": { "message": "Erreur lors du téléchargement de \"$title$\"", "placeholders": { "title": { "content": "$1" } } },
|
||||||
|
"generatingAlbumM3U": { "message": "Génération du M3U pour \"$artist$\"...", "placeholders": { "artist": { "content": "$1" } } },
|
||||||
|
"albumM3UGenerated": { "message": "M3U de l'album \"$artist$\" généré.", "placeholders": { "artist": { "content": "$1" } } },
|
||||||
|
"playbackError": { "message": "Erreur de lecture" },
|
||||||
|
"errorLabel": { "message": "Erreur" },
|
||||||
|
"reloadingPage": { "message": "Rechargement de la page..." },
|
||||||
|
"viewed": { "message": "Vu" },
|
||||||
|
"local": { "message": "Local" },
|
||||||
|
"topRatedSort": {"message": "Mieux notés"},
|
||||||
|
"recentSort": {"message": "Récents"},
|
||||||
|
"popularSort": {"message": "Populaires"},
|
||||||
|
"moviesSectionTitle": {"message": "Films"},
|
||||||
|
"seriesSectionTitle": {"message": "Séries"},
|
||||||
|
"searchResultsFor": {"message": "Résultats pour \"$query$\"", "placeholders": {"query": {"content": "$1"}}},
|
||||||
|
"contentFrom": {"message": "Contenu de $actor$", "placeholders": {"actor": {"content": "$1"}}},
|
||||||
|
"explore": {"message": "Explorer"},
|
||||||
|
"noGenre": {"message": "Non classé"},
|
||||||
|
"synopsis": {"message": "Synopsis"},
|
||||||
|
"noSynopsis": {"message": "Aucun synopsis disponible."},
|
||||||
|
"director": {"message": "Réalisateur:"},
|
||||||
|
"writer": {"message": "Scénariste:"},
|
||||||
|
"viewOnImdb": {"message": "Voir sur IMDb"},
|
||||||
|
"watchTrailer": {"message": "Voir la bande-annonce"},
|
||||||
|
"addToFavorites": {"message": "Ajouter aux favoris"},
|
||||||
|
"removeFromFavorites": {"message": "Supprimer des favoris"},
|
||||||
|
"notAvailable": {"message": "Non disponible"},
|
||||||
|
"mainCast": {"message": "Distribution Principale"},
|
||||||
|
"seasonsAndEpisodes": {"message": "Saisons & Épisodes"},
|
||||||
|
"similarContent": {"message": "Contenu Similaire"},
|
||||||
|
"episodesCount": {"message": "$count$ Épisodes", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"seasonsCount": {"message": "$count$ Saisons", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"runtimeMinutes": {"message": "$count$ min", "placeholders": {"count": {"content": "$1"}}},
|
||||||
|
"noTrailerFound": {"message": "Aucune bande-annonce n'a été trouvée pour ce titre."},
|
||||||
|
"fatalInitError": {"message": "Erreur d'Initialisation Fatale"},
|
||||||
|
"fatalInitErrorSub": {"message": "L'application n'a pas pu être chargée."},
|
||||||
|
"invalidStreamInfo": {"message": "Information invalide."},
|
||||||
|
"dbUnavailableForStreams": {"message": "Base de données locale non disponible."},
|
||||||
|
"noPlexServersForStreams": {"message": "Pas de serveurs Plex."},
|
||||||
|
"notFoundOnServers": {"message": "\"$query$\" non trouvé sur les serveurs.", "placeholders": {"query": {"content": "$1"}}},
|
||||||
|
"relativeTime_justNow": { "message": "À l'instant" },
|
||||||
|
"relativeTime_minutesAgo": { "message": "Il y a $count$ minutes", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"relativeTime_hoursAgo": { "message": "Il y a $count$ heures", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"relativeTime_yesterday": { "message": "Hier" },
|
||||||
|
"relativeTime_daysAgo": { "message": "Il y a $count$ jours", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"errorLoadingDetails": { "message": "Erreur de Chargement des Détails" },
|
||||||
|
"errorLoadingLocalContent": { "message": "Erreur lors du chargement du contenu local." },
|
||||||
|
"errorServerResponse": { "message": "Réponse non réussie du serveur." },
|
||||||
|
"errorPlexApi": { "message": "Erreur $status$ de l'API Plex.", "placeholders": { "status": { "content": "$1" } } },
|
||||||
|
"errorParsingPlexXml": { "message": "Erreur d'analyse du XML de Plex." },
|
||||||
|
"untitled": { "message": "Sans titre" },
|
||||||
|
"itemCount": { "message": "$count$ éléments", "placeholders": { "count": { "content": "$1" } } },
|
||||||
|
"noPhotoServers": { "message": "Aucun serveur de photos" }
|
||||||
|
}
|
147
css/base.css
Normal file
147
css/base.css
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
:root {
|
||||||
|
--primary: #0a0a0f;
|
||||||
|
--secondary: #101116;
|
||||||
|
--accent: #00e0ff;
|
||||||
|
--accent-dark: #0072ff;
|
||||||
|
--text-primary: #f0f0f5;
|
||||||
|
--text-secondary: rgba(240, 240, 245, 0.7);
|
||||||
|
--gradient: linear-gradient(135deg, var(--accent), var(--accent-dark));
|
||||||
|
--card-bg: rgba(20, 21, 27, 0.8);
|
||||||
|
--glass: rgba(255, 255, 255, 0.05);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.1);
|
||||||
|
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||||
|
--transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
--success: #4caf50;
|
||||||
|
--warning: #ffc107;
|
||||||
|
--danger: #f44336;
|
||||||
|
--info: #2196f3;
|
||||||
|
--border-radius-lg: 18px;
|
||||||
|
--border-radius-md: 14px;
|
||||||
|
--border-radius-sm: 10px;
|
||||||
|
--topbar-height: 60px;
|
||||||
|
--sidebar-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme {
|
||||||
|
--primary: #f4f7fa;
|
||||||
|
--secondary: #ffffff;
|
||||||
|
--text-primary: #1f2937;
|
||||||
|
--text-secondary: #6b7280;
|
||||||
|
--card-bg: #ffffff;
|
||||||
|
--glass: rgba(0, 0, 0, 0.03);
|
||||||
|
--glass-border: rgba(0, 0, 0, 0.08);
|
||||||
|
--shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
line-height: 1.7;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
position: relative;
|
||||||
|
padding-top: var(--topbar-height);
|
||||||
|
transition: background-color 0.3s, color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#main-container {
|
||||||
|
padding-left: 0;
|
||||||
|
transition: padding-left 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.details-view-active {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#particles-js {
|
||||||
|
position: fixed;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme #particles-js {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -50%) rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -50%) rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
#main-container.sidebar-open {
|
||||||
|
padding-left: var(--sidebar-width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
:root {
|
||||||
|
--border-radius-lg: 14px;
|
||||||
|
--border-radius-md: 10px;
|
||||||
|
--border-radius-sm: 8px;
|
||||||
|
--topbar-height: 55px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-track {
|
||||||
|
background: var(--primary);
|
||||||
|
border-left: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--accent-dark);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
body::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme::-webkit-scrollbar-track {
|
||||||
|
background: var(--secondary);
|
||||||
|
border-left: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #bdc3c7;
|
||||||
|
border-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #a3aab1;
|
||||||
|
}
|
297
css/components.css
Normal file
297
css/components.css
Normal file
@ -0,0 +1,297 @@
|
|||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.7rem;
|
||||||
|
padding: 0.7rem 1.8rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
line-height: 1.5;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn i {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--gradient);
|
||||||
|
color: var(--primary) !important;
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 224, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover:not(:disabled) {
|
||||||
|
background: linear-gradient(135deg, var(--accent-dark), var(--accent));
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 224, 255, 0.35);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: var(--glass);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover:not(:disabled) {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.15);
|
||||||
|
transform: translateY(-3px);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-theme .btn-secondary:hover:not(:disabled) {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
border-color: rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.25rem;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
background-color: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding: 0 2rem 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: clamp(1.6rem, 4vw, 2rem);
|
||||||
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
height: 4px;
|
||||||
|
width: 70px;
|
||||||
|
background: var(--gradient);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-subtitle {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-bottom: 0.8rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
position: relative;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
|
padding: 0.7rem 2.8rem 0.7rem 1.4rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--secondary);
|
||||||
|
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23f0f0f5'%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");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 1rem center;
|
||||||
|
background-size: 1rem;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-theme .filter-select {
|
||||||
|
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 {
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 224, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: none;
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
border: 5px solid rgba(240, 240, 245, 0.2);
|
||||||
|
border-top: 5px solid var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 1050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-theme .spinner {
|
||||||
|
border: 5px solid rgba(0, 0, 0, 0.1);
|
||||||
|
border-top: 5px solid var(--accent-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
#consoleOutput {
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
height: 250px;
|
||||||
|
overflow-y: scroll;
|
||||||
|
background-color: rgba(10, 10, 15, 0.9);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
display: none;
|
||||||
|
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-theme #consoleOutput {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#consoleOutput .console-log-entry {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-word;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#consoleOutput .log-time {
|
||||||
|
color: var(--accent);
|
||||||
|
margin-right: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#consoleOutput .log-message {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#consoleOutput .log-error .log-message {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
#consoleOutput .log-warning .log-message {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
#consoleOutput .log-success .log-message {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
background-color: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
padding: .6rem 1rem;
|
||||||
|
}
|
||||||
|
.form-control:focus {
|
||||||
|
background-color: var(--glass);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0,224,255,.2);
|
||||||
|
}
|
||||||
|
.form-control::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-switch {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 50px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
.toggle-switch input {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
.toggle-switch label {
|
||||||
|
position: absolute;
|
||||||
|
cursor: pointer;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: var(--glass-border);
|
||||||
|
transition: .4s;
|
||||||
|
border-radius: 28px;
|
||||||
|
}
|
||||||
|
.toggle-switch label:before {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
height: 20px;
|
||||||
|
width: 20px;
|
||||||
|
left: 4px;
|
||||||
|
bottom: 4px;
|
||||||
|
background-color: white;
|
||||||
|
transition: .4s;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + label {
|
||||||
|
background: var(--gradient);
|
||||||
|
}
|
||||||
|
.toggle-switch input:checked + label:before {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.section-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
367
css/content-grid.css
Normal file
367
css/content-grid.css
Normal file
@ -0,0 +1,367 @@
|
|||||||
|
#main-view {
|
||||||
|
min-height: calc(100vh - var(--navbar-height) - 100px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: var(--transition);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card:hover {
|
||||||
|
transform: translateY(-10px) scale(1.03);
|
||||||
|
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.5);
|
||||||
|
border-color: rgba(0, 224, 255, 0.5);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.35rem 0.8rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
z-index: 3;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .top-badge {
|
||||||
|
left: 1rem;
|
||||||
|
background: var(--warning);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card .available-badge {
|
||||||
|
right: 1rem;
|
||||||
|
background: var(--success);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-poster {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 0;
|
||||||
|
padding-bottom: 150%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card:hover .item-poster {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-poster img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to top, rgba(10, 10, 15, 0.9) 0%, transparent 50%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card:hover .item-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
padding: 1.2rem;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
margin-top: auto;
|
||||||
|
background: var(--card-bg);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card:hover .item-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-rating {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-rating.rating-good {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-rating.rating-ok {
|
||||||
|
color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-rating.rating-bad {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
z-index: 3;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: opacity 0.4s ease, transform 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card:hover .item-actions {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-size: 1rem;
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover:not(:disabled) {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: transparent;
|
||||||
|
box-shadow: 0 6px 15px rgba(0, 224, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.favorites-btn.active {
|
||||||
|
background: var(--danger);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.favorites-btn.active:hover {
|
||||||
|
background: #c62828;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.disabled-btn {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
background: rgba(80, 80, 80, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn.disabled-btn:hover {
|
||||||
|
transform: none;
|
||||||
|
background: rgba(80, 80, 80, 0.3);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.recommendations-section {
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 2.5rem;
|
||||||
|
margin-top: 4rem;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recommendations-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i.fas,
|
||||||
|
.empty-state i.far {
|
||||||
|
font-size: 3.5rem;
|
||||||
|
color: var(--accent);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p.lead {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p.text-muted {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state .btn {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-section {
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem 1.2rem;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background: rgba(0, 224, 255, 0.08);
|
||||||
|
transform: translateX(8px);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-poster {
|
||||||
|
width: 55px;
|
||||||
|
height: 82px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-remove-btn {
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.6;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover .history-remove-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-remove-btn:hover {
|
||||||
|
background-color: var(--danger);
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.content-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(125px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
642
css/details.css
Normal file
642
css/details.css
Normal file
@ -0,0 +1,642 @@
|
|||||||
|
.item-details {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
display: none;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--primary);
|
||||||
|
z-index: 1035;
|
||||||
|
clip-path: inset(0 0 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rem;
|
||||||
|
left: 2rem;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
background: var(--glass);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--primary);
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 224, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-backdrop-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 60vh;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-backdrop-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center 20%;
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-backdrop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(to top, var(--primary) 15%, transparent 50%),
|
||||||
|
linear-gradient(to right, var(--primary) 10%, transparent 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 8rem 2rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 3rem;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-poster-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-poster {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-poster:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#item-details-content {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-title {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-tagline {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem 1.8rem;
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-meta-item i {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-overview {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: rgba(240, 240, 245, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-genres {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.8rem;
|
||||||
|
margin-bottom: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-badge {
|
||||||
|
padding: 0.5rem 1.1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 50px;
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: default;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-crew p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-crew strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-external-links a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 0.4rem 0.9rem;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
background: var(--glass);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-external-links a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(0, 224, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-section {
|
||||||
|
margin-bottom: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-card {
|
||||||
|
text-align: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
background: var(--secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-card:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
background: var(--glass);
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-photo {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
margin: 0 auto 1rem auto;
|
||||||
|
border: 3px solid var(--glass-border);
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-card:hover .cast-photo {
|
||||||
|
transform: scale(1.05);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 6px 18px rgba(0, 224, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-name {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-character {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-items-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-card {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
overflow: hidden;
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-card:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-card:hover .similar-item-poster {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-poster {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
aspect-ratio: 2 / 3;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: linear-gradient(to top, rgba(10, 10, 15, 0.95) 0%, transparent 100%);
|
||||||
|
border-bottom-left-radius: var(--border-radius-md);
|
||||||
|
border-bottom-right-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-item-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seasons-accordion .accordion-item {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seasons-accordion .accordion-button {
|
||||||
|
background: var(--card-bg);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seasons-accordion .accordion-button:not(.collapsed) {
|
||||||
|
background: var(--glass);
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seasons-accordion .accordion-button:hover {
|
||||||
|
background: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.seasons-accordion .accordion-button::after {
|
||||||
|
filter: brightness(0) invert(1) opacity(0.7);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seasons-accordion .accordion-button:not(.collapsed)::after {
|
||||||
|
filter: invert(1) opacity(1) brightness(1.5) contrast(200%);
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%2300e0ff'%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");
|
||||||
|
}
|
||||||
|
|
||||||
|
.seasons-accordion .accordion-collapse {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: height 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-poster {
|
||||||
|
width: 70px;
|
||||||
|
height: 105px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-details {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.2rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-overview {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-episodes {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: var(--secondary);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1.2rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card:first-child {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card:hover {
|
||||||
|
background: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-number {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 1rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-meta span {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-overview {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.7rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.item-details-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-container {
|
||||||
|
padding-top: calc(5vh + var(--topbar-height));
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-poster-wrapper {
|
||||||
|
max-width: 350px;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-content {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-meta,
|
||||||
|
.item-details-genres,
|
||||||
|
.item-details-crew,
|
||||||
|
.item-details-actions,
|
||||||
|
.item-details-external-links {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.item-details-title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-items-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-poster-wrapper {
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-info {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.season-poster {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 150px;
|
||||||
|
height: auto;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.episode-number {
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.item-details-poster-wrapper {
|
||||||
|
max-width: 240px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details-title {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(95px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cast-photo {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.similar-items-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.back-button {
|
||||||
|
left: calc(var(--sidebar-width) + 2rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details::-webkit-scrollbar {
|
||||||
|
width: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details::-webkit-scrollbar-track {
|
||||||
|
background: var(--primary);
|
||||||
|
border-left: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--accent-dark);
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 3px solid var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-details::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme .item-details::-webkit-scrollbar-track {
|
||||||
|
background: var(--secondary);
|
||||||
|
border-left: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme .item-details::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #bdc3c7;
|
||||||
|
border-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme .item-details::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #a3aab1;
|
||||||
|
}
|
180
css/equalizer.css
Normal file
180
css/equalizer.css
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
#equalizer-panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%) translateY(100%);
|
||||||
|
width: 520px;
|
||||||
|
background-color: #1c1c22;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
border-left: 1px solid #333;
|
||||||
|
border-right: 1px solid #333;
|
||||||
|
border-radius: 12px 12px 0 0;
|
||||||
|
box-shadow: 0 -5px 25px rgba(0,0,0,0.3);
|
||||||
|
z-index: 100;
|
||||||
|
overflow: hidden;
|
||||||
|
display: none;
|
||||||
|
font-family: 'Montserrat', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 15px;
|
||||||
|
background-color: #25252d;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-header h5 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #999;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
.close-btn:hover { color: #fff; }
|
||||||
|
|
||||||
|
.equalizer-top-bar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
align-items: center;
|
||||||
|
padding: 15px;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-group.preamp {
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.equalizer-bands-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 20px 15px;
|
||||||
|
padding: 20px 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.band label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #aaa;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
background: #3a3a44;
|
||||||
|
outline: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-webkit-slider-runnable-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #3a3a44;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #00aaff;
|
||||||
|
border: 3px solid #1c1c22;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -6px;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 170, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider:hover::-webkit-slider-thumb {
|
||||||
|
background: #00caff;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 202, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-moz-range-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
background: #3a3a44;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eq-slider::-moz-range-thumb {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
background: #00aaff;
|
||||||
|
border: 3px solid #1c1c22;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
box-shadow: 0 0 5px rgba(0, 170, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slider-value {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: #888;
|
||||||
|
background: #2a2a33;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 35px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select-sm {
|
||||||
|
background-color: #2a2a33;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.visualizer-container {
|
||||||
|
height: 80px;
|
||||||
|
background-color: #111;
|
||||||
|
border-top: 1px solid #333;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#visualizer-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
}
|
80
css/footer.css
Normal file
80
css/footer.css
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
.footer {
|
||||||
|
background: var(--secondary);
|
||||||
|
padding: 1.5rem 2rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
margin-top: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer .container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-content {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-logo-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-logo-text {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
background: var(--gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
transition: var(--transition);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-logo-link:hover .footer-logo-text {
|
||||||
|
transform: scale(1.03);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-link:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-credit {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.footer-content {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
125
css/hero.css
Normal file
125
css/hero.css
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
.hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
position: relative;
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 550px;
|
||||||
|
max-height: 800px;
|
||||||
|
overflow: hidden;
|
||||||
|
background-color: var(--primary);
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
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%),
|
||||||
|
linear-gradient(to right, var(--primary) 10%, transparent 70%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-background-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
z-index: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-background {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center center;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
max-width: 700px;
|
||||||
|
padding: 0 2rem 4rem;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: clamp(2.5rem, 5vw, 3.8rem);
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.15;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
text-shadow: 0 3px 15px rgba(0, 0, 0, 0.4);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: clamp(1rem, 2vw, 1.2rem);
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
max-width: 550px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.8rem 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-meta-item i {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.hero {
|
||||||
|
min-height: 450px;
|
||||||
|
height: 60vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero {
|
||||||
|
height: auto;
|
||||||
|
min-height: unset;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-content {
|
||||||
|
padding: 0 1rem 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: clamp(2rem, 7vw, 2.8rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: clamp(0.9rem, 3vw, 1rem);
|
||||||
|
}
|
127
css/history.css
Normal file
127
css/history.css
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
#history-section .section-header {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#history-section .btn-danger.btn-sm {
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
transition: var(--transition);
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-item:hover {
|
||||||
|
background: rgba(0, 224, 255, 0.08);
|
||||||
|
transform: translateX(8px);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-poster {
|
||||||
|
width: 60px;
|
||||||
|
height: 90px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge.local-badge-history {
|
||||||
|
background-color: var(--success);
|
||||||
|
color: var(--primary);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding: 0.25rem 0.6rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-actions .action-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-actions .action-btn:hover {
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-actions .delete-btn:hover {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.history-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-main-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-actions {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding-top: 1rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
}
|
12
css/main.css
Normal file
12
css/main.css
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
@import url('base.css');
|
||||||
|
@import url('components.css');
|
||||||
|
@import url('navbar.css');
|
||||||
|
@import url('hero.css');
|
||||||
|
@import url('content-grid.css');
|
||||||
|
@import url('details.css');
|
||||||
|
@import url('stats.css');
|
||||||
|
@import url('history.css');
|
||||||
|
@import url('footer.css');
|
||||||
|
@import url('overlays.css');
|
||||||
|
@import url('music-player.css');
|
||||||
|
@import url('photos.css');
|
684
css/music-player.css
Normal file
684
css/music-player.css
Normal file
@ -0,0 +1,684 @@
|
|||||||
|
#musicPlayerContainer {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 320px;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--secondary);
|
||||||
|
box-shadow: 5px 0 35px rgba(0, 0, 0, 0.3);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 1040;
|
||||||
|
border-right: 1px solid var(--glass-border);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), height 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.miniplayer-active #musicPlayerContainer {
|
||||||
|
height: calc(100% - 85px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: transparent;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenav-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.7rem 1.2rem;
|
||||||
|
background: var(--primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenav-header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenav-header button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.3s ease, transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidenav-header button:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.music-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 0;
|
||||||
|
position: absolute;
|
||||||
|
top: 57px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: calc(100% - 57px);
|
||||||
|
background-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#artistListContainer {
|
||||||
|
z-index: 2;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
#songListContainer {
|
||||||
|
z-index: 3;
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-controls {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.search-wrapper i {
|
||||||
|
position: absolute;
|
||||||
|
left: 1.1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
.search-wrapper input,
|
||||||
|
.search-wrapper-songs input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.7rem 1.3rem 0.7rem 2.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 50px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.search-wrapper input:focus,
|
||||||
|
.search-wrapper-songs input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 224, 255, 0.2);
|
||||||
|
background: rgba(0, 224, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-grid {
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 0.25rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-grow: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.75rem;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card:hover {
|
||||||
|
background: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card.current-artist {
|
||||||
|
background: rgba(0, 224, 255, 0.1);
|
||||||
|
border-color: rgba(0, 224, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-thumb-wrapper {
|
||||||
|
background-color: var(--primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-thumb {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-thumb-placeholder {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card-title {
|
||||||
|
padding: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
flex-grow: 1;
|
||||||
|
transition: color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-card:hover .artist-card-title,
|
||||||
|
.artist-card.current-artist .artist-card-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-controls #artistCounter {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-sm {
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-icon-sm:hover {
|
||||||
|
background-color: var(--accent);
|
||||||
|
color: var(--primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-select {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-selected {
|
||||||
|
background-color: var(--glass);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.7rem 2.5rem 0.7rem 1.3rem;
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 50px;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
.select-selected:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-items {
|
||||||
|
position: absolute;
|
||||||
|
background-color: var(--primary);
|
||||||
|
top: 105%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 99;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 8px 20px rgba(0,0,0,0.3);
|
||||||
|
max-height: 250px;
|
||||||
|
}
|
||||||
|
.select-hide { display: none; }
|
||||||
|
.select-option {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 0.8rem 1.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.select-option:hover {
|
||||||
|
background-color: var(--glass);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-grid::-webkit-scrollbar,
|
||||||
|
.select-items::-webkit-scrollbar,
|
||||||
|
.song-list::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.artist-grid::-webkit-scrollbar-thumb,
|
||||||
|
.select-items::-webkit-scrollbar-thumb,
|
||||||
|
.song-list::-webkit-scrollbar-thumb {
|
||||||
|
background-color: var(--accent);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.artist-grid::-webkit-scrollbar-track,
|
||||||
|
.select-items::-webkit-scrollbar-track,
|
||||||
|
.song-list::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.song-list-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
}
|
||||||
|
.back-btn-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#artist-header-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-grow: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#artist-header-thumb {
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
#artist-header-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.search-wrapper-songs {
|
||||||
|
position: relative;
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.album-group-title {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.6rem 0.5rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
.song-item:hover {
|
||||||
|
background: var(--glass);
|
||||||
|
}
|
||||||
|
.song-item.current-song {
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
.song-item.current-song .song-number,
|
||||||
|
.song-item.current-song .item-title {
|
||||||
|
color: var(--primary) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-number {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.song-details {
|
||||||
|
flex-grow: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.song-details .item-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.song-item .play-icon {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
.song-item:hover .play-icon,
|
||||||
|
.song-item.current-song .play-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.song-item.current-song .play-icon {
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.list-item-empty {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px dashed var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
#side-nav-now-playing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--primary);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
#side-nav-now-playing .details {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
#side-nav-now-playing img {
|
||||||
|
width: 45px;
|
||||||
|
height: 45px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#side-nav-now-playing p {
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
#side-nav-track-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
#side-nav-track-artist {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
#side-nav-play-pause {
|
||||||
|
margin-left: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#miniplayer {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 85px;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(200px, 1fr) 2fr minmax(200px, 1fr);
|
||||||
|
gap: 1.5rem;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1045;
|
||||||
|
color: var(--text-primary);
|
||||||
|
transform: translateY(110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.miniplayer-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(16, 17, 22, 0.8);
|
||||||
|
backdrop-filter: blur(15px);
|
||||||
|
-webkit-backdrop-filter: blur(15px);
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-left-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.album-cover {
|
||||||
|
width: 55px;
|
||||||
|
height: 55px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
object-fit: cover;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
#trackInfo .details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
#trackTitle, #trackArtist {
|
||||||
|
margin: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
#trackTitle { font-weight: 600; }
|
||||||
|
#trackArtist { font-size: 0.8rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
.player-center-controls {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
#player-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
.control-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.control-btn:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--glass);
|
||||||
|
}
|
||||||
|
.control-btn.active { color: var(--accent); }
|
||||||
|
.control-btn.play-pause-main {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
.control-btn.play-pause-main:hover {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 224, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-and-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.time-label { font-size: 0.75rem; color: var(--text-secondary); }
|
||||||
|
|
||||||
|
#progressBarContainer {
|
||||||
|
flex-grow: 1;
|
||||||
|
height: 5px;
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
transition: height 0.2s ease;
|
||||||
|
}
|
||||||
|
#progressBarContainer:hover {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
#seek-hover-bar {
|
||||||
|
position: absolute;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(255, 255, 255, 0.25);
|
||||||
|
border-radius: inherit;
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
#played-bar {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: inherit;
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
#progress-handle {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%) scale(0);
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
background-color: var(--text-primary);
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
#progressBarContainer:hover #progress-handle {
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-right-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
#volumeControl { position: relative; }
|
||||||
|
.volume-slider-wrapper {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: calc(100% + 15px);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: var(--secondary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.volume-slider-wrapper.active {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
left: calc(100% + 5px);
|
||||||
|
}
|
||||||
|
#volumeSlider {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
/* Removed writing-mode: bt-lr; */
|
||||||
|
width: 100px; /* Adjusted for horizontal orientation */
|
||||||
|
height: 8px; /* Adjusted for horizontal orientation */
|
||||||
|
background: var(--glass);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
#volumeSlider::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
background: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#audioPlayer { display: none; }
|
||||||
|
body.miniplayer-active { padding-bottom: 85px; }
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body.miniplayer-active { padding-bottom: 110px; }
|
||||||
|
|
||||||
|
body.miniplayer-active #musicPlayerContainer {
|
||||||
|
height: calc(100% - 110px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#miniplayer {
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
grid-template-areas:
|
||||||
|
"info actions"
|
||||||
|
"center center";
|
||||||
|
height: 110px;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.player-left-info { grid-area: info; }
|
||||||
|
.player-center-controls { grid-area: center; padding: 0 1rem; }
|
||||||
|
.player-right-actions { grid-area: actions; justify-content: flex-end; }
|
||||||
|
#downloadBtn, #downloadAlbumBtn, #eqBtn, #volumeControl { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
#player-controls { gap: 1.5rem; }
|
||||||
|
}
|
218
css/navbar.css
Normal file
218
css/navbar.css
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
.top-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: var(--topbar-height);
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
background: rgba(10, 10, 15, 0.85);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 1030;
|
||||||
|
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1), background-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme .top-bar {
|
||||||
|
background: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
body.details-view-active .top-bar {
|
||||||
|
transform: translateY(-110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-left, .top-bar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-bar-center {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
background: var(--gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
transition: var(--transition);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo:hover {
|
||||||
|
transform: scale(1.03);
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.6rem 1.2rem 0.6rem 2.6rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 50px;
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input::placeholder {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(0, 224, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 1rem;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
transition: var(--transition);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus+.search-icon {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--topbar-height);
|
||||||
|
left: 0;
|
||||||
|
width: var(--sidebar-width);
|
||||||
|
height: calc(100vh - var(--topbar-height));
|
||||||
|
background: var(--secondary);
|
||||||
|
z-index: 1020;
|
||||||
|
border-right: 1px solid var(--glass-border);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.light-theme .sidebar-nav {
|
||||||
|
background: #eef2f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .nav-link {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.2rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.9rem 1.8rem;
|
||||||
|
transition: var(--transition);
|
||||||
|
border-left: 4px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .nav-link i {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
width: 20px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background-color: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-menu .nav-link.active {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
background: var(--glass);
|
||||||
|
border-left-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
#sidebar-toggle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.sidebar-nav {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
#main-container {
|
||||||
|
padding-left: var(--sidebar-width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
#sidebar-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.top-bar-center {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.top-bar {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.search-bar {
|
||||||
|
margin: 1rem;
|
||||||
|
position: absolute;
|
||||||
|
top: var(--topbar-height);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: auto;
|
||||||
|
z-index: 1025;
|
||||||
|
padding: 0 1rem;
|
||||||
|
display: block;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
#main-view {
|
||||||
|
padding-top: 5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.top-bar {
|
||||||
|
padding: 0 0.8rem;
|
||||||
|
}
|
||||||
|
.logo {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.sidebar-nav {
|
||||||
|
width: 100%;
|
||||||
|
transform: translateX(-105%);
|
||||||
|
}
|
||||||
|
.btn-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
245
css/overlays.css
Normal file
245
css/overlays.css
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(10, 10, 15, 0.96);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
z-index: 2000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active {
|
||||||
|
display: flex;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content {
|
||||||
|
position: relative;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 960px;
|
||||||
|
background: var(--secondary);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: 0 15px 50px rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
transform: scale(0.95);
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.active .lightbox-content {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: -15px;
|
||||||
|
right: -15px;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-close:hover {
|
||||||
|
transform: scale(1.1) rotate(90deg);
|
||||||
|
background: var(--accent-dark);
|
||||||
|
box-shadow: 0 6px 15px rgba(0, 224, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 56.25%;
|
||||||
|
height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-container iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
position: relative;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 350px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1.1rem 1.5rem;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
background: var(--secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-left-width: 5px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
transform: translateX(120%);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.5s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.show {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification i.fas {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification span {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.success {
|
||||||
|
border-left-color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.error {
|
||||||
|
border-left-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.info {
|
||||||
|
border-left-color: var(--info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.warning {
|
||||||
|
border-left-color: var(--warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-theme .notification {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-backdrop {
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
-webkit-backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-title {
|
||||||
|
color: var(--accent);
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
border-top: 1px solid var(--glass-border);
|
||||||
|
background: rgba(10, 10, 15, 0.5);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .btn-close {
|
||||||
|
filter: invert(1) grayscale(100%) brightness(150%) opacity(0.8);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal .btn-close:hover {
|
||||||
|
filter: invert(1) grayscale(100%) brightness(200%) opacity(1);
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.light-theme .modal-content {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
}
|
||||||
|
.light-theme .modal-header, .light-theme .modal-footer {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .nav-tabs {
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
padding: 0.5rem 1rem 0;
|
||||||
|
background-color: rgba(10,10,15,0.7);
|
||||||
|
}
|
||||||
|
.light-theme #settingsModal .nav-tabs {
|
||||||
|
background-color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .nav-tabs .nav-link {
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 3px solid transparent;
|
||||||
|
transition: var(--transition);
|
||||||
|
padding: 0.8rem 1.2rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .nav-tabs .nav-link:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-bottom-color: var(--glass-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .nav-tabs .nav-link.active {
|
||||||
|
color: var(--accent);
|
||||||
|
background-color: transparent;
|
||||||
|
border-bottom-color: var(--accent);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .tab-content label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .tab-content p,
|
||||||
|
#settingsModal .tab-content .text-muted {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .tab-content h5 {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
#settingsModal .tab-content input[type="checkbox"] {
|
||||||
|
margin-right: 0.6rem;
|
||||||
|
transform: scale(1.1);
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor {
|
||||||
|
height: 300px;
|
||||||
|
width: 100%;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
font-family: monospace;
|
243
css/photos.css
Normal file
243
css/photos.css
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
#photos-section {
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photos-header {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
#photos-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
list-style: none;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#photos-breadcrumb .breadcrumb-item a {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: var(--transition);
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
#photos-breadcrumb .breadcrumb-item a:hover {
|
||||||
|
color: var(--accent);
|
||||||
|
background-color: var(--glass);
|
||||||
|
}
|
||||||
|
|
||||||
|
#photos-breadcrumb .breadcrumb-item.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#photos-breadcrumb .breadcrumb-divider {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0.5;
|
||||||
|
margin: 0 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#photos-token-select {
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#photos-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card, .album-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
transition: var(--transition);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card:hover, .album-card:hover {
|
||||||
|
transform: translateY(-10px) scale(1.03);
|
||||||
|
box-shadow: 0 18px 45px rgba(0, 0, 0, 0.5);
|
||||||
|
border-color: rgba(0, 224, 255, 0.5);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
transition: transform 0.4s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:hover .album-card-icon {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card-meta {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card {
|
||||||
|
padding: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card-img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: transform 0.6s cubic-bezier(0.23, 1, 0.32, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card:hover .photo-card-img {
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card-caption {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.8rem;
|
||||||
|
background: linear-gradient(to top, rgba(10, 10, 15, 0.95) 20%, transparent 100%);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px);
|
||||||
|
transition: var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-card:hover .photo-card-caption {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#photo-lightbox {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(10, 10, 15, 0.96);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
z-index: 2050;
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#photo-lightbox.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-lightbox-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-lightbox-img {
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 85vh;
|
||||||
|
object-fit: contain;
|
||||||
|
box-shadow: 0 10px 40px rgba(0,0,0,0.5);
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-lightbox-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255,255,255,0.1);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
color: var(--text-primary);
|
||||||
|
width: 50px;
|
||||||
|
height: 70px;
|
||||||
|
border-radius: var(--border-radius-sm);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: var(--transition);
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
-webkit-backdrop-filter: blur(5px);
|
||||||
|
}
|
||||||
|
.photo-lightbox-btn:hover {
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--primary);
|
||||||
|
transform: translateY(-50%) scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
#photo-lightbox-prev {
|
||||||
|
left: 2vw;
|
||||||
|
}
|
||||||
|
#photo-lightbox-next {
|
||||||
|
right: 2vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
#photo-lightbox-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-lightbox-caption {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 2vh;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(10,10,15,0.8);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.8rem 1.5rem;
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
font-size: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 70vw;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
133
css/stats.css
Normal file
133
css/stats.css
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
#stats-section {
|
||||||
|
padding-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: var(--transition);
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card:hover {
|
||||||
|
transform: translateY(-8px);
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
color: var(--accent);
|
||||||
|
background: var(--gradient);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'Orbitron', sans-serif;
|
||||||
|
font-size: clamp(2.5rem, 5vw, 3.5rem);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: var(--border-radius-lg);
|
||||||
|
padding: 2rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
grid-column: span 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container.full-width {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-container canvas {
|
||||||
|
max-height: 400px;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stats-filters {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-details-card {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-details-card .card-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-details-card .server-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-details-card .server-list li {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-bottom: 1px solid var(--glass-border);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-details-card .server-list li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.token-details-card .server-list strong {
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.stats-card {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
BIN
img/icon16.png
Normal file
BIN
img/icon16.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
img/icon32.png
Normal file
BIN
img/icon32.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.9 KiB |
BIN
img/icon48.png
Normal file
BIN
img/icon48.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
BIN
img/no-poster.png
Normal file
BIN
img/no-poster.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 466 KiB |
BIN
img/no-profile.png
Normal file
BIN
img/no-profile.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 469 KiB |
234
js/api.js
Normal file
234
js/api.js
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
import { config } from './config.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { fetchWithTimeout } from './utils.js';
|
||||||
|
import { getFromDB } from './db.js';
|
||||||
|
import { _ } from './utils.js';
|
||||||
|
|
||||||
|
export async function fetchTMDB(endpoint, signal) {
|
||||||
|
let tmdbLang = 'en-US';
|
||||||
|
const langMap = {
|
||||||
|
'es': 'es-ES',
|
||||||
|
'en': 'en-US',
|
||||||
|
'fr': 'fr-FR',
|
||||||
|
'de': 'de-DE'
|
||||||
|
};
|
||||||
|
|
||||||
|
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 });
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ status_message: "Unknown error" }));
|
||||||
|
throw new Error(`HTTP error! status: ${response.status} - ${errorData.status_message}`);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
if (xmlDoc.querySelector('parsererror')) throw new Error("Failed to parse track XML");
|
||||||
|
|
||||||
|
const tracks = Array.from(xmlDoc.querySelectorAll("Track")).map(track => {
|
||||||
|
const part = track.querySelector("Part");
|
||||||
|
if (!part || !part.getAttribute("key")) return null;
|
||||||
|
|
||||||
|
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}`;
|
||||||
|
} else if (parentThumb) {
|
||||||
|
coverUrl = `${protocolo}://${ip}:${puerto}${parentThumb}?X-Plex-Token=${token}`;
|
||||||
|
} else if (grandparentThumb) {
|
||||||
|
coverUrl = `${protocolo}://${ip}:${puerto}${grandparentThumb}?X-Plex-Token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: fileUrl,
|
||||||
|
titulo: track.getAttribute("title") || 'Pista desconocida',
|
||||||
|
album: track.getAttribute("parentTitle") || 'Álbum desconocido',
|
||||||
|
artista: track.getAttribute("grandparentTitle") || 'Artista desconocido',
|
||||||
|
cover: coverUrl,
|
||||||
|
extension: part.getAttribute("container") || "mp3",
|
||||||
|
id: track.getAttribute("ratingKey"),
|
||||||
|
artistId: track.getAttribute("grandparentRatingKey") || artistaId,
|
||||||
|
year: track.getAttribute("parentYear") || track.getAttribute("year"),
|
||||||
|
genre: Array.from(track.querySelectorAll("Genre")).map(g => g.getAttribute('tag')).join(', ') || '',
|
||||||
|
index: parseInt(track.getAttribute("index") || 0, 10),
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return a.index - b.index;
|
||||||
|
});
|
||||||
|
|
||||||
|
return tracks;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function searchAndProcessServer(server, busqueda, tipoContenido) {
|
||||||
|
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}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetchWithTimeout(searchUrl, { headers: { 'Accept': 'application/xml' } });
|
||||||
|
if (!response.ok) return [];
|
||||||
|
|
||||||
|
const data = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xml = parser.parseFromString(data, "text/xml");
|
||||||
|
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());
|
||||||
|
if (exactMatch) {
|
||||||
|
videosToProcess = [exactMatch];
|
||||||
|
}
|
||||||
|
|
||||||
|
return videosToProcess.map(video => {
|
||||||
|
const part = video.querySelector("Part");
|
||||||
|
if (!part || !part.getAttribute("key")) return null;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
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) {
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (directoryToProcess && directoryToProcess.getAttribute("ratingKey")) {
|
||||||
|
const serieKey = directoryToProcess.getAttribute("ratingKey");
|
||||||
|
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");
|
||||||
|
if (!leavesXml.querySelector('parsererror')) {
|
||||||
|
return processShowEpisodes(leavesXml, busqueda, protocolo, ip, puerto, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
|
return episodes.map(episode => {
|
||||||
|
const part = episode.querySelector("Part");
|
||||||
|
if (!part || !part.getAttribute("key")) return null;
|
||||||
|
|
||||||
|
const serieTitulo = episode.getAttribute("grandparentTitle") || busqueda;
|
||||||
|
const serieYear = episode.getAttribute("parentYear");
|
||||||
|
const seasonNum = episode.getAttribute("parentIndex") || 'S';
|
||||||
|
const episodeNum = episode.getAttribute("index") || 'E';
|
||||||
|
const episodeTitle = episode.getAttribute("title") || 'Episodio';
|
||||||
|
const streamUrl = `${protocolo}://${ip}:${puerto}${part.getAttribute("key")}?X-Plex-Token=${token}`;
|
||||||
|
const groupTitle = `${serieTitulo}${serieYear ? ` (${serieYear})` : ''} - Temporada ${seasonNum}`.replace(/"/g, "'");
|
||||||
|
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 {
|
||||||
|
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));
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(searchTasks);
|
||||||
|
const allFoundStreams = results
|
||||||
|
.filter(r => r.status === 'fulfilled' && Array.isArray(r.value))
|
||||||
|
.flatMap(r => r.value);
|
||||||
|
|
||||||
|
const uniqueStreams = [];
|
||||||
|
const seenUrls = new Set();
|
||||||
|
for (const stream of allFoundStreams) {
|
||||||
|
if (!seenUrls.has(stream.url)) {
|
||||||
|
uniqueStreams.push(stream);
|
||||||
|
seenUrls.add(stream.url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipoContenido === 'movie') {
|
||||||
|
uniqueStreams.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uniqueStreams.length > 0) {
|
||||||
|
return { success: true, streams: uniqueStreams };
|
||||||
|
} else {
|
||||||
|
return { success: false, streams: [], message: _('notFoundOnServers', busqueda) };
|
||||||
|
}
|
||||||
|
}
|
4
js/background.js
Normal file
4
js/background.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
chrome.action.onClicked.addListener(() => {
|
||||||
|
const url = chrome.runtime.getURL("plex.html");
|
||||||
|
chrome.tabs.create({ url });
|
||||||
|
});
|
5
js/config.js
Normal file
5
js/config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export const config = {
|
||||||
|
defaultApiKey: '4e44d9029b1270a757cddc766a1bcb63',
|
||||||
|
dbName: 'PlexDB',
|
||||||
|
dbVersion: 6,
|
||||||
|
};
|
20
js/constants.js
Normal file
20
js/constants.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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'
|
||||||
|
};
|
264
js/db.js
Normal file
264
js/db.js
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
import { config } from './config.js';
|
||||||
|
import { state } from './state.js';
|
||||||
|
import { showNotification, emitirEventoActualizacion, mostrarSpinner, ocultarSpinner, _ } from './utils.js';
|
||||||
|
|
||||||
|
export function initDB() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!window.indexedDB) {
|
||||||
|
showNotification(_("essentialFeaturesNotSupported"), "warning");
|
||||||
|
return reject("IndexedDB not supported");
|
||||||
|
}
|
||||||
|
const request = indexedDB.open(config.dbName, config.dbVersion);
|
||||||
|
|
||||||
|
request.onupgradeneeded = e => {
|
||||||
|
state.db = e.target.result;
|
||||||
|
const transaction = e.target.transaction;
|
||||||
|
const storesToCreate = ['movies', 'series', 'artists', 'photos', 'tokens', 'conexiones_locales', 'settings'];
|
||||||
|
|
||||||
|
storesToCreate.forEach(storeName => {
|
||||||
|
if (!state.db.objectStoreNames.contains(storeName)) {
|
||||||
|
let storeOptions;
|
||||||
|
if (storeName === 'settings') {
|
||||||
|
storeOptions = { keyPath: 'id' };
|
||||||
|
} else {
|
||||||
|
storeOptions = { keyPath: 'id', autoIncrement: true };
|
||||||
|
}
|
||||||
|
const store = state.db.createObjectStore(storeName, storeOptions);
|
||||||
|
|
||||||
|
if (storeName === 'conexiones_locales') {
|
||||||
|
store.createIndex('tokenPrincipalIndex', 'tokenPrincipal', { unique: false });
|
||||||
|
store.createIndex('tokenSecundarioIndex', 'token', { unique: false });
|
||||||
|
}
|
||||||
|
} else if (storeName === 'conexiones_locales' && transaction) {
|
||||||
|
try {
|
||||||
|
const connectionsStore = transaction.objectStore('conexiones_locales');
|
||||||
|
if (!connectionsStore.indexNames.contains('tokenPrincipalIndex')) {
|
||||||
|
connectionsStore.createIndex('tokenPrincipalIndex', 'tokenPrincipal', { unique: false });
|
||||||
|
}
|
||||||
|
if (!connectionsStore.indexNames.contains('tokenSecundarioIndex')) {
|
||||||
|
connectionsStore.createIndex('tokenSecundarioIndex', 'token', { unique: false });
|
||||||
|
}
|
||||||
|
} catch (indexError) {
|
||||||
|
if (e.target.transaction) e.target.transaction.abort();
|
||||||
|
reject(`Failed to upgrade indexes for ${storeName}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = e => {
|
||||||
|
state.db = e.target.result;
|
||||||
|
state.db.onversionchange = () => {
|
||||||
|
state.db.close();
|
||||||
|
alert(_("dbUpdateNeeded"));
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
state.db.onerror = event => {
|
||||||
|
reject(event.target.error);
|
||||||
|
};
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onerror = e => {
|
||||||
|
showNotification(_("dbAccessError"), "error");
|
||||||
|
reject(e.target.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onblocked = () => {
|
||||||
|
alert(_("dbBlocked"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getFromDB(storeName) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!state.db || !state.db.objectStoreNames.contains(storeName)) {
|
||||||
|
return resolve([]);
|
||||||
|
}
|
||||||
|
const transaction = state.db.transaction([storeName], 'readonly');
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
const request = store.getAll();
|
||||||
|
request.onsuccess = e => resolve(e.target.result || []);
|
||||||
|
request.onerror = e => reject(e.target.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStore(storeName) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!state.db || !state.db.objectStoreNames.contains(storeName)) {
|
||||||
|
return resolve();
|
||||||
|
}
|
||||||
|
const transaction = state.db.transaction([storeName], 'readwrite');
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
const request = store.clear();
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = event => reject(event.target.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addItemsToStore(storeName, items) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!state.db || !state.db.objectStoreNames.contains(storeName)) {
|
||||||
|
return reject(`Store ${storeName} not available`);
|
||||||
|
}
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return resolve(0);
|
||||||
|
}
|
||||||
|
const transaction = state.db.transaction([storeName], 'readwrite');
|
||||||
|
const store = transaction.objectStore(storeName);
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
if (item !== undefined && item !== null) {
|
||||||
|
const request = store.put(item);
|
||||||
|
request.onsuccess = () => successCount++;
|
||||||
|
request.onerror = (e) => {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.oncomplete = () => resolve(successCount);
|
||||||
|
transaction.onerror = event => reject(event.target.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearContentData() {
|
||||||
|
showNotification(_("deletingContentData"), "info");
|
||||||
|
mostrarSpinner();
|
||||||
|
const storesToDelete = ['movies', 'series', 'artists', 'photos', 'conexiones_locales'];
|
||||||
|
try {
|
||||||
|
if (!state.db) throw new Error(_("dbNotAvailable"));
|
||||||
|
const storesPresent = storesToDelete.filter(name => state.db.objectStoreNames.contains(name));
|
||||||
|
if (storesPresent.length === 0) {
|
||||||
|
showNotification(_("noContentDataToDelete"), "info");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const transaction = state.db.transaction(storesPresent, 'readwrite');
|
||||||
|
const promises = storesPresent.map(storeName => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = transaction.objectStore(storeName).clear();
|
||||||
|
request.onsuccess = resolve;
|
||||||
|
request.onerror = e => reject(e.target.error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await Promise.all(promises);
|
||||||
|
showNotification(_("contentDataDeleted"), "success");
|
||||||
|
emitirEventoActualizacion();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(_("errorDeletingData", error.message), "error");
|
||||||
|
} finally {
|
||||||
|
ocultarSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadTokensToEditor() {
|
||||||
|
if (!state.aceEditor) {
|
||||||
|
showNotification(_("aceEditorNotAvailable"), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tokensData = await getFromDB('tokens');
|
||||||
|
const tokenList = tokensData.map(item => item.token);
|
||||||
|
const structure = { tokens: tokenList.length > 0 ? tokenList : ["Pega_Tu_X-Plex-Token_Aqui"] };
|
||||||
|
state.aceEditor.setValue(JSON.stringify(structure, null, 4), -1);
|
||||||
|
state.aceEditor.focus();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(_("errorLoadingTokens"), "error");
|
||||||
|
state.aceEditor.setValue(`{\n "error": "${_("errorLoadingTokensMessage", error.message)}"\n}`, -1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveTokensFromEditor() {
|
||||||
|
if (!state.aceEditor) {
|
||||||
|
showNotification(_("aceEditorNotAvailableToSave"), "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jsonContent = state.aceEditor.getValue();
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonContent);
|
||||||
|
if (!parsed || !Array.isArray(parsed.tokens)) {
|
||||||
|
throw new Error(_("invalidJsonFormat"));
|
||||||
|
}
|
||||||
|
const tokens = [...new Set(parsed.tokens.filter(t => typeof t === 'string' && t.trim()))];
|
||||||
|
await clearStore('tokens');
|
||||||
|
if (tokens.length > 0) {
|
||||||
|
await addItemsToStore('tokens', tokens.map(t => ({ token: t })));
|
||||||
|
}
|
||||||
|
showNotification(_("tokensSaved"), "success");
|
||||||
|
emitirEventoActualizacion();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(_("errorSavingTokens", error.message), "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportDatabase() {
|
||||||
|
mostrarSpinner();
|
||||||
|
try {
|
||||||
|
if (!state.db) throw new Error(_("dbNotAvailable"));
|
||||||
|
const dbData = {};
|
||||||
|
const storeNames = Array.from(state.db.objectStoreNames);
|
||||||
|
for (const storeName of storeNames) {
|
||||||
|
dbData[storeName] = await getFromDB(storeName);
|
||||||
|
}
|
||||||
|
const jsonString = JSON.stringify(dbData, null, 4);
|
||||||
|
const blob = new Blob([jsonString], { type: 'application/json' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[-:]/g, '');
|
||||||
|
a.download = `CinePlex_Backup_${timestamp}.json`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showNotification(_("dbExported"), "success");
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(_("errorExportingDb", error.message), "error");
|
||||||
|
} finally {
|
||||||
|
ocultarSpinner();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importDatabase(file) {
|
||||||
|
if (!file) return;
|
||||||
|
mostrarSpinner();
|
||||||
|
state.isImporting = true;
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = async (e) => {
|
||||||
|
try {
|
||||||
|
if (!state.db) throw new Error(_("dbNotAvailable"));
|
||||||
|
const dbData = JSON.parse(e.target.result);
|
||||||
|
if (typeof dbData !== 'object' || dbData === null) throw new Error(_("invalidJsonFile"));
|
||||||
|
|
||||||
|
const storesToImport = Object.keys(dbData).filter(name => state.db.objectStoreNames.contains(name));
|
||||||
|
if (storesToImport.length === 0) throw new Error(_("noDataToImport"));
|
||||||
|
|
||||||
|
const transaction = state.db.transaction(storesToImport, 'readwrite');
|
||||||
|
for (const storeName of storesToImport) {
|
||||||
|
await new Promise((res, rej) => {
|
||||||
|
const req = transaction.objectStore(storeName).clear();
|
||||||
|
req.onsuccess = res;
|
||||||
|
req.onerror = rej;
|
||||||
|
});
|
||||||
|
if (Array.isArray(dbData[storeName])) {
|
||||||
|
for (const item of dbData[storeName]) {
|
||||||
|
if (item) transaction.objectStore(storeName).put(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await new Promise((res, rej) => {
|
||||||
|
transaction.oncomplete = res;
|
||||||
|
transaction.onerror = e => rej(e.target.error);
|
||||||
|
});
|
||||||
|
showNotification(_("dbImported"), "success");
|
||||||
|
emitirEventoActualizacion();
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(_("errorImportingDb", error.message), "error");
|
||||||
|
} finally {
|
||||||
|
ocultarSpinner();
|
||||||
|
state.isImporting = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsText(file);
|
||||||
|
}
|
129
js/equalizer.js
Normal file
129
js/equalizer.js
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
export class Equalizer {
|
||||||
|
constructor(audioElement, canvasElement) {
|
||||||
|
this.audioElement = audioElement;
|
||||||
|
this.canvas = canvasElement;
|
||||||
|
this.canvasCtx = this.canvas.getContext('2d');
|
||||||
|
|
||||||
|
this.FREQUENCIES = [60, 170, 310, 600, 1000, 3000, 6000, 12000, 14000, 16000];
|
||||||
|
this.PRESETS = {
|
||||||
|
flat: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0],
|
||||||
|
rock: [5, 4, -5, -8, -4, 4, 8, 9, 9, 9],
|
||||||
|
pop: [-2, -1, 0, 2, 4, 4, 2, 0, -1, -2],
|
||||||
|
jazz: [4, 3, 1, 2, -2, -2, 0, 1, 3, 4],
|
||||||
|
classical: [5, 4, 3, -2, -3, -5, -2, 2, 4, 5],
|
||||||
|
bass_boost:[9, 7, 4, 1, -2, -4, -4, -3, -2, -2]
|
||||||
|
};
|
||||||
|
|
||||||
|
this.audioCtx = null;
|
||||||
|
this.source = null;
|
||||||
|
this.preamp = null;
|
||||||
|
this.filters = [];
|
||||||
|
this.analyser = null;
|
||||||
|
this.animationFrameId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
try {
|
||||||
|
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
this.source = this.audioCtx.createMediaElementSource(this.audioElement);
|
||||||
|
|
||||||
|
this.preamp = this.audioCtx.createGain();
|
||||||
|
|
||||||
|
this.filters = this.FREQUENCIES.map(freq => {
|
||||||
|
const filter = this.audioCtx.createBiquadFilter();
|
||||||
|
filter.type = 'peaking';
|
||||||
|
filter.frequency.value = freq;
|
||||||
|
filter.Q.value = 1.41;
|
||||||
|
filter.gain.value = 0;
|
||||||
|
return filter;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.analyser = this.audioCtx.createAnalyser();
|
||||||
|
this.analyser.fftSize = 256;
|
||||||
|
|
||||||
|
// Connect nodes: source -> preamp -> filters -> analyser -> destination
|
||||||
|
this.source.connect(this.preamp);
|
||||||
|
let lastNode = this.preamp;
|
||||||
|
this.filters.forEach(filter => {
|
||||||
|
lastNode.connect(filter);
|
||||||
|
lastNode = filter;
|
||||||
|
});
|
||||||
|
lastNode.connect(this.analyser);
|
||||||
|
this.analyser.connect(this.audioCtx.destination);
|
||||||
|
|
||||||
|
this.drawVisualizer();
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changeGain(bandIndex, value) {
|
||||||
|
if (this.filters[bandIndex]) {
|
||||||
|
this.filters[bandIndex].gain.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
changePreamp(value) {
|
||||||
|
if (this.preamp) {
|
||||||
|
this.preamp.gain.value = Math.pow(10, value / 20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPreset(name) {
|
||||||
|
const values = this.PRESETS[name];
|
||||||
|
if (!values) return;
|
||||||
|
|
||||||
|
const bandSliders = document.querySelectorAll('.band-slider');
|
||||||
|
values.forEach((val, i) => {
|
||||||
|
this.changeGain(i, val);
|
||||||
|
const slider = bandSliders[i];
|
||||||
|
if (slider) {
|
||||||
|
slider.value = val;
|
||||||
|
slider.nextElementSibling.textContent = `${val} dB`;
|
||||||
|
// GSAP animation for slider thumb
|
||||||
|
gsap.to(slider, {
|
||||||
|
duration: 0.4,
|
||||||
|
value: val,
|
||||||
|
ease: "power2.out"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drawVisualizer() {
|
||||||
|
this.animationFrameId = requestAnimationFrame(() => this.drawVisualizer());
|
||||||
|
if (!this.analyser) return;
|
||||||
|
|
||||||
|
const bufferLength = this.analyser.frequencyBinCount;
|
||||||
|
const dataArray = new Uint8Array(bufferLength);
|
||||||
|
this.analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
this.canvasCtx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
const barWidth = (this.canvas.width / bufferLength) * 2.5;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < bufferLength; i++) {
|
||||||
|
const barHeight = dataArray[i] / 2;
|
||||||
|
|
||||||
|
const r = barHeight + 25 * (i/bufferLength);
|
||||||
|
const g = 250 * (i/bufferLength);
|
||||||
|
const b = 50;
|
||||||
|
|
||||||
|
this.canvasCtx.fillStyle = `rgb(${r},${g},${b})`;
|
||||||
|
this.canvasCtx.fillRect(x, this.canvas.height - barHeight, barWidth, barHeight);
|
||||||
|
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if (this.animationFrameId) {
|
||||||
|
cancelAnimationFrame(this.animationFrameId);
|
||||||
|
}
|
||||||
|
if (this.audioCtx) {
|
||||||
|
this.audioCtx.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
333
js/eventListeners.js
Normal file
333
js/eventListeners.js
Normal file
@ -0,0 +1,333 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { switchView, resetView, showMainView, showItemDetails, applyFilters, searchByActor, loadContent, toggleFavorite, addStreamToList, downloadM3U, showTrailer, closeTrailer, openSettingsModal, saveSettings, updateSectionTitle, generateStatistics, loadFavorites, loadLocalContent, phpScriptGenerator, initPhotosView, handlePhotoGridClick, handlePhotoTokenChange, showNextPhoto, showPrevPhoto, closePhotoLightbox, activateSettingsTab, deleteHistoryItem, clearAllHistory, getTrailerKey } from './ui.js';
|
||||||
|
import { debounce, showNotification, _ } from './utils.js';
|
||||||
|
import { clearContentData, loadTokensToEditor, saveTokensFromEditor, exportDatabase, importDatabase } from './db.js';
|
||||||
|
import { startPlexScan } from './plex.js';
|
||||||
|
import { Equalizer } from './equalizer.js';
|
||||||
|
|
||||||
|
async function handleDatabaseUpdate() {
|
||||||
|
showNotification(_('updatingView'), "info", 2000);
|
||||||
|
await loadLocalContent();
|
||||||
|
|
||||||
|
switch(state.currentView) {
|
||||||
|
case 'stats':
|
||||||
|
generateStatistics();
|
||||||
|
break;
|
||||||
|
case 'movies':
|
||||||
|
case 'series':
|
||||||
|
case 'search':
|
||||||
|
loadContent();
|
||||||
|
break;
|
||||||
|
case 'favorites':
|
||||||
|
loadFavorites();
|
||||||
|
break;
|
||||||
|
case 'photos':
|
||||||
|
initPhotosView();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setupEventListeners() {
|
||||||
|
document.getElementById('sidebar-toggle').addEventListener('click', () => {
|
||||||
|
document.getElementById('sidebar-nav').classList.toggle('open');
|
||||||
|
document.getElementById('main-container').classList.toggle('sidebar-open');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('nav-movies').addEventListener('click', (e) => { e.preventDefault(); switchView('movies'); });
|
||||||
|
document.getElementById('nav-series').addEventListener('click', (e) => { e.preventDefault(); switchView('series'); });
|
||||||
|
document.getElementById('nav-photos').addEventListener('click', (e) => { e.preventDefault(); switchView('photos'); });
|
||||||
|
document.getElementById('nav-stats').addEventListener('click', (e) => { e.preventDefault(); switchView('stats'); });
|
||||||
|
document.getElementById('nav-favorites').addEventListener('click', (e) => { e.preventDefault(); switchView('favorites'); });
|
||||||
|
document.getElementById('nav-history').addEventListener('click', (e) => { e.preventDefault(); switchView('history'); });
|
||||||
|
document.getElementById('nav-recommendations').addEventListener('click', (e) => { e.preventDefault(); switchView('recommendations'); });
|
||||||
|
document.getElementById('reset-view-btn').addEventListener('click', (e) => { e.preventDefault(); resetView(); });
|
||||||
|
|
||||||
|
document.getElementById('footer-logo-btn').addEventListener('click', (e) => { e.preventDefault(); resetView(); });
|
||||||
|
document.getElementById('footer-movies').addEventListener('click', (e) => { e.preventDefault(); switchView('movies'); });
|
||||||
|
document.getElementById('footer-series').addEventListener('click', (e) => { e.preventDefault(); switchView('series'); });
|
||||||
|
document.getElementById('footer-stats').addEventListener('click', (e) => { e.preventDefault(); switchView('stats'); });
|
||||||
|
document.getElementById('footer-favorites').addEventListener('click', (e) => { e.preventDefault(); switchView('favorites'); });
|
||||||
|
|
||||||
|
document.getElementById('load-more').addEventListener('click', () => {
|
||||||
|
if (!state.isLoading) {
|
||||||
|
state.currentPage++;
|
||||||
|
loadContent(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('search-input').addEventListener('keyup', debounce(async (e) => {
|
||||||
|
const query = e.target.value.trim();
|
||||||
|
if (query === state.currentParams.query && state.currentView === 'search') return;
|
||||||
|
state.currentView = 'search';
|
||||||
|
state.currentParams.query = query;
|
||||||
|
state.currentPage = 1;
|
||||||
|
if (!query) {
|
||||||
|
switchView(state.currentParams.contentType === 'movie' ? 'movies' : 'series');
|
||||||
|
} else {
|
||||||
|
updateSectionTitle();
|
||||||
|
await loadContent();
|
||||||
|
}
|
||||||
|
}, 400));
|
||||||
|
|
||||||
|
document.getElementById('genre-filter').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('year-filter').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('sort-filter').addEventListener('change', applyFilters);
|
||||||
|
document.getElementById('stats-token-filter').addEventListener('change', generateStatistics);
|
||||||
|
document.getElementById('photos-token-select').addEventListener('change', handlePhotoTokenChange);
|
||||||
|
|
||||||
|
document.querySelector('.back-button').addEventListener('click', showMainView);
|
||||||
|
|
||||||
|
document.getElementById('main-view').addEventListener('click', handleMainViewClick);
|
||||||
|
document.getElementById('item-details-view').addEventListener('click', handleDetailsClick);
|
||||||
|
|
||||||
|
document.getElementById('settings-btn').addEventListener('click', openSettingsModal);
|
||||||
|
|
||||||
|
document.getElementById('import-db-btn').addEventListener('click', async () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = '.json';
|
||||||
|
input.onchange = async (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
await importDatabase(file);
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('settingsModal'))?.hide();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('confirmScanBtn').addEventListener('click', () => {
|
||||||
|
const selectedTypes = Array.from(document.querySelectorAll('#plex input[type="checkbox"]:checked'))
|
||||||
|
.map(cb => cb.value)
|
||||||
|
.filter(v => v && v !== 'on');
|
||||||
|
|
||||||
|
if (selectedTypes.length > 0) {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('settingsModal')).hide();
|
||||||
|
document.getElementById('consoleOutputContainer').style.display = 'block';
|
||||||
|
document.getElementById('consoleOutput').style.display = 'block';
|
||||||
|
startPlexScan(selectedTypes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('clearDataBtn').addEventListener('click', () => {
|
||||||
|
if (confirm(_('confirmClearContent'))) {
|
||||||
|
clearContentData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('saveTokensBtn').addEventListener('click', saveTokensFromEditor);
|
||||||
|
document.getElementById('exportDbBtn').addEventListener('click', exportDatabase);
|
||||||
|
document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings);
|
||||||
|
|
||||||
|
document.getElementById('settingsModal').addEventListener('shown.bs.modal', () => {
|
||||||
|
loadTokensToEditor();
|
||||||
|
activateSettingsTab('general');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('updateAll').addEventListener('change', (e) => {
|
||||||
|
document.querySelectorAll('#plex input[type="checkbox"]').forEach(cb => {
|
||||||
|
if (cb.id !== 'updateAll') cb.checked = e.target.checked;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector('.lightbox-close').addEventListener('click', closeTrailer);
|
||||||
|
document.getElementById('video-lightbox').addEventListener('click', (e) => {
|
||||||
|
if(e.target === e.currentTarget) closeTrailer();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('photo-lightbox-close').addEventListener('click', closePhotoLightbox);
|
||||||
|
document.getElementById('photo-lightbox-next').addEventListener('click', showNextPhoto);
|
||||||
|
document.getElementById('photo-lightbox-prev').addEventListener('click', showPrevPhoto);
|
||||||
|
document.getElementById('photo-lightbox').addEventListener('click', (e) => {
|
||||||
|
if (e.target.id === 'photo-lightbox') {
|
||||||
|
closePhotoLightbox();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('indexedDBUpdated', handleDatabaseUpdate);
|
||||||
|
|
||||||
|
const eqBtn = document.getElementById('eqBtn');
|
||||||
|
const closeEqBtn = document.getElementById('closeEqBtn');
|
||||||
|
const equalizerPanel = document.getElementById('equalizer-panel');
|
||||||
|
|
||||||
|
function initializeEqualizer() {
|
||||||
|
if (state.isEqualizerInitialized) return;
|
||||||
|
|
||||||
|
const audioPlayer = document.getElementById('audioPlayer');
|
||||||
|
const canvas = document.getElementById('visualizer-canvas');
|
||||||
|
state.equalizer = new Equalizer(audioPlayer, canvas);
|
||||||
|
|
||||||
|
if (state.equalizer.init()) {
|
||||||
|
state.isEqualizerInitialized = true;
|
||||||
|
setupEqualizerControls();
|
||||||
|
} else {
|
||||||
|
eqBtn.disabled = true;
|
||||||
|
eqBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
eqBtn.addEventListener('click', () => {
|
||||||
|
initializeEqualizer();
|
||||||
|
if (!state.isEqualizerInitialized) return;
|
||||||
|
|
||||||
|
gsap.set(equalizerPanel, { display: 'block' });
|
||||||
|
gsap.to(equalizerPanel, {
|
||||||
|
y: 0,
|
||||||
|
duration: 0.5,
|
||||||
|
ease: "power3.out"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
closeEqBtn.addEventListener('click', () => {
|
||||||
|
gsap.to(equalizerPanel, {
|
||||||
|
y: '100%',
|
||||||
|
duration: 0.4,
|
||||||
|
ease: "power3.in",
|
||||||
|
onComplete: () => gsap.set(equalizerPanel, { display: 'none' })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function setupEqualizerControls() {
|
||||||
|
const preampSlider = document.getElementById('preamp-slider');
|
||||||
|
preampSlider.addEventListener('input', (e) => {
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
state.equalizer.changePreamp(value);
|
||||||
|
e.target.nextElementSibling.textContent = `${value.toFixed(0)} dB`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const bandSliders = document.querySelectorAll('.band-slider');
|
||||||
|
bandSliders.forEach(slider => {
|
||||||
|
slider.addEventListener('input', (e) => {
|
||||||
|
const bandIndex = parseInt(e.target.dataset.band);
|
||||||
|
const value = parseFloat(e.target.value);
|
||||||
|
state.equalizer.changeGain(bandIndex, value);
|
||||||
|
e.target.nextElementSibling.textContent = `${value.toFixed(0)} dB`;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const presetsSelect = document.getElementById('eq-presets');
|
||||||
|
presetsSelect.addEventListener('change', (e) => {
|
||||||
|
state.equalizer.applyPreset(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioPlayer = document.getElementById('audioPlayer');
|
||||||
|
audioPlayer.addEventListener('play', initializeEqualizer, { once: true });
|
||||||
|
|
||||||
|
phpScriptGenerator.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMainViewClick(e) {
|
||||||
|
const clearHistoryBtn = e.target.closest('#clear-history-btn');
|
||||||
|
if (clearHistoryBtn) {
|
||||||
|
clearAllHistory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const historyItem = e.target.closest('.history-item');
|
||||||
|
if (historyItem) {
|
||||||
|
handleHistoryClick(e, historyItem);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const photoCard = e.target.closest('.album-card, .photo-card');
|
||||||
|
if (photoCard) {
|
||||||
|
handlePhotoGridClick(photoCard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = e.target.closest('.item-card');
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
state.lastClickedCardElement = card;
|
||||||
|
const actionBtn = e.target.closest('.action-btn');
|
||||||
|
|
||||||
|
if (actionBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const { id, type } = card.dataset;
|
||||||
|
const title = card.querySelector('.item-title')?.textContent;
|
||||||
|
if (actionBtn.classList.contains('info-btn')) showItemDetails(Number(id), type);
|
||||||
|
else if (actionBtn.classList.contains('favorites-btn')) toggleFavorite(Number(id), type);
|
||||||
|
else if (actionBtn.classList.contains('play-btn')) addStreamToList(title, type, actionBtn);
|
||||||
|
else if (actionBtn.classList.contains('download-btn')) downloadM3U(title, type, actionBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, type } = card.dataset;
|
||||||
|
if (id && type) showItemDetails(Number(id), type);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleHistoryClick(e, historyItem) {
|
||||||
|
const { id, type, title } = historyItem.dataset;
|
||||||
|
const button = e.target.closest('.action-btn');
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (button.classList.contains('delete-btn')) {
|
||||||
|
deleteHistoryItem(id, type, historyItem);
|
||||||
|
} else if (button.classList.contains('play-btn')) {
|
||||||
|
addStreamToList(title, type, button);
|
||||||
|
} else if (button.classList.contains('trailer-btn')) {
|
||||||
|
const originalIcon = button.innerHTML;
|
||||||
|
button.innerHTML = `<span class="spinner-border spinner-border-sm"></span>`;
|
||||||
|
button.disabled = true;
|
||||||
|
const trailerKey = await getTrailerKey(id, type);
|
||||||
|
if (trailerKey) {
|
||||||
|
showTrailer(trailerKey);
|
||||||
|
} else {
|
||||||
|
showNotification(_('trailerNotFound'), 'warning');
|
||||||
|
}
|
||||||
|
button.innerHTML = originalIcon;
|
||||||
|
button.disabled = false;
|
||||||
|
} else if (button.classList.contains('info-btn')) {
|
||||||
|
state.lastClickedCardElement = historyItem;
|
||||||
|
showItemDetails(Number(id), type);
|
||||||
|
}
|
||||||
|
} else if (e.target.closest('.info-btn')) {
|
||||||
|
state.lastClickedCardElement = historyItem;
|
||||||
|
showItemDetails(Number(id), type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDetailsClick(e) {
|
||||||
|
const trailerBtn = e.target.closest('.trailer-btn');
|
||||||
|
if (trailerBtn) {
|
||||||
|
showTrailer(trailerBtn.dataset.trailerKey);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const favoritesBtn = e.target.closest('.favorites-btn');
|
||||||
|
if (favoritesBtn) {
|
||||||
|
toggleFavorite(Number(favoritesBtn.dataset.id), favoritesBtn.dataset.type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const castCard = e.target.closest('.cast-card');
|
||||||
|
if (castCard) {
|
||||||
|
const { actorId } = castCard.dataset;
|
||||||
|
const actorName = castCard.querySelector('.cast-name').textContent;
|
||||||
|
if (actorId && actorName) await searchByActor(actorId, actorName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const similarCard = e.target.closest('.similar-item-card');
|
||||||
|
if (similarCard) {
|
||||||
|
state.lastClickedCardElement = similarCard;
|
||||||
|
const { id, type } = similarCard.dataset;
|
||||||
|
if (id && type) showItemDetails(Number(id), type);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addStreamBtn = e.target.closest('.play-btn');
|
||||||
|
if (addStreamBtn) {
|
||||||
|
const { title, type } = addStreamBtn.dataset;
|
||||||
|
addStreamToList(title, type, addStreamBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadM3uBtn = e.target.closest('.download-btn');
|
||||||
|
if (downloadM3uBtn) {
|
||||||
|
const { title, type } = downloadM3uBtn.dataset;
|
||||||
|
downloadM3U(title, type, downloadM3uBtn);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
39
js/i18n.js
Normal file
39
js/i18n.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
function localizeHtmlPage() {
|
||||||
|
const i18nRegex = /__MSG_(\w+)__/g;
|
||||||
|
|
||||||
|
function replaceMsg(match, p1) {
|
||||||
|
try {
|
||||||
|
return p1 ? chrome.i18n.getMessage(p1) : match;
|
||||||
|
} catch (e) {
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(
|
||||||
|
document.documentElement,
|
||||||
|
NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT,
|
||||||
|
null,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
let node;
|
||||||
|
while (node = walker.nextNode()) {
|
||||||
|
if (node.nodeType === Node.ELEMENT_NODE) {
|
||||||
|
for (const attr of node.attributes) {
|
||||||
|
if (i18nRegex.test(attr.value)) {
|
||||||
|
attr.value = attr.value.replace(i18nRegex, replaceMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (node.nodeType === Node.TEXT_NODE) {
|
||||||
|
if (i18nRegex.test(node.nodeValue)) {
|
||||||
|
node.nodeValue = node.nodeValue.replace(i18nRegex, replaceMsg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.lang = chrome.i18n.getUILanguage().split('-')[0];
|
||||||
|
document.title = document.title.replace(i18nRegex, replaceMsg);
|
||||||
|
document.body.classList.add('loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', localizeHtmlPage);
|
73
js/main.js
Normal file
73
js/main.js
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { config } from './config.js';
|
||||||
|
import { initDB, getFromDB } from './db.js';
|
||||||
|
import { MusicPlayer } from './musicPlayer.js';
|
||||||
|
import { setupEventListeners } from './eventListeners.js';
|
||||||
|
import { loadInitialContent, initializeFavorites, initializeUserData, loadLocalContent, applyTheme, applyHeroVisibility } from './ui.js';
|
||||||
|
import { showNotification, _ } from './utils.js';
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const settingsData = await getFromDB('settings');
|
||||||
|
if (settingsData && settingsData.length > 0) {
|
||||||
|
state.settings = { ...state.settings, ...settingsData[0] };
|
||||||
|
} else {
|
||||||
|
state.settings.language = chrome.i18n.getUILanguage().split('-')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!state.settings.apiKey) {
|
||||||
|
state.settings.apiKey = config.defaultApiKey;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state.settings.language = chrome.i18n.getUILanguage().split('-')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
try {
|
||||||
|
await initDB();
|
||||||
|
await loadSettings();
|
||||||
|
|
||||||
|
applyTheme(state.settings.theme);
|
||||||
|
applyHeroVisibility(state.settings.showHero);
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
state.musicPlayer = new MusicPlayer();
|
||||||
|
state.musicPlayer.setDB(state.db);
|
||||||
|
|
||||||
|
initializeFavorites();
|
||||||
|
initializeUserData();
|
||||||
|
|
||||||
|
await loadLocalContent();
|
||||||
|
await loadInitialContent();
|
||||||
|
|
||||||
|
setupEventListeners();
|
||||||
|
initializeThirdPartyLibs();
|
||||||
|
|
||||||
|
} catch (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>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function initializeThirdPartyLibs() {
|
||||||
|
if (typeof particlesJS !== 'undefined') {
|
||||||
|
particlesJS('particles-js', {
|
||||||
|
"particles": { "number": { "value": 45 }, "color": { "value": "#0072ff" }, "shape": { "type": "circle" }, "opacity": { "value": 0.25, "random": true }, "size": { "value": 2.5, "random": true }, "line_linked": { "enable": true, "distance": 150, "color": "#00e0ff", "opacity": 0.1 }, "move": { "enable": true, "speed": 1.2, "direction": "none", "random": true, "out_mode": "out" } },
|
||||||
|
"interactivity": { "events": { "onhover": { "enable": true, "mode": "grab" } }, "modes": { "grab": { "distance": 120, "line_linked": { "opacity": 0.3 } } } },
|
||||||
|
"retina_detect": true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof ace !== 'undefined') {
|
||||||
|
try {
|
||||||
|
state.aceEditor = ace.edit("editor");
|
||||||
|
state.aceEditor.setTheme("ace/theme/monokai");
|
||||||
|
state.aceEditor.session.setMode("ace/mode/json");
|
||||||
|
state.aceEditor.setOptions({ fontSize: "14px", useWorker: false });
|
||||||
|
} catch(e) {
|
||||||
|
state.aceEditor = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
801
js/musicPlayer.js
Normal file
801
js/musicPlayer.js
Normal file
@ -0,0 +1,801 @@
|
|||||||
|
import { getFromDB } from './db.js';
|
||||||
|
import { debounce, showNotification, _ } from './utils.js';
|
||||||
|
import { getMusicUrlsFromPlex } from './api.js';
|
||||||
|
|
||||||
|
export class MusicPlayer {
|
||||||
|
constructor() {
|
||||||
|
this.cancionesActuales = [];
|
||||||
|
this.indiceActual = -1;
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.audioPlayer = document.getElementById("audioPlayer");
|
||||||
|
this.currentArtist = "";
|
||||||
|
this.currentAlbumId = null;
|
||||||
|
this.currentSongId = null;
|
||||||
|
this.currentArtistId = null;
|
||||||
|
this.currentSongArtistId = null;
|
||||||
|
this.db = null;
|
||||||
|
this.artistListScrollPosition = 0;
|
||||||
|
this.artistsData = [];
|
||||||
|
this.artistsPageSize = 20;
|
||||||
|
this.currentPage = 0;
|
||||||
|
this.shuffleMode = false;
|
||||||
|
this.tokens = [];
|
||||||
|
this.isPlayerVisible = false;
|
||||||
|
this.isReady = false;
|
||||||
|
this.isInitializing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDB(databaseInstance) {
|
||||||
|
if (databaseInstance && this.db !== databaseInstance) {
|
||||||
|
this.db = databaseInstance;
|
||||||
|
if (!this.isReady && !this.isInitializing) {
|
||||||
|
this.asyncInitialize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async asyncInitialize() {
|
||||||
|
if (this.isReady || this.isInitializing || !this.db) return;
|
||||||
|
this.isInitializing = true;
|
||||||
|
try {
|
||||||
|
this.setupEventHandlers();
|
||||||
|
await this.loadMusicData();
|
||||||
|
this.updateArtistCounter();
|
||||||
|
this.markCurrentArtist();
|
||||||
|
this.setupIndexedDBUpdateListener();
|
||||||
|
this.isReady = true;
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(_('errorInitializingMusicPlayer'), "error");
|
||||||
|
this.isReady = false;
|
||||||
|
} finally {
|
||||||
|
this.isInitializing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMusicData() {
|
||||||
|
if (!this.db) {
|
||||||
|
document.getElementById('artistList').innerHTML = `<div class="list-item-empty">${_('dbUnavailableError')}</div>`;
|
||||||
|
this.artistsData = []; this.tokens = []; this.updateArtistCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const rawArtistData = await getFromDB('artists');
|
||||||
|
this.artistsData = rawArtistData || [];
|
||||||
|
this.loadTokens();
|
||||||
|
const initialArtistList = this._generateFullArtistListForToken('all');
|
||||||
|
this.loadArtists(initialArtistList, this.currentPage);
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(_('criticalErrorLoadingMusic'), "error");
|
||||||
|
this.artistsData = []; this.tokens = [];
|
||||||
|
document.getElementById('artistList').innerHTML = `<div class="list-item-empty">${_('errorLoadingArtists')}</div>`;
|
||||||
|
this.updateTokenSelectorUI();
|
||||||
|
this.updateArtistCounter();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventHandlers() {
|
||||||
|
document.querySelectorAll('#openMusicPlayerMobile, #openMusicPlayerDesktop, #openMusicPlayerFromMiniplayer').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => this.togglePlayerVisibility());
|
||||||
|
});
|
||||||
|
document.getElementById('closeSideNavBtn').addEventListener('click', () => this.hidePlayer());
|
||||||
|
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());
|
||||||
|
document.getElementById('playPauseBtn').addEventListener('click', () => this.togglePlayPause());
|
||||||
|
document.getElementById('nextBtn').addEventListener('click', () => this.playNext());
|
||||||
|
document.getElementById('prevBtn').addEventListener('click', () => this.playPrevious());
|
||||||
|
document.getElementById('shuffleBtn').addEventListener('click', () => this.toggleShuffle());
|
||||||
|
|
||||||
|
document.getElementById('volumeSlider').addEventListener("input", (event) => this.updateVolume(event));
|
||||||
|
document.getElementById('volume-icon-btn').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
document.querySelector('.volume-slider-wrapper').classList.toggle('active');
|
||||||
|
this.audioPlayer.muted = false;
|
||||||
|
if (this.audioPlayer.volume === 0) {
|
||||||
|
this.audioPlayer.volume = 0.5;
|
||||||
|
this.updateVolumeIcon(0.5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!document.getElementById('volumeControl').contains(e.target)) {
|
||||||
|
document.querySelector('.volume-slider-wrapper').classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
document.getElementById('downloadAlbumBtn').addEventListener('click', () => this.downloadAlbum());
|
||||||
|
document.getElementById('downloadBtn').addEventListener('click', () => {
|
||||||
|
if (this.indiceActual >= 0 && this.cancionesActuales[this.indiceActual]) {
|
||||||
|
const currentSong = this.cancionesActuales[this.indiceActual];
|
||||||
|
this.downloadSong(currentSong.url, currentSong.titulo, currentSong.extension);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.audioPlayer.addEventListener("ended", () => this.handleAudioEnded());
|
||||||
|
this.audioPlayer.addEventListener("timeupdate", () => this.updateProgressBar());
|
||||||
|
this.audioPlayer.addEventListener("error", () => this.handleAudioErrorEvent());
|
||||||
|
|
||||||
|
const progressBarContainer = document.getElementById('progressBarContainer');
|
||||||
|
progressBarContainer.addEventListener('click', (event) => this.seek(event));
|
||||||
|
progressBarContainer.addEventListener('mousemove', (event) => this.updateSeekHover(event));
|
||||||
|
progressBarContainer.addEventListener('mouseleave', () => this.hideSeekHover());
|
||||||
|
|
||||||
|
document.getElementById('artistList').addEventListener("click", (event) => {
|
||||||
|
const card = event.target.closest('.artist-card');
|
||||||
|
if(card) this.loadArtistSongs(card);
|
||||||
|
});
|
||||||
|
document.getElementById('listaCanciones').addEventListener("click", (event) => {
|
||||||
|
const item = event.target.closest('.song-item');
|
||||||
|
if(item) {
|
||||||
|
const index = parseInt(item.dataset.index, 10);
|
||||||
|
if (!isNaN(index) && this.cancionesActuales[index]) {
|
||||||
|
this.playSong(this.cancionesActuales[index], index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('prevArtistsBtn').addEventListener('click', () => this.loadPreviousArtists());
|
||||||
|
document.getElementById('nextArtistsBtn').addEventListener('click', () => this.loadNextArtists());
|
||||||
|
|
||||||
|
const tokenSelectorContainer = document.getElementById('tokenSelectorContainer');
|
||||||
|
tokenSelectorContainer.addEventListener('click', e => {
|
||||||
|
const target = e.target;
|
||||||
|
const selectItems = tokenSelectorContainer.querySelector('.select-items');
|
||||||
|
if (target.closest('.select-selected')) {
|
||||||
|
selectItems.classList.toggle('select-hide');
|
||||||
|
target.closest('.select-selected').classList.toggle('select-arrow-active');
|
||||||
|
} else if (target.classList.contains('select-option')) {
|
||||||
|
const selectedText = tokenSelectorContainer.querySelector('.select-selected span');
|
||||||
|
selectedText.textContent = target.textContent;
|
||||||
|
selectedText.dataset.value = target.dataset.value;
|
||||||
|
selectItems.classList.add('select-hide');
|
||||||
|
tokenSelectorContainer.querySelector('.select-selected').classList.remove('select-arrow-active');
|
||||||
|
|
||||||
|
this.currentPage = 0;
|
||||||
|
this.loadArtistsByToken(target.dataset.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!tokenSelectorContainer.contains(e.target)) {
|
||||||
|
tokenSelectorContainer.querySelector('.select-items').classList.add('select-hide');
|
||||||
|
tokenSelectorContainer.querySelector('.select-selected').classList.remove('select-arrow-active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('trackInfo').addEventListener('click', (event) => {
|
||||||
|
if (event.target.closest('#albumCover')) {
|
||||||
|
if (this.indiceActual >= 0 && this.cancionesActuales[this.indiceActual]) {
|
||||||
|
this.showInfoModal(this.cancionesActuales[this.indiceActual]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_getCurrentTokenFilter() {
|
||||||
|
return document.querySelector("#tokenSelectorContainer .select-selected span").dataset.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAudioEnded() { this.playNext(); }
|
||||||
|
handleAudioErrorEvent() { this.handleAudioError(_('playbackError')); }
|
||||||
|
|
||||||
|
togglePlayerVisibility() {
|
||||||
|
if (this.isPlayerVisible) this.hidePlayer();
|
||||||
|
else this.showPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
async showPlayer() {
|
||||||
|
if (this.isPlayerVisible) return;
|
||||||
|
|
||||||
|
gsap.to('#musicPlayerContainer', { x: 0, duration: 0.5, ease: 'power3.out' });
|
||||||
|
this.isPlayerVisible = true;
|
||||||
|
|
||||||
|
if (!this.isReady) await this.asyncInitialize();
|
||||||
|
if (!this.isReady) {
|
||||||
|
document.getElementById('artistList').innerHTML = `<div class="list-item-empty">${_('errorInitializingMusicPlayer')}</div>`;
|
||||||
|
this.updateArtistCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.artistsData.length === 0 && this.isReady) {
|
||||||
|
try { await this.loadMusicData(); } catch (error) {
|
||||||
|
document.getElementById('artistList').innerHTML = `<div class="list-item-empty">${_('errorLoadingArtists')}</div>`;
|
||||||
|
this.updateArtistCounter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.markCurrentArtist();
|
||||||
|
this.markCurrentSong();
|
||||||
|
}
|
||||||
|
|
||||||
|
hidePlayer() {
|
||||||
|
if (!this.isPlayerVisible) return;
|
||||||
|
gsap.to('#musicPlayerContainer', { x: '-100%', duration: 0.4, ease: 'power3.in' });
|
||||||
|
this.isPlayerVisible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDatabaseUpdate() {
|
||||||
|
if (!this.isReady) await this.asyncInitialize();
|
||||||
|
if (!this.isReady) return;
|
||||||
|
showNotification(_('updatingMusicData'), "info", 1500);
|
||||||
|
const currentTokenFilter = this._getCurrentTokenFilter();
|
||||||
|
const songListPanel = document.getElementById("songListContainer");
|
||||||
|
const wasSongListVisible = songListPanel.style.visibility === 'visible' && songListPanel.style.opacity === '1';
|
||||||
|
const wasArtistId = this.currentArtistId;
|
||||||
|
|
||||||
|
await this.loadMusicData();
|
||||||
|
|
||||||
|
this.loadArtistsByToken(currentTokenFilter);
|
||||||
|
|
||||||
|
if (wasSongListVisible && wasArtistId) {
|
||||||
|
const artistCard = document.querySelector(`.artist-card[data-id='${wasArtistId}']`);
|
||||||
|
if (artistCard) {
|
||||||
|
try {
|
||||||
|
const canciones = await getMusicUrlsFromPlex(artistCard.dataset.token, artistCard.dataset.protocolo, artistCard.dataset.ip, artistCard.dataset.puerto, wasArtistId);
|
||||||
|
this.handleSongsLoaded(canciones, wasArtistId);
|
||||||
|
this.markCurrentSong();
|
||||||
|
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
tl.set("#artistListContainer", { x: "-100%", autoAlpha: 0 });
|
||||||
|
tl.set("#songListContainer", { x: "0%", autoAlpha: 1 });
|
||||||
|
|
||||||
|
} catch (error) { this.showArtistList(); }
|
||||||
|
} else this.showArtistList();
|
||||||
|
} else {
|
||||||
|
this.loadArtistsByToken(currentTokenFilter);
|
||||||
|
this.markCurrentArtist();
|
||||||
|
}
|
||||||
|
showNotification(_('musicDataUpdated'), "success", 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
setupIndexedDBUpdateListener() {
|
||||||
|
window.removeEventListener('indexedDBUpdated', this.boundHandleDatabaseUpdate);
|
||||||
|
this.boundHandleDatabaseUpdate = this.handleDatabaseUpdate.bind(this);
|
||||||
|
window.addEventListener('indexedDBUpdated', this.boundHandleDatabaseUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTokens() {
|
||||||
|
const tokenMap = new Map();
|
||||||
|
(this.artistsData || []).forEach(servidor => {
|
||||||
|
if (!servidor) return;
|
||||||
|
const primaryToken = servidor.tokenPrincipal;
|
||||||
|
const serverToken = servidor.token;
|
||||||
|
const serverName = servidor.serverName || servidor.ip || `Token Desconocido`;
|
||||||
|
if (primaryToken && !tokenMap.has(primaryToken)) tokenMap.set(primaryToken, serverName);
|
||||||
|
else if (serverToken && !tokenMap.has(serverToken) && !primaryToken) tokenMap.set(serverToken, serverName);
|
||||||
|
});
|
||||||
|
this.tokens = Array.from(tokenMap, ([value, name]) => ({ value, name }));
|
||||||
|
this.updateTokenSelectorUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTokenSelectorUI() {
|
||||||
|
const selectItems = document.querySelector('#tokenSelectorContainer .select-items');
|
||||||
|
const selectedText = document.querySelector('#tokenSelectorContainer .select-selected span');
|
||||||
|
const previousValue = selectedText.dataset.value;
|
||||||
|
|
||||||
|
selectItems.innerHTML = `<div class="select-option" data-value="all">${_('musicAllServers')}</div>`;
|
||||||
|
|
||||||
|
this.tokens.forEach(token => {
|
||||||
|
const optionDiv = document.createElement('div');
|
||||||
|
optionDiv.classList.add('select-option');
|
||||||
|
optionDiv.dataset.value = token.value;
|
||||||
|
optionDiv.textContent = token.name;
|
||||||
|
selectItems.appendChild(optionDiv);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentSelection = this.tokens.find(t => t.value === previousValue);
|
||||||
|
if (currentSelection) {
|
||||||
|
selectedText.textContent = currentSelection.name;
|
||||||
|
selectedText.dataset.value = currentSelection.value;
|
||||||
|
} else {
|
||||||
|
selectedText.textContent = _('musicAllServers');
|
||||||
|
selectedText.dataset.value = 'all';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_generateFullArtistListForToken(token) {
|
||||||
|
let filteredSourceData = (token === "all" || !token) ? this.artistsData : (this.artistsData || []).filter(s => s && (s.tokenPrincipal === token || (!s.tokenPrincipal && s.token === token)));
|
||||||
|
const artistMap = new Map();
|
||||||
|
(filteredSourceData || []).forEach(servidor => {
|
||||||
|
if (servidor && Array.isArray(servidor.titulos)) {
|
||||||
|
servidor.titulos.forEach(artista => {
|
||||||
|
if (artista && typeof artista.id !== 'undefined' && artista.id !== null && !artistMap.has(artista.id)) {
|
||||||
|
artistMap.set(artista.id, {
|
||||||
|
id: artista.id, title: artista.title || 'Artista Desconocido',
|
||||||
|
token: servidor.token, ip: servidor.ip, puerto: servidor.puerto,
|
||||||
|
protocolo: servidor.protocolo, serverName: servidor.serverName || servidor.ip || 'Servidor Desconocido',
|
||||||
|
thumb: artista.thumb
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Array.from(artistMap.values()).sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadArtists(fullArtistListForFilter, page) {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
const totalArtists = fullArtistListForFilter.length;
|
||||||
|
const start = page * this.artistsPageSize;
|
||||||
|
const end = Math.min(start + this.artistsPageSize, totalArtists);
|
||||||
|
const artistGrid = document.getElementById("artistList");
|
||||||
|
artistGrid.innerHTML = '';
|
||||||
|
|
||||||
|
if (totalArtists === 0) {
|
||||||
|
artistGrid.innerHTML = `<div class="list-item-empty" style="grid-column: 1 / -1;">${_('noArtistsFound')}</div>`;
|
||||||
|
document.getElementById('prevArtistsBtn').style.display = 'none';
|
||||||
|
document.getElementById('nextArtistsBtn').style.display = 'none';
|
||||||
|
this.updateArtistCounter(0, 0, 0); return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const artistsToDisplay = fullArtistListForFilter.slice(start, end);
|
||||||
|
artistsToDisplay.forEach((artista) => {
|
||||||
|
if (artista && typeof artista.id !== 'undefined' && artista.id !== null && artista.title) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'artist-card';
|
||||||
|
|
||||||
|
const thumbUrl = artista.thumb
|
||||||
|
? `${artista.protocolo}://${artista.ip}:${artista.puerto}${artista.thumb}?X-Plex-Token=${artista.token}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<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>'}
|
||||||
|
</div>
|
||||||
|
<div class="artist-card-title">${artista.title}</div>
|
||||||
|
`;
|
||||||
|
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.title = `${artista.title} en ${artista.serverName || 'Servidor Desconocido'}`;
|
||||||
|
artistGrid.appendChild(card);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
gsap.from(".artist-card", {
|
||||||
|
duration: 0.5,
|
||||||
|
opacity: 0,
|
||||||
|
y: 20,
|
||||||
|
stagger: 0.05,
|
||||||
|
ease: "power3.out"
|
||||||
|
});
|
||||||
|
document.getElementById('prevArtistsBtn').style.display = page > 0 ? 'inline-flex' : 'none';
|
||||||
|
document.getElementById('nextArtistsBtn').style.display = end < totalArtists ? 'inline-flex' : 'none';
|
||||||
|
this.markCurrentArtist();
|
||||||
|
this.updateArtistCounter(start + 1, end, totalArtists);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadArtistsByToken(token) {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
this.currentPage = 0;
|
||||||
|
this.loadArtists(this._generateFullArtistListForToken(token), this.currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPreviousArtists() {
|
||||||
|
if (!this.isReady || this.currentPage <= 0) return;
|
||||||
|
this.currentPage--;
|
||||||
|
this.loadArtists(this._generateFullArtistListForToken(this._getCurrentTokenFilter()), this.currentPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
loadNextArtists() {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
const fullList = this._generateFullArtistListForToken(this._getCurrentTokenFilter());
|
||||||
|
if ((this.currentPage + 1) * this.artistsPageSize < fullList.length) {
|
||||||
|
this.currentPage++;
|
||||||
|
this.loadArtists(fullList, this.currentPage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateArtistCounter(start = 0, end = 0, total = 0) {
|
||||||
|
const counter = document.getElementById('artistCounter');
|
||||||
|
if (!this.isReady) counter.textContent = _('artistsCounterLoading');
|
||||||
|
else if (total === 0) counter.textContent = _('artistsCounterSingle', '0');
|
||||||
|
else counter.textContent = _('artistsCounter', [start, end, total]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadArtistSongs(selectedCard) {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
const { token, protocolo, ip, puerto, id: artistaId, thumb } = selectedCard.dataset;
|
||||||
|
this.currentArtist = selectedCard.querySelector('.artist-card-title').textContent;
|
||||||
|
this.currentArtistId = artistaId;
|
||||||
|
if (!artistaId) return;
|
||||||
|
|
||||||
|
const thumbUrl = thumb ? `${protocolo}://${ip}:${puerto}${thumb}?X-Plex-Token=${token}` : 'img/no-profile.png';
|
||||||
|
document.getElementById('artist-header-thumb').src = thumbUrl;
|
||||||
|
document.getElementById('artist-header-title').textContent = this.currentArtist;
|
||||||
|
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
tl.to("#artistListContainer", { x: "-100%", autoAlpha: 0, duration: 0.5, ease: "power3.inOut" })
|
||||||
|
.fromTo("#songListContainer", { x: "100%", autoAlpha: 0 }, { x: "0%", autoAlpha: 1, duration: 0.5, ease: "power3.inOut" }, "-=0.5");
|
||||||
|
|
||||||
|
document.getElementById('loader').style.display = 'block';
|
||||||
|
document.getElementById('listaCanciones').innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
let canciones = await getMusicUrlsFromPlex(token, protocolo, ip, puerto, artistaId);
|
||||||
|
this.handleSongsLoaded(canciones, artistaId);
|
||||||
|
} catch (error) {
|
||||||
|
showNotification(_('errorFetchingArtistSongs'), "error");
|
||||||
|
document.getElementById('listaCanciones').innerHTML = `<div class="list-item-empty">${_('errorLoadingSongs')}</div>`;
|
||||||
|
this.showArtistList();
|
||||||
|
} finally {
|
||||||
|
document.getElementById('loader').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSongsLoaded(canciones, artistId) {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
if (!Array.isArray(canciones)) canciones = [];
|
||||||
|
|
||||||
|
if (canciones.length > 0) {
|
||||||
|
if (!this.shuffleMode) {
|
||||||
|
canciones.sort((a, b) => {
|
||||||
|
const albumCompare = (a.album || '').localeCompare(b.album || '');
|
||||||
|
if (albumCompare !== 0) return albumCompare;
|
||||||
|
const trackIndexA = a.trackIndex !== undefined ? a.trackIndex : (a.title || '');
|
||||||
|
const trackIndexB = b.trackIndex !== undefined ? b.trackIndex : (b.title || '');
|
||||||
|
return trackIndexA - trackIndexB;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.shuffleArray(canciones);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.cancionesActuales = canciones;
|
||||||
|
this.currentAlbumId = artistId;
|
||||||
|
this.displaySongList(canciones);
|
||||||
|
this.markCurrentSong();
|
||||||
|
this.markCurrentArtist(artistId);
|
||||||
|
}
|
||||||
|
|
||||||
|
shuffleArray(array) {
|
||||||
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
displaySongList(canciones) {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
const lista = document.getElementById("listaCanciones");
|
||||||
|
lista.innerHTML = '';
|
||||||
|
if (!Array.isArray(canciones) || canciones.length === 0) {
|
||||||
|
lista.innerHTML = `<div class="list-item-empty">${_('noSongsFound')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const albums = canciones.reduce((acc, cancion) => {
|
||||||
|
const albumTitle = cancion.album || 'Otras Canciones';
|
||||||
|
if (!acc[albumTitle]) acc[albumTitle] = [];
|
||||||
|
acc[albumTitle].push(cancion);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const albumTitle in albums) {
|
||||||
|
const albumWrapper = document.createElement('div');
|
||||||
|
albumWrapper.className = 'album-group';
|
||||||
|
const albumHeader = document.createElement('h6');
|
||||||
|
albumHeader.className = 'album-group-title';
|
||||||
|
albumHeader.textContent = albumTitle;
|
||||||
|
albumWrapper.appendChild(albumHeader);
|
||||||
|
|
||||||
|
albums[albumTitle].forEach(cancion => {
|
||||||
|
const originalIndex = this.cancionesActuales.findIndex(c => c.id === cancion.id);
|
||||||
|
if (cancion && cancion.titulo) {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'song-item';
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="song-number">${cancion.index || originalIndex + 1}</span>
|
||||||
|
<div class="song-details">
|
||||||
|
<div class="item-title">${cancion.titulo}</div>
|
||||||
|
</div>
|
||||||
|
<i class="fas fa-play play-icon"></i>
|
||||||
|
`;
|
||||||
|
item.dataset.index = originalIndex;
|
||||||
|
item.dataset.id = cancion.id;
|
||||||
|
item.dataset.artistId = cancion.artistId;
|
||||||
|
item.title = `${cancion.titulo} - ${cancion.album}`;
|
||||||
|
albumWrapper.appendChild(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
fragment.appendChild(albumWrapper);
|
||||||
|
}
|
||||||
|
lista.appendChild(fragment);
|
||||||
|
this.markCurrentSong();
|
||||||
|
gsap.from(".album-group", { duration: 0.5, opacity: 0, y: 20, stagger: 0.1, ease: "power3.out" });
|
||||||
|
}
|
||||||
|
|
||||||
|
playSong(cancion, index) {
|
||||||
|
if (!this.isReady || !this.audioPlayer || !cancion || !cancion.url) return;
|
||||||
|
|
||||||
|
const miniplayer = document.getElementById('miniplayer');
|
||||||
|
if (miniplayer.style.display === 'none') {
|
||||||
|
gsap.fromTo(miniplayer, { y: '100%' }, { display: 'grid', y: '0%', duration: 0.5, ease: 'power3.out' });
|
||||||
|
}
|
||||||
|
document.body.classList.add('miniplayer-active');
|
||||||
|
|
||||||
|
this.audioPlayer.innerHTML = '';
|
||||||
|
const source = document.createElement('source');
|
||||||
|
source.src = cancion.url;
|
||||||
|
source.type = this.getMimeType(cancion.extension);
|
||||||
|
this.audioPlayer.appendChild(source);
|
||||||
|
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
tl.to(['#albumCover', '#trackTitle', '#trackArtist'], { opacity: 0, y: -10, duration: 0.2, ease: "power2.in", stagger: 0.05 })
|
||||||
|
.add(() => {
|
||||||
|
document.getElementById('albumCover').src = cancion.cover || 'img/no-poster.png';
|
||||||
|
document.getElementById('trackTitle').textContent = cancion.titulo;
|
||||||
|
document.getElementById('trackArtist').textContent = cancion.artista;
|
||||||
|
})
|
||||||
|
.to(['#albumCover', '#trackTitle', '#trackArtist'], { opacity: 1, y: 0, duration: 0.4, ease: "power2.out", stagger: 0.07 });
|
||||||
|
|
||||||
|
this.audioPlayer.load();
|
||||||
|
this.audioPlayer.play().then(() => {
|
||||||
|
this.isPlaying = true;
|
||||||
|
document.getElementById('playPauseBtn').innerHTML = '<i class="fas fa-pause"></i>';
|
||||||
|
this.indiceActual = index;
|
||||||
|
this.currentSongId = cancion.id;
|
||||||
|
this.currentSongArtistId = cancion.artistId;
|
||||||
|
this.markCurrentSong();
|
||||||
|
this.markCurrentArtist(cancion.artistId);
|
||||||
|
}).catch((error) => {
|
||||||
|
this.handleAudioError(_('playbackError'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getMimeType(extension) {
|
||||||
|
const mimeTypes = { 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'ogg': 'audio/ogg', 'oga': 'audio/ogg', 'm4a': 'audio/mp4', 'mp4': 'audio/mp4', 'aac': 'audio/aac', 'flac': 'audio/flac', 'opus': 'audio/opus', 'weba': 'audio/webm', 'webm': 'audio/webm' };
|
||||||
|
return mimeTypes[extension?.toLowerCase()] || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
togglePlayPause() {
|
||||||
|
if (!this.isReady || !this.audioPlayer || this.indiceActual < 0) return;
|
||||||
|
const btn = document.getElementById('playPauseBtn');
|
||||||
|
if (this.isPlaying) {
|
||||||
|
this.audioPlayer.pause();
|
||||||
|
btn.innerHTML = '<i class="fas fa-play"></i>';
|
||||||
|
} else {
|
||||||
|
this.audioPlayer.play().then(() => { btn.innerHTML = '<i class="fas fa-pause"></i>'; })
|
||||||
|
.catch(err => { this.isPlaying = false; btn.innerHTML = '<i class="fas fa-play"></i>'; });
|
||||||
|
}
|
||||||
|
this.isPlaying = !this.isPlaying;
|
||||||
|
}
|
||||||
|
|
||||||
|
playNext() {
|
||||||
|
if (!this.isReady || this.cancionesActuales.length === 0) return;
|
||||||
|
let nextIndex;
|
||||||
|
if (this.shuffleMode) {
|
||||||
|
if (this.cancionesActuales.length <= 1) {
|
||||||
|
nextIndex = 0;
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
nextIndex = Math.floor(Math.random() * this.cancionesActuales.length);
|
||||||
|
} while (nextIndex === this.indiceActual);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
nextIndex = (this.indiceActual + 1) % this.cancionesActuales.length;
|
||||||
|
}
|
||||||
|
if (this.cancionesActuales[nextIndex]) this.playSong(this.cancionesActuales[nextIndex], nextIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
playPrevious() {
|
||||||
|
if (!this.isReady || this.cancionesActuales.length === 0) return;
|
||||||
|
let prevIndex;
|
||||||
|
if (this.shuffleMode) {
|
||||||
|
if (this.cancionesActuales.length <= 1) {
|
||||||
|
prevIndex = 0;
|
||||||
|
} else {
|
||||||
|
do {
|
||||||
|
prevIndex = Math.floor(Math.random() * this.cancionesActuales.length);
|
||||||
|
} while (prevIndex === this.indiceActual);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
prevIndex = (this.indiceActual - 1 + this.cancionesActuales.length) % this.cancionesActuales.length;
|
||||||
|
}
|
||||||
|
if (this.cancionesActuales[prevIndex]) this.playSong(this.cancionesActuales[prevIndex], prevIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleShuffle() {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
this.shuffleMode = !this.shuffleMode;
|
||||||
|
document.getElementById('shuffleBtn').classList.toggle("active", this.shuffleMode);
|
||||||
|
showNotification(this.shuffleMode ? _('shuffleOn') : _('shuffleOff'), 'info', 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
markCurrentSong() {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
document.querySelectorAll(".song-item").forEach(item => {
|
||||||
|
item.classList.remove("current-song");
|
||||||
|
});
|
||||||
|
if (this.currentSongId !== null) {
|
||||||
|
const songItem = document.querySelector(`.song-item[data-id='${this.currentSongId}']`);
|
||||||
|
if (songItem) {
|
||||||
|
songItem.classList.add("current-song");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markCurrentArtist(artistIdToMark = null) {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
const targetArtistId = artistIdToMark !== null ? artistIdToMark : this.currentArtistId;
|
||||||
|
document.querySelectorAll(".artist-card").forEach(card => card.classList.remove("current-artist"));
|
||||||
|
if (targetArtistId !== null) {
|
||||||
|
const artistCard = document.querySelector(`.artist-card[data-id='${targetArtistId}']`);
|
||||||
|
if (artistCard) artistCard.classList.add("current-artist");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressBar() {
|
||||||
|
if (!this.isReady || !this.audioPlayer) return;
|
||||||
|
const { currentTime, duration } = this.audioPlayer;
|
||||||
|
if (!isNaN(duration) && duration > 0) {
|
||||||
|
const progressPercent = (currentTime / duration) * 100;
|
||||||
|
document.getElementById("played-bar").style.width = `${progressPercent}%`;
|
||||||
|
document.getElementById("progress-handle").style.left = `${progressPercent}%`;
|
||||||
|
document.getElementById("elapsedTime").textContent = this.formatTime(currentTime);
|
||||||
|
document.getElementById("remainingTime").textContent = this.formatTime(duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSeekHover(event) {
|
||||||
|
const progressBarContainer = document.getElementById("progressBarContainer");
|
||||||
|
const rect = progressBarContainer.getBoundingClientRect();
|
||||||
|
const hoverWidth = ((event.clientX - rect.left) / rect.width) * 100;
|
||||||
|
document.getElementById('seek-hover-bar').style.width = `${Math.max(0, Math.min(hoverWidth, 100))}%`;
|
||||||
|
}
|
||||||
|
hideSeekHover() {
|
||||||
|
document.getElementById('seek-hover-bar').style.width = `0%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAudioError(message = _('playbackError')) {
|
||||||
|
if (!this.isReady || !this.audioPlayer) return;
|
||||||
|
showNotification(message, "error");
|
||||||
|
document.getElementById("remainingTime").textContent = _('errorLabel');
|
||||||
|
document.getElementById('playPauseBtn').innerHTML = '<i class="fas fa-play"></i>';
|
||||||
|
this.isPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
seek(event) {
|
||||||
|
if (!this.isReady || !this.audioPlayer || isNaN(this.audioPlayer.duration) || this.audioPlayer.duration <= 0) return;
|
||||||
|
const progressBarContainer = document.getElementById("progressBarContainer");
|
||||||
|
const rect = progressBarContainer.getBoundingClientRect();
|
||||||
|
const seekTime = ((event.clientX - rect.left) / rect.width) * this.audioPlayer.duration;
|
||||||
|
this.audioPlayer.currentTime = Math.max(0, Math.min(seekTime, this.audioPlayer.duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
formatTime(seconds) {
|
||||||
|
if (isNaN(seconds) || seconds < 0) return "0:00";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const remainingSeconds = Math.floor(seconds % 60);
|
||||||
|
return `${minutes}:${remainingSeconds < 10 ? "0" : ""}${remainingSeconds}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadSong(url, titulo, extension) {
|
||||||
|
if (!this.isReady || !url || !titulo) return;
|
||||||
|
showNotification(_('downloadingSong', titulo), "info");
|
||||||
|
fetch(url).then(response => response.blob()).then(blob => {
|
||||||
|
const urlBlob = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.style.display = "none";
|
||||||
|
a.href = urlBlob;
|
||||||
|
a.download = `${titulo.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, '_')}.${extension || 'mp3'}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(urlBlob);
|
||||||
|
a.remove();
|
||||||
|
showNotification(_('songDownloaded', titulo), "success");
|
||||||
|
}).catch(err => showNotification(_('errorDownloadingSong', titulo), "error"));
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadAlbum() {
|
||||||
|
if (!this.isReady || this.cancionesActuales.length === 0 || !this.currentArtist) return;
|
||||||
|
showNotification(_('generatingAlbumM3U', this.currentArtist), "info");
|
||||||
|
const m3uContent = ["#EXTM3U", ...this.cancionesActuales.map(c => `#EXTINF:-1 tvg-name="${c.artista} - ${c.titulo}",${c.artista} - ${c.titulo}\n${c.url}`)];
|
||||||
|
const blob = new Blob([m3uContent.join("\n")], { type: "audio/x-mpegurl;charset=utf-8" });
|
||||||
|
const urlBlob = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = urlBlob;
|
||||||
|
a.download = `${this.currentArtist.replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, '_')}_Album.m3u`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(urlBlob);
|
||||||
|
a.remove();
|
||||||
|
showNotification(_('albumM3UGenerated', this.currentArtist), "success");
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVolume(event) {
|
||||||
|
if (!this.isReady || !this.audioPlayer) return;
|
||||||
|
const volume = event.currentTarget.value;
|
||||||
|
this.audioPlayer.volume = volume;
|
||||||
|
this.updateVolumeIcon(volume);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateVolumeIcon(volume) {
|
||||||
|
const vol = parseFloat(volume);
|
||||||
|
const icon = document.querySelector('#volume-icon-btn i');
|
||||||
|
if (vol === 0) {
|
||||||
|
icon.className = 'fas fa-volume-mute';
|
||||||
|
} else if (vol < 0.5) {
|
||||||
|
icon.className = 'fas fa-volume-down';
|
||||||
|
} else {
|
||||||
|
icon.className = 'fas fa-volume-up';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterArtists() {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
const value = document.getElementById("searchArtist").value.toLowerCase();
|
||||||
|
const tokenFilter = this._getCurrentTokenFilter();
|
||||||
|
const fullList = this._generateFullArtistListForToken(tokenFilter);
|
||||||
|
const filteredArtists = fullList.filter(a => a.title?.toLowerCase().includes(value));
|
||||||
|
const artistGrid = document.getElementById("artistList");
|
||||||
|
artistGrid.innerHTML = '';
|
||||||
|
if (filteredArtists.length === 0) {
|
||||||
|
artistGrid.innerHTML = `<div class="list-item-empty" style="grid-column: 1 / -1;">${_('noArtistsFound')}</div>`;
|
||||||
|
} else {
|
||||||
|
filteredArtists.forEach((artista) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'artist-card';
|
||||||
|
const thumbUrl = artista.thumb ? `${artista.protocolo}://${artista.ip}:${artista.puerto}${artista.thumb}?X-Plex-Token=${artista.token}` : null;
|
||||||
|
card.innerHTML = `
|
||||||
|
<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>'}
|
||||||
|
</div>
|
||||||
|
<div class="artist-card-title">${artista.title}</div>
|
||||||
|
`;
|
||||||
|
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.title = `${artista.title} en ${artista.serverName || 'Servidor Desconocido'}`;
|
||||||
|
artistGrid.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value === '') {
|
||||||
|
this.loadArtists(fullList, this.currentPage);
|
||||||
|
} else {
|
||||||
|
document.getElementById('prevArtistsBtn').style.display = 'none';
|
||||||
|
document.getElementById('nextArtistsBtn').style.display = 'none';
|
||||||
|
document.getElementById('artistCounter').style.display = 'none';
|
||||||
|
this.markCurrentArtist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterSongs() {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
const value = document.getElementById("searchSong").value.toLowerCase();
|
||||||
|
const filteredSongs = this.cancionesActuales.filter(c => c.titulo?.toLowerCase().includes(value) || c.album?.toLowerCase().includes(value));
|
||||||
|
this.displaySongList(filteredSongs);
|
||||||
|
}
|
||||||
|
|
||||||
|
showArtistList() {
|
||||||
|
if (!this.isReady) return;
|
||||||
|
|
||||||
|
const tl = gsap.timeline();
|
||||||
|
tl.to("#songListContainer", { x: "100%", autoAlpha: 0, duration: 0.5, ease: "power3.inOut" })
|
||||||
|
.to("#artistListContainer", { x: "0%", autoAlpha: 1, duration: 0.5, ease: "power3.inOut" }, "-=0.5");
|
||||||
|
|
||||||
|
if (document.getElementById("searchArtist").value === '') {
|
||||||
|
this.loadArtists(this._generateFullArtistListForToken(this._getCurrentTokenFilter()), this.currentPage);
|
||||||
|
} else {
|
||||||
|
this.filterArtists();
|
||||||
|
}
|
||||||
|
this.markCurrentArtist();
|
||||||
|
}
|
||||||
|
|
||||||
|
showInfoModal(cancion) {
|
||||||
|
if (!this.isReady || !cancion) return;
|
||||||
|
document.querySelector('#infoModalLabel').textContent = `${_('infoModalTitle')}: ${cancion.titulo}`;
|
||||||
|
document.querySelector('#modalTitle span').textContent = cancion.titulo || 'N/A';
|
||||||
|
document.querySelector('#modalArtist span').textContent = cancion.artista || 'N/A';
|
||||||
|
document.querySelector('#modalAlbum span').textContent = cancion.album || 'N/A';
|
||||||
|
document.querySelector('#modalSong span').textContent = cancion.titulo || 'N/A';
|
||||||
|
document.querySelector('#modalYear span').textContent = cancion.year || 'N/A';
|
||||||
|
document.querySelector('#modalGenre span').textContent = cancion.genre || 'N/A';
|
||||||
|
const infoModal = bootstrap.Modal.getOrCreateInstance(document.getElementById('infoModal'));
|
||||||
|
infoModal.show();
|
||||||
|
}
|
||||||
|
}
|
218
js/plex.js
Normal file
218
js/plex.js
Normal file
@ -0,0 +1,218 @@
|
|||||||
|
import { state } from './state.js';
|
||||||
|
import { getFromDB, clearStore, addItemsToStore } from './db.js';
|
||||||
|
import { logToConsole, emitirEventoActualizacion, mostrarSpinner, ocultarSpinner, showNotification, fetchWithTimeout, TimeoutError, _ } from './utils.js';
|
||||||
|
|
||||||
|
function mapSectionTypeToObjectName(sectionType) {
|
||||||
|
switch (sectionType) {
|
||||||
|
case 'movie': return 'movies';
|
||||||
|
case 'show': return 'series';
|
||||||
|
case 'artist': return 'artists';
|
||||||
|
case 'photo': return 'photos';
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPlexResources(token, signal) {
|
||||||
|
const response = await fetchWithTimeout(`https://plex.tv/api/resources?X-Plex-Token=${token}`, { signal }, 10000);
|
||||||
|
if (!response.ok) throw new Error(`Error ${response.status} obteniendo recursos (token ${token.substring(0, 4)})`);
|
||||||
|
const data = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(data, "application/xml");
|
||||||
|
if (xmlDoc.querySelector('parsererror')) throw new Error('Error parseando XML de recursos Plex');
|
||||||
|
return xmlDoc.querySelectorAll('Device[product="Plex Media Server"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSectionContent(url, signal, timeout = 7000) {
|
||||||
|
const response = await fetchWithTimeout(url, { signal }, timeout);
|
||||||
|
if (!response.ok) throw new Error(`Error ${response.status} obteniendo contenido`);
|
||||||
|
const data = await response.text();
|
||||||
|
const parser = new DOMParser();
|
||||||
|
const xmlDoc = parser.parseFromString(data, "text/xml");
|
||||||
|
if (xmlDoc.querySelector('parsererror')) throw new Error('Error parseando XML de contenido');
|
||||||
|
return xmlDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseAndStoreSectionItems(contentXml, storeName, serverData) {
|
||||||
|
const type = storeName === 'movies' ? 'movie' : (storeName === 'series' ? 'show' : (storeName === 'artists' ? 'artist' : 'photo'));
|
||||||
|
let items;
|
||||||
|
|
||||||
|
if (type === 'movie' || type === 'show') {
|
||||||
|
const itemSelector = type === 'movie' ? 'Video' : 'Directory';
|
||||||
|
items = Array.from(contentXml.querySelectorAll(itemSelector)).map(el => ({
|
||||||
|
title: el.getAttribute('title'),
|
||||||
|
year: el.getAttribute('year'),
|
||||||
|
genre: el.querySelector('Genre')?.getAttribute('tag') || _('noGenre')
|
||||||
|
}));
|
||||||
|
} else if (type === 'artist' || type === 'photo') {
|
||||||
|
items = Array.from(contentXml.querySelectorAll('Directory')).map(el => ({
|
||||||
|
id: el.getAttribute('ratingKey'),
|
||||||
|
title: el.getAttribute('title'),
|
||||||
|
thumb: el.getAttribute('thumb')
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const validItems = items.filter(i => i && i.title);
|
||||||
|
logToConsole(` Encontrados ${validItems.length} items válidos en "${serverData.sectionTitle}".`);
|
||||||
|
if (validItems.length > 0) {
|
||||||
|
const entry = {
|
||||||
|
ip: serverData.ip,
|
||||||
|
puerto: serverData.puerto,
|
||||||
|
token: serverData.token,
|
||||||
|
protocolo: serverData.protocolo,
|
||||||
|
serverName: serverData.nombre,
|
||||||
|
titulos: validItems,
|
||||||
|
tokenPrincipal: serverData.tokenPrincipal
|
||||||
|
};
|
||||||
|
await addItemsToStore(storeName, [entry]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processServer(device, token, tipos, signal) {
|
||||||
|
const serverName = device.getAttribute('name') || 'Servidor Sin Nombre';
|
||||||
|
const accessToken = device.getAttribute('accessToken');
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error(`Servidor: ${serverName} - Sin token de acceso.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const connections = Array.from(device.querySelectorAll('Connection'));
|
||||||
|
const connection = connections.find(c => c.getAttribute('local') === '0') || connections.find(c => c.getAttribute('local') === '1');
|
||||||
|
if (!connection) {
|
||||||
|
throw new Error(`Servidor: ${serverName} - Sin conexión válida.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = connection.getAttribute('protocol');
|
||||||
|
const address = connection.getAttribute('address');
|
||||||
|
const port = connection.getAttribute('port');
|
||||||
|
if (!address || !port) {
|
||||||
|
throw new Error(`Servidor: ${serverName} - IP o Puerto no encontrados.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logToConsole(` -> Procesando servidor: ${serverName} (${address}:${port})`);
|
||||||
|
|
||||||
|
const connectionData = { ip: address, puerto: port, token: accessToken, protocolo: protocol, tokenPrincipal: token, nombre: serverName };
|
||||||
|
await addItemsToStore('conexiones_locales', [connectionData]);
|
||||||
|
|
||||||
|
const sectionsUrl = `${protocol}://${address}:${port}/library/sections?X-Plex-Token=${accessToken}`;
|
||||||
|
const sectionsXml = await fetchSectionContent(sectionsUrl, signal);
|
||||||
|
const directories = sectionsXml.querySelectorAll('Directory');
|
||||||
|
logToConsole(` Encontradas ${directories.length} secciones en ${serverName}.`);
|
||||||
|
|
||||||
|
const sectionPromises = Array.from(directories).map(async (dir) => {
|
||||||
|
const type = dir.getAttribute('type');
|
||||||
|
const storeName = mapSectionTypeToObjectName(type);
|
||||||
|
if (storeName && tipos.includes(storeName)) {
|
||||||
|
const sectionId = dir.getAttribute('key');
|
||||||
|
const sectionTitle = dir.getAttribute('title');
|
||||||
|
logToConsole(` Procesando sección: "${sectionTitle}" (Tipo: ${type})`);
|
||||||
|
const contentUrl = `${protocol}://${address}:${port}/library/sections/${sectionId}/all?X-Plex-Token=${accessToken}`;
|
||||||
|
try {
|
||||||
|
const contentXml = await fetchSectionContent(contentUrl, signal);
|
||||||
|
const serverData = { ...connectionData, sectionTitle };
|
||||||
|
await parseAndStoreSectionItems(contentXml, storeName, serverData);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TimeoutError) {
|
||||||
|
logToConsole(` [REINTENTO PENDIENTE] La sección "${sectionTitle}" tardó demasiado en responder.`);
|
||||||
|
state.plexScanRetryQueue.push({
|
||||||
|
url: contentUrl,
|
||||||
|
storeName: storeName,
|
||||||
|
serverData: { ...connectionData, sectionTitle }
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
logToConsole(` Error procesando sección "${sectionTitle}": ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(sectionPromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processRetryQueue(signal) {
|
||||||
|
if (state.plexScanRetryQueue.length === 0) {
|
||||||
|
logToConsole(_('noRetriesPending'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logToConsole(_('startingRetryPhase', state.plexScanRetryQueue.length));
|
||||||
|
const queue = [...state.plexScanRetryQueue];
|
||||||
|
state.plexScanRetryQueue = [];
|
||||||
|
|
||||||
|
for (const item of queue) {
|
||||||
|
if (signal.aborted) {
|
||||||
|
logToConsole(_('retryPhaseCancelled'));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const { url, storeName, serverData } = item;
|
||||||
|
logToConsole(_('retyingSection', serverData.sectionTitle));
|
||||||
|
try {
|
||||||
|
const contentXml = await fetchSectionContent(url, signal, 30000);
|
||||||
|
await parseAndStoreSectionItems(contentXml, storeName, serverData);
|
||||||
|
logToConsole(_('retrySuccess', serverData.sectionTitle));
|
||||||
|
} catch (e) {
|
||||||
|
logToConsole(_('retryError', [serverData.sectionTitle, e.message]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logToConsole(_('retryPhaseFinished'));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startPlexScan(tipos) {
|
||||||
|
if (state.isScanningPlex) {
|
||||||
|
showNotification(_('plexScanInProgress'), "warning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.isScanningPlex = true;
|
||||||
|
state.plexScanAbortController = new AbortController();
|
||||||
|
state.plexScanRetryQueue = [];
|
||||||
|
const signal = state.plexScanAbortController.signal;
|
||||||
|
|
||||||
|
mostrarSpinner();
|
||||||
|
logToConsole(_('plexScanStarting'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tokensData = await getFromDB('tokens');
|
||||||
|
const tokens = tokensData.map(item => item.token).filter(Boolean);
|
||||||
|
if (tokens.length === 0) throw new Error(_('noPlexTokens'));
|
||||||
|
|
||||||
|
logToConsole(_('clearingSections', tipos.join(', ')));
|
||||||
|
await Promise.all(tipos.map(tipo => clearStore(tipo)));
|
||||||
|
await clearStore('conexiones_locales');
|
||||||
|
logToConsole(_('sectionsCleared'));
|
||||||
|
|
||||||
|
const tokenPromises = tokens.map(async (token) => {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
try {
|
||||||
|
const devices = await fetchPlexResources(token, signal);
|
||||||
|
logToConsole(_('tokenFoundServers', [token.substring(0,4), devices.length]));
|
||||||
|
|
||||||
|
const serverPromises = Array.from(devices).map(device => processServer(device, token, tipos, signal));
|
||||||
|
await Promise.allSettled(serverPromises);
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
logToConsole(_('errorProcessingToken', [token.substring(0,4), e.message]));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(tokenPromises);
|
||||||
|
|
||||||
|
logToConsole(_('initialScanPhaseComplete'));
|
||||||
|
|
||||||
|
await processRetryQueue(signal);
|
||||||
|
|
||||||
|
logToConsole(_('plexScanFinished'));
|
||||||
|
showNotification(_('plexScanFinished'), 'success');
|
||||||
|
emitirEventoActualizacion();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== 'AbortError') {
|
||||||
|
logToConsole(_('plexScanFatalError', error.message));
|
||||||
|
showNotification(_('errorDuringScan', error.message), 'error');
|
||||||
|
} else {
|
||||||
|
logToConsole(_('scanCancelled'));
|
||||||
|
showNotification(_('scanCancelledInfo'), 'info');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
state.isScanningPlex = false;
|
||||||
|
state.plexScanAbortController = null;
|
||||||
|
ocultarSpinner();
|
||||||
|
}
|
||||||
|
}
|
47
js/state.js
Normal file
47
js/state.js
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
export const state = {
|
||||||
|
currentPage: 1,
|
||||||
|
currentView: 'movies',
|
||||||
|
currentParams: { contentType: 'movie', page: 1, query: '', genre: '', sort: 'popularity.desc', year: '' },
|
||||||
|
settings: {
|
||||||
|
id: 'user_settings',
|
||||||
|
apiKey: '',
|
||||||
|
theme: 'dark',
|
||||||
|
showHero: true,
|
||||||
|
language: 'es',
|
||||||
|
phpScriptUrl: '',
|
||||||
|
phpUseSecretKey: false,
|
||||||
|
phpSecretKey: '',
|
||||||
|
phpSavePath: '',
|
||||||
|
phpFilename: 'CinePlex_Playlist.m3u',
|
||||||
|
phpFileAction: 'append',
|
||||||
|
},
|
||||||
|
localMovies: [],
|
||||||
|
localSeries: [],
|
||||||
|
localArtists: [],
|
||||||
|
localPhotos: [],
|
||||||
|
db: null,
|
||||||
|
lastScrollPosition: 0,
|
||||||
|
currentItemId: null,
|
||||||
|
currentItemType: null,
|
||||||
|
lastClickedCardElement: null,
|
||||||
|
favorites: [],
|
||||||
|
userHistory: [],
|
||||||
|
userPreferences: { genres: {}, keywords: {}, ratings: [], cast: {}, crew: {} },
|
||||||
|
isLoading: false,
|
||||||
|
isImporting: false,
|
||||||
|
isAddingStream: false,
|
||||||
|
isDownloadingM3U: false,
|
||||||
|
isScanningPlex: false,
|
||||||
|
musicPlayer: null,
|
||||||
|
currentContentFetchController: null,
|
||||||
|
plexScanAbortController: null,
|
||||||
|
aceEditor: null,
|
||||||
|
searchTimeout: null,
|
||||||
|
plexScanRetryQueue: [],
|
||||||
|
isEqualizerInitialized: false,
|
||||||
|
equalizer: null,
|
||||||
|
photoStack: [],
|
||||||
|
currentPhotoToken: null,
|
||||||
|
currentPhotoItems: [],
|
||||||
|
currentPhotoLightboxIndex: 0,
|
||||||
|
};
|
137
js/utils.js
Normal file
137
js/utils.js
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
export class TimeoutError extends Error {
|
||||||
|
constructor(message) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'TimeoutError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function _(key, substitutions) {
|
||||||
|
try {
|
||||||
|
return chrome.i18n.getMessage(key, substitutions);
|
||||||
|
} catch (e) {
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showNotification(message, type = 'info', duration = 3000) {
|
||||||
|
const notificationContainer = document.getElementById('notification-container');
|
||||||
|
const template = document.getElementById('notification-template');
|
||||||
|
|
||||||
|
if (!notificationContainer || !template) {
|
||||||
|
alert(`${type.toUpperCase()}: ${message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const notificationClone = template.querySelector('.notification').cloneNode(true);
|
||||||
|
notificationClone.className = `notification ${type}`;
|
||||||
|
|
||||||
|
const icon = notificationClone.querySelector('i');
|
||||||
|
const span = notificationClone.querySelector('span');
|
||||||
|
span.textContent = message;
|
||||||
|
|
||||||
|
let iconClass = 'fa-info-circle';
|
||||||
|
if (type === 'success') iconClass = 'fa-check-circle';
|
||||||
|
else if (type === 'error') iconClass = 'fa-exclamation-triangle';
|
||||||
|
else if (type === 'warning') iconClass = 'fa-exclamation-circle';
|
||||||
|
icon.className = `fas ${iconClass}`;
|
||||||
|
|
||||||
|
notificationContainer.prepend(notificationClone);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notificationClone.classList.add('show');
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notificationClone.classList.remove('show');
|
||||||
|
notificationClone.addEventListener('transitionend', () => notificationClone.remove(), { once: true });
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func.apply(this, args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRelativeTime(timestamp) {
|
||||||
|
const now = Date.now();
|
||||||
|
const diffSeconds = Math.round((now - timestamp) / 1000);
|
||||||
|
const diffMinutes = Math.round(diffSeconds / 60);
|
||||||
|
const diffHours = Math.round(diffMinutes / 60);
|
||||||
|
const diffDays = Math.round(diffHours / 24);
|
||||||
|
|
||||||
|
if (diffSeconds < 60) return _('relativeTime_justNow');
|
||||||
|
if (diffMinutes < 60) return _('relativeTime_minutesAgo', String(diffMinutes));
|
||||||
|
if (diffHours < 24) return _('relativeTime_hoursAgo', String(diffHours));
|
||||||
|
if (diffDays === 1) return _('relativeTime_yesterday');
|
||||||
|
if (diffDays < 7) return _('relativeTime_daysAgo', String(diffDays));
|
||||||
|
|
||||||
|
return (new Date(timestamp)).toLocaleDateString(_('appLocaleCode'), { day: 'numeric', month: 'short' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mostrarSpinner() {
|
||||||
|
const spinner = document.getElementById('spinner');
|
||||||
|
if (spinner) spinner.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ocultarSpinner() {
|
||||||
|
const spinner = document.getElementById('spinner');
|
||||||
|
if (spinner) spinner.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logToConsole(message) {
|
||||||
|
const consoleOutput = document.getElementById('consoleOutput');
|
||||||
|
if (!consoleOutput) return;
|
||||||
|
|
||||||
|
const logEntry = document.createElement('div');
|
||||||
|
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 messageSpan = document.createElement('span');
|
||||||
|
messageSpan.className = 'log-message';
|
||||||
|
messageSpan.textContent = message;
|
||||||
|
|
||||||
|
logEntry.appendChild(timeSpan);
|
||||||
|
logEntry.appendChild(document.createTextNode(' '));
|
||||||
|
logEntry.appendChild(messageSpan);
|
||||||
|
consoleOutput.appendChild(logEntry);
|
||||||
|
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitirEventoActualizacion() {
|
||||||
|
const event = new CustomEvent('indexedDBUpdated');
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithTimeout(url, options, timeout = 7000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const signal = controller.signal;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
controller.abort();
|
||||||
|
reject(new TimeoutError(`Timeout(${timeout}ms)`));
|
||||||
|
}, timeout);
|
||||||
|
|
||||||
|
fetch(url, { ...options, signal })
|
||||||
|
.then(response => { clearTimeout(timer); resolve(response); })
|
||||||
|
.catch(err => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
reject(new TimeoutError(`Timeout(${timeout}ms)`));
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
10
lib/ScrollTrigger.min.js
vendored
Normal file
10
lib/ScrollTrigger.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
16
lib/ace.js
Normal file
16
lib/ace.js
Normal file
File diff suppressed because one or more lines are too long
7
lib/bootstrap.bundle.min.js
vendored
Normal file
7
lib/bootstrap.bundle.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
lib/chart.umd.min.js
vendored
Normal file
1
lib/chart.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
10
lib/gsap.min.js
vendored
Normal file
10
lib/gsap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
lib/mode-json.js
Normal file
7
lib/mode-json.js
Normal file
File diff suppressed because one or more lines are too long
8
lib/particles.min.js
vendored
Normal file
8
lib/particles.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
lib/theme-monokai.js
Normal file
7
lib/theme-monokai.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
define("ace/theme/monokai-css",["require","exports","module"],function(e,t,n){n.exports=".ace-monokai .ace_gutter {\n background: #2F3129;\n color: #8F908A\n}\n\n.ace-monokai .ace_print-margin {\n width: 1px;\n background: #555651\n}\n\n.ace-monokai {\n background-color: #272822;\n color: #F8F8F2\n}\n\n.ace-monokai .ace_cursor {\n color: #F8F8F0\n}\n\n.ace-monokai .ace_marker-layer .ace_selection {\n background: #49483E\n}\n\n.ace-monokai.ace_multiselect .ace_selection.ace_start {\n box-shadow: 0 0 3px 0px #272822;\n}\n\n.ace-monokai .ace_marker-layer .ace_step {\n background: rgb(102, 82, 0)\n}\n\n.ace-monokai .ace_marker-layer .ace_bracket {\n margin: -1px 0 0 -1px;\n border: 1px solid #49483E\n}\n\n.ace-monokai .ace_marker-layer .ace_active-line {\n background: #202020\n}\n\n.ace-monokai .ace_gutter-active-line {\n background-color: #272727\n}\n\n.ace-monokai .ace_marker-layer .ace_selected-word {\n border: 1px solid #49483E\n}\n\n.ace-monokai .ace_invisible {\n color: #52524d\n}\n\n.ace-monokai .ace_entity.ace_name.ace_tag,\n.ace-monokai .ace_keyword,\n.ace-monokai .ace_meta.ace_tag,\n.ace-monokai .ace_storage {\n color: #F92672\n}\n\n.ace-monokai .ace_punctuation,\n.ace-monokai .ace_punctuation.ace_tag {\n color: #fff\n}\n\n.ace-monokai .ace_constant.ace_character,\n.ace-monokai .ace_constant.ace_language,\n.ace-monokai .ace_constant.ace_numeric,\n.ace-monokai .ace_constant.ace_other {\n color: #AE81FF\n}\n\n.ace-monokai .ace_invalid {\n color: #F8F8F0;\n background-color: #F92672\n}\n\n.ace-monokai .ace_invalid.ace_deprecated {\n color: #F8F8F0;\n background-color: #AE81FF\n}\n\n.ace-monokai .ace_support.ace_constant,\n.ace-monokai .ace_support.ace_function {\n color: #66D9EF\n}\n\n.ace-monokai .ace_fold {\n background-color: #A6E22E;\n border-color: #F8F8F2\n}\n\n.ace-monokai .ace_storage.ace_type,\n.ace-monokai .ace_support.ace_class,\n.ace-monokai .ace_support.ace_type {\n font-style: italic;\n color: #66D9EF\n}\n\n.ace-monokai .ace_entity.ace_name.ace_function,\n.ace-monokai .ace_entity.ace_other,\n.ace-monokai .ace_entity.ace_other.ace_attribute-name,\n.ace-monokai .ace_variable {\n color: #A6E22E\n}\n\n.ace-monokai .ace_variable.ace_parameter {\n font-style: italic;\n color: #FD971F\n}\n\n.ace-monokai .ace_string {\n color: #E6DB74\n}\n\n.ace-monokai .ace_comment {\n color: #75715E\n}\n\n.ace-monokai .ace_indent-guide {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQImWPQ0FD0ZXBzd/wPAAjVAoxeSgNeAAAAAElFTkSuQmCC) right repeat-y\n}\n\n.ace-monokai .ace_indent-guide-active {\n background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAACCAYAAACZgbYnAAAAEklEQVQIW2PQ1dX9zzBz5sz/ABCcBFFentLlAAAAAElFTkSuQmCC) right repeat-y;\n}\n"}),define("ace/theme/monokai",["require","exports","module","ace/theme/monokai-css","ace/lib/dom"],function(e,t,n){t.isDark=!0,t.cssClass="ace-monokai",t.cssText=e("./monokai-css");var r=e("../lib/dom");r.importCssString(t.cssText,t.cssClass,!1)}); (function() {
|
||||||
|
window.require(["ace/theme/monokai"], function(m) {
|
||||||
|
if (typeof module == "object" && typeof exports == "object" && module) {
|
||||||
|
module.exports = m;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
31
manifest.json
Normal file
31
manifest.json
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"manifest_version": 3,
|
||||||
|
"name": "__MSG_appName__",
|
||||||
|
"version": "1.0",
|
||||||
|
"description": "__MSG_appDescription__",
|
||||||
|
"default_locale": "en",
|
||||||
|
|
||||||
|
"icons": {
|
||||||
|
"48": "img/icon48.png"
|
||||||
|
},
|
||||||
|
|
||||||
|
"permissions": [
|
||||||
|
"storage",
|
||||||
|
"notifications"
|
||||||
|
],
|
||||||
|
"host_permissions": [
|
||||||
|
"https://*.plex.tv/*"
|
||||||
|
],
|
||||||
|
"background": {
|
||||||
|
"service_worker": "js/background.js",
|
||||||
|
"type": "module"
|
||||||
|
},
|
||||||
|
|
||||||
|
"action": {
|
||||||
|
"default_icon": {
|
||||||
|
"16": "img/icon16.png",
|
||||||
|
"32": "img/icon32.png"
|
||||||
|
},
|
||||||
|
"default_title": "__MSG_appName__"
|
||||||
|
}
|
||||||
|
}
|
650
plex.html
Normal file
650
plex.html
Normal file
@ -0,0 +1,650 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="es">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<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://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Montserrat:wght@300;400;500;600;700&family=Orbitron:wght@500;600;700&display=swap"
|
||||||
|
rel="stylesheet">
|
||||||
|
<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>
|
||||||
|
<div id="particles-js"></div>
|
||||||
|
|
||||||
|
<header class="top-bar">
|
||||||
|
<div class="top-bar-left">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<div class="top-bar-center">
|
||||||
|
<div class="search-bar">
|
||||||
|
<input type="text" class="search-input" id="search-input" placeholder="__MSG_searchPlaceholder__">
|
||||||
|
<i class="fas fa-search search-icon"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="top-bar-right">
|
||||||
|
<button id="openMusicPlayerDesktop" class="btn-icon" title="__MSG_openMusicPlayer__">
|
||||||
|
<i class="fas fa-music"></i>
|
||||||
|
</button>
|
||||||
|
<button id="settings-btn" class="btn-icon" title="__MSG_settings__">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<nav class="sidebar-nav" id="sidebar-nav">
|
||||||
|
<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" href="#" id="nav-series"><i class="fas fa-tv"></i><span>__MSG_navSeries__</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-stats"><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-history"><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 d-lg-none" href="#" id="openMusicPlayerMobile"><i class="fas fa-music"></i><span>__MSG_navMusic__</span></a></li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div id="main-container">
|
||||||
|
<div id="main-view">
|
||||||
|
<section class="hero" id="hero-section">
|
||||||
|
<div class="hero-background-container">
|
||||||
|
<div class="hero-background hero-background-1"></div>
|
||||||
|
<div class="hero-background hero-background-2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="hero-content">
|
||||||
|
<h1 class="hero-title">__MSG_heroWelcome__</h1>
|
||||||
|
<p class="hero-subtitle">__MSG_heroSubtitle__</p>
|
||||||
|
<div class="hero-meta">
|
||||||
|
<span class="hero-meta-item" id="hero-rating"></span>
|
||||||
|
<span class="hero-meta-item" id="hero-year"></span>
|
||||||
|
<span class="hero-meta-item" id="hero-extra"></span>
|
||||||
|
</div>
|
||||||
|
<div class="hero-buttons">
|
||||||
|
<button class="btn btn-primary" id="hero-play-btn" disabled><i class="fas fa-plus-circle"></i> __MSG_addStream__</button>
|
||||||
|
<button class="btn btn-secondary" id="hero-info-btn" disabled><i class="fas fa-info-circle"></i> __MSG_moreInfo__</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<main class="main-content">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<section id="content-section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title" id="main-section-title">__MSG_popularMovies__</h2>
|
||||||
|
</div>
|
||||||
|
<div class="filters mb-4" style="display: none;">
|
||||||
|
<select class="filter-select" id="genre-filter">
|
||||||
|
<option value="">__MSG_allGenres__</option>
|
||||||
|
</select>
|
||||||
|
<select class="filter-select" id="year-filter">
|
||||||
|
<option value="">__MSG_allYears__</option>
|
||||||
|
</select>
|
||||||
|
<select class="filter-select" id="sort-filter">
|
||||||
|
<option value="popularity.desc">__MSG_sortPopular__</option>
|
||||||
|
<option value="vote_average.desc">__MSG_sortTopRated__</option>
|
||||||
|
<option id="sort-release-date" value="release_date.desc">__MSG_sortRecent__</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="content-grid" class="content-grid">
|
||||||
|
<div class="col-12 text-center mt-5">
|
||||||
|
<div class="spinner" style="position: static; margin: auto; display: block;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-center mt-5"><button id="load-more" class="btn btn-primary" style="display: none;">__MSG_loadMore__</button></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="photos-section" style="display: none;">
|
||||||
|
<div class="photos-header">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol id="photos-breadcrumb" class="breadcrumb">
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<select class="filter-select" id="photos-token-select">
|
||||||
|
<option value="">__MSG_selectServer__</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="photos-grid"></div>
|
||||||
|
<div id="photos-loader" class="text-center py-5" style="display: none;">
|
||||||
|
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
||||||
|
<span class="visually-hidden">__MSG_loading__</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="photos-empty-state" class="empty-state" style="display: none;">
|
||||||
|
<i class="fas fa-image"></i>
|
||||||
|
<p class="lead">__MSG_photosEmptyState__</p>
|
||||||
|
<p class="text-muted">__MSG_photosEmptyStateSub__</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="stats-section" style="display: none;">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">__MSG_statsTitle__</h2>
|
||||||
|
</div>
|
||||||
|
<div id="stats-filters" class="filters mb-4" style="display: none;">
|
||||||
|
<select class="filter-select" id="stats-token-filter">
|
||||||
|
<option value="all">__MSG_statsAllTokens__</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="stats-loader" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
|
||||||
|
<span class="visually-hidden">__MSG_loading__</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 lead">__MSG_statsAnalyzing__</p>
|
||||||
|
</div>
|
||||||
|
<div id="stats-content" class="stats-grid" style="display: none;">
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<i class="fas fa-key stat-icon"></i>
|
||||||
|
<span class="stat-label">__MSG_statsActiveTokens__</span>
|
||||||
|
</div>
|
||||||
|
<div id="total-tokens" class="stat-value">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<i class="fas fa-server stat-icon"></i>
|
||||||
|
<span class="stat-label">__MSG_statsServersFound__</span>
|
||||||
|
</div>
|
||||||
|
<div id="total-servers" class="stat-value">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<i class="fas fa-film stat-icon"></i>
|
||||||
|
<span class="stat-label">__MSG_statsUniqueMovies__</span>
|
||||||
|
</div>
|
||||||
|
<div id="total-movies" class="stat-value">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<i class="fas fa-tv stat-icon"></i>
|
||||||
|
<span class="stat-label">__MSG_statsUniqueSeries__</span>
|
||||||
|
</div>
|
||||||
|
<div id="total-series" class="stat-value">0</div>
|
||||||
|
</div>
|
||||||
|
<div class="stats-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<i class="fas fa-music stat-icon"></i>
|
||||||
|
<span class="stat-label">__MSG_statsUniqueArtists__</span>
|
||||||
|
</div>
|
||||||
|
<div id="total-artists" class="stat-value">0</div>
|
||||||
|
</div>
|
||||||
|
<div id="token-details-card" class="stats-card token-details-card">
|
||||||
|
<div class="stat-header">
|
||||||
|
<i class="fas fa-network-wired stat-icon"></i>
|
||||||
|
<span class="stat-label">__MSG_statsTokenServers__</span>
|
||||||
|
</div>
|
||||||
|
<ul id="token-server-list" class="server-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container full-width">
|
||||||
|
<h3 class="chart-title">__MSG_statsChartMoviesByGenre__</h3>
|
||||||
|
<canvas id="movie-genres-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container full-width">
|
||||||
|
<h3 class="chart-title">__MSG_statsChartSeriesByGenre__</h3>
|
||||||
|
<canvas id="series-genres-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="chart-container full-width">
|
||||||
|
<h3 class="chart-title">__MSG_statsChartByDecade__</h3>
|
||||||
|
<canvas id="decade-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section id="recommendations-section" style="display: none;">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">__MSG_recommendationsTitle__</h2>
|
||||||
|
</div>
|
||||||
|
<div id="recommendations-grid" class="content-grid"></div>
|
||||||
|
</section>
|
||||||
|
<section id="history-section" style="display: none;">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2 class="section-title">__MSG_historyTitle__</h2>
|
||||||
|
<button id="clear-history-btn" class="btn btn-danger btn-sm"><i class="fas fa-trash-alt me-2"></i>__MSG_clearHistory__</button>
|
||||||
|
</div>
|
||||||
|
<div id="history-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="consoleOutputContainer" class="mt-5" style="display: none;">
|
||||||
|
<h3 class="section-subtitle mt-4">__MSG_consoleTitle__</h3>
|
||||||
|
<div id="consoleOutput"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<div class="footer-content">
|
||||||
|
<a class="footer-logo-link" href="#" id="footer-logo-btn">
|
||||||
|
<span class="footer-logo-text">__MSG_appName__</span>
|
||||||
|
</a>
|
||||||
|
<div class="footer-links">
|
||||||
|
<a href="#" class="footer-link" id="footer-movies">__MSG_navMovies__</a>
|
||||||
|
<a href="#" class="footer-link" id="footer-series">__MSG_navSeries__</a>
|
||||||
|
<a href="#" class="footer-link" id="footer-stats">__MSG_navStats__</a>
|
||||||
|
<a href="#" class="footer-link" id="footer-favorites">__MSG_navFavorites__</a>
|
||||||
|
</div>
|
||||||
|
<p class="footer-credit">__MSG_footerCredit__</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="item-details" id="item-details-view">
|
||||||
|
<div class="back-button"><i class="fas fa-arrow-left"></i></div>
|
||||||
|
<div id="item-details-content"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="video-lightbox" class="lightbox">
|
||||||
|
<div class="lightbox-content">
|
||||||
|
<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"
|
||||||
|
allowfullscreen title="Video Player"></iframe></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="photo-lightbox" style="display: none;">
|
||||||
|
<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-prev" aria-label="__MSG_previous__"><i class="fas fa-chevron-left"></i></button>
|
||||||
|
<img src="" alt="__MSG_photoViewer__" class="photo-lightbox-img" id="photo-lightbox-img">
|
||||||
|
<button class="photo-lightbox-btn" id="photo-lightbox-next" aria-label="__MSG_next__"><i class="fas fa-chevron-right"></i></button>
|
||||||
|
<div class="photo-lightbox-caption" id="photo-lightbox-caption"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="notification-container" style="position: fixed; bottom: 1rem; right: 1rem; z-index: 1090;"></div>
|
||||||
|
<div id="notification-template" style="display: none;">
|
||||||
|
<div class="notification">
|
||||||
|
<div class="notification-content"><i class="fas"></i><span>__MSG_notificationTemplateText__</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="spinner" id="spinner"></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">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="settingsModalLabel"><i class="fas fa-cog me-2"></i>__MSG_settingsTitleFull__</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="__MSG_close__"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body p-0">
|
||||||
|
<ul class="nav nav-tabs" id="settingsTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="general-tab" data-bs-toggle="tab" data-bs-target="#general" type="button" role="tab" aria-controls="general" aria-selected="true">
|
||||||
|
<i class="fas fa-sliders-h me-2"></i>__MSG_settingsTabGeneral__
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="plex-tab" data-bs-toggle="tab" data-bs-target="#plex" type="button" role="tab" aria-controls="plex" aria-selected="false">
|
||||||
|
<i class="fas fa-server me-2"></i>__MSG_settingsTabPlex__
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="php-gen-tab" data-bs-toggle="tab" data-bs-target="#php-gen" type="button" role="tab" aria-controls="php-gen" aria-selected="false">
|
||||||
|
<i class="fab fa-php me-2"></i>__MSG_settingsTabPhpGen__
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="data-tab" data-bs-toggle="tab" data-bs-target="#data" type="button" role="tab" aria-controls="data" aria-selected="false">
|
||||||
|
<i class="fas fa-database me-2"></i>__MSG_settingsTabData__
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tab-content p-4" id="settingsTabsContent">
|
||||||
|
<div class="tab-pane fade show active" id="general" role="tabpanel" aria-labelledby="general-tab">
|
||||||
|
<h5 class="mb-3">__MSG_settingsApiServer__</h5>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="tmdbApiKey" class="form-label">__MSG_settingsTmdbApiLabel__</label>
|
||||||
|
<input type="password" class="form-control" id="tmdbApiKey" placeholder="__MSG_settingsTmdbApiPlaceholder__">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="appLanguage" class="form-label">__MSG_settingsTmdbLangLabel__</label>
|
||||||
|
<select class="form-control" id="appLanguage">
|
||||||
|
<option value="es">__MSG_lang_es__</option>
|
||||||
|
<option value="en">__MSG_lang_en__</option>
|
||||||
|
<option value="fr">__MSG_lang_fr__</option>
|
||||||
|
<option value="de">__MSG_lang_de__</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phpScriptUrl" class="form-label">__MSG_settingsPhpUrlLabel__</label>
|
||||||
|
<input type="url" class="form-control" id="phpScriptUrl" placeholder="__MSG_settingsPhpUrlPlaceholder__">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5 class="mt-4 mb-3">__MSG_settingsInterface__</h5>
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<label for="lightModeToggle" class="form-label mb-0">__MSG_settingsLightTheme__</label>
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="lightModeToggle">
|
||||||
|
<label for="lightModeToggle"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<label for="showHeroToggle" class="form-label mb-0">__MSG_settingsShowHero__</label>
|
||||||
|
<div class="toggle-switch">
|
||||||
|
<input type="checkbox" id="showHeroToggle" checked>
|
||||||
|
<label for="showHeroToggle"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="plex" role="tabpanel" aria-labelledby="plex-tab">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<h5 class="mb-3">__MSG_settingsScanContent__</h5>
|
||||||
|
<p class="small text-muted mb-3">__MSG_settingsScanDesc__</p>
|
||||||
|
<label class="d-block mb-2"><input type="checkbox" id="updateMovies" value="movies"> __MSG_settingsScanMovies__</label>
|
||||||
|
<label class="d-block mb-2"><input type="checkbox" id="updateShows" value="series"> __MSG_settingsScanShows__</label>
|
||||||
|
<label class="d-block mb-2"><input type="checkbox" id="updateArtists" value="artists"> __MSG_settingsScanArtists__</label>
|
||||||
|
<label class="d-block mb-2"><input type="checkbox" id="updatePhotos" value="photos"> __MSG_settingsScanPhotos__</label>
|
||||||
|
<hr class="my-3">
|
||||||
|
<label class="d-block mb-3"><input type="checkbox" id="updateAll"> __MSG_settingsSelectAll__</label>
|
||||||
|
<button type="button" class="btn btn-primary w-100" id="confirmScanBtn"><i class="fas fa-sync-alt me-1"></i> __MSG_settingsStartScan__</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<h5 class="mb-3">__MSG_settingsPlexTokens__</h5>
|
||||||
|
<p class="small text-muted mb-2">__MSG_settingsPlexTokensDesc__</p>
|
||||||
|
<div id="editor"></div>
|
||||||
|
<button type="button" class="btn btn-success mt-3 w-100" id="saveTokensBtn"><i class="fas fa-save me-1"></i> __MSG_settingsSaveTokens__</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="php-gen" role="tabpanel" aria-labelledby="php-gen-tab">
|
||||||
|
<h5 class="mb-3">__MSG_settingsPhpGenTitle__</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-5">
|
||||||
|
<h6>__MSG_settingsPhpFileOptions__</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phpSavePath" class="form-label">__MSG_settingsPhpSavePathLabel__</label>
|
||||||
|
<input type="text" id="phpSavePath" class="form-control form-control-sm" placeholder="__MSG_settingsPhpSavePathPlaceholder__">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="phpFilename" class="form-label">__MSG_settingsPhpFilenameLabel__</label>
|
||||||
|
<input type="text" id="phpFilename" class="form-control form-control-sm" value="CinePlex_Playlist.m3u">
|
||||||
|
</div>
|
||||||
|
<h6 class="mt-3">__MSG_settingsPhpFileAction__</h6>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="radio" name="phpFileAction" id="phpFileActionAppend" checked>
|
||||||
|
<label class="form-check-label" for="phpFileActionAppend">
|
||||||
|
__MSG_settingsPhpActionAppend__
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-3">
|
||||||
|
<input class="form-check-input" type="radio" name="phpFileAction" id="phpFileActionOverwrite">
|
||||||
|
<label class="form-check-label" for="phpFileActionOverwrite">
|
||||||
|
__MSG_settingsPhpActionOverwrite__
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6>__MSG_settingsPhpSecurity__</h6>
|
||||||
|
<div class="form-check form-switch mb-2">
|
||||||
|
<input class="form-check-input" type="checkbox" id="phpSecretKeyCheck">
|
||||||
|
<label class="form-check-label" for="phpSecretKeyCheck">__MSG_settingsPhpUseSecretKey__</label>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<input type="text" id="phpSecretKey" class="form-control form-control-sm" placeholder="__MSG_settingsPhpSecretKeyPlaceholder__">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-7">
|
||||||
|
<h6>__MSG_settingsPhpGeneratedCode__</h6>
|
||||||
|
<textarea id="generatedPhpCode" class="form-control" rows="12" readonly placeholder="__MSG_settingsPhpGeneratedPlaceholder__"></textarea>
|
||||||
|
<div class="d-grid gap-2 mt-2">
|
||||||
|
<button class="btn btn-primary" id="generatePhpScriptBtn"><i class="fas fa-cogs me-2"></i>__MSG_settingsGenerateScript__</button>
|
||||||
|
<button class="btn btn-secondary" id="copyPhpScriptBtn"><i class="fas fa-copy me-2"></i>__MSG_settingsCopyScript__</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="data" role="tabpanel" aria-labelledby="data-tab">
|
||||||
|
<h5 class="mb-3">__MSG_settingsDataManagement__</h5>
|
||||||
|
<div class="d-grid gap-3">
|
||||||
|
<button type="button" class="btn btn-info" id="import-db-btn"><i class="fas fa-file-import me-2"></i>__MSG_settingsImportDb__</button>
|
||||||
|
<button type="button" class="btn btn-info" id="exportDbBtn"><i class="fas fa-file-export me-2"></i>__MSG_settingsExportDb__</button>
|
||||||
|
<hr>
|
||||||
|
<button type="button" class="btn btn-danger" id="clearDataBtn"><i class="fas fa-trash me-2"></i>__MSG_settingsClearContent__</button>
|
||||||
|
<p class="small text-muted text-center mt-2">__MSG_settingsClearContentDesc__</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">__MSG_settingsClose__</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="saveSettingsBtn"><i class="fas fa-save me-1"></i> __MSG_settingsSave__</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="musicPlayerContainer">
|
||||||
|
<div class="sidenav">
|
||||||
|
<div class="sidenav-header">
|
||||||
|
<h4>__MSG_musicSidenavTitle__</h4>
|
||||||
|
<button id="closeSideNavBtn"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="artistListContainer" class="music-panel">
|
||||||
|
<div class="panel-controls">
|
||||||
|
<div id="tokenSelectorContainer" class="custom-select">
|
||||||
|
<div class="select-selected">
|
||||||
|
<span data-value="all">__MSG_musicAllServers__</span>
|
||||||
|
<i class="fas fa-chevron-down"></i>
|
||||||
|
</div>
|
||||||
|
<div class="select-items select-hide"></div>
|
||||||
|
</div>
|
||||||
|
<div class="search-wrapper">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<input type="text" id="searchArtist" placeholder="__MSG_musicSearchArtistPlaceholder__">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="artistList" class="artist-grid"></div>
|
||||||
|
<div id="artistNavigation" class="pagination-controls">
|
||||||
|
<button id="prevArtistsBtn" class="btn-icon-sm" style="display: none;"><i class="fas fa-chevron-left"></i></button>
|
||||||
|
<span id="artistCounter"></span>
|
||||||
|
<button id="nextArtistsBtn" class="btn-icon-sm" style="display: none;"><i class="fas fa-chevron-right"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="songListContainer" class="music-panel">
|
||||||
|
<div class="panel-controls song-list-controls">
|
||||||
|
<button id="backBtn" class="btn-icon back-btn-icon"><i class="fas fa-arrow-left"></i></button>
|
||||||
|
<div id="artist-header-info">
|
||||||
|
<img id="artist-header-thumb" src="img/no-profile.png" alt="Artista">
|
||||||
|
<h5 id="artist-header-title">Nombre del Artista</h5>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="search-wrapper search-wrapper-songs">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
<input type="text" id="searchSong" placeholder="__MSG_musicSearchDiscographyPlaceholder__">
|
||||||
|
</div>
|
||||||
|
<div id="listaCanciones" class="song-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="side-nav-now-playing" style="display: none;">
|
||||||
|
<img id="side-nav-album-cover" src="img/no-poster.png" alt="">
|
||||||
|
<div class="details">
|
||||||
|
<p id="side-nav-track-title">__MSG_musicNothingPlaying__</p>
|
||||||
|
<p id="side-nav-track-artist"></p>
|
||||||
|
</div>
|
||||||
|
<div id="side-nav-play-pause" class="control-btn play-pause-main"><i class="fas fa-play"></i></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="loader" class="text-center p-4" style="display: none;">
|
||||||
|
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||||
|
<span class="visually-hidden">__MSG_loading__</span>
|
||||||
|
</div>
|
||||||
|
__MSG_loading__
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="miniplayer" style="display: none;">
|
||||||
|
<div class="miniplayer-bg"></div>
|
||||||
|
<div class="player-left-info" id="trackInfo">
|
||||||
|
<img id="albumCover" src="img/no-poster.png" alt="" class="album-cover">
|
||||||
|
<div class="details">
|
||||||
|
<p id="trackTitle">__MSG_musicSelectSong__</p>
|
||||||
|
<p id="trackArtist">__MSG_musicToStart__</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-center-controls">
|
||||||
|
<div id="player-controls">
|
||||||
|
<button id="prevBtn" class="control-btn" title="__MSG_previous__"><i class="fas fa-step-backward"></i></button>
|
||||||
|
<button id="playPauseBtn" class="control-btn play-pause-main" title="Reproducir/Pausar"><i class="fas fa-play"></i></button>
|
||||||
|
<button id="nextBtn" class="control-btn" title="__MSG_next__"><i class="fas fa-step-forward"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="time-and-progress">
|
||||||
|
<span id="elapsedTime" class="time-label">0:00</span>
|
||||||
|
<div id="progressBarContainer">
|
||||||
|
<div id="seek-hover-bar"></div>
|
||||||
|
<div id="played-bar"></div>
|
||||||
|
<div id="progress-handle"></div>
|
||||||
|
</div>
|
||||||
|
<span id="remainingTime" class="time-label">0:00</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="player-right-actions">
|
||||||
|
<button id="downloadBtn" class="control-btn" title="__MSG_miniplayerDownloadSong__"><i class="fas fa-download"></i></button>
|
||||||
|
<button id="downloadAlbumBtn" class="control-btn" title="__MSG_miniplayerDownloadAlbum__"><i class="fas fa-file-audio"></i></button>
|
||||||
|
<div id="volumeControl">
|
||||||
|
<button id="volume-icon-btn" class="control-btn" title="__MSG_miniplayerVolume__"><i class="fas fa-volume-up"></i></button>
|
||||||
|
<div class="volume-slider-wrapper">
|
||||||
|
<input type="range" id="volumeSlider" min="0" max="1" step="0.01" value="1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="audioPlayer"></audio>
|
||||||
|
|
||||||
|
<div id="equalizer-panel">
|
||||||
|
<div class="equalizer-header">
|
||||||
|
<h5>__MSG_eqTitle__</h5>
|
||||||
|
<button id="closeEqBtn" class="close-btn"><i class="fas fa-times"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="equalizer-top-bar">
|
||||||
|
<div class="control-group presets">
|
||||||
|
<label for="eq-presets">__MSG_eqPresetsLabel__</label>
|
||||||
|
<select id="eq-presets" class="custom-select-sm">
|
||||||
|
<option value="flat">__MSG_eqPresetFlat__</option>
|
||||||
|
<option value="rock">__MSG_eqPresetRock__</option>
|
||||||
|
<option value="pop">__MSG_eqPresetPop__</option>
|
||||||
|
<option value="jazz">__MSG_eqPresetJazz__</option>
|
||||||
|
<option value="classical">__MSG_eqPresetClassical__</option>
|
||||||
|
<option value="bass_boost">__MSG_eqPresetBassBoost__</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="control-group preamp">
|
||||||
|
<label>__MSG_eqPreampLabel__</label>
|
||||||
|
<input type="range" id="preamp-slider" class="eq-slider" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="equalizer-bands-grid">
|
||||||
|
<div class="band">
|
||||||
|
<label>60</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="0" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>170</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="1" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>310</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="2" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>600</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="3" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>1k</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="4" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>3k</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="5" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>6k</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="6" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>12k</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="7" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>14k</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="8" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
<div class="band">
|
||||||
|
<label>16k</label>
|
||||||
|
<input type="range" class="eq-slider band-slider" data-band="9" min="-12" max="12" step="1" value="0">
|
||||||
|
<span class="slider-value">0 dB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="visualizer-container">
|
||||||
|
<canvas id="visualizer-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="infoModal" tabindex="-1" aria-labelledby="infoModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="infoModalLabel">__MSG_infoModalTitle__</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="__MSG_close__"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p id="modalTitle"><strong>__MSG_infoModalFieldTitle__</strong> <span></span></p>
|
||||||
|
<p id="modalArtist"><strong>__MSG_infoModalFieldArtist__</strong> <span></span></p>
|
||||||
|
<p id="modalAlbum"><strong>__MSG_infoModalFieldAlbum__</strong> <span></span></p>
|
||||||
|
<p id="modalSong"><strong>__MSG_infoModalFieldSong__</strong> <span></span></p>
|
||||||
|
<p id="modalYear"><strong>__MSG_infoModalFieldYear__</strong> <span></span></p>
|
||||||
|
<p id="modalGenre"><strong>__MSG_infoModalFieldGenre__</strong> <span></span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="lib/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="lib/gsap.min.js"></script>
|
||||||
|
<script src="lib/ScrollTrigger.min.js"></script>
|
||||||
|
<script src="lib/particles.min.js"></script>
|
||||||
|
<script src="lib/ace.js"></script>
|
||||||
|
<script src="lib/chart.umd.min.js"></script>
|
||||||
|
<script src="js/i18n.js"></script>
|
||||||
|
<script type="module" src="js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
Loading…
x
Reference in New Issue
Block a user