// MusicRoom - Utilities module // DOM helpers, formatting, toast notifications (function() { const M = window.MusicRoom; // DOM selector helper M.$ = (s) => document.querySelector(s); // Shared HTML escaping helper - use for any server-controlled string // interpolated into innerHTML (text and attribute contexts) M.escapeHtml = function(str) { if (str == null) return ""; return String(str) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); }; // Format seconds as m:ss M.fmt = function(sec) { if (!sec || !isFinite(sec)) return "0:00"; const m = Math.floor(sec / 60); const s = Math.floor(sec % 60); return m + ":" + String(s).padStart(2, "0"); }; // Toast history M.toastHistory = []; function pruneVisibleToasts(container) { const limit = window.matchMedia("(max-width: 768px)").matches ? 2 : 3; const toasts = [...container.querySelectorAll(".toast:not(.toast-confirm)")]; while (toasts.length > limit) { toasts.shift().remove(); } } // Toast notifications (log style - multiple visible) M.showToast = function(message, type = "info", duration = 5000) { const container = M.$("#toast-container"); const toast = document.createElement("div"); toast.className = "toast toast-" + type; toast.textContent = message; container.appendChild(toast); pruneVisibleToasts(container); setTimeout(() => { toast.classList.add("fade-out"); setTimeout(() => toast.remove(), 300); }, duration); // Add to history M.toastHistory.push({ message, type, time: new Date() }); M.updateToastHistory(); }; M.showConfirmToast = function(message, onConfirm, options = {}) { const container = M.$("#toast-container"); const toast = document.createElement("div"); toast.className = "toast toast-warning toast-confirm"; const text = document.createElement("span"); text.className = "toast-confirm-message"; text.textContent = message; const actions = document.createElement("div"); actions.className = "toast-actions"; const cancel = document.createElement("button"); cancel.type = "button"; cancel.className = "toast-action-cancel"; cancel.textContent = options.cancelText || "Cancel"; const confirm = document.createElement("button"); confirm.type = "button"; confirm.className = "toast-action-confirm"; confirm.textContent = options.confirmText || "Confirm"; actions.append(cancel, confirm); toast.append(text, actions); container.appendChild(toast); let settled = false; const close = () => { toast.classList.add("fade-out"); setTimeout(() => toast.remove(), 300); }; const finish = async (confirmed) => { if (settled) return; settled = true; close(); if (confirmed) await onConfirm(); }; cancel.onclick = () => finish(false); confirm.onclick = () => finish(true); setTimeout(() => finish(false), options.duration || 12000); M.toastHistory.push({ message: `Confirm: ${message}`, type: "warning", time: new Date() }); M.updateToastHistory(); }; // Update toast history panel M.updateToastHistory = function() { const list = M.$("#toast-history-list"); if (!list) return; list.innerHTML = ""; // Show newest first const items = [...M.toastHistory].reverse().slice(0, 50); for (const item of items) { const div = document.createElement("div"); div.className = "history-item history-" + item.type; const time = item.time.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); div.innerHTML = `${time} ${M.escapeHtml(item.message)}`; list.appendChild(div); } }; // Toggle toast history panel M.toggleToastHistory = function() { const panel = M.$("#toast-history"); if (panel) { panel.classList.toggle("hidden"); if (!panel.classList.contains("hidden")) { M.updateToastHistory(); } } }; // Flash permission denied animation M.flashPermissionDenied = function() { const row = M.$("#progress-row"); row.classList.remove("denied"); void row.offsetWidth; // Trigger reflow to restart animation row.classList.add("denied"); setTimeout(() => row.classList.remove("denied"), 500); }; // Set track title (UI and document title) M.setTrackTitle = function(title) { M.currentTitle = title; const containerEl = M.$("#track-name"); const marqueeEl = containerEl?.querySelector(".marquee-inner"); if (!containerEl || !marqueeEl) return; document.title = title ? `${title} - MusicRoom` : "MusicRoom"; // First set simple content to measure marqueeEl.innerHTML = `${M.escapeHtml(title)}`; // Check if title overflows and needs scrolling requestAnimationFrame(() => { const titleEl = M.$("#track-title"); const needsScroll = titleEl && titleEl.scrollWidth > containerEl.clientWidth; containerEl.classList.toggle("scrolling", needsScroll); // Duplicate text for seamless wrap-around scrolling if (needsScroll) { marqueeEl.innerHTML = `${M.escapeHtml(title)}   •   ${M.escapeHtml(title)}   •   `; } }); }; // Get current server time (extrapolated) M.getServerTime = function() { if (M.serverPaused) return M.serverTimestamp; return M.serverTimestamp + (Date.now() - M.lastServerUpdate) / 1000; }; // Check if current user can control playback M.canControl = function() { if (!M.currentUser) return false; if (M.currentUser.isAdmin) return true; if (M.currentUser.isGuest) return false; return M.currentUser.permissions?.some(p => p.resource_type === "channel" && (p.resource_id === M.currentChannelId || p.resource_id === null) && p.permission === "control" ); }; M.canCreateUserContent = function() { return !!M.currentUser && !M.currentUser.isGuest; }; M.updatePermissionUI = function() { const canCreate = M.canCreateUserContent(); const newChannel = M.$("#btn-new-channel"); const newPlaylist = M.$("#btn-new-playlist"); const fetchUrl = M.$("#btn-fetch-url"); if (newChannel) newChannel.classList.toggle("hidden", !canCreate); if (newPlaylist) newPlaylist.classList.toggle("hidden", !canCreate); if (fetchUrl) { const ytdlpReady = M.serverStatus?.ytdlp?.enabled && M.serverStatus?.ytdlp?.available; fetchUrl.style.display = canCreate && ytdlpReady ? "" : "none"; } }; })();