diff --git a/_locales/de/messages.json b/_locales/de/messages.json index 790802e..d527f09 100644 --- a/_locales/de/messages.json +++ b/_locales/de/messages.json @@ -66,6 +66,7 @@ "settingsApiServer": { "message": "API- und Serverkonfiguration" }, "settingsTmdbApiLabel": { "message": "TMDB API-Schlüssel (Optional)" }, "settingsTmdbApiPlaceholder": { "message": "Verwendet den Standardschlüssel, wenn leer gelassen" }, + "openaiApiKey": { "message": "OpenAI API-Schlüssel" }, "settingsTmdbLangLabel": { "message": "Sprache für TMDB & UI" }, "settingsRegionLabel": { "message": "Region für Anbieter" }, "allRegions": { "message": "Alle Regionen" }, @@ -348,5 +349,17 @@ "m3uInstruction3": { "message": "Klicken Sie auf die Download-Schaltfläche." }, "m3uInstruction4": { "message": "Importieren Sie die .m3u-Datei in Ihren kompatiblen Player." }, "settingsRegionLabel": { "message": "Region für die Inhaltsentdeckung" }, - "allRegions": { "message": "Alle Regionen" } + "allRegions": { "message": "Alle Regionen" }, + "chatOpen": { "message": "Chat öffnen" }, + "chatTitle": { "message": "KI-Assistent" }, + "chatClose": { "message": "X" }, + "chatPlaceholder": { "message": "Nachricht eingeben..." }, + "chatSend": { "message": "➤" }, + "chatGptError": { "message": "Entschuldigung, ich konnte keine Antwort erhalten. Bitte versuchen Sie es erneut." }, + "chatWelcome": { "message": "Willkommen! Ich bin Ihr CinePlex-Assistent. Fragen Sie mich nach Filmen, Serien oder allem, was Sie sonst noch wissen möchten." }, + "chatApiKeyMissing": { "message": "Der OpenAI-API-Schlüssel ist nicht festgelegt. Bitte konfigurieren Sie ihn in den Erweiterungseinstellungen." }, + "chatApiInvalidResponse": { "message": "Die API hat eine ungültige Antwort zurückgegeben. Bitte versuchen Sie es erneut." }, + "chatApiError": { "message": "Fehler bei der Kommunikation mit dem KI-Assistenten" }, + "downloadAll": { "message": "Alles herunterladen" }, + "download": { "message": "Herunterladen" } } \ No newline at end of file diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 04e533e..dc0c05a 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -66,6 +66,7 @@ "settingsApiServer": { "message": "API and Server Configuration" }, "settingsTmdbApiLabel": { "message": "TMDB API Key (Optional)" }, "settingsTmdbApiPlaceholder": { "message": "Will use default key if left blank" }, + "openaiApiKey": { "message": "OpenAI API Key" }, "settingsTmdbLangLabel": { "message": "Language for TMDB & UI" }, "settingsPhpUrlLabel": { "message": "Server URL for Adding Streams" }, "settingsPhpUrlPlaceholder": { "message": "https://your-server.com/path/to/script.php" }, @@ -345,5 +346,17 @@ "m3uInstruction3": { "message": "Click the download button." }, "m3uInstruction4": { "message": "Import the .m3u file into your compatible player." }, "settingsRegionLabel": { "message": "Region for content discovery" }, - "allRegions": { "message": "All regions" } + "allRegions": { "message": "All regions" }, + "chatOpen": { "message": "Open Chat" }, + "chatTitle": { "message": "AI Assistant" }, + "chatClose": { "message": "X" }, + "chatPlaceholder": { "message": "Type your message..." }, + "chatSend": { "message": "➤" }, + "chatGptError": { "message": "Sorry, I couldn't get a response. Please try again." }, + "chatWelcome": { "message": "Welcome! I'm your CinePlex assistant. Ask me about movies, series, or anything else you'd like to know." }, + "chatApiKeyMissing": { "message": "OpenAI API key is not set. Please configure it in the extension settings." }, + "chatApiInvalidResponse": { "message": "The API returned an invalid response. Please try again." }, + "chatApiError": { "message": "Error communicating with the AI assistant" }, + "downloadAll": { "message": "Download All" }, + "download": { "message": "Download" } } \ No newline at end of file diff --git a/_locales/es/messages.json b/_locales/es/messages.json index 2e5c8ba..8de184a 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -66,6 +66,7 @@ "settingsApiServer": { "message": "Configuración de API y Servidor" }, "settingsTmdbApiLabel": { "message": "Clave de API de TMDB (Opcional)" }, "settingsTmdbApiPlaceholder": { "message": "Se usará la clave por defecto si se deja en blanco" }, + "openaiApiKey": { "message": "Clave API de OpenAI" }, "settingsTmdbLangLabel": { "message": "Idioma para TMDB y la interfaz" }, "settingsPhpUrlLabel": { "message": "URL del Servidor para Añadir Streams" }, "settingsPhpUrlPlaceholder": { "message": "https://tu-servidor.com/ruta/al/script.php" }, @@ -345,5 +346,17 @@ "m3uInstruction3": { "message": "Haz clic en el botón de descarga." }, "m3uInstruction4": { "message": "Importa el archivo .m3u en tu reproductor compatible." }, "settingsRegionLabel": { "message": "Región para descubrimiento de contenido" }, - "allRegions": { "message": "Todas las regiones" } + "allRegions": { "message": "Todas las regiones" }, + "chatOpen": { "message": "Abrir Chat" }, + "chatTitle": { "message": "Asistente IA" }, + "chatClose": { "message": "X" }, + "chatPlaceholder": { "message": "Escribe tu mensaje..." }, + "chatSend": { "message": "➤" }, + "chatGptError": { "message": "Lo siento, no pude obtener una respuesta. Inténtalo de nuevo." }, + "chatWelcome": { "message": "¡Bienvenido! Soy tu asistente de CinePlex. Pregúntame sobre películas, series o cualquier otra cosa que quieras saber." }, + "chatApiKeyMissing": { "message": "La clave de la API de OpenAI no está configurada. Por favor, configúrala en los ajustes de la extensión." }, + "chatApiInvalidResponse": { "message": "La API ha devuelto una respuesta no válida. Por favor, inténtalo de nuevo." }, + "chatApiError": { "message": "Error al comunicarse con el asistente de IA" }, + "downloadAll": { "message": "Descargar todo" }, + "download": { "message": "Descargar" } } \ No newline at end of file diff --git a/_locales/fr/messages.json b/_locales/fr/messages.json index f7a387e..1e6b205 100644 --- a/_locales/fr/messages.json +++ b/_locales/fr/messages.json @@ -66,6 +66,7 @@ "settingsApiServer": { "message": "Configuration API et Serveur" }, "settingsTmdbApiLabel": { "message": "Clé API de TMDB (Optionnel)" }, "settingsTmdbApiPlaceholder": { "message": "Utilisera la clé par défaut si laissé vide" }, + "openaiApiKey": { "message": "Clé API OpenAI" }, "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" }, @@ -345,5 +346,17 @@ "m3uInstruction3": { "message": "Cliquez sur le bouton de téléchargement." }, "m3uInstruction4": { "message": "Importez le fichier .m3u dans votre lecteur compatible." }, "settingsRegionLabel": { "message": "Région pour la découverte de contenu" }, - "allRegions": { "message": "Toutes les régions" } + "allRegions": { "message": "Toutes les régions" }, + "chatOpen": { "message": "Ouvrir le chat" }, + "chatTitle": { "message": "Assistant IA" }, + "chatClose": { "message": "X" }, + "chatPlaceholder": { "message": "Écrivez votre message..." }, + "chatSend": { "message": "➤" }, + "chatGptError": { "message": "Désolé, je n'ai pas pu obtenir de réponse. Veuillez réessayer." }, + "chatWelcome": { "message": "Bienvenue ! Je suis votre assistant CinePlex. Posez-moi des questions sur les films, les séries ou tout ce que vous voulez savoir." }, + "chatApiKeyMissing": { "message": "La clé API OpenAI n'est pas définie. Veuillez la configurer dans les paramètres de l'extension." }, + "chatApiInvalidResponse": { "message": "L'API a renvoyé une réponse non valide. Veuillez réessayer." }, + "chatApiError": { "message": "Erreur de communication avec l'assistant IA" }, + "downloadAll": { "message": "Tout télécharger" }, + "download": { "message": "Télécharger" } } \ No newline at end of file diff --git a/_locales/it/messages.json b/_locales/it/messages.json index eaab74d..c1072d3 100644 --- a/_locales/it/messages.json +++ b/_locales/it/messages.json @@ -66,6 +66,7 @@ "settingsApiServer": { "message": "Configurazione API e Server" }, "settingsTmdbApiLabel": { "message": "Chiave API TMDB (Opzionale)" }, "settingsTmdbApiPlaceholder": { "message": "Verrà usata la chiave predefinita se lasciato vuoto" }, + "openaiApiKey": { "message": "Chiave API OpenAI" }, "settingsTmdbLangLabel": { "message": "Lingua per TMDB e Interfaccia" }, "settingsPhpUrlLabel": { "message": "URL del server per aggiungere gli stream" }, "settingsPhpUrlPlaceholder": { "message": "https://tuo-server.com/percorso/dello/script.php" }, @@ -345,5 +346,17 @@ "m3uInstruction3": { "message": "Clicca sul pulsante di download." }, "m3uInstruction4": { "message": "Importa il file .m3u nel tuo lettore compatibile." }, "settingsRegionLabel": { "message": "Regione per la scoperta di contenuti" }, - "allRegions": { "message": "Tutte le regioni" } + "allRegions": { "message": "Tutte le regioni" }, + "chatOpen": { "message": "Apri chat" }, + "chatTitle": { "message": "Assistente AI" }, + "chatClose": { "message": "X" }, + "chatPlaceholder": { "message": "Scrivi il tuo messaggio..." }, + "chatSend": { "message": "➤" }, + "chatGptError": { "message": "Spiacenti, non sono riuscito a ottenere una risposta. Per favore, riprova." }, + "chatWelcome": { "message": "Benvenuto! Sono il tuo assistente CinePlex. Chiedimi di film, serie o qualsiasi altra cosa tu voglia sapere." }, + "chatApiKeyMissing": { "message": "La chiave API di OpenAI non è impostata. Configurala nelle impostazioni dell'estensione." }, + "chatApiInvalidResponse": { "message": "L'API ha restituito una risposta non valida. Per favore, riprova." }, + "chatApiError": { "message": "Errore di comunicazione con l'assistente AI" }, + "downloadAll": { "message": "Scarica tutto" }, + "download": { "message": "Scarica" } } \ No newline at end of file diff --git a/_locales/pt/messages.json b/_locales/pt/messages.json index 75f8760..dc2a762 100644 --- a/_locales/pt/messages.json +++ b/_locales/pt/messages.json @@ -66,6 +66,7 @@ "settingsApiServer": { "message": "Configuração de API e Servidor" }, "settingsTmdbApiLabel": { "message": "Chave da API do TMDB (Opcional)" }, "settingsTmdbApiPlaceholder": { "message": "Usará a chave padrão se deixado em branco" }, + "openaiApiKey": { "message": "Chave da API OpenAI" }, "settingsTmdbLangLabel": { "message": "Idioma para TMDB e Interface" }, "settingsPhpUrlLabel": { "message": "URL do Servidor para Adicionar Streams" }, "settingsPhpUrlPlaceholder": { "message": "https://seu-servidor.com/caminho/para/script.php" }, @@ -345,5 +346,17 @@ "m3uInstruction3": { "message": "Clique no botão de download." }, "m3uInstruction4": { "message": "Importe o arquivo .m3u para o seu reprodutor compatível." }, "settingsRegionLabel": { "message": "Região para descoberta de conteúdo" }, - "allRegions": { "message": "Todas as regiões" } + "allRegions": { "message": "Todas as regiões" }, + "chatOpen": { "message": "Abrir chat" }, + "chatTitle": { "message": "Assistente de IA" }, + "chatClose": { "message": "X" }, + "chatPlaceholder": { "message": "Digite sua mensagem..." }, + "chatSend": { "message": "➤" }, + "chatGptError": { "message": "Desculpe, não consegui obter uma resposta. Por favor, tente novamente." }, + "chatWelcome": { "message": "Bem-vindo! Eu sou seu assistente CinePlex. Pergunte-me sobre filmes, séries ou qualquer outra coisa que você queira saber." }, + "chatApiKeyMissing": { "message": "A chave da API OpenAI não está definida. Configure-a nas configurações da extensão." }, + "chatApiInvalidResponse": { "message": "A API retornou uma resposta inválida. Por favor, tente novamente." }, + "chatApiError": { "message": "Erro ao se comunicar com o assistente de IA" }, + "downloadAll": { "message": "Baixar tudo" }, + "download": { "message": "Baixar" } } \ No newline at end of file diff --git a/css/main.css b/css/main.css index d52352b..df33319 100644 --- a/css/main.css +++ b/css/main.css @@ -2936,9 +2936,7 @@ body.miniplayer-active #musicPlayerContainer { } .fab-btn { - position: fixed; - bottom: 2rem; - right: 2rem; + position: relative; width: 60px; height: 60px; border-radius: 50%; @@ -3908,4 +3906,271 @@ body.miniplayer-active { padding-bottom: 85px; } .provider-card:hover .provider-tooltip { opacity: 1; transform: translateY(0); +} + +/* --- chat.css --- */ +#fab-container { + position: fixed; + bottom: 2rem; + right: 2rem; + z-index: 1050; + display: flex; + flex-direction: row-reverse; + gap: 1rem; + align-items: flex-end; + transition: bottom 0.5s cubic-bezier(0.4, 0, 0.2, 1); +} + +body.miniplayer-active #fab-container { + bottom: calc(85px + 2rem); +} + +.chat-fab, .fab-btn { + position: relative; + right: auto; + bottom: auto; +} + +.chat-fab { + width: 60px; + height: 60px; + background: var(--gradient); + color: var(--primary); + border-radius: 50%; + border: none; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.8rem; + cursor: pointer; + box-shadow: 0 5px 20px rgba(0, 224, 255, 0.3); + transition: var(--transition); +} +.chat-fab:hover { + transform: scale(1.1); + box-shadow: 0 8px 25px rgba(0, 224, 255, 0.4); +} + +.chat-window { + position: fixed; + bottom: 95px; + right: 2rem; + width: 400px; + height: 520px; + max-width: 90vw; + max-height: 70vh; + background-color: var(--primary); + border-radius: var(--border-radius-lg); + box-shadow: var(--shadow); + border: 1px solid var(--glass-border); + display: flex; + flex-direction: column; + overflow: hidden; + z-index: 1051; + backdrop-filter: blur(15px); + -webkit-backdrop-filter: blur(15px); + background: rgba(10, 10, 15, 0.8); + cursor: default; +} +.light-theme .chat-window { + background: rgba(244, 247, 250, 0.8); +} + +.chat-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.8rem 1.2rem; + background-color: rgba(255,255,255,0.05); + border-bottom: 1px solid var(--glass-border); + cursor: move; + flex-shrink: 0; +} +.chat-title { + font-family: 'Orbitron', sans-serif; + color: var(--text-primary); + font-size: 1.1rem; + margin: 0; +} +.chat-close-btn { + background: none; + border: none; + color: var(--text-secondary); + font-size: 1.2rem; + cursor: pointer; + transition: var(--transition); +} +.chat-close-btn:hover { + color: var(--accent); + transform: rotate(90deg); +} + +.chat-messages { + flex-grow: 1; + padding: 1rem; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.chat-messages::-webkit-scrollbar { + width: 8px; +} + +.chat-messages::-webkit-scrollbar-track { + background: transparent; +} + +.chat-messages::-webkit-scrollbar-thumb { + background-color: var(--glass-border); + border-radius: 4px; +} + +.message { + padding: 0.8rem 1.2rem; + border-radius: var(--border-radius-md); + max-width: 85%; + line-height: 1.6; + word-wrap: break-word; +} +.message p { + margin: 0; + white-space: pre-wrap; + word-break: break-word; +} +.message p:last-child { + margin-bottom: 0; +} + +.user-message { + background: var(--gradient); + color: var(--primary); + align-self: flex-end; + border-bottom-right-radius: 4px; +} + +.assistant-message { + background-color: var(--secondary); + color: var(--text-primary); + align-self: flex-start; + border-bottom-left-radius: 4px; +} + +.chat-action-buttons { + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin-top: .8rem; + padding-top: .8rem; + border-top: 1px solid var(--glass-border); +} + +.chat-action-buttons button { + background-color: var(--glass); + border: 1px solid var(--glass-border); + color: var(--text-secondary); + padding: .4rem .8rem; + border-radius: 20px; + font-size: .8rem; + cursor: pointer; + transition: var(--transition); +} + +.chat-action-buttons button:hover { + background-color: var(--accent); + color: var(--primary); + border-color: var(--accent); +} + +.assistant-message code { + background-color: var(--primary); + padding: 0.2em 0.4em; + margin: 0; + font-size: 85%; + border-radius: 6px; + font-family: monospace; +} + +.typing-indicator { + display: flex; + gap: 5px; +} +.typing-indicator span { + width: 8px; + height: 8px; + background-color: var(--text-secondary); + border-radius: 50%; + animation: typing 1s infinite; +} +.typing-indicator span:nth-child(2) { animation-delay: 0.2s; } +.typing-indicator span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes typing { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-5px); } +} + +.chat-input-form { + display: flex; + padding: 0.8rem; + border-top: 1px solid var(--glass-border); + gap: 0.8rem; + background-color: rgba(255,255,255,0.05); + align-items: flex-end; +} + +.chat-input { + flex-grow: 1; + background: var(--glass); + border: 1px solid var(--glass-border); + border-radius: 20px; + padding: 0.6rem 1rem; + color: var(--text-primary); + resize: none; + font-family: 'Montserrat', sans-serif; + font-size: 0.95rem; +} +.chat-input:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(0, 224, 255, 0.2); +} + +.chat-send-btn { + flex-shrink: 0; + background: var(--accent); + color: var(--primary); + border: none; + border-radius: 50%; + width: 40px; + height: 40px; + font-size: 1rem; + cursor: pointer; + transition: var(--transition); +} +.chat-send-btn:hover { + background: var(--accent-dark); + transform: scale(1.1); +} +.chat-send-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 768px) { + body.miniplayer-active #fab-container { + bottom: calc(110px + 1rem); + } +} + +@media (max-width: 480px) { + .chat-window { + width: 100%; + height: 100%; + bottom: 0; + right: 0; + border-radius: 0; + max-height: none; + } } \ No newline at end of file diff --git a/js/chat.js b/js/chat.js new file mode 100644 index 0000000..58a0c15 --- /dev/null +++ b/js/chat.js @@ -0,0 +1,342 @@ +import { state } from './state.js'; +import { showNotification, _ } from './utils.js'; +import { getFromDB } from './db.js'; +import { showItemDetails, addStreamToList, downloadM3U } from './ui.js'; + +export class Chat { + constructor() { + this.dom = { + fab: document.getElementById('chat-fab'), + window: document.getElementById('chat-window'), + header: document.querySelector('.chat-header'), + closeBtn: document.getElementById('chat-close-btn'), + messagesContainer: document.getElementById('chat-messages'), + inputForm: document.getElementById('chat-input-form'), + input: document.getElementById('chat-input'), + sendBtn: document.getElementById('chat-send-btn') + }; + this.isOpen = false; + this.isDragging = false; + this.offset = { x: 0, y: 0 }; + this.conversationHistory = []; + + this.bindEvents(); + } + + bindEvents() { + this.dom.fab.addEventListener('click', () => this.toggle()); + this.dom.closeBtn.addEventListener('click', () => this.close()); + this.dom.inputForm.addEventListener('submit', (e) => { + e.preventDefault(); + this.sendMessage(); + }); + this.dom.input.addEventListener('input', this.autoResizeTextarea.bind(this)); + this.dom.input.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + this.sendMessage(); + } + }); + this.dom.header.addEventListener('mousedown', this.startDrag.bind(this)); + document.addEventListener('mousemove', this.drag.bind(this)); + document.addEventListener('mouseup', this.stopDrag.bind(this)); + } + + toggle() { + this.isOpen ? this.close() : this.open(); + } + + open() { + if (this.isOpen) return; + this.isOpen = true; + + this.dom.window.style.top = ''; + this.dom.window.style.left = ''; + this.dom.window.style.bottom = '95px'; + this.dom.window.style.right = '2rem'; + + this.dom.window.style.display = 'flex'; + gsap.fromTo(this.dom.window, { opacity: 0, scale: 0.9, y: 20 }, { opacity: 1, scale: 1, y: 0, duration: 0.3, ease: 'power3.out' }); + gsap.to(this.dom.fab, { scale: 0, opacity: 0, duration: 0.2, ease: 'power2.in' }); + + if (this.conversationHistory.length === 0) { + this.addMessage(_('chatWelcome'), 'assistant'); + this.conversationHistory.push({ role: 'assistant', content: _('chatWelcome') }); + } + } + + close() { + if (!this.isOpen) return; + this.isOpen = false; + gsap.to(this.dom.window, { opacity: 0, scale: 0.9, y: 20, duration: 0.3, ease: 'power2.in', onComplete: () => { + this.dom.window.style.display = 'none'; + }}); + gsap.fromTo(this.dom.fab, { scale: 0, opacity: 0 }, { scale: 1, opacity: 1, duration: 0.3, ease: 'back.out(1.7)', delay: 0.2 }); + } + + async sendMessage() { + const userInput = this.dom.input.value.trim(); + if (!userInput) return; + + this.addMessage(userInput, 'user'); + this.conversationHistory.push({ role: 'user', content: userInput }); + this.dom.input.value = ''; + this.autoResizeTextarea(); + this.dom.sendBtn.disabled = true; + + this.addTypingIndicator(); + + try { + const response = await this.getOpenAIResponse(); + this.removeTypingIndicator(); + this.addMessage(response, 'assistant'); + this.conversationHistory.push({ role: 'assistant', content: response }); + } catch (error) { + this.removeTypingIndicator(); + this.addMessage(error.message, 'assistant', true); + } finally { + this.dom.sendBtn.disabled = false; + } + } + + addMessage(text, sender, isError = false) { + const messageEl = document.createElement('div'); + messageEl.classList.add('message', `${sender}-message`); + if(isError) messageEl.style.color = 'var(--danger)'; + + const p = document.createElement('p'); + p.textContent = text; + messageEl.appendChild(p); + + if (sender === 'assistant' && !isError) { + this.addMediaActionButtons(messageEl, text); + } + + this.dom.messagesContainer.appendChild(messageEl); + this.scrollToBottom(); + } + + addTypingIndicator() { + const indicator = document.createElement('div'); + indicator.id = 'typing-indicator'; + indicator.classList.add('message', 'assistant-message', 'typing-indicator'); + indicator.innerHTML = ''; + this.dom.messagesContainer.appendChild(indicator); + this.scrollToBottom(); + } + + removeTypingIndicator() { + const indicator = document.getElementById('typing-indicator'); + if (indicator) indicator.remove(); + } + + async getLocalContent() { + const movieEntries = await getFromDB('movies'); + const seriesEntries = await getFromDB('series'); + + const processEntries = (entries, type) => { + return entries.flatMap(entry => + (entry.titulos || []).map(titulo => ({ ...titulo, type })) + ); + }; + + const movies = processEntries(movieEntries, 'movie'); + const series = processEntries(seriesEntries, 'show'); + + return { movies, series }; + } + + async findLocalContent(title) { + const { movies, series } = await this.getLocalContent(); + const searchTerm = title.trim().toLowerCase(); + + const allContent = [...movies, ...series]; + + let foundContent = allContent.find(m => m.title.toLowerCase() === searchTerm); + if (foundContent) return foundContent; + + foundContent = allContent.find(m => m.title.toLowerCase().includes(searchTerm)); + return foundContent; + } + + async getOpenAIResponse() { + const apiKey = state.settings.openaiApiKey; + if (!apiKey) { + return _('chatApiKeyMissing'); + } + + const systemPrompt = `You are a helpful assistant for a movie and TV show enthusiast named CinePlex Assistant. Your main goal is to provide information about films, series, etc. When you identify a movie or series title in your response, you MUST enclose it in double quotes, for example: "The Matrix". Do not mention the user's local library or your ability to access it.`; + + const messagesForApi = [ + { role: 'system', content: systemPrompt }, + ...this.conversationHistory + ]; + + try { + const response = await fetch('https://api.proxyapi.ru/openai/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${apiKey}` + }, + body: JSON.stringify({ + model: 'gpt-4o-2024-05-13', + messages: messagesForApi, + max_tokens: 250, + temperature: 0.7 + }) + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + const errorMessage = errorData?.error?.message || errorData?.error || `Error HTTP ${response.status}`; + console.error('Proxy API Error Response:', errorData); + throw new Error(errorMessage); + } + + const data = await response.json(); + + if (data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content) { + return data.choices[0].message.content; + } else { + console.error('Invalid successful response format from proxy:', data); + throw new Error(_('chatApiInvalidResponse')); + } + + } catch (error) { + console.error('OpenAI Proxy API call failed:', error); + showNotification(_('chatApiError'), 'error'); + return _('chatApiError') + `: ${error.message}`; + } + } + + autoResizeTextarea() { + this.dom.input.style.height = 'auto'; + const scrollHeight = this.dom.input.scrollHeight; + if (scrollHeight > 200) { + this.dom.input.style.height = '200px'; + this.dom.input.style.overflowY = 'auto'; + } else { + this.dom.input.style.height = `${scrollHeight}px`; + this.dom.input.style.overflowY = 'hidden'; + } + } + + scrollToBottom() { + this.dom.messagesContainer.scrollTop = this.dom.messagesContainer.scrollHeight; + } + + startDrag(e) { + if (e.target !== this.dom.header && e.target !== this.dom.header.querySelector('.chat-title')) return; + this.isDragging = true; + const rect = this.dom.window.getBoundingClientRect(); + this.offset.x = e.clientX - rect.left; + this.offset.y = e.clientY - rect.top; + this.dom.header.style.cursor = 'grabbing'; + } + + drag(e) { + if (!this.isDragging) return; + e.preventDefault(); + + let newX = e.clientX - this.offset.x; + let newY = e.clientY - this.offset.y; + + const winWidth = this.dom.window.offsetWidth; + const winHeight = this.dom.window.offsetHeight; + const docWidth = document.documentElement.clientWidth; + const docHeight = document.documentElement.clientHeight; + + newX = Math.max(0, Math.min(newX, docWidth - winWidth)); + newY = Math.max(0, Math.min(newY, docHeight - winHeight)); + + this.dom.window.style.left = `${newX}px`; + this.dom.window.style.top = `${newY}px`; + this.dom.window.style.bottom = 'auto'; + this.dom.window.style.right = 'auto'; + } + + stopDrag() { + this.isDragging = false; + this.dom.header.style.cursor = 'move'; + } + + async addMediaActionButtons(messageEl, text) { + const contentTitles = this.extractContentTitles(text); + if (contentTitles.length === 0) return; + + const allContent = []; + for (const title of contentTitles) { + const content = await this.findLocalContent(title); + if (content) { + allContent.push(content); + } + } + + if (allContent.length > 0) { + allContent.forEach(content => { + const itemContainer = document.createElement('div'); + itemContainer.className = 'chat-item-actions'; + + const titleEl = document.createElement('span'); + titleEl.className = 'chat-action-title'; + titleEl.textContent = content.title; + itemContainer.appendChild(titleEl); + + const buttonContainer = document.createElement('div'); + buttonContainer.className = 'chat-action-buttons'; + + const buttons = [ + { label: _('moreInfo'), action: 'info', icon: 'ℹ️' }, + { label: _('addStream'), action: 'stream', icon: '▶️' }, + { label: _('download'), action: 'download', icon: '⬇️' } + ]; + + buttons.forEach(btnInfo => { + const button = document.createElement('button'); + button.innerHTML = `${btnInfo.icon} ${btnInfo.label}`; + button.addEventListener('click', () => this.handleMediaAction(btnInfo.action, content, button)); + buttonContainer.appendChild(button); + }); + itemContainer.appendChild(buttonContainer); + messageEl.appendChild(itemContainer); + }); + + if (allContent.length > 1) { + const downloadAllButtonContainer = document.createElement('div'); + downloadAllButtonContainer.className = 'chat-action-buttons chat-download-all'; + const downloadAllButton = document.createElement('button'); + downloadAllButton.innerHTML = `⬇️ ${_('downloadAll')}`; + downloadAllButton.addEventListener('click', (e) => { + const streams = allContent.map(content => ({ title: content.title, type: content.type })); + downloadM3U(streams, e.target); + }); + downloadAllButtonContainer.appendChild(downloadAllButton); + messageEl.appendChild(downloadAllButtonContainer); + } + } + } + + extractContentTitles(text) { + const matches = text.match(/"([^"]+)"/g); + if (!matches) return []; + return matches.map(match => match.substring(1, match.length - 1)); + } + + handleMediaAction(action, content, buttonEl) { + switch (action) { + case 'info': + showItemDetails(Number(content.id), content.type); + this.close(); + break; + case 'stream': + addStreamToList(content.title, content.type, buttonEl); + break; + case 'download': + downloadM3U(content.title, content.type, buttonEl); + break; + default: + showNotification(`Acción desconocida: ${action}`, 'warning'); + } + } +} \ No newline at end of file diff --git a/js/config.js b/js/config.js index 9cde9d5..50e56f9 100644 --- a/js/config.js +++ b/js/config.js @@ -2,4 +2,5 @@ export const config = { defaultApiKey: '4e44d9029b1270a757cddc766a1bcb63', dbName: 'PlexDB', dbVersion: 9, + defaultOpenaiApiKey: 'sk-r5HgSL5o102oGaXEuF13P5sNqpLtDVDq', }; \ No newline at end of file diff --git a/js/main.js b/js/main.js index 8dae4a3..b68106f 100644 --- a/js/main.js +++ b/js/main.js @@ -3,6 +3,7 @@ import { config } from './config.js'; import { initDB, getFromDB } from './db.js'; import { MusicPlayer } from './musicPlayer.js'; import { ActivityViewer } from './activityViewer.js'; +import { Chat } from './chat.js'; import { setupEventListeners } from './eventListeners.js'; import { fetchAllProviders, renderProviders, getRegions } from './providers.js'; import { loadInitialContent, initializeFavorites, initializeUserData, loadLocalContent, applyTheme, applyHeroVisibility } from './ui.js'; @@ -15,12 +16,10 @@ async function loadSettings() { state.settings = { ...state.settings, ...settingsData[0] }; } - // Ensure a default region is set if none exists if (!state.settings.watchRegion) { state.settings.watchRegion = 'US'; } - // Ensure language is always set, fallback to UI language if (!state.settings.language) { state.settings.language = chrome.i18n.getUILanguage().split('-')[0]; } @@ -36,7 +35,6 @@ async function loadSettings() { } catch (error) { console.error("Could not load settings from DB, using defaults.", error); - // Fallback to defaults in case of any error state.settings.watchRegion = 'US'; } } @@ -54,7 +52,7 @@ document.addEventListener('DOMContentLoaded', async () => { state.musicPlayer = new MusicPlayer(); state.musicPlayer.setDB(state.db); state.activityViewer = new ActivityViewer(document.getElementById('activityViewerModal')); - + state.chat = new Chat(); initializeFavorites(); initializeUserData(); diff --git a/js/php-script-generator.js b/js/php-script-generator.js new file mode 100644 index 0000000..1e7c10d --- /dev/null +++ b/js/php-script-generator.js @@ -0,0 +1,59 @@ +import { showNotification, _ } from './utils.js'; + +export const phpScriptGenerator = (() => { + let dom = {}; + + function cacheDom() { + const settingsModal = document.getElementById('settingsModal'); + if (!settingsModal) return false; + + dom.secretKeyCheck = settingsModal.querySelector('#phpSecretKeyCheck'); + dom.secretKey = settingsModal.querySelector('#phpSecretKey'); + dom.savePath = settingsModal.querySelector('#phpSavePath'); + dom.filename = settingsModal.querySelector('#phpFilename'); + dom.fileActionAppendRadio = settingsModal.querySelector('#phpFileActionAppend'); + dom.generatedCode = settingsModal.querySelector('#generatedPhpCode'); + dom.generateBtn = settingsModal.querySelector('#generatePhpScriptBtn'); + dom.copyBtn = settingsModal.querySelector('#copyPhpScriptBtn'); + + return dom.generateBtn && dom.copyBtn; + } + + function init() { + if (!cacheDom()) { + return; + } + dom.generateBtn.addEventListener('click', generatePhpScript); + dom.copyBtn.addEventListener('click', copyScript); + } + + function generatePhpScript() { + const useSecretKey = dom.secretKeyCheck.checked; + const secretKey = dom.secretKey.value.trim(); + const savePath = dom.savePath.value.trim(); + const filename = dom.filename.value.trim() || 'CinePlex_Playlist.m3u'; + const appendToFile = dom.fileActionAppendRadio.checked; + + let script = ` $success, 'message' => $message, 'filename' => $filename]);\n exit;\n}\n`; + if (useSecretKey) { + script += `\n$auth_key = isset($_SERVER['HTTP_X_SECRET_KEY']) ? $_SERVER['HTTP_X_SECRET_KEY'] : '';\nif (!defined('SECRET_KEY') || SECRET_KEY === '' || $auth_key !== SECRET_KEY) {\n sendResponse(false, 'Acceso no autorizado. Clave secreta inválida o no proporcionada.', '', 403);\n}\n`; + } + script += `\n$json_data = file_get_contents('php://input');\n$data = json_decode($json_data, true);\n\nif (json_last_error() !== JSON_ERROR_NONE) {\n sendResponse(false, 'Error: Datos JSON inválidos.');\n}\n\nif (!isset($data['streams']) || !is_array($data['streams']) || empty($data['streams'])) {\n sendResponse(false, 'Error: El JSON debe contener un array "streams" no vacío.');\n}\n\n$save_dir = SAVE_DIRECTORY !== '' ? rtrim(SAVE_DIRECTORY, '/\\') : __DIR__;\n\nif (!is_dir($save_dir) || !is_writable($save_dir)) {\n sendResponse(false, 'Error del servidor: El directorio de destino no existe o no tiene permisos de escritura.', '', 500);\n}\n\n$safe_filename = preg_replace('/[^\\w\\s._-]/', '', basename(FILENAME));\n$safe_filename = preg_replace('/\\s+/', '_', $safe_filename);\n$target_path = $save_dir . DIRECTORY_SEPARATOR . $safe_filename;\n\n$content_to_write = "";\n\nif (FILE_ACTION_APPEND) {\n $file_exists = file_exists($target_path);\n if (!$file_exists) {\n $content_to_write .= "#EXTM3U\\n";\n }\n foreach ($data['streams'] as $stream) {\n if (isset($stream['extinf'], $stream['url'])) {\n $content_to_write .= trim($stream['extinf']) . "\\n";\n $content_to_write .= trim($stream['url']) . "\\n";\n }\n }\n if (file_put_contents($target_path, $content_to_write, FILE_APPEND | LOCK_EX) !== false) {\n sendResponse(true, 'Streams añadidos correctamente al archivo.', $safe_filename, 200);\n } else {\n sendResponse(false, 'Error del servidor: No se pudo añadir contenido al archivo.', '', 500);\n }\n} else { // Overwrite mode\n $content_to_write = "#EXTM3U\\n";\n foreach ($data['streams'] as $stream) {\n if (isset($stream['extinf'], $stream['url'])) {\n $content_to_write .= trim($stream['extinf']) . "\\n";\n $content_to_write .= trim($stream['url']) . "\\n";\n }\n }\n if (file_put_contents($target_path, $content_to_write, LOCK_EX) !== false) {\n sendResponse(true, 'Archivo de streams sobrescrito correctamente.', $safe_filename, 201);\n } else {\n sendResponse(false, 'Error del servidor: No se pudo escribir el archivo.', '', 500);\n }\n}\n?>`; + dom.generatedCode.value = script; + showNotification(_("scriptGenerated"), "success"); + } + + function copyScript() { + if (!dom.generatedCode.value || dom.generatedCode.value.trim() === '') { + showNotification(_("errorGeneratingScript"), "warning"); + return; + } + navigator.clipboard.writeText(dom.generatedCode.value).then(() => { + showNotification(_("scriptCopied"), "success"); + }).catch(err => { + showNotification(_("errorCopyingScript"), "error"); + }); + } + + return { init }; +})(); \ No newline at end of file diff --git a/js/plex.js b/js/plex.js index 3cd7c36..82f5099 100644 --- a/js/plex.js +++ b/js/plex.js @@ -87,9 +87,11 @@ async function parseAndStoreSectionItems(contentXml, storeName, serverData) { if (type === 'movie' || type === 'show') { const itemSelector = type === 'movie' ? 'Video' : 'Directory'; items = Array.from(contentXml.querySelectorAll(itemSelector)).map(el => ({ + id: el.getAttribute('ratingKey'), title: el.getAttribute('title'), year: el.getAttribute('year'), - genre: el.querySelector('Genre')?.getAttribute('tag') || _('noGenre') + genre: el.querySelector('Genre')?.getAttribute('tag') || _('noGenre'), + type: type })); } else if (type === 'artist' || type === 'photo') { items = Array.from(contentXml.querySelectorAll('Directory')).map(el => ({ diff --git a/js/state.js b/js/state.js index 9a70c46..cb3e788 100644 --- a/js/state.js +++ b/js/state.js @@ -1,3 +1,5 @@ +import { config } from './config.js'; + export const state = { currentPage: 1, currentView: 'movies', @@ -5,6 +7,7 @@ export const state = { settings: { id: 'user_settings', apiKey: '', + openaiApiKey: config.defaultOpenaiApiKey || '', theme: 'dark', showHero: true, language: 'es', @@ -46,6 +49,7 @@ export const state = { isScanningJellyfin: false, musicPlayer: null, activityViewer: null, + chat: null, currentContentFetchController: null, plexScanAbortController: null, aceEditor: null, diff --git a/js/ui.js b/js/ui.js index e2a0c89..056bf95 100644 --- a/js/ui.js +++ b/js/ui.js @@ -1462,48 +1462,7 @@ export async function addStreamToList(title, type, buttonElement = null) { } } -export async function downloadM3U(title, type, buttonElement = null) { - if (state.isDownloadingM3U) return; - state.isDownloadingM3U = true; - let originalButtonContent = null; - if (buttonElement) { - originalButtonContent = buttonElement.innerHTML; - buttonElement.disabled = true; - buttonElement.innerHTML = ``; - } - - showNotification(_('generatingM3U', title), "info"); - - try { - const streamData = await fetchAllAvailableStreams(title, type); - - if (!streamData.success || streamData.streams.length === 0) throw new Error(streamData.message); - - let m3uContent = "#EXTM3U\n"; - streamData.streams.forEach(stream => { - m3uContent += `${stream.extinf}\n${stream.url}\n`; - }); - - const blob = new Blob([m3uContent], { type: "audio/x-mpegurl;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${title.replace(/[^a-z0-9]/gi, '_')}.m3u`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - showNotification(_('m3uDownloaded', title), 'success'); - } catch (error) { - showNotification(_('errorGeneratingM3U', error.message), "error"); - } finally { - state.isDownloadingM3U = false; - if (buttonElement && originalButtonContent) { - buttonElement.innerHTML = originalButtonContent; - buttonElement.disabled = false; - } - } -} +export async function downloadM3U(items, buttonElement = null) { if (state.isDownloadingM3U) return; state.isDownloadingM3U = true; let originalButtonContent = null; if (buttonElement) { originalButtonContent = buttonElement.innerHTML; buttonElement.disabled = true; buttonElement.innerHTML = ``; } const itemsArray = Array.isArray(items) ? items : [{ title: items, type: arguments[1] }]; const collectiveTitle = itemsArray.length > 1 ? 'CinePlex_Playlist' : itemsArray[0].title; showNotification(_('generatingM3U', collectiveTitle), "info"); try { let m3uContent = "#EXTM3U\n"; let streamsFound = 0; for (const item of itemsArray) { try { const streamData = await fetchAllAvailableStreams(item.title, item.type); if (streamData.success && streamData.streams.length > 0) { streamsFound += streamData.streams.length; streamData.streams.forEach(stream => { m3uContent += `${stream.extinf}\n${stream.url}\n`; }); } } catch (error) { console.warn(`Could not fetch streams for ${item.title}:`, error); } } if (streamsFound === 0) throw new Error(_('noStreamsFoundForSelection')); const blob = new Blob([m3uContent], { type: "audio/x-mpegurl;charset=utf-8" }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${collectiveTitle.replace(/[^a-z0-9]/gi, '_')}.m3u`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); showNotification(_('m3uDownloaded', collectiveTitle), 'success'); } catch (error) { showNotification(_('errorGeneratingM3U', error.message), "error"); } finally { state.isDownloadingM3U = false; if (buttonElement && originalButtonContent) { buttonElement.innerHTML = originalButtonContent; buttonElement.disabled = false; } }} export function showTrailer(key) { const lightbox = document.getElementById('video-lightbox'); @@ -1563,6 +1522,7 @@ export function activateSettingsTab(tabId) { export function openSettingsModal() { document.getElementById('tmdbApiKey').value = state.settings.apiKey; + document.getElementById('openaiApiKey').value = state.settings.openaiApiKey || ''; document.getElementById('phpScriptUrl').value = state.settings.phpScriptUrl || ''; document.getElementById('lightModeToggle').checked = state.settings.theme === 'light'; document.getElementById('showHeroToggle').checked = state.settings.showHero; @@ -1589,6 +1549,7 @@ export async function saveSettings() { const newSettings = { id: 'user_settings', apiKey: document.getElementById('tmdbApiKey').value.trim(), + openaiApiKey: document.getElementById('openaiApiKey').value.trim(), theme: document.getElementById('lightModeToggle').checked ? 'light' : 'dark', showHero: document.getElementById('showHeroToggle').checked, phpScriptUrl: document.getElementById('phpScriptUrl').value.trim(), diff --git a/plex.html b/plex.html index 460134b..96f45e7 100644 --- a/plex.html +++ b/plex.html @@ -331,8 +331,6 @@ - -
+