testando ui otimista
Deploy / Deploy (push) Successful in 1m27s Details

This commit is contained in:
JotaChina 2026-03-26 18:58:08 -03:00
parent e08d80c223
commit ba26dc1fe3
2 changed files with 573 additions and 421 deletions

View File

@ -12,12 +12,14 @@ const pino = require("pino");
const os = require("os"); const os = require("os");
const crypto = require("crypto"); const crypto = require("crypto");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const pendingSaves = new Set();
// --- RASTREAMENTO DE USUÁRIOS --- // --- RASTREAMENTO DE USUÁRIOS ---
const activeUsers = {}; // Mapeia socket.id -> { username, room } const activeUsers = {}; // Mapeia socket.id -> { username, room }
//import { LOG_SERVER } from "../utils.js" //import { LOG_SERVER } from "../utils.js"
const LOG_SERVER = `/var/www/html/trens/src_mmpSearch/logs/creation_logs/server` 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 SESSION_JSON = `/var/www/html/trens/src_mmpSearch/logs/creation_logs/sessions`;
const DEFAULT_PROJECT_XML = `<?xml version="1.0"?> const DEFAULT_PROJECT_XML = `<?xml version="1.0"?>
<!DOCTYPE lmms-project> <!DOCTYPE lmms-project>
@ -99,7 +101,6 @@ const DEFAULT_PROJECT_XML = `<?xml version="1.0"?>
</song> </song>
</lmms-project>`; </lmms-project>`;
// ------------------------- // -------------------------
// LOGGER DINÂMICO (GERENCIADOR) // LOGGER DINÂMICO (GERENCIADOR)
// ------------------------- // -------------------------
@ -153,20 +154,26 @@ const app = express();
const PORT = process.env.PORT || 33001; const PORT = process.env.PORT || 33001;
// ====== HTTPS (Opção B) ====== // ====== HTTPS (Opção B) ======
const CERT_FULLCHAIN = process.env.SSL_FULLCHAIN || "/etc/letsencrypt/live/alice.ufsj.edu.br/fullchain.pem"; const CERT_FULLCHAIN =
const CERT_PRIVKEY = process.env.SSL_PRIVKEY || "/etc/letsencrypt/live/alice.ufsj.edu.br/privkey.pem"; 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)) { if (!fs.existsSync(CERT_FULLCHAIN) || !fs.existsSync(CERT_PRIVKEY)) {
console.error("[HTTPS] Certificados não encontrados.\n" + console.error(
` fullchain: ${CERT_FULLCHAIN}\n` + "[HTTPS] Certificados não encontrados.\n" +
` privkey : ${CERT_PRIVKEY}\n` + ` fullchain: ${CERT_FULLCHAIN}\n` +
"Defina SSL_FULLCHAIN/SSL_PRIVKEY ou instale os certificados no caminho padrão."); ` privkey : ${CERT_PRIVKEY}\n` +
"Defina SSL_FULLCHAIN/SSL_PRIVKEY ou instale os certificados no caminho padrão.",
);
process.exit(1); process.exit(1);
} }
const httpsOptions = { const httpsOptions = {
cert: fs.readFileSync(CERT_FULLCHAIN), cert: fs.readFileSync(CERT_FULLCHAIN),
key : fs.readFileSync(CERT_PRIVKEY), key: fs.readFileSync(CERT_PRIVKEY),
}; };
const httpsServer = https.createServer(httpsOptions, app); const httpsServer = https.createServer(httpsOptions, app);
@ -194,14 +201,14 @@ function dataFile(roomName) {
function ensureRoom(roomName) { function ensureRoom(roomName) {
if (roomStates[roomName]) return roomStates[roomName]; if (roomStates[roomName]) return roomStates[roomName];
// tenta carregar do disco
try { try {
const p = dataFile(roomName); const p = dataFile(roomName);
if (fs.existsSync(p)) { if (fs.existsSync(p)) {
const j = JSON.parse(fs.readFileSync(p, "utf8")); const j = JSON.parse(fs.readFileSync(p, "utf8"));
roomStates[roomName] = { roomStates[roomName] = {
// 🔥 CORREÇÃO: Fallback para DEFAULT_PROJECT_XML se o JSON salvo estiver sem XML // ✅ Mantém o patternState em vez do projectXml gigante
projectXml: j.projectXml || DEFAULT_PROJECT_XML, patternState: j.patternState || null,
projectXml: j.projectXml || DEFAULT_PROJECT_XML, // Fallback para renderização
audio: j.audio || { tracks: [], clips: [] }, audio: j.audio || { tracks: [], clips: [] },
seq: j.seq || 0, seq: j.seq || 0,
tokensSeen: new Set(), tokensSeen: new Set(),
@ -212,9 +219,8 @@ function ensureRoom(roomName) {
console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e); console.warn(`[persist] falha ao carregar estado da sala ${roomName}:`, e);
} }
// Cria sala NOVA na memória
roomStates[roomName] = { roomStates[roomName] = {
// 🔥 CORREÇÃO: Inicia com o XML do LMMS completo, não mais null patternState: null,
projectXml: DEFAULT_PROJECT_XML, projectXml: DEFAULT_PROJECT_XML,
audio: { tracks: [], clips: [] }, audio: { tracks: [], clips: [] },
seq: 0, seq: 0,
@ -225,15 +231,39 @@ function ensureRoom(roomName) {
function saveRoom(roomName) { function saveRoom(roomName) {
const r = ensureRoom(roomName); const r = ensureRoom(roomName);
try {
fs.mkdirSync(SESSION_JSON, { recursive: true }); // Se já tem um salvamento agendado para esta sala, ignora
fs.writeFileSync( if (pendingSaves.has(roomName)) return;
dataFile(roomName), pendingSaves.add(roomName);
JSON.stringify({ projectXml: r.projectXml, audio: r.audio, seq: r.seq })
); // Aguarda 2 segundos antes de salvar (Debounce)
} catch (e) { setTimeout(() => {
console.warn(`[persist] falha ao salvar estado da sala ${roomName}:`, e); 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
} }
// ------------------------- // -------------------------
@ -247,6 +277,8 @@ const io = new Server(httpsServer, {
methods: ["GET", "POST"], methods: ["GET", "POST"],
credentials: true, 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!"); console.log("Servidor Backend do MMP-Search está no ar!");
@ -269,15 +301,10 @@ function basenameNoExt(p) {
function applyAuthoritativeAction(roomName, action) { function applyAuthoritativeAction(roomName, action) {
const room = ensureRoom(roomName); const room = ensureRoom(roomName);
const state = room.audio; 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; let mutated = false;
// REMOVIDO: checagem de tokensSeen e if (action.__token)
switch (action.type) { switch (action.type) {
case "ADD_AUDIO_LANE": { case "ADD_AUDIO_LANE": {
// Usa o trackId enviado pelo cliente, se existir // Usa o trackId enviado pelo cliente, se existir
@ -304,7 +331,7 @@ function applyAuthoritativeAction(roomName, action) {
) { ) {
console.warn( console.warn(
`[Server] Ação ADD_AUDIO_CLIP rejeitada (dados inválidos):`, `[Server] Ação ADD_AUDIO_CLIP rejeitada (dados inválidos):`,
action action,
); );
return null; // Rejeita a ação, não faz broadcast return null; // Rejeita a ação, não faz broadcast
} }
@ -340,7 +367,7 @@ function applyAuthoritativeAction(roomName, action) {
if (!action.clipId || !action.props) { if (!action.clipId || !action.props) {
console.warn( console.warn(
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`, `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (dados base inválidos):`,
action action,
); );
return null; return null;
} }
@ -352,7 +379,7 @@ function applyAuthoritativeAction(roomName, action) {
) { ) {
console.warn( console.warn(
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (trackId inválido):`, `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (trackId inválido):`,
action action,
); );
return null; return null;
} }
@ -362,7 +389,7 @@ function applyAuthoritativeAction(roomName, action) {
) { ) {
console.warn( console.warn(
`[Server] Ação UPDATE_AUDIO_CLIP rejeitada (startTimeInSeconds inválido):`, `[Server] Ação UPDATE_AUDIO_CLIP rejeitada (startTimeInSeconds inválido):`,
action action,
); );
return null; return null;
} }
@ -377,7 +404,7 @@ function applyAuthoritativeAction(roomName, action) {
case "REMOVE_AUDIO_CLIP": { case "REMOVE_AUDIO_CLIP": {
const i = state.clips.findIndex( const i = state.clips.findIndex(
(x) => String(x.id) === String(action.clipId) (x) => String(x.id) === String(action.clipId),
); );
if (i >= 0) { if (i >= 0) {
state.clips.splice(i, 1); state.clips.splice(i, 1);
@ -398,13 +425,13 @@ function applyAuthoritativeAction(roomName, action) {
// Se removemos a track, os clips dela têm que sumir. // Se removemos a track, os clips dela têm que sumir.
const prevCount = state.clips.length; const prevCount = state.clips.length;
state.clips = state.clips.filter((c) => { state.clips = state.clips.filter((c) => {
// Mantém apenas se tiver trackId E se esse trackId ainda existir na lista de tracks // 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); const parentTrackExists = state.tracks.some((t) => t.id === c.trackId);
return c.trackId && parentTrackExists; return c.trackId && parentTrackExists;
}); });
if (state.clips.length !== prevCount) { if (state.clips.length !== prevCount) {
mutated = true; mutated = true;
} }
break; break;
} }
@ -416,9 +443,9 @@ function applyAuthoritativeAction(roomName, action) {
if (!mutated) return null; if (!mutated) return null;
room.seq += 1; // numeração autoritativa // REMOVIDO: room.seq += 1; e atribuição de __seq
saveRoom(roomName); // persistência em disco saveRoom(roomName);
return { ...action, __seq: room.seq }; // devolve ação com seq para broadcast return action; // Apenas retorna a ação modificada
} }
io.on("connection", (socket) => { io.on("connection", (socket) => {
@ -439,61 +466,83 @@ io.on("connection", (socket) => {
activeUsers[socket.id] = { username: finalUserName, room: roomName }; activeUsers[socket.id] = { username: finalUserName, room: roomName };
await socket.join(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 // 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 // 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", { io.to(roomName).emit("room_status", {
room: roomName, count: clientsInRoom.length, users: clientsInRoom room: roomName,
count: clientsInRoom.length,
users: clientsInRoom,
}); });
logMyRooms(socket, "JOIN"); logMyRooms(socket, "JOIN");
const room = ensureRoom(roomName); const room = ensureRoom(roomName);
if (room.projectXml) { // Envia o JSON se existir, senão envia o XML de fallback
socket.emit("load_project_state", room.projectXml); 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", { 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 --- // --- GATILHO DE DESPERTAR ÚNICO ---
socket.on("request_full_sync", ({ room }) => { socket.on("request_full_sync", ({ room }) => {
const targetRoom = ensureRoom(room); const targetRoom = ensureRoom(room);
if (targetRoom && targetRoom.projectXml) { if (targetRoom && targetRoom.projectXml) {
console.log(`[SYNC] Cliente ${socket.id} acordou e pediu Full Sync da sala ${room}`); console.log(`[SYNC] Cliente ${socket.id} acordou e pediu Full Sync`);
socket.emit("load_project_state", targetRoom.projectXml); // 🔥 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) // SAÍDA E LIMPEZA UNIFICADA (Aviso e Desconexão)
// ========================================================= // =========================================================
socket.on("disconnecting", () => { socket.on("disconnecting", () => {
const user = activeUsers[socket.id]; const user = activeUsers[socket.id];
if (user) { if (user) {
console.log(`[ROOM] 🔴 ${user.username} saiu da sala ${user.room}`); console.log(`[ROOM] 🔴 ${user.username} saiu da sala ${user.room}`);
socket.to(user.room).emit("user_left", { username: user.username }); 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 // CLOCK SYNC
@ -511,7 +560,7 @@ socket.on("disconnect", () => {
cb && cb({ ok: false, error: "invalid_payload" }); cb && cb({ ok: false, error: "invalid_payload" });
console.warn( console.warn(
`[broadcast_action] payload inválido de ${socket.id}:`, `[broadcast_action] payload inválido de ${socket.id}:`,
payload payload,
); );
return; return;
} }
@ -528,7 +577,7 @@ socket.on("disconnect", () => {
socketId: socket.id, socketId: socket.id,
action: action, action: action,
}, },
"action_received" "action_received",
); );
} catch (e) { } catch (e) {
console.warn(`[Logger] Falha ao logar ação para sala ${roomName}:`, 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) // Garante que o emissor está na sala (caso o join tenha falhado/atrasado)
if (!socket.rooms.has(roomName)) { if (!socket.rooms.has(roomName)) {
console.warn( 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); await socket.join(roomName);
logMyRooms(socket, "FORCED_JOIN"); logMyRooms(socket, "FORCED_JOIN");
@ -550,18 +599,48 @@ socket.on("disconnect", () => {
// Carrega estado da sala // Carrega estado da sala
const room = ensureRoom(roomName); 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) // Persiste estado do Pattern (Notas/Sequenciador)
// Aceita tanto carregamento total quanto atualização de notas if (
if ((action.type === "LOAD_PROJECT" || action.type === "SYNC_PATTERN_STATE") && action.xml) { (action.type === "LOAD_PROJECT" ||
// Proteção: Não salva se o XML for vazio (evita corromper a sala com o bug antigo) action.type === "SYNC_PATTERN_STATE") &&
if (action.xml.trim().length > 0) { action.xml
) {
const xmlTrimmed = action.xml.trim();
// Validação de Integridade: O XML chegou inteiro?
if (xmlTrimmed.length > 0 && xmlTrimmed.endsWith("</lmms-project>")) {
room.projectXml = action.xml; room.projectXml = action.xml;
saveRoom(roomName); saveRoom(roomName);
console.log( 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 { } 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); const idsInRoom = socketsInRoom.map((s) => s.id);
console.log( console.log(
`[broadcast_action] ${action.type} (token=${action.__token}) para sala "${roomName}" com ${idsInRoom.length} cliente(s):`, `[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 // Se foi uma ação do editor de áudio e foi aplicada, rebroadcast com __seq
if (maybeApplied) { if (maybeApplied) {
io.to(roomName).emit("action_broadcast", { action: maybeApplied }); socket.to(roomName).emit("action_broadcast", { action: maybeApplied });
return; return;
} }
// Caso contrário, apenas repercute a ação como antes (sem __seq) // Caso contrário, repercute a ação UMA ÚNICA VEZ para todo mundo na sala
io.to(roomName).emit("action_broadcast", { action }); socket.to(roomName).emit("action_broadcast", { action });
}); }); // <--- Fim do socket.on("broadcast_action")
// ======================================= // =======================================
// Retrocompat (daw_action) // Retrocompat (daw_action)
@ -594,7 +673,7 @@ socket.on("disconnect", () => {
if (!room || !action) return; if (!room || !action) return;
console.warn( 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 // (Opcional, mas recomendado) Adicionando log dinâmico aqui também
@ -606,7 +685,7 @@ socket.on("disconnect", () => {
socketId: socket.id, socketId: socket.id,
action: action, action: action,
}, },
"action_received (deprecated daw_action)" "action_received (deprecated daw_action)",
); );
} catch (e) { } catch (e) {
console.warn(`[Logger] Falha ao logar daw_action para sala ${room}:`, 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)) { if (!socket.rooms.has(room)) {
console.warn( 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); await socket.join(room);
logMyRooms(socket, "FORCED_JOIN(daw)"); logMyRooms(socket, "FORCED_JOIN(daw)");
@ -625,7 +704,7 @@ socket.on("disconnect", () => {
r.projectXml = action.xml; r.projectXml = action.xml;
saveRoom(room); saveRoom(room);
console.log( 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); const idsInRoom = socketsInRoom.map((s) => s.id);
console.log( console.log(
`[daw_action] ${action.type} para sala "${room}" (${idsInRoom.length} cliente(s)):`, `[daw_action] ${action.type} para sala "${room}" (${idsInRoom.length} cliente(s)):`,
idsInRoom idsInRoom,
); );
io.in(room).emit("action_broadcast", { action }); io.in(room).emit("action_broadcast", { action });
@ -668,9 +747,7 @@ app.use(express.json({ limit: "250mb" }));
app.use((req, res, next) => { app.use((req, res, next) => {
const origin = req.headers.origin; const origin = req.headers.origin;
const allowed = new Set([ const allowed = new Set(["https://alice.ufsj.edu.br"]);
"https://alice.ufsj.edu.br",
]);
if (origin && allowed.has(origin)) { if (origin && allowed.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Access-Control-Allow-Origin", origin);
@ -700,14 +777,15 @@ function buildLmmsCommand(inputMmp, outputAudio, ext) {
return { cmd: LMMS_BIN, args: lmmsArgs }; return { cmd: LMMS_BIN, args: lmmsArgs };
} }
function sanitizeFileName(name) { function sanitizeFileName(name) {
return String(name || "projeto") return (
.normalize("NFKD") String(name || "projeto")
.replace(/[^\w\s.-]/g, "") .normalize("NFKD")
.trim() .replace(/[^\w\s.-]/g, "")
.replace(/\s+/g, "_") .trim()
.slice(0, 120) || "projeto"; .replace(/\s+/g, "_")
.slice(0, 120) || "projeto"
);
} }
function isSafeZipEntry(name) { function isSafeZipEntry(name) {
@ -721,12 +799,17 @@ function isSafeZipEntry(name) {
async function safeUnzip(zipPath, destDir) { async function safeUnzip(zipPath, destDir) {
// lista entradas // lista entradas
const list = await run("unzip", ["-Z1", zipPath], { timeoutMs: 60_000 }); 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) { 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 // extrai
@ -747,7 +830,6 @@ async function findFirstMmp(dir) {
return null; return null;
} }
function run(cmd, args, { timeoutMs, cwd } = {}) { function run(cmd, args, { timeoutMs, cwd } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const p = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], cwd }); 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())); p.stderr.on("data", (d) => (err += d.toString()));
const t = setTimeout(() => { const t = setTimeout(() => {
try { p.kill("SIGKILL"); } catch {} try {
p.kill("SIGKILL");
} catch {}
reject(new Error(`${cmd} timeout (${timeoutMs}ms)\n${err || out}`)); reject(new Error(`${cmd} timeout (${timeoutMs}ms)\n${err || out}`));
}, timeoutMs || LMMS_TIMEOUT_MS); }, timeoutMs || LMMS_TIMEOUT_MS);
@ -781,23 +865,37 @@ app.get("/api/admin/salas", (req, res) => {
// Itera por todas as salas ativas na memória do Socket.IO // Itera por todas as salas ativas na memória do Socket.IO
io.sockets.adapter.rooms.forEach((value, key) => { io.sockets.adapter.rooms.forEach((value, key) => {
// O Socket.IO cria uma "sala" automática para cada usuário. // O Socket.IO cria uma "sala" automática para cada usuário.
// Vamos filtrar para mostrar apenas as salas reais do seu sistema. // Vamos filtrar para mostrar apenas as salas reais do seu sistema.
if (!io.sockets.sockets.has(key)) { if (!io.sockets.sockets.has(key)) {
roomsData[key] = { roomsData[key] = {
total_usuarios: value.size, total_usuarios: value.size,
ids_conectados: Array.from(value) ids_conectados: Array.from(value),
}; };
} }
}); });
res.json({ res.json({
salas_ativas: Object.keys(roomsData).length, salas_ativas: Object.keys(roomsData).length,
detalhes: roomsData 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 // trava simples pra não renderizar a mesma sala em paralelo
const renderLocks = new Map(); const renderLocks = new Map();
@ -807,7 +905,9 @@ app.post("/render", async (req, res) => {
const ext = String(format || "wav").toLowerCase(); const ext = String(format || "wav").toLowerCase();
const allowed = new Set(["wav", "ogg", "flac", "mp3"]); const allowed = new Set(["wav", "ogg", "flac", "mp3"]);
if (!allowed.has(ext)) { 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) // trava sala (se aplicável)
@ -833,7 +933,8 @@ app.post("/render", async (req, res) => {
await safeUnzip(zipPath, tmpDir); await safeUnzip(zipPath, tmpDir);
inputMmpPath = await findFirstMmp(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/... cwdForLmms = path.dirname(inputMmpPath); // isso faz o LMMS resolver samples/...
} else { } else {
@ -847,7 +948,9 @@ app.post("/render", async (req, res) => {
} }
if (!projectXml || String(projectXml).trim().length === 0) { 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"); inputMmpPath = path.join(tmpDir, "project.mmp");
@ -862,15 +965,25 @@ app.post("/render", async (req, res) => {
const downloadName = `${fileBase}.${ext}`; const downloadName = `${fileBase}.${ext}`;
return res.download(outputPath, downloadName, (err) => { 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 (roomName) renderLocks.delete(roomName);
if (err) console.error("[/render] download error:", err); if (err) console.error("[/render] download error:", err);
}); });
} catch (e) { } 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); if (roomName) renderLocks.delete(roomName);
console.error("[/render] fail:", e); 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,22 +996,26 @@ app.post("/notify-update", express.json(), (req, res) => {
// O evento deve ser algo que ui.js entenda. // O evento deve ser algo que ui.js entenda.
io.emit("system_update", { io.emit("system_update", {
type: "RELOAD_SAMPLES", 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( 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." }); res.status(400).send({ success: false, message: "updateType inválido." });
}); });
app.get("/", (req, res) => { app.get("/", (req, res) => {
res.send( res.send("Servidor Backend do MMP-Search está no ar novamente!");
"Servidor Backend do MMP-Search está no ar novamente!"
);
}); });
// ====== START ====== // ====== START ======

View File

@ -48,37 +48,68 @@ import {
import { renderAll, showToast } from "./ui.js"; // showToast() import { renderAll, showToast } from "./ui.js"; // showToast()
import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js"; import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js";
import { PORT_SOCK } from "./config.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 USER_NAME = null;
export let currentRoom = null; export let currentRoom = null;
let roomSeq = 0; let roomSeq = 0;
let lastActionToken = 0; let lastActionToken = 0;
let isOfflineMode = false; 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 --- // --- FUNÇÃO DE AUTENTICAÇÃO INTELIGENTE ---
export async function getOrFetchUsername() { export async function getOrFetchUsername() {
// Se já pegamos o nome antes, não pede de novo // Se já pegamos o nome antes, não pede de novo
if (USER_NAME) return USER_NAME; if (USER_NAME) return USER_NAME;
try { try {
const res = await fetch('/api/check_auth', { credentials: 'include' }); const res = await fetch("/api/check_auth", { credentials: "include" });
const data = await res.json(); const data = await res.json();
if (data.logged_in) { if (data.logged_in) {
USER_NAME = data.user; USER_NAME = data.user;
return USER_NAME; return USER_NAME;
}
} catch (e) {
console.warn("API de Auth indisponível, caindo para modo visitante.");
} }
} catch (e) {
console.warn("API de Auth indisponível, caindo para modo visitante.");
}
// Se não estiver logado, barra a tela e pede apelido // 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:"); let name = prompt(
if (!name || name.trim() === "") { "Você não está logado. Digite seu apelido para entrar na DAW Online:",
name = "Produtor_" + Math.floor(Math.random() * 1000); );
} if (!name || name.trim() === "") {
USER_NAME = name.trim(); name = "Produtor_" + Math.floor(Math.random() * 1000);
return USER_NAME; }
USER_NAME = name.trim();
return USER_NAME;
} }
// --- TELEMETRIA PARA A DISSERTAÇÃO --- // --- TELEMETRIA PARA A DISSERTAÇÃO ---
@ -88,13 +119,13 @@ let lastReceivedSeq = 0;
function _getRackIdFallback() { function _getRackIdFallback() {
// 1) pega do primeiro bassline que já tenha instrumentSourceId // 1) pega do primeiro bassline que já tenha instrumentSourceId
const b = (appState.pattern.tracks || []).find( const b = (appState.pattern.tracks || []).find(
(t) => t.type === "bassline" && t.instrumentSourceId (t) => t.type === "bassline" && t.instrumentSourceId,
); );
if (b?.instrumentSourceId) return b.instrumentSourceId; if (b?.instrumentSourceId) return b.instrumentSourceId;
// 2) pega do primeiro instrumento do rack (parentBasslineId) // 2) pega do primeiro instrumento do rack (parentBasslineId)
const child = (appState.pattern.tracks || []).find( const child = (appState.pattern.tracks || []).find(
(t) => t.type !== "bassline" && t.parentBasslineId (t) => t.type !== "bassline" && t.parentBasslineId,
); );
if (child?.parentBasslineId) return child.parentBasslineId; if (child?.parentBasslineId) return child.parentBasslineId;
@ -105,15 +136,17 @@ function _nextPatternIndex() {
const bassMax = Math.max( const bassMax = Math.max(
-1, -1,
...(appState.pattern.tracks || []) ...(appState.pattern.tracks || [])
.filter((t) => t.type === "bassline" && Number.isFinite(Number(t.patternIndex))) .filter(
.map((t) => Number(t.patternIndex)) (t) => t.type === "bassline" && Number.isFinite(Number(t.patternIndex)),
)
.map((t) => Number(t.patternIndex)),
); );
const nonBassMax = Math.max( const nonBassMax = Math.max(
-1, -1,
...(appState.pattern.tracks || []) ...(appState.pattern.tracks || [])
.filter((t) => t.type !== "bassline") .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; return Math.max(bassMax, nonBassMax) + 1;
@ -213,20 +246,19 @@ export function sendActionSafe(action) {
// --- MONITORAMENTO DE PRESENÇA --- // --- MONITORAMENTO DE PRESENÇA ---
socket.on("room_status", (data) => { socket.on("room_status", (data) => {
console.log( console.log(
`%c[MONITOR DE SALA] 👥 ${data.count} pessoa(s) na sala '${data.room}'`, `%c[MONITOR DE SALA] 👥 ${data.count} pessoa(s) na sala '${data.room}'`,
'background: #222; color: #bada55; font-size: 16px; font-weight: bold; padding: 4px;' "background: #222; color: #bada55; font-size: 16px; font-weight: bold; padding: 4px;",
); );
console.log("IDs conectados:", data.users); console.log("IDs conectados:", data.users);
// Se quiser um aviso visual na tela da DAW: // Se quiser um aviso visual na tela da DAW:
if(data.count > 1) { if (data.count > 1) {
showToast(`👥 Agora há ${data.count} produtores na sala!`, "success"); showToast(`👥 Agora há ${data.count} produtores na sala!`, "success");
} }
}); });
// --------------------------------- // ---------------------------------
// --- SUBSTITUA SEU socket.on("connect") POR ESTE ---
socket.on("connect", async () => { socket.on("connect", async () => {
console.log(`Conectado ao servidor com ID: ${socket.id}`); console.log(`Conectado ao servidor com ID: ${socket.id}`);
@ -235,16 +267,15 @@ socket.on("connect", async () => {
if (urlRoom) { if (urlRoom) {
currentRoom = urlRoom; currentRoom = urlRoom;
console.log(`Modo Online. Sala detectada na URL: ${currentRoom}`); 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 // Como tem sala na URL, EXIGE o login/apelido agora
const myName = await getOrFetchUsername(); const myName = await getOrFetchUsername();
socket.emit("join_room", { socket.emit("join_room", {
roomName: currentRoom, roomName: currentRoom,
userName: myName userName: myName,
}); });
} else { } else {
// Entrou na DAW pura, não pede nada ainda. // Entrou na DAW pura, não pede nada ainda.
console.log("Modo Local. Aguardando criação de sala."); console.log("Modo Local. Aguardando criação de sala.");
@ -253,6 +284,9 @@ socket.on("connect", async () => {
syncServerTime(); syncServerTime();
setInterval(syncServerTime, 10000); 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) => { socket.on("connect_error", (err) => {
console.error("Falha crítica na conexão do Socket:", err.message); console.error("Falha crítica na conexão do Socket:", err.message);
alert( 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( showToast(
"❌ Falha grave de conexão. Desativa o Ad Blocker? 😅", "❌ Falha grave de conexão. Desativa o Ad Blocker? 😅",
"error", "error",
10000 10000,
); );
}); });
@ -276,7 +310,7 @@ socket.on("connect_error", (err) => {
socket.on("system_update", (data) => { socket.on("system_update", (data) => {
if (data.type === "RELOAD_SAMPLES") { if (data.type === "RELOAD_SAMPLES") {
console.log( console.log(
`[System Update] Recebida ordem para recarregar samples: ${data.message}` `[System Update] Recebida ordem para recarregar samples: ${data.message}`,
); );
loadAndRenderSampleBrowser(); loadAndRenderSampleBrowser();
@ -286,61 +320,42 @@ socket.on("system_update", (data) => {
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// RECEBER ESTADO SALVO DA SALA // RECEBER ESTADO SALVO DA SALA
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
socket.on("load_project_state", async (projectXml) => { socket.on("load_project_state", async (payload) => {
console.log("Recebendo estado salvo da sala..."); // Ignora se já carregou (proteção que você já fez)
showToast("🔄 Recebendo estado atual da sala...", "info", 4000); if (hasLoadedInitialState && !payload.forceSync) {
return;
}
console.log("Recebendo estado salvo da sala...");
if (isLoadingProject) return; if (isLoadingProject) return;
isLoadingProject = true; isLoadingProject = true;
try { try {
// 1. Carrega o XML que veio do servidor (pode estar desatualizado) // Se vier com "patternState" (JSON), carrega direto!
await parseMmpContent(projectXml); 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);
}
// 🔥 CORREÇÃO: Força a restauração da sessão LOCAL logo após carregar o XML if (currentRoom && !payload.forceSync) {
// Isso garante que suas alterações locais (BPM, steps) "ganhem" do servidor sessionStorage.removeItem(`temp_state_${currentRoom}`);
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();
}
} }
renderAll(); renderAll();
saveStateToSession(); saveStateToSession();
hasLoadedInitialState = true;
showToast("🎵 Projeto carregado com sucesso", "success"); 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) { } catch (e) {
console.error("Erro ao carregar projeto:", e); console.error("Erro crítico ao carregar projeto:", e);
showToast("❌ Erro ao carregar projeto", "error"); 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( console.error(
"[SOCKET] UPDATE_AUDIO_CLIP bloqueada (startTime):", "[SOCKET] UPDATE_AUDIO_CLIP bloqueada (startTime):",
action action,
); );
return; 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; action.__token = token;
// Usa o relógio sincronizado com o servidor // Usa o relógio sincronizado com o servidor
action.__send_time = Date.now() + serverOffsetMs; action.__send_time = Date.now() + serverOffsetMs;
action.__senderId = socket.id; action.__senderId = socket.id;
action.__senderName = USER_NAME; 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 = const isTransport =
action.type === "TOGGLE_PLAYBACK" || action.type === "TOGGLE_PLAYBACK" ||
action.type === "STOP_PLAYBACK" || action.type === "STOP_PLAYBACK" ||
@ -438,52 +463,39 @@ export function sendAction(action) {
} }
// (Lógica Global/Local) // (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")) { if (!inRoom || (isTransport && appState.global.syncMode === "local")) {
console.log("[SOCKET] (local) executando ação:", action.type);
handleActionBroadcast(action);
return; return;
} }
if (isTransport && appState.global.syncMode === "global") { if (isTransport && appState.global.syncMode === "global") {
action.__syncMode = "global"; action.__syncMode = "global";
} }
console.log( // 🔥 Agora sim envia para o servidor (GZIP habilitado)
"[SOCKET] Enviando broadcast_action:", socket.emit("broadcast_action", { roomName: currentRoom, action }, (ack) => {
action.type, if (ack && ack.ok && ack.token === token) {
"para", if (lastActionTimeout) clearTimeout(lastActionTimeout);
currentRoom, if (lastBroadcastTimeout) clearTimeout(lastBroadcastTimeout);
"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;
}
}
});
if (lastActionTimeout) clearTimeout(lastActionTimeout); if (lastActionTimeout) clearTimeout(lastActionTimeout);
lastActionTimeout = setTimeout(() => { lastActionTimeout = setTimeout(() => {
console.warn("[SOCKET] ACK não recebido:", action.type, token); if (action.type !== "SYNC_PATTERN_STATE") {
showToast(`⚠️ ACK (${action.type})`, "warning"); showToast(`⚠️ Rede lenta (${action.type})`, "warning");
}, 500); }
}, 2500);
if (lastBroadcastTimeout) clearTimeout(lastBroadcastTimeout); if (lastBroadcastTimeout) clearTimeout(lastBroadcastTimeout);
pendingToken = token; 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);
}
lastBroadcastTimeout = setTimeout(() => {
// ⚠️ 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
@ -542,7 +554,8 @@ function _genPlaylistClipId() {
function _ensureBasslineForPatternIndex(patternIndex) { function _ensureBasslineForPatternIndex(patternIndex) {
let b = appState.pattern.tracks.find( 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) // fallback: cria se não existir (não quebra nada)
@ -578,7 +591,7 @@ function _ensureBasslineForPatternIndex(patternIndex) {
function _getDefaultRackId() { function _getDefaultRackId() {
const inst = (appState.pattern.tracks || []).find( const inst = (appState.pattern.tracks || []).find(
(t) => t.type !== "bassline" && t.parentBasslineId (t) => t.type !== "bassline" && t.parentBasslineId,
); );
return inst?.parentBasslineId ?? null; return inst?.parentBasslineId ?? null;
} }
@ -600,32 +613,32 @@ socket.on("action_broadcast", (payload) => {
// 1. Calcula a latência (se foi outro usuário que mandou) // 1. Calcula a latência (se foi outro usuário que mandou)
let latencyMs = 0; let latencyMs = 0;
if (action.__send_time && action.userId !== socket.id) { 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) // 2. Verifica a Ordem dos Pacotes (Jitter/Perda)
let isOutOfOrder = false; let isOutOfOrder = false;
let missingPackets = 0; let missingPackets = 0;
if (action.__seq) { if (action.__seq) {
if (lastReceivedSeq !== 0 && action.__seq !== lastReceivedSeq + 1) { if (lastReceivedSeq !== 0 && action.__seq !== lastReceivedSeq + 1) {
isOutOfOrder = true; isOutOfOrder = true;
missingPackets = Math.abs(action.__seq - lastReceivedSeq) - 1; missingPackets = Math.abs(action.__seq - lastReceivedSeq) - 1;
} }
lastReceivedSeq = action.__seq; lastReceivedSeq = action.__seq;
} }
// 3. Salva no array se não for a sua própria mensagem voltando // 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! // TRAVA DE SEGURANÇA: Se o array não existir ainda, cria na hora!
window.jamTelemetry = window.jamTelemetry || []; window.jamTelemetry = window.jamTelemetry || [];
if (action.userId !== socket.id) { if (action.userId !== socket.id) {
window.jamTelemetry.push({ window.jamTelemetry.push({
type: action.type, type: action.type,
seq: action.__seq, seq: action.__seq,
latency_ms: latencyMs.toFixed(2), latency_ms: latencyMs.toFixed(2),
out_of_order: isOutOfOrder, out_of_order: isOutOfOrder,
missing_packets: missingPackets, missing_packets: missingPackets,
timestamp: new Date().toISOString() timestamp: new Date().toISOString(),
}); });
} }
// --- FIM DA COLETA DE DADOS --- // --- FIM DA COLETA DE DADOS ---
@ -667,7 +680,12 @@ function _syncNotesWithStepToggle(pattern, stepIndex, isActive) {
if (pattern.notes.some(samePos)) return; if (pattern.notes.some(samePos)) return;
// tenta reaproveitar "template" de nota pra manter key/vol/pan coerentes // 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({ pattern.notes.push({
vol: tpl.vol ?? 100, vol: tpl.vol ?? 100,
@ -757,17 +775,19 @@ async function handleActionBroadcast(action) {
} }
case "CREATE_NEW_PATTERN": { case "CREATE_NEW_PATTERN": {
const patternIndex = const patternIndex = Number.isFinite(Number(action.patternIndex))
Number.isFinite(Number(action.patternIndex)) ? Number(action.patternIndex) : _nextPatternIndex(); ? 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 // 1) garante patterns em todas as tracks reais
_ensurePatternsUpTo(patternIndex); _ensurePatternsUpTo(patternIndex);
// 2) cria bassline track container (pattern “da playlist”) // 2) cria bassline track container (pattern “da playlist”)
let b = (appState.pattern.tracks || []).find( let b = (appState.pattern.tracks || []).find(
(t) => t.type === "bassline" && Number(t.patternIndex) === patternIndex (t) => t.type === "bassline" && Number(t.patternIndex) === patternIndex,
); );
if (!b) { if (!b) {
@ -798,17 +818,20 @@ async function handleActionBroadcast(action) {
// 4) já seleciona essa pattern (opcional, mas fica UX boa) // 4) já seleciona essa pattern (opcional, mas fica UX boa)
appState.pattern.activePatternIndex = patternIndex; 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(); renderAll();
saveStateToSession(); saveStateToSession();
// 5) persiste no servidor (igual você já faz em outros updates) // 5) persiste no servidor (igual você já faz em outros updates)
const isFromSelf = action.__senderId === socket.id; const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
const xml = generateXmlFromStateExported(); debouncedSyncPatternState();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} }
break; break;
@ -821,17 +844,25 @@ async function handleActionBroadcast(action) {
? Number(action.patternIndex) ? Number(action.patternIndex)
: null; : null;
const nonBass = (appState.pattern.tracks || []).filter(t => t.type !== "bassline"); const nonBass = (appState.pattern.tracks || []).filter(
const currentCount = nonBass.reduce((m, t) => Math.max(m, t.patterns?.length ?? 0), 0); (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) // Próximo índice livre (fim da lista)
const maxFromNonBass = nonBass.reduce( const maxFromNonBass = nonBass.reduce(
(m, t) => Math.max(m, (t.patterns?.length || 0) - 1), (m, t) => Math.max(m, (t.patterns?.length || 0) - 1),
-1 -1,
); );
const maxFromBass = (appState.pattern.tracks || []) 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); .reduce((m, t) => Math.max(m, Number(t.patternIndex)), -1);
const nextFree = Math.max(maxFromNonBass, maxFromBass) + 1; const nextFree = Math.max(maxFromNonBass, maxFromBass) + 1;
@ -842,7 +873,7 @@ async function handleActionBroadcast(action) {
const idxAlreadyExists = const idxAlreadyExists =
idx <= maxFromNonBass || idx <= maxFromNonBass ||
(appState.pattern.tracks || []).some( (appState.pattern.tracks || []).some(
t => t.type === "bassline" && Number(t.patternIndex) === idx (t) => t.type === "bassline" && Number(t.patternIndex) === idx,
); );
if (idxAlreadyExists) idx = nextFree; if (idxAlreadyExists) idx = nextFree;
@ -856,7 +887,8 @@ async function handleActionBroadcast(action) {
for (const t of nonBass) { for (const t of nonBass) {
t.patterns[idx] = t.patterns[idx] || _makeEmptyPattern(idx); t.patterns[idx] = t.patterns[idx] || _makeEmptyPattern(idx);
t.patterns[idx].name = finalName; 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) // 3) cria a lane "bassline" (a coluna do pattern)
@ -868,7 +900,9 @@ async function handleActionBroadcast(action) {
// 4) opcional: já selecionar // 4) opcional: já selecionar
if (action.select) { if (action.select) {
appState.pattern.activePatternIndex = idx; appState.pattern.activePatternIndex = idx;
appState.pattern.tracks.forEach((track) => (track.activePatternIndex = idx)); appState.pattern.tracks.forEach(
(track) => (track.activePatternIndex = idx),
);
} }
renderAll(); renderAll();
@ -878,8 +912,13 @@ async function handleActionBroadcast(action) {
} }
case "REMOVE_LAST_PATTERN": { case "REMOVE_LAST_PATTERN": {
const nonBass = (appState.pattern.tracks || []).filter(t => t.type !== "bassline"); const nonBass = (appState.pattern.tracks || []).filter(
const count = nonBass.reduce((m, t) => Math.max(m, t.patterns?.length ?? 0), 0); (t) => t.type !== "bassline",
);
const count = nonBass.reduce(
(m, t) => Math.max(m, t.patterns?.length ?? 0),
0,
);
const last = count - 1; const last = count - 1;
if (last <= 0) break; if (last <= 0) break;
@ -888,7 +927,7 @@ async function handleActionBroadcast(action) {
// remove a lane bassline correspondente // remove a lane bassline correspondente
appState.pattern.tracks = appState.pattern.tracks.filter( 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 // ajusta seleção
@ -923,10 +962,9 @@ async function handleActionBroadcast(action) {
saveStateToSession(); saveStateToSession();
// ✅ sync XML (inclui bbtco depois do patch no file.js) // ✅ sync XML (inclui bbtco depois do patch no file.js)
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
try { try {
const xml = generateXmlFromStateExported(); debouncedSyncPatternState();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} catch {} } catch {}
} }
break; break;
@ -947,10 +985,9 @@ async function handleActionBroadcast(action) {
renderAll(); renderAll();
saveStateToSession(); saveStateToSession();
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
try { try {
const xml = generateXmlFromStateExported(); debouncedSyncPatternState();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} catch {} } catch {}
} }
break; break;
@ -960,7 +997,9 @@ async function handleActionBroadcast(action) {
const { patternIndex, clipId } = action; const { patternIndex, clipId } = action;
const b = _ensureBasslineForPatternIndex(patternIndex); 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 // limpa seleção se era o selecionado
if (appState.global.selectedPlaylistClipId === clipId) { if (appState.global.selectedPlaylistClipId === clipId) {
@ -971,10 +1010,9 @@ async function handleActionBroadcast(action) {
renderAll(); renderAll();
saveStateToSession(); saveStateToSession();
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
try { try {
const xml = generateXmlFromStateExported(); debouncedSyncPatternState();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} catch {} } catch {}
} }
break; break;
@ -983,8 +1021,7 @@ async function handleActionBroadcast(action) {
case "SELECT_PLAYLIST_PATTERN_CLIP": { case "SELECT_PLAYLIST_PATTERN_CLIP": {
const { patternIndex, clipId } = action; const { patternIndex, clipId } = action;
appState.global.selectedPlaylistClipId = clipId ?? null; appState.global.selectedPlaylistClipId = clipId ?? null;
appState.global.selectedPlaylistPatternIndex = appState.global.selectedPlaylistPatternIndex = patternIndex ?? null;
patternIndex ?? null;
renderAll(); renderAll();
saveStateToSession(); saveStateToSession();
break; break;
@ -1016,12 +1053,8 @@ async function handleActionBroadcast(action) {
} }
const isFromSelf = action.__senderId === socket.id; const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
const xml = generateXmlFromStateExported(); debouncedSyncPatternState();
sendAction({
type: "SYNC_PATTERN_STATE",
xml,
});
} }
saveStateToSession(); saveStateToSession();
@ -1062,7 +1095,7 @@ async function handleActionBroadcast(action) {
case "STOP_AUDIO_PLAYBACK": case "STOP_AUDIO_PLAYBACK":
setTimeout( setTimeout(
() => stopAudioEditorPlayback(action.rewind || false), () => stopAudioEditorPlayback(action.rewind || false),
delayMs delayMs,
); );
break; break;
@ -1133,11 +1166,11 @@ async function handleActionBroadcast(action) {
showToast("📂 Carregando...", "info"); showToast("📂 Carregando...", "info");
// Esta parte está CORRETA. Sempre limpa o sessionStorage. // Esta parte está CORRETA. Sempre limpa o sessionStorage.
if (window.ROOM_NAME) { if (currentRoom) {
try { try {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); sessionStorage.removeItem(`temp_state_${currentRoom}`);
console.log( console.log(
"Socket: Estado da sessão local limpo para LOAD_PROJECT." "Socket: Estado da sessão local limpo para LOAD_PROJECT.",
); );
} catch (e) { } catch (e) {
console.error("Socket: Falha ao limpar estado da sessão:", e); console.error("Socket: Falha ao limpar estado da sessão:", e);
@ -1156,25 +1189,6 @@ async function handleActionBroadcast(action) {
isLoadingProject = false; isLoadingProject = false;
break; 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": case "RESET_ROOM":
console.log("Socket: Recebendo comando de RESET_ROOM do servidor."); // console.log("Socket: Recebendo comando de RESET_ROOM do servidor."); //
const who = actorOf(action); const who = actorOf(action);
@ -1230,9 +1244,8 @@ async function handleActionBroadcast(action) {
// 🔥 CORREÇÃO: Avisa o servidor que o XML mudou! // 🔥 CORREÇÃO: Avisa o servidor que o XML mudou!
const isFromSelf = action.__senderId === socket.id; const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
const xml = generateXmlFromStateExported(); debouncedSyncPatternState();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} }
saveStateToSession(); saveStateToSession();
@ -1248,16 +1261,15 @@ async function handleActionBroadcast(action) {
const who = actorOf(action); const who = actorOf(action);
showToast(`❌ Faixa removida por ${who}`, "warning"); showToast(`❌ Faixa removida por ${who}`, "warning");
// Sincroniza o XML com o servidor para persistir a remoção
const isFromSelf = action.__senderId === socket.id; const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
// Importante: gerar o XML atualizado (sem a faixa) debouncedSyncPatternState();
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} }
saveStateToSession(); saveStateToSession();
} else { } 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! // Não faz nada, protegendo as faixas locais!
} }
break; break;
@ -1271,9 +1283,8 @@ async function handleActionBroadcast(action) {
// 🔥 CORREÇÃO: Avisa o servidor que o XML mudou (removeu o TripleOscillator, por exemplo) // 🔥 CORREÇÃO: Avisa o servidor que o XML mudou (removeu o TripleOscillator, por exemplo)
const isFromSelf = action.__senderId === socket.id; const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
const xml = generateXmlFromStateExported(); debouncedSyncPatternState();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} }
saveStateToSession(); saveStateToSession();
@ -1312,8 +1323,10 @@ async function handleActionBroadcast(action) {
// 3. Remove os clips associados a essa track (Limpeza) // 3. Remove os clips associados a essa track (Limpeza)
if (lastTrack && lastTrack.id) { if (lastTrack && lastTrack.id) {
// Filtra mantendo apenas os clips que NÃO pertencem à track removida // Filtra mantendo apenas os clips que NÃO pertencem à track removida
appState.audio.clips = appState.audio.clips.filter(clip => clip.trackId !== lastTrack.id); appState.audio.clips = appState.audio.clips.filter(
(clip) => clip.trackId !== lastTrack.id,
);
} }
// 4. Atualiza a tela // 4. Atualiza a tela
@ -1341,7 +1354,12 @@ async function handleActionBroadcast(action) {
// Notes // Notes
case "TOGGLE_NOTE": { 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) // ✅ garante que o índice exista em todas as tracks (evita “silêncio” na playlist)
_ensurePatternsUpTo(pi); _ensurePatternsUpTo(pi);
@ -1351,7 +1369,8 @@ async function handleActionBroadcast(action) {
t.patterns[pi] = t.patterns[pi] || _makeEmptyPattern(pi); t.patterns[pi] = t.patterns[pi] || _makeEmptyPattern(pi);
// mantém metadados estáveis p/ export/sync // 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 (!t.patterns[pi].name) t.patterns[pi].name = `Pattern ${pi + 1}`;
if (!Array.isArray(t.patterns[pi].notes)) t.patterns[pi].notes = []; 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 // importante: se o Audio Editor estiver tocando, precisa re-schedulear
restartAudioEditorIfPlaying(); restartAudioEditorIfPlaying();
try { updateStepUI(ti, pi, si, isActive); } catch {} try {
updateStepUI(ti, pi, si, isActive);
} catch {}
if (!isFromSelf) schedulePatternRerender(); if (!isFromSelf) schedulePatternRerender();
} }
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
const xml = generateXmlFromStateExported(); debouncedSyncPatternState();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} }
saveStateToSession(); saveStateToSession();
@ -1406,12 +1426,11 @@ async function handleActionBroadcast(action) {
// 4) Sync online somente se fui EU quem enviei // 4) Sync online somente se fui EU quem enviei
const isFromSelf = action.__senderId === socket.id; const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) { if (currentRoom && isFromSelf) {
try { try {
const xml = generateXmlFromStateExported(); // ← CORREÇÃO
sendAction({ sendAction({
type: "SYNC_PATTERN_STATE", type: "SYNC_PATTERN_STATE",
xml, patternState: appState.pattern, // Envia o objeto puro
}); });
} catch (e) { } catch (e) {
console.error("Erro gerando XML UPDATE_PATTERN_NOTES", 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 stepsPerBar = 16; // 4/4 em 1/16
const maxSteps = appState.pattern.tracks const maxSteps = appState.pattern.tracks
.filter(t => t.type !== "bassline") .filter((t) => t.type !== "bassline")
.map(t => t.patterns?.[patternIndex]?.steps?.length || 0) .map((t) => t.patterns?.[patternIndex]?.steps?.length || 0)
.reduce((a, b) => Math.max(a, b), 0); .reduce((a, b) => Math.max(a, b), 0);
const barsEl = document.getElementById("bars-input"); const barsEl = document.getElementById("bars-input");
@ -1513,7 +1532,6 @@ async function handleActionBroadcast(action) {
} }
} catch {} } catch {}
// Mostra o toast (só uma vez) // Mostra o toast (só uma vez)
if (!isFromSelf) { if (!isFromSelf) {
const who = actorOf(action); const who = actorOf(action);
@ -1535,7 +1553,9 @@ async function handleActionBroadcast(action) {
case "AUDIO_SNAPSHOT_REQUEST": { case "AUDIO_SNAPSHOT_REQUEST": {
// 🛑 BLOQUEADO: Agora o Servidor (backend) é quem manda o snapshot autoritativo. // 🛑 BLOQUEADO: Agora o Servidor (backend) é quem manda o snapshot autoritativo.
// Se deixarmos os clientes responderem, eles vão enviar estados antigos ("zumbis"). // 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; break;
} }
@ -1553,7 +1573,7 @@ async function handleActionBroadcast(action) {
const who = actorOf(action); const who = actorOf(action);
// Só mostra toast se realmente tiver conteúdo, para não spammar na entrada // Só mostra toast se realmente tiver conteúdo, para não spammar na entrada
if ((action.snapshot?.tracks?.length || 0) > 0) { if ((action.snapshot?.tracks?.length || 0) > 0) {
showToast(`🔁 Sync áudio por ${who}`, "success"); showToast(`🔁 Sync áudio por ${who}`, "success");
} }
} catch (e) { } catch (e) {
console.error("Erro AUDIO_SNAPSHOT:", e); console.error("Erro AUDIO_SNAPSHOT:", e);
@ -1584,11 +1604,11 @@ async function handleActionBroadcast(action) {
break; break;
} }
const trackExists = appState.audio?.tracks?.some( const trackExists = appState.audio?.tracks?.some(
(t) => t.id === trackId (t) => t.id === trackId,
); );
if (!trackExists) { if (!trackExists) {
console.warn( console.warn(
`ADD_AUDIO_CLIP Pista ${trackId} não existe, criando...` `ADD_AUDIO_CLIP Pista ${trackId} não existe, criando...`,
); );
appState.audio.tracks.push({ appState.audio.tracks.push({
id: trackId, id: trackId,
@ -1605,7 +1625,7 @@ async function handleActionBroadcast(action) {
startTimeInSeconds, startTimeInSeconds,
clipId, clipId,
name, name,
patternData // <-- AQUI ESTÁ A "PARTITURA" patternData, // <-- AQUI ESTÁ A "PARTITURA"
); );
// --- FIM DA MODIFICAÇÃO --- // --- FIM DA MODIFICAÇÃO ---
@ -1617,7 +1637,7 @@ async function handleActionBroadcast(action) {
`🎧 ${who} add “${(name || filePath || "") `🎧 ${who} add “${(name || filePath || "")
.split(/[\\/]/) .split(/[\\/]/)
.pop()} em ${pista}`, .pop()} em ${pista}`,
"success" "success",
); );
} catch (e) { } catch (e) {
console.error("Erro ADD_AUDIO_CLIP:", e); console.error("Erro ADD_AUDIO_CLIP:", e);
@ -1632,8 +1652,8 @@ async function handleActionBroadcast(action) {
isLoadingProject = true; isLoadingProject = true;
showToast("📂 Carregando beat index...", "info"); showToast("📂 Carregando beat index...", "info");
if (window.ROOM_NAME) { if (currentRoom) {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`); sessionStorage.removeItem(`temp_state_${currentRoom}`);
} }
try { try {
@ -1669,28 +1689,33 @@ async function handleActionBroadcast(action) {
break; break;
} }
case "REMOVE_AUDIO_LANE_BY_ID": { // case "REMOVE_AUDIO_LANE_BY_ID": {
//
const { trackId } = action; const { trackId } = action;
// 1. Encontra o índice da faixa com esse ID específico // 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) { if (trackIndex !== -1) {
// 2. Remove a faixa exata do array local // 2. Remove a faixa exata do array local
appState.audio.tracks.splice(trackIndex, 1); appState.audio.tracks.splice(trackIndex, 1);
// 3. Remove os clips associados a este ID (Limpeza local) // 3. Remove os clips associados a este ID (Limpeza local)
appState.audio.clips = appState.audio.clips.filter(c => c.trackId !== trackId); appState.audio.clips = appState.audio.clips.filter(
(c) => c.trackId !== trackId,
);
renderAll(); // Atualiza a tela renderAll(); // Atualiza a tela
const who = actorOf(action); const who = actorOf(action);
// showToast(`🗑️ Pista removida por ${who}`, "warning"); // showToast(`🗑️ Pista removida por ${who}`, "warning");
// 4. Salva a sessão para garantir persistência no F5 local // 4. Salva a sessão para garantir persistência no F5 local
saveStateToSession(); saveStateToSession();
} else { } else {
console.warn(`Tentativa de remover track inexistente: ${trackId}`); console.warn(`Tentativa de remover track inexistente: ${trackId}`);
} }
break; break;
} }
@ -1703,35 +1728,45 @@ async function handleActionBroadcast(action) {
// ============================================================================ // ============================================================================
// RECUPERAÇÃO DE ESTADO (TAB SLEEP / WAKE UP) // RECUPERAÇÃO DE ESTADO (TAB SLEEP / WAKE UP)
// ============================================================================ // ============================================================================
let lastHiddenTime = Date.now();
/*
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") { if (document.visibilityState === "hidden") {
console.log("%c[SISTEMA] Aba reativada! Solicitando estado...", "color: #00ff00; background: #222; padding: 4px;"); 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 // Só força o Full Sync se a aba ficou escondida por MAIS DE 5 SEGUNDOS.
if (socket.connected && currentRoom) { // Isso evita o loop infinito ao abrir o DevTools (F12) ou dar Alt-Tab rápido.
// Deleta o cache local velho if (sleepTimeMs > 5000) {
sessionStorage.removeItem(`temp_state_${currentRoom}`); 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 if (socket.connected && currentRoom) {
socket.emit("request_full_sync", { room: currentRoom }); sessionStorage.removeItem(`temp_state_${currentRoom}`);
showToast("🔄 Sincronizando com a sala...", "info"); 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 // NOTIFICAÇÕES DE PRESENÇA NA SALA
// ============================================================================ // ============================================================================
socket.on("user_joined", ({ username }) => { socket.on("user_joined", ({ username }) => {
// Toca um sonzinho rápido e mostra o popup verde // Toca um sonzinho rápido e mostra o popup verde
showToast(`👋 ${username} entrou na sala!`, "success", 4000); showToast(`👋 ${username} entrou na sala!`, "success", 4000);
console.log(`[PRESENÇA] ${username} conectou-se.`); console.log(`[PRESENÇA] ${username} conectou-se.`);
}); });
socket.on("user_left", ({ username }) => { socket.on("user_left", ({ username }) => {
// Mostra o popup amarelo de aviso // Mostra o popup amarelo de aviso
showToast(`🏃 ${username} saiu da sala.`, "warning", 4000); showToast(`🏃 ${username} saiu da sala.`, "warning", 4000);
console.log(`[PRESENÇA] ${username} desconectou-se.`); console.log(`[PRESENÇA] ${username} desconectou-se.`);
}); });
// EXPORTS // EXPORTS