// KefinTweaks Toaster // Provides toast notification functionality using Jellyfin's existing toast system // Usage: window.KefinTweaksToaster.toast(message, duration, autoHide, options) (function() { 'use strict'; // Common logging function const LOG = (...args) => console.log('[KefinTweaks Toaster]', ...args); const WARN = (...args) => console.warn('[KefinTweaks Toaster]', ...args); const ERR = (...args) => console.error('[KefinTweaks Toaster]', ...args); LOG('Initializing'); let stylesInjected = false; function ensureToastStyles() { if (stylesInjected) return; stylesInjected = true; const style = document.createElement('style'); style.textContent = ` .toast.kefin-toast-rich { display: flex; align-items: center; gap: 0.75em; max-width: min(28rem, calc(100vw - 2rem)); } .toast.kefin-toast-rich .kefin-toast-message { flex: 1 1 auto; min-width: 0; line-height: 1.35; } .toast.kefin-toast-rich .kefin-toast-link { color: inherit; text-decoration: underline; cursor: pointer; } .toast.kefin-toast-rich .kefin-toast-action { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0; margin: 0; border: none; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; } .toast.kefin-toast-rich .kefin-toast-action:hover { background: rgba(255, 255, 255, 0.12); } .toast.kefin-toast-rich .kefin-toast-action .material-icons { font-size: 1.25rem; } .toast.kefin-toast-rich.kefin-toast-with-buttons { flex-wrap: wrap; align-items: flex-start; } .toast.kefin-toast-rich .kefin-toast-buttons { flex: 1 1 100%; display: flex; flex-wrap: wrap; gap: 0.5em; margin-top: 0.15em; } .toast.kefin-toast-rich .kefin-toast-text-btn { flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center; padding: 0.35em 0.9em; margin: 0; border: 1px solid rgba(255, 255, 255, 0.28); border-radius: 4px; background: rgba(255, 255, 255, 0.08); color: inherit; font: inherit; font-weight: 500; cursor: pointer; } .toast.kefin-toast-rich .kefin-toast-text-btn:hover { background: rgba(255, 255, 255, 0.16); } `; document.head.appendChild(style); } /** * Get the toast container * The container is the last child at the root of body with class toastContainer * @returns {HTMLElement|null} The toast container element or null if not found */ function getToastContainer() { // Look for existing toastContainer at the root of body (last child) const container = document.querySelector('body > .toastContainer:last-child'); if (container) { return container; } // Fallback: try to find any toastContainer const anyContainer = document.querySelector('.toastContainer'); if (anyContainer) { return anyContainer; } return null; } /** * Convert duration to milliseconds * @param {string|number} duration - Duration in seconds (string like "3s") or milliseconds (number) * @returns {number} Duration in milliseconds */ function parseDuration(duration) { if (typeof duration === 'number') { return duration; } if (typeof duration === 'string') { // Remove 's' if present and convert to number const seconds = parseFloat(duration.replace('s', '')); if (!isNaN(seconds)) { return seconds * 1000; } } // Default to 3 seconds return 3000; } function dismissToastElement(toastElement, container, customContainer) { if (!toastElement?.parentNode) return; toastElement.classList.add('toastHide'); setTimeout(() => { if (toastElement.parentNode) { toastElement.parentNode.removeChild(toastElement); } if (customContainer && container && !container.querySelector('.toast')) { container.remove(); } }, 3000); } function buildToastContent(toastElement, message, options) { const link = options?.link; const action = options?.action; const buttons = Array.isArray(options?.buttons) ? options.buttons.filter((b) => b && typeof b.label === 'string' && typeof b.onClick === 'function') : []; const rich = !!(link || action || buttons.length); if (!rich) { toastElement.textContent = message; return; } ensureToastStyles(); toastElement.classList.add('kefin-toast-rich'); if (buttons.length) { toastElement.classList.add('kefin-toast-with-buttons'); } const messageEl = document.createElement('span'); messageEl.className = 'kefin-toast-message'; if (link?.label && typeof message === 'string' && message.includes(link.label)) { const idx = message.indexOf(link.label); if (idx > 0) { messageEl.appendChild(document.createTextNode(message.slice(0, idx))); } const anchor = document.createElement('a'); anchor.className = 'kefin-toast-link'; anchor.href = link.href || '#'; anchor.textContent = link.label; anchor.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); if (typeof link.onClick === 'function') { link.onClick(); } else if (link.href) { if (window.Dashboard?.navigate) { window.Dashboard.navigate(link.href); } else { window.location.hash = link.href.startsWith('#') ? link.href : `#/${link.href}`; } } }); messageEl.appendChild(anchor); const after = message.slice(idx + link.label.length); if (after) { messageEl.appendChild(document.createTextNode(after)); } } else { messageEl.textContent = message; if (link?.label) { messageEl.appendChild(document.createTextNode(' ')); const anchor = document.createElement('a'); anchor.className = 'kefin-toast-link'; anchor.href = link.href || '#'; anchor.textContent = link.label; anchor.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); if (typeof link.onClick === 'function') link.onClick(); else if (link.href && window.Dashboard?.navigate) { window.Dashboard.navigate(link.href); } }); messageEl.appendChild(anchor); } } toastElement.appendChild(messageEl); if (action && typeof action.onClick === 'function') { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'kefin-toast-action'; btn.title = action.label || 'Undo'; btn.setAttribute('aria-label', action.label || 'Undo'); const icon = document.createElement('span'); icon.className = 'material-icons'; icon.setAttribute('aria-hidden', 'true'); icon.textContent = action.icon || 'undo'; btn.appendChild(icon); btn.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); try { action.onClick(); } catch (e) { ERR('Toast action failed:', e); } }); toastElement.appendChild(btn); } if (buttons.length) { const row = document.createElement('div'); row.className = 'kefin-toast-buttons'; buttons.forEach((spec) => { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'kefin-toast-text-btn'; btn.textContent = spec.label; btn.addEventListener('click', (ev) => { ev.preventDefault(); ev.stopPropagation(); try { spec.onClick(); } catch (e) { ERR('Toast button action failed:', e); } }); row.appendChild(btn); }); toastElement.appendChild(row); } } /** * Display a toast message * @param {string} message - The message to display * @param {string|number} duration - Duration in seconds * @param {boolean} autoHide - Whether to automatically hide the toast after duration (default: true) * @param {{ link?: { label: string, href?: string, onClick?: Function }, action?: { icon?: string, label?: string, onClick: Function }, buttons?: Array<{ label: string, onClick: Function }> }} [options] * @returns {{ dismiss: Function, element: HTMLElement }|undefined} */ function toast(message, duration = '3', autoHide = true, options = null) { if (!message) { WARN('Toast message is required'); return; } let container = getToastContainer(); let customContainer = false; if (!container) { // Create a new toast container const newContainer = document.createElement('div'); newContainer.className = 'toastContainer'; document.body.appendChild(newContainer); container = newContainer; customContainer = true; } // Remove any existing toast element from the container const existingToast = container.querySelector('.toast'); if (existingToast) { existingToast.remove(); LOG('Removed existing toast to make room for new one'); } // Create the toast element with just 'toast' class initially const toastElement = document.createElement('div'); toastElement.className = 'toast'; buildToastContent(toastElement, message, options || {}); // Add to container first container.appendChild(toastElement); LOG(`Displaying toast: "${message}"`); // Force a browser reflow by reading a layout property // This ensures the element is rendered with just 'toast' class before adding 'toastVisible' void toastElement.offsetHeight; // Now add toastVisible class - the browser will see the state change and apply the transition toastElement.classList.add('toastVisible'); const dismiss = () => dismissToastElement(toastElement, container, customContainer); // Only set up auto-hide if enabled if (autoHide) { // Calculate duration in milliseconds const durationMs = parseDuration(duration); // After the duration, start the fade-out setTimeout(() => { dismiss(); }, durationMs); } return { dismiss, element: toastElement }; } // Expose toaster to global scope window.KefinTweaksToaster = { toast }; // Drain deferred root-update toast queued by kefinTweaks-plugin before toaster loaded try { const pending = window.__kefinPendingRootUpdateToast; if (pending?.message) { window.__kefinPendingRootUpdateToast = null; toast(pending.message, '5'); } } catch (e) { WARN('Failed to flush pending root-update toast:', e); } LOG('Initialized successfully'); LOG('Available at window.KefinTweaksToaster'); })();