diff --git a/assets/js/creations/server/server.js b/assets/js/creations/server/server.js index 780449b4..e0d57fbd 100755 --- a/assets/js/creations/server/server.js +++ b/assets/js/creations/server/server.js @@ -12,12 +12,14 @@ const pino = require("pino"); const os = require("os"); const crypto = require("crypto"); const { spawn } = require("child_process"); +const pendingSaves = new Set(); + // --- RASTREAMENTO DE USUÁRIOS --- const activeUsers = {}; // Mapeia socket.id -> { username, room } //import { LOG_SERVER } from "../utils.js" -const LOG_SERVER = `/var/www/html/trens/src_mmpSearch/logs/creation_logs/server` -const SESSION_JSON = `/var/www/html/trens/src_mmpSearch/logs/creation_logs/sessions` +const LOG_SERVER = `/var/www/html/trens/src_mmpSearch/logs/creation_logs/server`; +const SESSION_JSON = `/var/www/html/trens/src_mmpSearch/logs/creation_logs/sessions`; const DEFAULT_PROJECT_XML = ` @@ -99,7 +101,6 @@ const DEFAULT_PROJECT_XML = ` `; - // ------------------------- // LOGGER DINÂMICO (GERENCIADOR) // ------------------------- @@ -153,20 +154,26 @@ const app = express(); const PORT = process.env.PORT || 33001; // ====== HTTPS (Opção B) ====== -const CERT_FULLCHAIN = process.env.SSL_FULLCHAIN || "/etc/letsencrypt/live/alice.ufsj.edu.br/fullchain.pem"; -const CERT_PRIVKEY = process.env.SSL_PRIVKEY || "/etc/letsencrypt/live/alice.ufsj.edu.br/privkey.pem"; +const CERT_FULLCHAIN = + process.env.SSL_FULLCHAIN || + "/etc/letsencrypt/live/alice.ufsj.edu.br/fullchain.pem"; +const CERT_PRIVKEY = + process.env.SSL_PRIVKEY || + "/etc/letsencrypt/live/alice.ufsj.edu.br/privkey.pem"; if (!fs.existsSync(CERT_FULLCHAIN) || !fs.existsSync(CERT_PRIVKEY)) { - console.error("[HTTPS] Certificados não encontrados.\n" + - ` fullchain: ${CERT_FULLCHAIN}\n` + - ` privkey : ${CERT_PRIVKEY}\n` + - "Defina SSL_FULLCHAIN/SSL_PRIVKEY ou instale os certificados no caminho padrão."); + console.error( + "[HTTPS] Certificados não encontrados.\n" + + ` fullchain: ${CERT_FULLCHAIN}\n` + + ` privkey : ${CERT_PRIVKEY}\n` + + "Defina SSL_FULLCHAIN/SSL_PRIVKEY ou instale os certificados no caminho padrão.", + ); process.exit(1); } const httpsOptions = { cert: fs.readFileSync(CERT_FULLCHAIN), - key : fs.readFileSync(CERT_PRIVKEY), + key: fs.readFileSync(CERT_PRIVKEY), }; const httpsServer = https.createServer(httpsOptions, app); @@ -194,14 +201,14 @@ function dataFile(roomName) { function ensureRoom(roomName) { if (roomStates[roomName]) return roomStates[roomName]; - // tenta carregar do disco try { const p = dataFile(roomName); if (fs.existsSync(p)) { const j = JSON.parse(fs.readFileSync(p, "utf8")); roomStates[roomName] = { - // 🔥 CORREÇÃO: Fallback para DEFAULT_PROJECT_XML se o JSON salvo estiver sem XML - projectXml: j.projectXml || DEFAULT_PROJECT_XML, + // ✅ Mantém o patternState em vez do projectXml gigante + patternState: j.patternState || null, + projectXml: j.projectXml || DEFAULT_PROJECT_XML, // Fallback para renderização audio: j.audio || { tracks: [], clips: [] }, seq: j.seq || 0, tokensSeen: new Set(), @@ -212,9 +219,8 @@ function ensureRoom(roomName) { console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e); } - // Cria sala NOVA na memória roomStates[roomName] = { - // 🔥 CORREÇÃO: Inicia com o XML do LMMS completo, não mais null + patternState: null, projectXml: DEFAULT_PROJECT_XML, audio: { tracks: [], clips: [] }, seq: 0, @@ -225,28 +231,54 @@ function ensureRoom(roomName) { function saveRoom(roomName) { const r = ensureRoom(roomName); - try { - fs.mkdirSync(SESSION_JSON, { recursive: true }); - fs.writeFileSync( - dataFile(roomName), - JSON.stringify({ projectXml: r.projectXml, audio: r.audio, seq: r.seq }) - ); - } catch (e) { - console.warn(`[persist] falha ao salvar estado da sala ${roomName}:`, e); - } + + // Se já tem um salvamento agendado para esta sala, ignora + if (pendingSaves.has(roomName)) return; + pendingSaves.add(roomName); + + // Aguarda 2 segundos antes de salvar (Debounce) + setTimeout(() => { + try { + fs.mkdirSync(SESSION_JSON, { recursive: true }); + // Extraia a stringificação do Event Loop principal usando promessas ou apenas aceite o custo mitigado pelo debounce + const payload = JSON.stringify({ + patternState: r.patternState, + projectXml: r.projectXml, // Mantemos o backup antigo se existir + audio: r.audio, + seq: r.seq, + }); + + fs.writeFile(dataFile(roomName), payload, (err) => { + pendingSaves.delete(roomName); // Libera para futuros salvamentos + if (err) + console.warn( + `[persist] Erro assíncrono ao salvar sala ${roomName}:`, + err, + ); + }); + } catch (e) { + pendingSaves.delete(roomName); + console.warn( + `[persist] falha ao preparar salvamento da sala ${roomName}:`, + e, + ); + } + }, 2000); // 2 segundos de respiro para o servidor } // ------------------------- -// Server / IO +// Server / IO // ------------------------- const io = new Server(httpsServer, { cors: { // CORREÇÃO: Simplificado para a origem real do cliente, que é onde o HTML é servido. // O cliente provavelmente é servido a partir de https://alice.ufsj.edu.br (porta 443). - origin: "https://alice.ufsj.edu.br", + origin: "https://alice.ufsj.edu.br", methods: ["GET", "POST"], credentials: true, }, + // Evita que o servidor chute o usuário ao receber XMLs pesados! + maxHttpBufferSize: 5e7, }); console.log("Servidor Backend do MMP-Search está no ar!"); @@ -269,15 +301,10 @@ function basenameNoExt(p) { function applyAuthoritativeAction(roomName, action) { const room = ensureRoom(roomName); const state = room.audio; - - // idempotência por token (se fornecido) - if (action.__token && room.tokensSeen.has(action.__token)) { - return null; - } - if (action.__token) room.tokensSeen.add(action.__token); - let mutated = false; + // REMOVIDO: checagem de tokensSeen e if (action.__token) + switch (action.type) { case "ADD_AUDIO_LANE": { // Usa o trackId enviado pelo cliente, se existir @@ -304,7 +331,7 @@ function applyAuthoritativeAction(roomName, action) { ) { console.warn( `[Server] Ação ADD_AUDIO_CLIP rejeitada (dados inválidos):`, - action + action, ); return null; // Rejeita a ação, não faz broadcast } @@ -340,7 +367,7 @@ function applyAuthoritativeAction(roomName, action) { if (!action.clipId || !action.props) { console.warn( `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`, - action + action, ); return null; } @@ -352,7 +379,7 @@ function applyAuthoritativeAction(roomName, action) { ) { console.warn( `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (trackId inválido):`, - action + action, ); return null; } @@ -362,7 +389,7 @@ function applyAuthoritativeAction(roomName, action) { ) { console.warn( `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (startTimeInSeconds inválido):`, - action + action, ); return null; } @@ -377,7 +404,7 @@ function applyAuthoritativeAction(roomName, action) { case "REMOVE_AUDIO_CLIP": { const i = state.clips.findIndex( - (x) => String(x.id) === String(action.clipId) + (x) => String(x.id) === String(action.clipId), ); if (i >= 0) { state.clips.splice(i, 1); @@ -398,13 +425,13 @@ function applyAuthoritativeAction(roomName, action) { // Se removemos a track, os clips dela têm que sumir. const prevCount = state.clips.length; state.clips = state.clips.filter((c) => { - // Mantém apenas se tiver trackId E se esse trackId ainda existir na lista de tracks - const parentTrackExists = state.tracks.some(t => t.id === c.trackId); - return c.trackId && parentTrackExists; + // Mantém apenas se tiver trackId E se esse trackId ainda existir na lista de tracks + const parentTrackExists = state.tracks.some((t) => t.id === c.trackId); + return c.trackId && parentTrackExists; }); if (state.clips.length !== prevCount) { - mutated = true; + mutated = true; } break; } @@ -416,9 +443,9 @@ function applyAuthoritativeAction(roomName, action) { if (!mutated) return null; - room.seq += 1; // numeração autoritativa - saveRoom(roomName); // persistência em disco - return { ...action, __seq: room.seq }; // devolve ação com seq para broadcast + // REMOVIDO: room.seq += 1; e atribuição de __seq + saveRoom(roomName); + return action; // Apenas retorna a ação modificada } io.on("connection", (socket) => { @@ -439,61 +466,83 @@ io.on("connection", (socket) => { activeUsers[socket.id] = { username: finalUserName, room: roomName }; await socket.join(roomName); - console.log(`[ROOM] 🟢 Usuário ${finalUserName} (${socket.id}) entrou na sala: ${roomName}`); + console.log( + `[ROOM] 🟢 Usuário ${finalUserName} (${socket.id}) entrou na sala: ${roomName}`, + ); // Avisa os outros para mostrar o Popup Verde - socket.to(roomName).emit("user_joined", { username: finalUserName, id: socket.id }); + socket + .to(roomName) + .emit("user_joined", { username: finalUserName, id: socket.id }); // Atualiza o monitor de console - const clientsInRoom = Array.from(io.sockets.adapter.rooms.get(roomName) || []); + const clientsInRoom = Array.from( + io.sockets.adapter.rooms.get(roomName) || [], + ); io.to(roomName).emit("room_status", { - room: roomName, count: clientsInRoom.length, users: clientsInRoom + room: roomName, + count: clientsInRoom.length, + users: clientsInRoom, }); logMyRooms(socket, "JOIN"); const room = ensureRoom(roomName); - if (room.projectXml) { - socket.emit("load_project_state", room.projectXml); + // Envia o JSON se existir, senão envia o XML de fallback + if (room.patternState) { + socket.emit("load_project_state", { patternState: room.patternState }); + } else if (room.projectXml) { + socket.emit("load_project_state", { xml: room.projectXml }); } socket.emit("action_broadcast", { - action: { type: "AUDIO_SNAPSHOT", snapshot: room.audio, __seq: room.seq, __target: socket.id }, + action: { + type: "AUDIO_SNAPSHOT", + snapshot: room.audio, + __seq: room.seq, + __target: socket.id, + }, }); - socket.to(roomName).emit("feedback", `Usuário ${finalUserName} entrou na sala.`); + socket + .to(roomName) + .emit("feedback", `Usuário ${finalUserName} entrou na sala.`); }); -// --- GATILHO DE DESPERTAR ÚNICO --- -socket.on("request_full_sync", ({ room }) => { - const targetRoom = ensureRoom(room); - if (targetRoom && targetRoom.projectXml) { - console.log(`[SYNC] Cliente ${socket.id} acordou e pediu Full Sync da sala ${room}`); - socket.emit("load_project_state", targetRoom.projectXml); - } -}); + // --- GATILHO DE DESPERTAR ÚNICO --- + socket.on("request_full_sync", ({ room }) => { + const targetRoom = ensureRoom(room); + if (targetRoom && targetRoom.projectXml) { + console.log(`[SYNC] Cliente ${socket.id} acordou e pediu Full Sync`); + // 🔥 Manda o XML dentro de um objeto avisando que é um pedido FORÇADO + socket.emit("load_project_state", { + xml: targetRoom.projectXml, + forceSync: true, + }); + } + }); -// ========================================================= -// SAÍDA E LIMPEZA UNIFICADA (Aviso e Desconexão) -// ========================================================= -socket.on("disconnecting", () => { - const user = activeUsers[socket.id]; - if (user) { + // ========================================================= + // SAÍDA E LIMPEZA UNIFICADA (Aviso e Desconexão) + // ========================================================= + socket.on("disconnecting", () => { + const user = activeUsers[socket.id]; + if (user) { console.log(`[ROOM] 🔴 ${user.username} saiu da sala ${user.room}`); socket.to(user.room).emit("user_left", { username: user.username }); - } -}); - -socket.on("disconnect", () => { - delete activeUsers[socket.id]; // Limpa a identidade - console.log(`Cliente desconectado: ${socket.id}`); - - // Remove locks de renderização - for (const [roomName, lockId] of renderLocks.entries()) { - if (lockId === socket.id) { - renderLocks.delete(roomName); - console.log(`Lock de render da sala ${roomName} liberado.`); } - } -}); + }); + + socket.on("disconnect", () => { + delete activeUsers[socket.id]; // Limpa a identidade + console.log(`Cliente desconectado: ${socket.id}`); + + // Remove locks de renderização + for (const [roomName, lockId] of renderLocks.entries()) { + if (lockId === socket.id) { + renderLocks.delete(roomName); + console.log(`Lock de render da sala ${roomName} liberado.`); + } + } + }); // ======================================= // CLOCK SYNC @@ -511,7 +560,7 @@ socket.on("disconnect", () => { cb && cb({ ok: false, error: "invalid_payload" }); console.warn( `[broadcast_action] payload inválido de ${socket.id}:`, - payload + payload, ); return; } @@ -528,7 +577,7 @@ socket.on("disconnect", () => { socketId: socket.id, action: action, }, - "action_received" + "action_received", ); } catch (e) { console.warn(`[Logger] Falha ao logar ação para sala ${roomName}:`, e); @@ -541,7 +590,7 @@ socket.on("disconnect", () => { // Garante que o emissor está na sala (caso o join tenha falhado/atrasado) if (!socket.rooms.has(roomName)) { console.warn( - `[broadcast_action] ${socket.id} NÃO estava na sala ${roomName}. Forçando join...` + `[broadcast_action] ${socket.id} NÃO estava na sala ${roomName}. Forçando join...`, ); await socket.join(roomName); logMyRooms(socket, "FORCED_JOIN"); @@ -550,18 +599,48 @@ socket.on("disconnect", () => { // Carrega estado da sala const room = ensureRoom(roomName); + // ========================================== + // 1. PREVENÇÃO DE ECO DUPLO E MEMORY LEAK + // ========================================== + if (action.__token) { + if (room.tokensSeen.has(action.__token)) { + console.log(`[Deduplicação] Ação repetida ignorada: ${action.type}`); + return; // Mata a ação aqui, não faz broadcast! + } + room.tokensSeen.add(action.__token); + + // Limpa tokens antigos (mantém apenas os últimos 1000) + if (room.tokensSeen.size > 1000) { + const oldestToken = room.tokensSeen.values().next().value; + room.tokensSeen.delete(oldestToken); + } + } + + // ========================================== + // 2. ORDEM AUTORITATIVA GLOBAL (JUIZ) + // ========================================== + room.seq += 1; + action.__seq = room.seq; // TODAS as ações agora ganham um número de sequência + // Persiste estado do Pattern (Notas/Sequenciador) - // Aceita tanto carregamento total quanto atualização de notas - if ((action.type === "LOAD_PROJECT" || action.type === "SYNC_PATTERN_STATE") && action.xml) { - // Proteção: Não salva se o XML for vazio (evita corromper a sala com o bug antigo) - if (action.xml.trim().length > 0) { + if ( + (action.type === "LOAD_PROJECT" || + action.type === "SYNC_PATTERN_STATE") && + action.xml + ) { + const xmlTrimmed = action.xml.trim(); + + // Validação de Integridade: O XML chegou inteiro? + if (xmlTrimmed.length > 0 && xmlTrimmed.endsWith("")) { room.projectXml = action.xml; saveRoom(roomName); console.log( - `[broadcast_action] XML da sala ${roomName} atualizado via ${action.type}.` + `[broadcast_action] XML da sala ${roomName} salvo com segurança (${action.type}).`, ); } else { - console.warn(`[Server] Ignorando XML vazio vindo de ${action.type}`); + console.warn( + `[Server] ALERTA: XML corrompido/incompleto rejeitado de ${socket.id} na ação ${action.type}`, + ); } } @@ -573,18 +652,18 @@ socket.on("disconnect", () => { const idsInRoom = socketsInRoom.map((s) => s.id); console.log( `[broadcast_action] ${action.type} (token=${action.__token}) para sala "${roomName}" com ${idsInRoom.length} cliente(s):`, - idsInRoom + idsInRoom, ); // Se foi uma ação do editor de áudio e foi aplicada, rebroadcast com __seq if (maybeApplied) { - io.to(roomName).emit("action_broadcast", { action: maybeApplied }); + socket.to(roomName).emit("action_broadcast", { action: maybeApplied }); return; } - // Caso contrário, apenas repercute a ação como antes (sem __seq) - io.to(roomName).emit("action_broadcast", { action }); - }); + // Caso contrário, repercute a ação UMA ÚNICA VEZ para todo mundo na sala + socket.to(roomName).emit("action_broadcast", { action }); + }); // <--- Fim do socket.on("broadcast_action") // ======================================= // Retrocompat (daw_action) @@ -594,7 +673,7 @@ socket.on("disconnect", () => { if (!room || !action) return; console.warn( - "[DEPRECATION] Recebido daw_action. Migre para broadcast_action com ACK." + "[DEPRECATION] Recebido daw_action. Migre para broadcast_action com ACK.", ); // (Opcional, mas recomendado) Adicionando log dinâmico aqui também @@ -606,7 +685,7 @@ socket.on("disconnect", () => { socketId: socket.id, action: action, }, - "action_received (deprecated daw_action)" + "action_received (deprecated daw_action)", ); } catch (e) { console.warn(`[Logger] Falha ao logar daw_action para sala ${room}:`, e); @@ -614,7 +693,7 @@ socket.on("disconnect", () => { if (!socket.rooms.has(room)) { console.warn( - `[daw_action] ${socket.id} NÃO estava na sala ${room}. Forçando join...` + `[daw_action] ${socket.id} NÃO estava na sala ${room}. Forçando join...`, ); await socket.join(room); logMyRooms(socket, "FORCED_JOIN(daw)"); @@ -625,7 +704,7 @@ socket.on("disconnect", () => { r.projectXml = action.xml; saveRoom(room); console.log( - `[daw_action] Estado da sala ${room} atualizado (LOAD_PROJECT).` + `[daw_action] Estado da sala ${room} atualizado (LOAD_PROJECT).`, ); } @@ -633,7 +712,7 @@ socket.on("disconnect", () => { const idsInRoom = socketsInRoom.map((s) => s.id); console.log( `[daw_action] ${action.type} para sala "${room}" (${idsInRoom.length} cliente(s)):`, - idsInRoom + idsInRoom, ); io.in(room).emit("action_broadcast", { action }); @@ -668,9 +747,7 @@ app.use(express.json({ limit: "250mb" })); app.use((req, res, next) => { const origin = req.headers.origin; - const allowed = new Set([ - "https://alice.ufsj.edu.br", - ]); + const allowed = new Set(["https://alice.ufsj.edu.br"]); if (origin && allowed.has(origin)) { res.setHeader("Access-Control-Allow-Origin", origin); @@ -700,14 +777,15 @@ function buildLmmsCommand(inputMmp, outputAudio, ext) { return { cmd: LMMS_BIN, args: lmmsArgs }; } - function sanitizeFileName(name) { - return String(name || "projeto") - .normalize("NFKD") - .replace(/[^\w\s.-]/g, "") - .trim() - .replace(/\s+/g, "_") - .slice(0, 120) || "projeto"; + return ( + String(name || "projeto") + .normalize("NFKD") + .replace(/[^\w\s.-]/g, "") + .trim() + .replace(/\s+/g, "_") + .slice(0, 120) || "projeto" + ); } function isSafeZipEntry(name) { @@ -721,12 +799,17 @@ function isSafeZipEntry(name) { async function safeUnzip(zipPath, destDir) { // lista entradas const list = await run("unzip", ["-Z1", zipPath], { timeoutMs: 60_000 }); - const entries = String(list.out || "").split("\n").map(x => x.trim()).filter(Boolean); + const entries = String(list.out || "") + .split("\n") + .map((x) => x.trim()) + .filter(Boolean); - if (!entries.length) throw new Error("zip sem entradas (ou unzip falhou ao listar)"); + if (!entries.length) + throw new Error("zip sem entradas (ou unzip falhou ao listar)"); for (const e of entries) { - if (!isSafeZipEntry(e)) throw new Error(`zip contém caminho inseguro: ${e}`); + if (!isSafeZipEntry(e)) + throw new Error(`zip contém caminho inseguro: ${e}`); } // extrai @@ -747,7 +830,6 @@ async function findFirstMmp(dir) { return null; } - function run(cmd, args, { timeoutMs, cwd } = {}) { return new Promise((resolve, reject) => { const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd }); @@ -758,7 +840,9 @@ function run(cmd, args, { timeoutMs, cwd } = {}) { p.stderr.on("data", (d) => (err += d.toString())); const t = setTimeout(() => { - try { p.kill("SIGKILL"); } catch {} + try { + p.kill("SIGKILL"); + } catch {} reject(new Error(`${cmd} timeout (${timeoutMs}ms)\n${err || out}`)); }, timeoutMs || LMMS_TIMEOUT_MS); @@ -778,26 +862,40 @@ function run(cmd, args, { timeoutMs, cwd } = {}) { // --- ENDPOINT DE MONITORAMENTO DE SALAS (ADMIN) --- app.get("/api/admin/salas", (req, res) => { const roomsData = {}; - + // Itera por todas as salas ativas na memória do Socket.IO io.sockets.adapter.rooms.forEach((value, key) => { - // O Socket.IO cria uma "sala" automática para cada usuário. - // Vamos filtrar para mostrar apenas as salas reais do seu sistema. - if (!io.sockets.sockets.has(key)) { - roomsData[key] = { - total_usuarios: value.size, - ids_conectados: Array.from(value) - }; - } + // O Socket.IO cria uma "sala" automática para cada usuário. + // Vamos filtrar para mostrar apenas as salas reais do seu sistema. + if (!io.sockets.sockets.has(key)) { + roomsData[key] = { + total_usuarios: value.size, + ids_conectados: Array.from(value), + }; + } }); - res.json({ - salas_ativas: Object.keys(roomsData).length, - detalhes: roomsData + res.json({ + salas_ativas: Object.keys(roomsData).length, + detalhes: roomsData, }); }); // -------------------------------------------------- +// Rota para salvar o backup do JSON sem usar o WebSocket +app.post("/api/save_room_state", (req, res) => { + const { roomName, patternState } = req.body; + + if (!roomName || !patternState) { + return res.status(400).json({ error: "Faltam dados" }); + } + + const room = ensureRoom(roomName); + room.patternState = patternState; + saveRoom(roomName); + return res.status(200).json({ ok: true }); +}); + // trava simples pra não renderizar a mesma sala em paralelo const renderLocks = new Map(); @@ -807,7 +905,9 @@ app.post("/render", async (req, res) => { const ext = String(format || "wav").toLowerCase(); const allowed = new Set(["wav", "ogg", "flac", "mp3"]); if (!allowed.has(ext)) { - return res.status(400).json({ ok: false, error: "invalid_format", allowed: [...allowed] }); + return res + .status(400) + .json({ ok: false, error: "invalid_format", allowed: [...allowed] }); } // trava sala (se aplicável) @@ -833,7 +933,8 @@ app.post("/render", async (req, res) => { await safeUnzip(zipPath, tmpDir); inputMmpPath = await findFirstMmp(tmpDir); - if (!inputMmpPath) throw new Error("nenhum .mmp encontrado após extrair o pacote"); + if (!inputMmpPath) + throw new Error("nenhum .mmp encontrado após extrair o pacote"); cwdForLmms = path.dirname(inputMmpPath); // isso faz o LMMS resolver samples/... } else { @@ -847,7 +948,9 @@ app.post("/render", async (req, res) => { } if (!projectXml || String(projectXml).trim().length === 0) { - return res.status(400).json({ ok: false, error: "missing_xml_or_room" }); + return res + .status(400) + .json({ ok: false, error: "missing_xml_or_room" }); } inputMmpPath = path.join(tmpDir, "project.mmp"); @@ -862,15 +965,25 @@ app.post("/render", async (req, res) => { const downloadName = `${fileBase}.${ext}`; return res.download(outputPath, downloadName, (err) => { - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} if (roomName) renderLocks.delete(roomName); if (err) console.error("[/render] download error:", err); }); } catch (e) { - try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch {} if (roomName) renderLocks.delete(roomName); console.error("[/render] fail:", e); - return res.status(500).json({ ok: false, error: "render_failed", details: String(e?.message || e) }); + return res + .status(500) + .json({ + ok: false, + error: "render_failed", + details: String(e?.message || e), + }); } }); @@ -883,25 +996,29 @@ app.post("/notify-update", express.json(), (req, res) => { // O evento deve ser algo que ui.js entenda. io.emit("system_update", { type: "RELOAD_SAMPLES", - message: "Novo Sample/Project adicionado. Recarregando o navegador de arquivos...", + message: + "Novo Sample/Project adicionado. Recarregando o navegador de arquivos...", }); console.log( - "[Notificação] Evento 'RELOAD_SAMPLES' emitido para todos os clientes." + "[Notificação] Evento 'RELOAD_SAMPLES' emitido para todos os clientes.", ); - return res.status(200).send({ success: true, message: "Notificação de Samples/Projetos enviada." }); + return res + .status(200) + .send({ + success: true, + message: "Notificação de Samples/Projetos enviada.", + }); } res.status(400).send({ success: false, message: "updateType inválido." }); }); app.get("/", (req, res) => { - res.send( - "Servidor Backend do MMP-Search está no ar novamente!" - ); + res.send("Servidor Backend do MMP-Search está no ar novamente!"); }); // ====== START ====== httpsServer.listen(PORT, () => { console.log(`Servidor escutando na porta ${PORT}`); -}); \ No newline at end of file +}); diff --git a/assets/js/creations/socket.js b/assets/js/creations/socket.js index 259bfa78..6224bd09 100755 --- a/assets/js/creations/socket.js +++ b/assets/js/creations/socket.js @@ -48,37 +48,68 @@ import { import { renderAll, showToast } from "./ui.js"; // showToast() import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js"; import { PORT_SOCK } from "./config.js"; -import { DEFAULT_PROJECT_XML } from "./utils.js" +import { DEFAULT_PROJECT_XML } from "./utils.js"; +export let hasLoadedInitialState = false; export let USER_NAME = null; export let currentRoom = null; let roomSeq = 0; let lastActionToken = 0; let isOfflineMode = false; +let syncInterval = null; + +// --- DEBOUNCER DO XML (Impede o flood de rede) --- +let syncXmlTimeout = null; + +export function debouncedSyncPatternState() { + if (!currentRoom) return; + if (syncXmlTimeout) clearTimeout(syncXmlTimeout); + + syncXmlTimeout = setTimeout(async () => { + try { + // Pegamos apenas o JSON do estado atual + const patternState = appState.pattern; + + // Faz o upload silencioso do JSON + await fetch("/api/save_room_state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ roomName: currentRoom, patternState }), + }); + console.log("[Backup] Estado JSON salvo com segurança via HTTP."); + } catch (err) { + console.error("Erro ao salvar JSON para sync:", err); + } + }, 2000); +} + +// -------------------------------------------------- // --- FUNÇÃO DE AUTENTICAÇÃO INTELIGENTE --- export async function getOrFetchUsername() { - // Se já pegamos o nome antes, não pede de novo - if (USER_NAME) return USER_NAME; - - try { - const res = await fetch('/api/check_auth', { credentials: 'include' }); - const data = await res.json(); - if (data.logged_in) { - USER_NAME = data.user; - return USER_NAME; - } - } catch (e) { - console.warn("API de Auth indisponível, caindo para modo visitante."); + // Se já pegamos o nome antes, não pede de novo + if (USER_NAME) return USER_NAME; + + try { + const res = await fetch("/api/check_auth", { credentials: "include" }); + const data = await res.json(); + if (data.logged_in) { + USER_NAME = data.user; + return USER_NAME; } - - // Se não estiver logado, barra a tela e pede apelido - let name = prompt("Você não está logado. Digite seu apelido para entrar na DAW Online:"); - if (!name || name.trim() === "") { - name = "Produtor_" + Math.floor(Math.random() * 1000); - } - USER_NAME = name.trim(); - return USER_NAME; + } catch (e) { + console.warn("API de Auth indisponível, caindo para modo visitante."); + } + + // Se não estiver logado, barra a tela e pede apelido + let name = prompt( + "Você não está logado. Digite seu apelido para entrar na DAW Online:", + ); + if (!name || name.trim() === "") { + name = "Produtor_" + Math.floor(Math.random() * 1000); + } + USER_NAME = name.trim(); + return USER_NAME; } // --- TELEMETRIA PARA A DISSERTAÇÃO --- @@ -88,13 +119,13 @@ let lastReceivedSeq = 0; function _getRackIdFallback() { // 1) pega do primeiro bassline que já tenha instrumentSourceId const b = (appState.pattern.tracks || []).find( - (t) => t.type === "bassline" && t.instrumentSourceId + (t) => t.type === "bassline" && t.instrumentSourceId, ); if (b?.instrumentSourceId) return b.instrumentSourceId; // 2) pega do primeiro instrumento do rack (parentBasslineId) const child = (appState.pattern.tracks || []).find( - (t) => t.type !== "bassline" && t.parentBasslineId + (t) => t.type !== "bassline" && t.parentBasslineId, ); if (child?.parentBasslineId) return child.parentBasslineId; @@ -105,15 +136,17 @@ function _nextPatternIndex() { const bassMax = Math.max( -1, ...(appState.pattern.tracks || []) - .filter((t) => t.type === "bassline" && Number.isFinite(Number(t.patternIndex))) - .map((t) => Number(t.patternIndex)) + .filter( + (t) => t.type === "bassline" && Number.isFinite(Number(t.patternIndex)), + ) + .map((t) => Number(t.patternIndex)), ); const nonBassMax = Math.max( -1, ...(appState.pattern.tracks || []) .filter((t) => t.type !== "bassline") - .map((t) => (t.patterns?.length || 0) - 1) + .map((t) => (t.patterns?.length || 0) - 1), ); return Math.max(bassMax, nonBassMax) + 1; @@ -213,38 +246,36 @@ export function sendActionSafe(action) { // --- MONITORAMENTO DE PRESENÇA --- socket.on("room_status", (data) => { - console.log( - `%c[MONITOR DE SALA] 👥 ${data.count} pessoa(s) na sala '${data.room}'`, - 'background: #222; color: #bada55; font-size: 16px; font-weight: bold; padding: 4px;' - ); - console.log("IDs conectados:", data.users); - - // Se quiser um aviso visual na tela da DAW: - if(data.count > 1) { - showToast(`👥 Agora há ${data.count} produtores na sala!`, "success"); - } + console.log( + `%c[MONITOR DE SALA] 👥 ${data.count} pessoa(s) na sala '${data.room}'`, + "background: #222; color: #bada55; font-size: 16px; font-weight: bold; padding: 4px;", + ); + console.log("IDs conectados:", data.users); + + // Se quiser um aviso visual na tela da DAW: + if (data.count > 1) { + showToast(`👥 Agora há ${data.count} produtores na sala!`, "success"); + } }); // --------------------------------- -// --- SUBSTITUA SEU socket.on("connect") POR ESTE --- socket.on("connect", async () => { console.log(`Conectado ao servidor com ID: ${socket.id}`); - + const urlRoom = new URLSearchParams(window.location.search).get("room"); if (urlRoom) { currentRoom = urlRoom; console.log(`Modo Online. Sala detectada na URL: ${currentRoom}`); - showToast(`🎧 Conectado à sala ${currentRoom}`, "info"); - + //showToast(`🎧 Conectado à sala ${currentRoom}`, "info"); + // Como tem sala na URL, EXIGE o login/apelido agora const myName = await getOrFetchUsername(); - - socket.emit("join_room", { - roomName: currentRoom, - userName: myName - }); + socket.emit("join_room", { + roomName: currentRoom, + userName: myName, + }); } else { // Entrou na DAW pura, não pede nada ainda. console.log("Modo Local. Aguardando criação de sala."); @@ -253,6 +284,9 @@ socket.on("connect", async () => { syncServerTime(); setInterval(syncServerTime, 10000); + // Mata o relógio antigo antes de criar um novo para evitar DDoS no servidor + if (syncInterval) clearInterval(syncInterval); + syncInterval = setInterval(syncServerTime, 10000); }); // ----------------------------------------------------------------------------- @@ -261,12 +295,12 @@ socket.on("connect", async () => { socket.on("connect_error", (err) => { console.error("Falha crítica na conexão do Socket:", err.message); alert( - "🚧 FALHA NA CONEXÃO 🚧\n\n❌Não foi possível conectar ao servidor em tempo real. ❌\n\n😅 Causa provável: Um Ad Blocker ou Firewall está bloqueando a conexão.\n\n😎 Por favor, desative seu Ad Blocker para este site e recarregue a página." + "🚧 FALHA NA CONEXÃO 🚧\n\n❌Não foi possível conectar ao servidor em tempo real. ❌\n\n😅 Causa provável: Um Ad Blocker ou Firewall está bloqueando a conexão.\n\n😎 Por favor, desative seu Ad Blocker para este site e recarregue a página.", ); showToast( "❌ Falha grave de conexão. Desativa o Ad Blocker? 😅", "error", - 10000 + 10000, ); }); @@ -276,7 +310,7 @@ socket.on("connect_error", (err) => { socket.on("system_update", (data) => { if (data.type === "RELOAD_SAMPLES") { console.log( - `[System Update] Recebida ordem para recarregar samples: ${data.message}` + `[System Update] Recebida ordem para recarregar samples: ${data.message}`, ); loadAndRenderSampleBrowser(); @@ -286,61 +320,42 @@ socket.on("system_update", (data) => { // ----------------------------------------------------------------------------- // RECEBER ESTADO SALVO DA SALA // ----------------------------------------------------------------------------- -socket.on("load_project_state", async (projectXml) => { +socket.on("load_project_state", async (payload) => { + // Ignora se já carregou (proteção que você já fez) + if (hasLoadedInitialState && !payload.forceSync) { + return; + } + console.log("Recebendo estado salvo da sala..."); - showToast("🔄 Recebendo estado atual da sala...", "info", 4000); - if (isLoadingProject) return; isLoadingProject = true; - + try { - // 1. Carrega o XML que veio do servidor (pode estar desatualizado) - await parseMmpContent(projectXml); - - // 🔥 CORREÇÃO: Força a restauração da sessão LOCAL logo após carregar o XML - // Isso garante que suas alterações locais (BPM, steps) "ganhem" do servidor - if (currentRoom) { - const raw = sessionStorage.getItem(`temp_state_${currentRoom}`); - if (raw) { - // Se existe 'raw', confiamos que é o estado mais recente do usuário. - console.log("Re-aplicando sessão local (mesmo se vazia)..."); - await loadStateFromSession(); - } + // Se vier com "patternState" (JSON), carrega direto! + if (payload.patternState) { + console.log("Carregando estado via JSON direto (Ultra Rápido) 🚀"); + appState.pattern = payload.patternState; + } + // Fallback: Se ainda for um XML antigo do banco, faz o parse pesado + else if (payload.xml) { + console.log("Fazendo parse de projeto legado em XML..."); + await parseMmpContent(payload.xml); + } + + if (currentRoom && !payload.forceSync) { + sessionStorage.removeItem(`temp_state_${currentRoom}`); } renderAll(); saveStateToSession(); + hasLoadedInitialState = true; showToast("🎵 Projeto carregado com sucesso", "success"); - - const hasAudio = - (appState.audio?.clips?.length || 0) > 0 || - (appState.audio?.tracks?.length || 0) > 0; - - if (!hasAudio && currentRoom) { - console.log( - "Projeto XML carregado, sem áudio. Pedindo snapshot de áudio..." - ); - - // Libera a trava IMEDIATAMENTE antes de pedir - // Caso contrário, a resposta chega muito rápido e cai no bloqueio "justReset=true" - appState.global.justReset = false; - - sendAction({ type: "AUDIO_SNAPSHOT_REQUEST" }); - } - } catch (e) { - console.error("Erro ao carregar projeto:", e); + console.error("Erro crítico ao carregar projeto:", e); showToast("❌ Erro ao carregar projeto", "error"); + } finally { + isLoadingProject = false; } - isLoadingProject = false; - - // Mantemos o timeout apenas como segurança extra para outros casos - setTimeout(() => { - if (appState.global.justReset) { - // console.log("Socket: Limpando flag 'justReset' (timeout)."); - appState.global.justReset = false; - } - }, 250); }); // ----------------------------------------------------------------------------- @@ -403,19 +418,29 @@ export function sendAction(action) { ) { console.error( "[SOCKET] UPDATE_AUDIO_CLIP bloqueada (startTime):", - action + action, ); return; } } - const token = (++lastActionToken).toString(); + // Usa o ID do socket + contador para garantir que nunca haverá colisão + // com sessões antigas, outros usuários ou F5. + const token = `${socket.id}_${++lastActionToken}`; action.__token = token; // Usa o relógio sincronizado com o servidor action.__send_time = Date.now() + serverOffsetMs; action.__senderId = socket.id; action.__senderName = USER_NAME; + // --- 🚀 A MÁGICA: UI OTIMISTA (Zero Latência) --- + // 1. Já marca o token como processado para não rodar duplicado + processedTokens.add(token); + + // 2. Aplica na SUA máquina IMEDIATAMENTE na hora do clique! + handleActionBroadcast(action); + // ---------------------------------------------- + const isTransport = action.type === "TOGGLE_PLAYBACK" || action.type === "STOP_PLAYBACK" || @@ -424,7 +449,7 @@ export function sendAction(action) { action.type === "STOP_AUDIO_PLAYBACK" || action.type === "SET_LOOP_STATE" || action.type === "SET_SEEK_TIME" || - action.type === "SET_SYNC_MODE"; + action.type === "SET_SYNC_MODE"; if (inRoom && isTransport) { const leadTimeMs = 200; @@ -438,55 +463,42 @@ export function sendAction(action) { } // (Lógica Global/Local) + // Se for modo local, ou não tiver sala, não precisa fazer o socket.emit if (!inRoom || (isTransport && appState.global.syncMode === "local")) { - console.log("[SOCKET] (local) executando ação:", action.type); - handleActionBroadcast(action); return; } + if (isTransport && appState.global.syncMode === "global") { action.__syncMode = "global"; } - console.log( - "[SOCKET] Enviando broadcast_action:", - action.type, - "para", - currentRoom, - "token=", - token - ); - - socket - .compress(false) - .emit("broadcast_action", { roomName: currentRoom, action }, (ack) => { - if (ack && ack.ok && ack.token === token) { - if (lastActionTimeout) { - clearTimeout(lastActionTimeout); - lastActionTimeout = null; - } - } - }); + // 🔥 Agora sim envia para o servidor (GZIP habilitado) + socket.emit("broadcast_action", { roomName: currentRoom, action }, (ack) => { + if (ack && ack.ok && ack.token === token) { + if (lastActionTimeout) clearTimeout(lastActionTimeout); + if (lastBroadcastTimeout) clearTimeout(lastBroadcastTimeout); + } + }); if (lastActionTimeout) clearTimeout(lastActionTimeout); lastActionTimeout = setTimeout(() => { - console.warn("[SOCKET] ACK não recebido:", action.type, token); - showToast(`⚠️ ACK (${action.type})`, "warning"); - }, 500); + if (action.type !== "SYNC_PATTERN_STATE") { + showToast(`⚠️ Rede lenta (${action.type})`, "warning"); + } + }, 2500); if (lastBroadcastTimeout) clearTimeout(lastBroadcastTimeout); pendingToken = token; + lastBroadcastTimeout = setTimeout(() => { - if (processedTokens.has(token)) return; - console.warn("[SOCKET] Eco não recebido, fallback:", action.type, token); - showToast(`⚠️ Eco (${action.type}), fallback`, "warning"); - processedTokens.add(token); - handleActionBroadcast(action); - }, 900); + // ⚠️ ATENÇÃO: Removemos o handleActionBroadcast(action) daqui! + // Ele não precisa mais rodar no fallback porque você já rodou ele ali em cima na hora do clique! + console.warn("[SOCKET] Falha de sincronia na rede para:", action.type); + }, 4000); } - // ----------------------------------------------------------------------------- -// POP-UPS +// POP-UPS // ----------------------------------------------------------------------------- function basenameNoExt(path) { if (!path) return ""; @@ -542,7 +554,8 @@ function _genPlaylistClipId() { function _ensureBasslineForPatternIndex(patternIndex) { let b = appState.pattern.tracks.find( - (t) => t.type === "bassline" && Number(t.patternIndex) === Number(patternIndex) + (t) => + t.type === "bassline" && Number(t.patternIndex) === Number(patternIndex), ); // fallback: cria se não existir (não quebra nada) @@ -564,7 +577,7 @@ function _ensureBasslineForPatternIndex(patternIndex) { } if (!Array.isArray(b.playlist_clips)) b.playlist_clips = []; - + // Isso deixa o modo focado funcionando em patterns recém-criados. if (!b.instrumentSourceId) b.instrumentSourceId = _getDefaultRackId(); @@ -578,7 +591,7 @@ function _ensureBasslineForPatternIndex(patternIndex) { function _getDefaultRackId() { const inst = (appState.pattern.tracks || []).find( - (t) => t.type !== "bassline" && t.parentBasslineId + (t) => t.type !== "bassline" && t.parentBasslineId, ); return inst?.parentBasslineId ?? null; } @@ -596,36 +609,36 @@ socket.on("action_broadcast", (payload) => { // --- INÍCIO DA COLETA DE DADOS --- const receiveTime = Date.now() + serverOffsetMs; - + // 1. Calcula a latência (se foi outro usuário que mandou) let latencyMs = 0; if (action.__send_time && action.userId !== socket.id) { - latencyMs = receiveTime - action.__send_time; + latencyMs = receiveTime - action.__send_time; } // 2. Verifica a Ordem dos Pacotes (Jitter/Perda) let isOutOfOrder = false; let missingPackets = 0; if (action.__seq) { - if (lastReceivedSeq !== 0 && action.__seq !== lastReceivedSeq + 1) { - isOutOfOrder = true; - missingPackets = Math.abs(action.__seq - lastReceivedSeq) - 1; - } - lastReceivedSeq = action.__seq; + if (lastReceivedSeq !== 0 && action.__seq !== lastReceivedSeq + 1) { + isOutOfOrder = true; + missingPackets = Math.abs(action.__seq - lastReceivedSeq) - 1; + } + lastReceivedSeq = action.__seq; } // 3. Salva no array se não for a sua própria mensagem voltando // TRAVA DE SEGURANÇA: Se o array não existir ainda, cria na hora! window.jamTelemetry = window.jamTelemetry || []; if (action.userId !== socket.id) { - window.jamTelemetry.push({ - type: action.type, - seq: action.__seq, - latency_ms: latencyMs.toFixed(2), - out_of_order: isOutOfOrder, - missing_packets: missingPackets, - timestamp: new Date().toISOString() - }); + window.jamTelemetry.push({ + type: action.type, + seq: action.__seq, + latency_ms: latencyMs.toFixed(2), + out_of_order: isOutOfOrder, + missing_packets: missingPackets, + timestamp: new Date().toISOString(), + }); } // --- FIM DA COLETA DE DADOS --- @@ -667,7 +680,12 @@ function _syncNotesWithStepToggle(pattern, stepIndex, isActive) { if (pattern.notes.some(samePos)) return; // tenta reaproveitar "template" de nota pra manter key/vol/pan coerentes - const tpl = pattern.notes[0] || { vol: 100, len: TICKS_PER_STEP, pan: 0, key: 57 }; + const tpl = pattern.notes[0] || { + vol: 100, + len: TICKS_PER_STEP, + pan: 0, + key: 57, + }; pattern.notes.push({ vol: tpl.vol ?? 100, @@ -757,17 +775,19 @@ async function handleActionBroadcast(action) { } case "CREATE_NEW_PATTERN": { - const patternIndex = - Number.isFinite(Number(action.patternIndex)) ? Number(action.patternIndex) : _nextPatternIndex(); + const patternIndex = Number.isFinite(Number(action.patternIndex)) + ? Number(action.patternIndex) + : _nextPatternIndex(); - const name = (action.name || "").trim() || `Beat/Bassline ${patternIndex}`; + const name = + (action.name || "").trim() || `Beat/Bassline ${patternIndex}`; // 1) garante patterns em todas as tracks reais _ensurePatternsUpTo(patternIndex); // 2) cria bassline track container (pattern “da playlist”) let b = (appState.pattern.tracks || []).find( - (t) => t.type === "bassline" && Number(t.patternIndex) === patternIndex + (t) => t.type === "bassline" && Number(t.patternIndex) === patternIndex, ); if (!b) { @@ -798,17 +818,20 @@ async function handleActionBroadcast(action) { // 4) já seleciona essa pattern (opcional, mas fica UX boa) appState.pattern.activePatternIndex = patternIndex; - (appState.pattern.tracks || []).forEach((t) => (t.activePatternIndex = patternIndex)); + (appState.pattern.tracks || []).forEach( + (t) => (t.activePatternIndex = patternIndex), + ); - try { schedulePatternRerender(); } catch {} + try { + schedulePatternRerender(); + } catch {} renderAll(); saveStateToSession(); // 5) persiste no servidor (igual você já faz em outros updates) const isFromSelf = action.__senderId === socket.id; - if (window.ROOM_NAME && isFromSelf) { - const xml = generateXmlFromStateExported(); - sendAction({ type: "SYNC_PATTERN_STATE", xml }); + if (currentRoom && isFromSelf) { + debouncedSyncPatternState(); } break; @@ -821,17 +844,25 @@ async function handleActionBroadcast(action) { ? Number(action.patternIndex) : null; - const nonBass = (appState.pattern.tracks || []).filter(t => t.type !== "bassline"); - const currentCount = nonBass.reduce((m, t) => Math.max(m, t.patterns?.length ?? 0), 0); + const nonBass = (appState.pattern.tracks || []).filter( + (t) => t.type !== "bassline", + ); + const currentCount = nonBass.reduce( + (m, t) => Math.max(m, t.patterns?.length ?? 0), + 0, + ); // Próximo índice livre (fim da lista) const maxFromNonBass = nonBass.reduce( (m, t) => Math.max(m, (t.patterns?.length || 0) - 1), - -1 + -1, ); const maxFromBass = (appState.pattern.tracks || []) - .filter(t => t.type === "bassline" && Number.isFinite(Number(t.patternIndex))) + .filter( + (t) => + t.type === "bassline" && Number.isFinite(Number(t.patternIndex)), + ) .reduce((m, t) => Math.max(m, Number(t.patternIndex)), -1); const nextFree = Math.max(maxFromNonBass, maxFromBass) + 1; @@ -842,7 +873,7 @@ async function handleActionBroadcast(action) { const idxAlreadyExists = idx <= maxFromNonBass || (appState.pattern.tracks || []).some( - t => t.type === "bassline" && Number(t.patternIndex) === idx + (t) => t.type === "bassline" && Number(t.patternIndex) === idx, ); if (idxAlreadyExists) idx = nextFree; @@ -856,7 +887,8 @@ async function handleActionBroadcast(action) { for (const t of nonBass) { t.patterns[idx] = t.patterns[idx] || _makeEmptyPattern(idx); t.patterns[idx].name = finalName; - if (t.patterns[idx].pos == null) t.patterns[idx].pos = idx * LMMS_BAR_TICKS; + if (t.patterns[idx].pos == null) + t.patterns[idx].pos = idx * LMMS_BAR_TICKS; } // 3) cria a lane "bassline" (a coluna do pattern) @@ -868,7 +900,9 @@ async function handleActionBroadcast(action) { // 4) opcional: já selecionar if (action.select) { appState.pattern.activePatternIndex = idx; - appState.pattern.tracks.forEach((track) => (track.activePatternIndex = idx)); + appState.pattern.tracks.forEach( + (track) => (track.activePatternIndex = idx), + ); } renderAll(); @@ -878,8 +912,13 @@ async function handleActionBroadcast(action) { } case "REMOVE_LAST_PATTERN": { - const nonBass = (appState.pattern.tracks || []).filter(t => t.type !== "bassline"); - const count = nonBass.reduce((m, t) => Math.max(m, t.patterns?.length ?? 0), 0); + const nonBass = (appState.pattern.tracks || []).filter( + (t) => t.type !== "bassline", + ); + const count = nonBass.reduce( + (m, t) => Math.max(m, t.patterns?.length ?? 0), + 0, + ); const last = count - 1; if (last <= 0) break; @@ -888,7 +927,7 @@ async function handleActionBroadcast(action) { // remove a lane bassline correspondente appState.pattern.tracks = appState.pattern.tracks.filter( - t => !(t.type === "bassline" && Number(t.patternIndex) === last) + (t) => !(t.type === "bassline" && Number(t.patternIndex) === last), ); // ajusta seleção @@ -923,10 +962,9 @@ async function handleActionBroadcast(action) { saveStateToSession(); // ✅ sync XML (inclui bbtco depois do patch no file.js) - if (window.ROOM_NAME && isFromSelf) { + if (currentRoom && isFromSelf) { try { - const xml = generateXmlFromStateExported(); - sendAction({ type: "SYNC_PATTERN_STATE", xml }); + debouncedSyncPatternState(); } catch {} } break; @@ -947,10 +985,9 @@ async function handleActionBroadcast(action) { renderAll(); saveStateToSession(); - if (window.ROOM_NAME && isFromSelf) { + if (currentRoom && isFromSelf) { try { - const xml = generateXmlFromStateExported(); - sendAction({ type: "SYNC_PATTERN_STATE", xml }); + debouncedSyncPatternState(); } catch {} } break; @@ -960,7 +997,9 @@ async function handleActionBroadcast(action) { const { patternIndex, clipId } = action; const b = _ensureBasslineForPatternIndex(patternIndex); - b.playlist_clips = b.playlist_clips.filter((c) => String(c.id) !== String(clipId)); + b.playlist_clips = b.playlist_clips.filter( + (c) => String(c.id) !== String(clipId), + ); // limpa seleção se era o selecionado if (appState.global.selectedPlaylistClipId === clipId) { @@ -971,10 +1010,9 @@ async function handleActionBroadcast(action) { renderAll(); saveStateToSession(); - if (window.ROOM_NAME && isFromSelf) { + if (currentRoom && isFromSelf) { try { - const xml = generateXmlFromStateExported(); - sendAction({ type: "SYNC_PATTERN_STATE", xml }); + debouncedSyncPatternState(); } catch {} } break; @@ -983,8 +1021,7 @@ async function handleActionBroadcast(action) { case "SELECT_PLAYLIST_PATTERN_CLIP": { const { patternIndex, clipId } = action; appState.global.selectedPlaylistClipId = clipId ?? null; - appState.global.selectedPlaylistPatternIndex = - patternIndex ?? null; + appState.global.selectedPlaylistPatternIndex = patternIndex ?? null; renderAll(); saveStateToSession(); break; @@ -1016,12 +1053,8 @@ async function handleActionBroadcast(action) { } const isFromSelf = action.__senderId === socket.id; - if (window.ROOM_NAME && isFromSelf) { - const xml = generateXmlFromStateExported(); - sendAction({ - type: "SYNC_PATTERN_STATE", - xml, - }); + if (currentRoom && isFromSelf) { + debouncedSyncPatternState(); } saveStateToSession(); @@ -1062,7 +1095,7 @@ async function handleActionBroadcast(action) { case "STOP_AUDIO_PLAYBACK": setTimeout( () => stopAudioEditorPlayback(action.rewind || false), - delayMs + delayMs, ); break; @@ -1133,11 +1166,11 @@ async function handleActionBroadcast(action) { showToast("📂 Carregando...", "info"); // Esta parte está CORRETA. Sempre limpa o sessionStorage. - if (window.ROOM_NAME) { + if (currentRoom) { try { - sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); + sessionStorage.removeItem(`temp_state_${currentRoom}`); console.log( - "Socket: Estado da sessão local limpo para LOAD_PROJECT." + "Socket: Estado da sessão local limpo para LOAD_PROJECT.", ); } catch (e) { console.error("Socket: Falha ao limpar estado da sessão:", e); @@ -1156,29 +1189,10 @@ async function handleActionBroadcast(action) { isLoadingProject = false; break; - case "SYNC_PATTERN_STATE": - // 🔥 CORREÇÃO CRÍTICA: - // Esta ação serve apenas para o SERVIDOR atualizar o arquivo .json no disco. - // Os clientes online NÃO devem recarregar o XML, pois eles já atualizaram - // o estado via ações atômicas (TOGGLE_NOTE, ADD_TRACK, etc). - // Recarregar aqui causa o "resetProjectState" que mata o áudio. - - console.log("Socket: XML salvo no servidor (Sync silencioso)."); - - // Se quiser garantir, salvamos apenas na sessão local do navegador - // sem mexer na engine de áudio ou na tela. - if (action.xml) { - // Atualiza apenas a string do XML na memória global para referência futura - const parser = new DOMParser(); - appState.global.originalXmlDoc = parser.parseFromString(action.xml, "application/xml"); - saveStateToSession(); - } - break; - case "RESET_ROOM": console.log("Socket: Recebendo comando de RESET_ROOM do servidor."); // const who = actorOf(action); - handleLocalProjectReset(); + handleLocalProjectReset(); showToast(`🧹 Reset por ${who}`, "warning"); // Salva o estado localmente também! saveStateToSession(); // @@ -1227,12 +1241,11 @@ async function handleActionBroadcast(action) { renderPatternEditor(); const who = actorOf(action); showToast(`🥁 Faixa add por ${who}`, "info"); - + // 🔥 CORREÇÃO: Avisa o servidor que o XML mudou! const isFromSelf = action.__senderId === socket.id; - if (window.ROOM_NAME && isFromSelf) { - const xml = generateXmlFromStateExported(); - sendAction({ type: "SYNC_PATTERN_STATE", xml }); + if (currentRoom && isFromSelf) { + debouncedSyncPatternState(); } saveStateToSession(); @@ -1242,22 +1255,21 @@ async function handleActionBroadcast(action) { case "REMOVE_TRACK_BY_ID": { const { trackId } = action; const removed = removeTrackById(trackId); // Tenta remover pelo ID seguro - + if (removed) { renderPatternEditor(); const who = actorOf(action); showToast(`❌ Faixa removida por ${who}`, "warning"); - // Sincroniza o XML com o servidor para persistir a remoção const isFromSelf = action.__senderId === socket.id; - if (window.ROOM_NAME && isFromSelf) { - // Importante: gerar o XML atualizado (sem a faixa) - const xml = generateXmlFromStateExported(); - sendAction({ type: "SYNC_PATTERN_STATE", xml }); + if (currentRoom && isFromSelf) { + debouncedSyncPatternState(); } saveStateToSession(); } else { - console.warn(`Tentativa de remover track inexistente/fantasma: ${trackId}`); + console.warn( + `Tentativa de remover track inexistente/fantasma: ${trackId}`, + ); // Não faz nada, protegendo as faixas locais! } break; @@ -1268,12 +1280,11 @@ async function handleActionBroadcast(action) { renderPatternEditor(); const who = actorOf(action); showToast(`❌ Faixa remov. por ${who}`, "warning"); - + // 🔥 CORREÇÃO: Avisa o servidor que o XML mudou (removeu o TripleOscillator, por exemplo) const isFromSelf = action.__senderId === socket.id; - if (window.ROOM_NAME && isFromSelf) { - const xml = generateXmlFromStateExported(); - sendAction({ type: "SYNC_PATTERN_STATE", xml }); + if (currentRoom && isFromSelf) { + debouncedSyncPatternState(); } saveStateToSession(); @@ -1312,17 +1323,19 @@ async function handleActionBroadcast(action) { // 3. Remove os clips associados a essa track (Limpeza) if (lastTrack && lastTrack.id) { - // Filtra mantendo apenas os clips que NÃO pertencem à track removida - appState.audio.clips = appState.audio.clips.filter(clip => clip.trackId !== lastTrack.id); + // Filtra mantendo apenas os clips que NÃO pertencem à track removida + appState.audio.clips = appState.audio.clips.filter( + (clip) => clip.trackId !== lastTrack.id, + ); } // 4. Atualiza a tela renderAll(); - + // 5. Notifica const who = actorOf(action); showToast(`🗑️ Pista de áudio removida por ${who}`, "error"); - + // 6. Salva sessão saveStateToSession(); break; @@ -1341,7 +1354,12 @@ async function handleActionBroadcast(action) { // Notes case "TOGGLE_NOTE": { - const { trackIndex: ti, patternIndex: pi, stepIndex: si, isActive } = action; + const { + trackIndex: ti, + patternIndex: pi, + stepIndex: si, + isActive, + } = action; // ✅ garante que o índice exista em todas as tracks (evita “silêncio” na playlist) _ensurePatternsUpTo(pi); @@ -1351,7 +1369,8 @@ async function handleActionBroadcast(action) { t.patterns[pi] = t.patterns[pi] || _makeEmptyPattern(pi); // mantém metadados estáveis p/ export/sync - if (t.patterns[pi].pos == null) t.patterns[pi].pos = pi * LMMS_BAR_TICKS; + if (t.patterns[pi].pos == null) + t.patterns[pi].pos = pi * LMMS_BAR_TICKS; if (!t.patterns[pi].name) t.patterns[pi].name = `Pattern ${pi + 1}`; if (!Array.isArray(t.patterns[pi].notes)) t.patterns[pi].notes = []; @@ -1362,13 +1381,14 @@ async function handleActionBroadcast(action) { // importante: se o Audio Editor estiver tocando, precisa re-schedulear restartAudioEditorIfPlaying(); - try { updateStepUI(ti, pi, si, isActive); } catch {} + try { + updateStepUI(ti, pi, si, isActive); + } catch {} if (!isFromSelf) schedulePatternRerender(); } - if (window.ROOM_NAME && isFromSelf) { - const xml = generateXmlFromStateExported(); - sendAction({ type: "SYNC_PATTERN_STATE", xml }); + if (currentRoom && isFromSelf) { + debouncedSyncPatternState(); } saveStateToSession(); @@ -1406,12 +1426,11 @@ async function handleActionBroadcast(action) { // 4) Sync online somente se fui EU quem enviei const isFromSelf = action.__senderId === socket.id; - if (window.ROOM_NAME && isFromSelf) { + if (currentRoom && isFromSelf) { try { - const xml = generateXmlFromStateExported(); // ← CORREÇÃO sendAction({ type: "SYNC_PATTERN_STATE", - xml, + patternState: appState.pattern, // Envia o objeto puro }); } catch (e) { console.error("Erro gerando XML UPDATE_PATTERN_NOTES", e); @@ -1503,8 +1522,8 @@ async function handleActionBroadcast(action) { const stepsPerBar = 16; // 4/4 em 1/16 const maxSteps = appState.pattern.tracks - .filter(t => t.type !== "bassline") - .map(t => t.patterns?.[patternIndex]?.steps?.length || 0) + .filter((t) => t.type !== "bassline") + .map((t) => t.patterns?.[patternIndex]?.steps?.length || 0) .reduce((a, b) => Math.max(a, b), 0); const barsEl = document.getElementById("bars-input"); @@ -1513,7 +1532,6 @@ async function handleActionBroadcast(action) { } } catch {} - // Mostra o toast (só uma vez) if (!isFromSelf) { const who = actorOf(action); @@ -1535,25 +1553,27 @@ async function handleActionBroadcast(action) { case "AUDIO_SNAPSHOT_REQUEST": { // 🛑 BLOQUEADO: Agora o Servidor (backend) é quem manda o snapshot autoritativo. // Se deixarmos os clientes responderem, eles vão enviar estados antigos ("zumbis"). - console.log("Socket: Ignorando pedido de snapshot (Server Authoritative Mode)."); + console.log( + "Socket: Ignorando pedido de snapshot (Server Authoritative Mode).", + ); break; } case "AUDIO_SNAPSHOT": { if (action.__target && action.__target !== socket.id) break; - + // Removemos a verificação "if (hasClips) break" que impedia // de carregar um estado vazio se o local já tivesse algo (como as faixas zumbis). - + try { console.log("Socket: Aplicando Snapshot de Áudio Autoritativo..."); await applyAudioSnapshot(action.snapshot); renderAll(); - + const who = actorOf(action); // Só mostra toast se realmente tiver conteúdo, para não spammar na entrada if ((action.snapshot?.tracks?.length || 0) > 0) { - showToast(`🔁 Sync áudio por ${who}`, "success"); + showToast(`🔁 Sync áudio por ${who}`, "success"); } } catch (e) { console.error("Erro AUDIO_SNAPSHOT:", e); @@ -1584,11 +1604,11 @@ async function handleActionBroadcast(action) { break; } const trackExists = appState.audio?.tracks?.some( - (t) => t.id === trackId + (t) => t.id === trackId, ); if (!trackExists) { console.warn( - `ADD_AUDIO_CLIP Pista ${trackId} não existe, criando...` + `ADD_AUDIO_CLIP Pista ${trackId} não existe, criando...`, ); appState.audio.tracks.push({ id: trackId, @@ -1605,7 +1625,7 @@ async function handleActionBroadcast(action) { startTimeInSeconds, clipId, name, - patternData // <-- AQUI ESTÁ A "PARTITURA" + patternData, // <-- AQUI ESTÁ A "PARTITURA" ); // --- FIM DA MODIFICAÇÃO --- @@ -1617,7 +1637,7 @@ async function handleActionBroadcast(action) { `🎧 ${who} add “${(name || filePath || "") .split(/[\\/]/) .pop()}” em ${pista}`, - "success" + "success", ); } catch (e) { console.error("Erro ADD_AUDIO_CLIP:", e); @@ -1632,8 +1652,8 @@ async function handleActionBroadcast(action) { isLoadingProject = true; showToast("📂 Carregando beat index...", "info"); - if (window.ROOM_NAME) { - sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); + if (currentRoom) { + sessionStorage.removeItem(`temp_state_${currentRoom}`); } try { @@ -1669,28 +1689,33 @@ async function handleActionBroadcast(action) { break; } - case "REMOVE_AUDIO_LANE_BY_ID": { // + case "REMOVE_AUDIO_LANE_BY_ID": { + // const { trackId } = action; - + // 1. Encontra o índice da faixa com esse ID específico - const trackIndex = appState.audio.tracks.findIndex(t => t.id === trackId); - + const trackIndex = appState.audio.tracks.findIndex( + (t) => t.id === trackId, + ); + if (trackIndex !== -1) { - // 2. Remove a faixa exata do array local - appState.audio.tracks.splice(trackIndex, 1); - - // 3. Remove os clips associados a este ID (Limpeza local) - appState.audio.clips = appState.audio.clips.filter(c => c.trackId !== trackId); - - renderAll(); // Atualiza a tela - - const who = actorOf(action); - // showToast(`🗑️ Pista removida por ${who}`, "warning"); - - // 4. Salva a sessão para garantir persistência no F5 local - saveStateToSession(); + // 2. Remove a faixa exata do array local + appState.audio.tracks.splice(trackIndex, 1); + + // 3. Remove os clips associados a este ID (Limpeza local) + appState.audio.clips = appState.audio.clips.filter( + (c) => c.trackId !== trackId, + ); + + renderAll(); // Atualiza a tela + + const who = actorOf(action); + // showToast(`🗑️ Pista removida por ${who}`, "warning"); + + // 4. Salva a sessão para garantir persistência no F5 local + saveStateToSession(); } else { - console.warn(`Tentativa de remover track inexistente: ${trackId}`); + console.warn(`Tentativa de remover track inexistente: ${trackId}`); } break; } @@ -1703,35 +1728,45 @@ async function handleActionBroadcast(action) { // ============================================================================ // RECUPERAÇÃO DE ESTADO (TAB SLEEP / WAKE UP) // ============================================================================ +let lastHiddenTime = Date.now(); +/* document.addEventListener("visibilitychange", () => { - if (document.visibilityState === "visible") { - console.log("%c[SISTEMA] Aba reativada! Solicitando estado...", "color: #00ff00; background: #222; padding: 4px;"); + if (document.visibilityState === "hidden") { + lastHiddenTime = Date.now(); // Marca a hora que você saiu da aba + } + else if (document.visibilityState === "visible") { + const sleepTimeMs = Date.now() - lastHiddenTime; - // Usa o currentRoom oficial - if (socket.connected && currentRoom) { - // Deleta o cache local velho - sessionStorage.removeItem(`temp_state_${currentRoom}`); + // Só força o Full Sync se a aba ficou escondida por MAIS DE 5 SEGUNDOS. + // Isso evita o loop infinito ao abrir o DevTools (F12) ou dar Alt-Tab rápido. + if (sleepTimeMs > 5000) { + console.log(`%c[SISTEMA] Aba reativada após ${Math.round(sleepTimeMs/1000)}s! Solicitando estado...`, "color: #00ff00; background: #222; padding: 4px;"); - // Pede o estado fresquinho - socket.emit("request_full_sync", { room: currentRoom }); - showToast("🔄 Sincronizando com a sala...", "info"); + if (socket.connected && currentRoom) { + sessionStorage.removeItem(`temp_state_${currentRoom}`); + socket.emit("request_full_sync", { room: currentRoom }); + showToast("🔄 Sincronizando aba inativa...", "info"); + } + } else { + console.log("[SISTEMA] Aba reativada rapidamente. Ignorando Full Sync para poupar processamento."); } } }); +*/ // ============================================================================ // NOTIFICAÇÕES DE PRESENÇA NA SALA // ============================================================================ socket.on("user_joined", ({ username }) => { - // Toca um sonzinho rápido e mostra o popup verde - showToast(`👋 ${username} entrou na sala!`, "success", 4000); - console.log(`[PRESENÇA] ${username} conectou-se.`); + // Toca um sonzinho rápido e mostra o popup verde + showToast(`👋 ${username} entrou na sala!`, "success", 4000); + console.log(`[PRESENÇA] ${username} conectou-se.`); }); socket.on("user_left", ({ username }) => { - // Mostra o popup amarelo de aviso - showToast(`🏃 ${username} saiu da sala.`, "warning", 4000); - console.log(`[PRESENÇA] ${username} desconectou-se.`); + // Mostra o popup amarelo de aviso + showToast(`🏃 ${username} saiu da sala.`, "warning", 4000); + console.log(`[PRESENÇA] ${username} desconectou-se.`); }); // EXPORTS