212 lines
6.8 KiB
JavaScript
212 lines
6.8 KiB
JavaScript
// 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, """)
|
|
.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 = `<span class="history-time">${time}</span> ${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 = `<span id="track-title">${M.escapeHtml(title)}</span>`;
|
|
|
|
// 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 = `<span id="track-title">${M.escapeHtml(title)}</span><span class="marquee-spacer"> • </span><span>${M.escapeHtml(title)}</span><span class="marquee-spacer"> • </span>`;
|
|
}
|
|
});
|
|
};
|
|
|
|
// 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";
|
|
}
|
|
};
|
|
})();
|