// js/socket.js — V5.5 (Toast para Sync Mode) // -------------------relómm------------------------------------------------------ // IMPORTS & STATE // ----------------------------------------------------------------------------- import { appState, resetProjectState } from "./state.js"; import { addTrackToState, removeLastTrackFromState, updateTrackSample, } from "./pattern/pattern_state.js"; import { addAudioTrackLane, removeAudioClip, addAudioClipToTimeline, updateAudioClipProperties, sliceAudioClip, getAudioSnapshot, // 👈 novo applyAudioSnapshot, // 👈 novo } from "./audio/audio_state.js"; import { togglePlayback, stopPlayback, rewindPlayback, } from "./pattern/pattern_audio.js"; import { startAudioEditorPlayback, stopAudioEditorPlayback, updateTransportLoop, seekAudioEditor, // 👈 Adicionado restartAudioEditorIfPlaying, // 👈 Adicionado } from "./audio/audio_audio.js"; import { parseMmpContent } from "./file.js"; import { renderAll, showToast } from "./ui.js"; // showToast() import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js"; // ----------------------------------------------------------------------------- // Gera um ID único otimista (ex: "track_1678886401000_abc123") // ----------------------------------------------------------------------------- function generateUniqueId(prefix = "item") { return `${prefix}_${Date.now()}_${Math.random() .toString(36) .substring(2, 9)}`; } // ----------------------------------------------------------------------------- // CONFIGURAÇÃO DO SOCKET.IO // ----------------------------------------------------------------------------- const socket = io("http://localhost:33007", { transports: ["websocket", "polling"], withCredentials: true, }); let USER_NAME = `Alicer-${Math.floor(Math.random() * 9999)}`; let currentRoom = null; // ----------------------------------------------------------------------------- // CLOCK SYNC — Sincroniza relógio cliente-servidor // ----------------------------------------------------------------------------- let serverOffsetMs = 0; let rttMs = 0; function sampleServerTime() { return new Promise((resolve) => { const t0 = Date.now(); socket.emit("what_time_is_it", null, (reply) => { const t1 = Date.now(); const rtt = t1 - t0; const oneWay = rtt / 2; const serverNow = reply && typeof reply === "object" ? reply.serverNowMs : reply; const offset = serverNow - (t0 + oneWay); resolve({ offset, rtt }); }); }); } function ewma(prev, next, alpha = 0.3) { if (prev === null || prev === undefined) return next; return prev * (1 - alpha) + next * alpha; } async function syncServerTime(iterations = 5) { let off = null; let rtt = null; for (let i = 0; i < iterations; i++) { try { const s = await sampleServerTime(); off = ewma(off, s.offset); rtt = ewma(rtt, s.rtt); } catch {} } if (off != null) serverOffsetMs = off; if (rtt != null) rttMs = rtt; } function delayFromServerTimeMs(scheduleAtServerMs) { const localNow = Date.now(); const serverNowEst = localNow + serverOffsetMs; return Math.max(0, scheduleAtServerMs - serverNowEst); } // ----------------------------------------------------------------------------- // ESTADO DE TOKENS / ACK / FALLBACK // ----------------------------------------------------------------------------- let lastActionTimeout = null; let lastBroadcastTimeout = null; let pendingToken = null; let lastActionToken = 0; const processedTokens = new Set(); // ----------------------------------------------------------------------------- // FUNÇÕES AUXILIARES // ----------------------------------------------------------------------------- export function setUserName(name) { USER_NAME = name; } // ----------------------------------------------------------------------------- // CONEXÃO / JOIN / LOGS // ----------------------------------------------------------------------------- socket.on("connect", () => { console.log(`Conectado ao servidor com ID: ${socket.id}`); showToast("✅ Conectado ao servidor", "success"); if (USER_NAME.startsWith("Alicer-")) { USER_NAME = `Alicer-${socket.id.substring(0, 4)}`; } const urlRoom = new URLSearchParams(window.location.search).get("room"); currentRoom = urlRoom || null; if (currentRoom) { console.log(`Modo Online. Sala detectada: ${currentRoom}`); showToast(`🎧 Conectado à sala ${currentRoom}`, "info"); // Mostra o botão se estiver online const syncModeBtn = document.getElementById("sync-mode-btn"); //if (syncModeBtn) syncModeBtn.style.display = ""; // Garante visibilidade } else { console.log("Modo Local. Conectado, mas não em uma sala."); showToast("🔌 Modo local (fora de sala)", "warning"); // Esconde se offline const syncModeBtn = document.getElementById("sync-mode-btn"); //if (syncModeBtn) syncModeBtn.style.display = "none"; } syncServerTime(); setInterval(syncServerTime, 10000); }); // ----------------------------------------------------------------------------- // DETECÇÃO DE AD BLOCK // ----------------------------------------------------------------------------- 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." ); showToast( "❌ Falha grave de conexão. Desativa o Ad Blocker? 😅", "error", 10000 ); }); // ----------------------------------------------------------------------------- // RECEBER ESTADO SALVO DA SALA // ----------------------------------------------------------------------------- socket.on("load_project_state", async (projectXml) => { console.log("Recebendo estado salvo da sala..."); showToast("🔄 Recebendo estado atual da sala...", "info", 4000); if (isLoadingProject) return; isLoadingProject = true; try { await parseMmpContent(projectXml); renderAll(); showToast("🎵 Projeto carregado com sucesso", "success"); } catch (e) { console.error("Erro ao carregar projeto:", e); showToast("❌ Erro ao carregar projeto", "error"); } isLoadingProject = false; }); // ----------------------------------------------------------------------------- // ENTRAR NA SALA (Join) // ----------------------------------------------------------------------------- export function joinRoom() { const urlRoom = new URLSearchParams(window.location.search).get("room"); currentRoom = urlRoom || currentRoom; if (currentRoom) { console.log(`Entrando na sala: ${currentRoom} como ${USER_NAME}`); showToast(`🚪 Entrando na sala ${currentRoom}`, "info"); socket.emit("join_room", { roomName: currentRoom, userName: USER_NAME }); setTimeout(() => { try { const hasAudio = (appState.audio?.clips?.length || 0) > 0 || (appState.audio?.tracks?.length || 0) > 0; if (!hasAudio && currentRoom) { sendAction({ type: "AUDIO_SNAPSHOT_REQUEST" }); } } catch {} }, 800); } else { console.warn("joinRoom() chamado, mas nenhuma sala encontrada na URL."); showToast("⚠️ Nenhuma sala encontrada na URL", "warning"); } } // ----------------------------------------------------------------------------- // ENVIAR AÇÃO COM ACK + AGENDAMENTO DE TRANSPORTE // ----------------------------------------------------------------------------- export function sendAction(action) { const inRoom = Boolean(currentRoom); // (Blindagem de ID/Validação) if (action.type === "ADD_AUDIO_LANE" && !action.trackId) { action.trackId = generateUniqueId("track"); console.log(`[SOCKET] ADD_AUDIO_LANE ID gerado: ${action.trackId}`); } if (action.type === "ADD_AUDIO_CLIP") { if (!action.clipId) { action.clipId = generateUniqueId("clip"); } if ( !action.trackId || action.startTimeInSeconds == null || isNaN(action.startTimeInSeconds) ) { console.error("[SOCKET] ADD_AUDIO_CLIP bloqueada:", action); showToast("❌ Erro clip (inválido)", "error"); return; } } if (action.type === "UPDATE_AUDIO_CLIP") { if (!action.clipId || !action.props) { console.error("[SOCKET] UPDATE_AUDIO_CLIP bloqueada (base):", action); return; } const { trackId, startTimeInSeconds } = action.props; if ( trackId !== undefined && (trackId == null || (typeof trackId === "number" && isNaN(trackId))) ) { console.error("[SOCKET] UPDATE_AUDIO_CLIP bloqueada (trackId):", action); return; } if ( startTimeInSeconds !== undefined && (startTimeInSeconds == null || isNaN(startTimeInSeconds)) ) { console.error( "[SOCKET] UPDATE_AUDIO_CLIP bloqueada (startTime):", action ); return; } } const token = (++lastActionToken).toString(); action.__token = token; action.__senderId = socket.id; action.__senderName = USER_NAME; // ================================================================= // 👇 INÍCIO DA CORREÇÃO (Expandir `isTransport` para SET_SYNC_MODE) // ================================================================= const isTransport = action.type === "TOGGLE_PLAYBACK" || action.type === "STOP_PLAYBACK" || action.type === "REWIND_PLAYBACK" || action.type === "START_AUDIO_PLAYBACK" || action.type === "STOP_AUDIO_PLAYBACK" || action.type === "SET_LOOP_STATE" || action.type === "SET_SEEK_TIME" || action.type === "SET_SYNC_MODE"; // 👈 Adicionado // ================================================================= // 👆 FIM DA CORREÇÃO // ================================================================= if (inRoom && isTransport) { const leadTimeMs = 200; const serverNowEst = Date.now() + serverOffsetMs; if ( action.type === "TOGGLE_PLAYBACK" || action.type === "START_AUDIO_PLAYBACK" ) { action.scheduleAtServerMs = Math.round(serverNowEst + leadTimeMs); } } // (Lógica Global/Local) 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; } } }); if (lastActionTimeout) clearTimeout(lastActionTimeout); lastActionTimeout = setTimeout(() => { console.warn("[SOCKET] ACK não recebido:", action.type, token); showToast(`⚠️ ACK (${action.type})`, "warning"); }, 500); 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); } // HELPERS POP-UPS function basenameNoExt(path) { if (!path) return ""; const base = String(path).split(/[\\/]/).pop(); return base.replace(/\.[^/.]+$/, ""); } function trackLabel(track, idx) { const name = track?.name || track?.label || track?.sampleName || track?.sample?.displayName; return name ? `(Faixa ${idx + 1})` : `Faixa ${idx + 1}`; } function actorOf(action) { const n = action.__senderName || action.userName; if (n && typeof n === "string") return n; const sid = action.__senderId ? String(action.__senderId) : ""; return `Alicer-${sid.slice(0, 4) || "????"}`; } function instrumentLabel(track, tIdx) { const name = track?.name || track?.label || track?.sampleName || track?.sample?.displayName || track?.filePath || track?.sampleFile || track?.samplePath; if (name && typeof name === "string") { const base = name.split(/[\\/]/).pop(); return base.replace(/\.[^/.]+$/, ""); } return `Faixa ${tIdx + 1}`; } let rerenderScheduled = false; function schedulePatternRerender() { if (rerenderScheduled) return; rerenderScheduled = true; requestAnimationFrame(() => { renderPatternEditor(); rerenderScheduled = false; }); } // RECEBER BROADCAST socket.on("feedback", (msg) => { console.log("[Servidor]", msg); showToast(msg, "info"); }); socket.on("action_broadcast", (payload) => { const action = payload && payload.type ? payload : payload?.action || payload; if (action && action.__token) { processedTokens.add(action.__token); if (lastBroadcastTimeout && pendingToken === action.__token) { clearTimeout(lastBroadcastTimeout); lastBroadcastTimeout = null; pendingToken = null; } if (lastActionTimeout) { clearTimeout(lastActionTimeout); lastActionTimeout = null; } } handleActionBroadcast(action); }); // PROCESSAR AÇÕES RECEBIDAS let isLoadingProject = false; async function handleActionBroadcast(action) { if (!action || !action.type) return; if ( action.type !== "LOAD_PROJECT" && action.type !== "RESET_PROJECT" && isLoadingProject ) { console.warn(`[AÇÃO IGNORADA] ${action.type} (carregando).`); return; } // (Filtro Global/Local) const isTransport = action.type === "TOGGLE_PLAYBACK" || action.type === "STOP_PLAYBACK" || action.type === "REWIND_PLAYBACK" || action.type === "START_AUDIO_PLAYBACK" || action.type === "STOP_AUDIO_PLAYBACK" || action.type === "SET_LOOP_STATE" || action.type === "SET_SEEK_TIME" || action.type === "SET_SYNC_MODE"; // 👈 Adicionado const isFromSelf = action.__senderId === socket.id; if (isTransport && !isFromSelf) { if (appState.global.syncMode === "local") { console.log(`[SOCKET] (ignorado) ${action.type}, modo local.`); return; } if (action.__syncMode !== "global") { console.log(`[SOCKET] (ignorado) ${action.type}, remetente não global.`); return; } } const scheduleAtServerMs = action.scheduleAtServerMs ?? null; const delayMs = scheduleAtServerMs ? delayFromServerTimeMs(scheduleAtServerMs) : 0; switch (action.type) { // Transporte Principal case "TOGGLE_PLAYBACK": { setTimeout(togglePlayback, delayMs); const who = actorOf(action); showToast(`▶ ${who} Play bases`, "info"); break; } case "STOP_PLAYBACK": { setTimeout(stopPlayback, delayMs); const who = actorOf(action); showToast(`⏹ ${who} Stop bases`, "info"); break; } case "REWIND_PLAYBACK": setTimeout(rewindPlayback, delayMs); break; // Transporte Áudio Editor case "START_AUDIO_PLAYBACK": if (action.loopState) { appState.global.isLoopActive = action.loopState.isLoopActive; appState.global.loopStartTime = action.loopState.loopStartTime; appState.global.loopEndTime = action.loopState.loopEndTime; const btn = document.getElementById("audio-editor-loop-btn"); if (btn) { btn.classList.toggle("active", appState.global.isLoopActive); } updateTransportLoop(); try { const area = document.getElementById("loop-region"); if (area) area.classList.toggle("visible", appState.global.isLoopActive); } catch (e) {} } const seekTime = action.seekTime ?? appState.audio.audioEditorSeekTime; setTimeout(() => startAudioEditorPlayback(seekTime), delayMs); break; case "STOP_AUDIO_PLAYBACK": setTimeout( () => stopAudioEditorPlayback(action.rewind || false), delayMs ); break; // ================================================================= // 👇 INÍCIO DA CORREÇÃO (Handlers Sincronia de Loop/Seek/SyncMode) // ================================================================= case "SET_LOOP_STATE": { const changed = appState.global.isLoopActive !== !!action.isLoopActive || appState.global.loopStartTime !== action.loopStartTime || appState.global.loopEndTime !== action.loopEndTime; if (changed) { appState.global.isLoopActive = !!action.isLoopActive; appState.global.loopStartTime = action.loopStartTime; appState.global.loopEndTime = action.loopEndTime; const btn = document.getElementById("audio-editor-loop-btn"); if (btn) { btn.classList.toggle("active", appState.global.isLoopActive); } updateTransportLoop(); restartAudioEditorIfPlaying(); renderAll(); if (!isFromSelf) { const who = actorOf(action); showToast(`🔁 ${who} alterou o loop.`, "info"); } } break; } case "SET_SEEK_TIME": { // Evita aplicar seek se o tempo for muito próximo para não causar "tremidas" if ( Math.abs((appState.audio.audioEditorSeekTime || 0) - action.seekTime) > 0.05 ) { seekAudioEditor(action.seekTime); if (!isFromSelf) { const who = actorOf(action); showToast(`⏯️ ${who} moveu a agulha.`, "info"); } } break; } case "SET_SYNC_MODE": { const newMode = action.mode === "local" ? "local" : "global"; const changed = appState.global.syncMode !== newMode; if (changed) { appState.global.syncMode = newMode; const btn = document.getElementById("sync-mode-btn"); if (btn) { btn.classList.toggle("active", newMode === "global"); btn.textContent = newMode === "global" ? "Global" : "Local"; } // Mostra o Toast AQUI, após a mudança ser aplicada const who = actorOf(action); const modeText = newMode === "global" ? "Global 🌐" : "Local 🏠"; showToast(`${who} mudou modo para ${modeText}`, "info"); } break; } // ================================================================= // 👆 FIM DA CORREÇÃO // ================================================================= // Estado Global case "LOAD_PROJECT": isLoadingProject = true; showToast("📂 Carregando...", "info"); try { await parseMmpContent(action.xml); renderAll(); showToast("🎶 Projeto sync", "success"); } catch (e) { console.error("Erro LOAD_PROJECT:", e); showToast("❌ Erro projeto", "error"); } isLoadingProject = false; break; case "RESET_PROJECT": resetProjectState(); document.getElementById("bpm-input").value = 140; document.getElementById("bars-input").value = 1; document.getElementById("compasso-a-input").value = 4; document.getElementById("compasso-b-input").value = 4; renderAll(); const who = actorOf(action); showToast(`🧹 Reset por ${who}`, "warning"); break; // Configs case "SET_BPM": { document.getElementById("bpm-input").value = action.value; renderAll(); const who = actorOf(action); showToast(`🕰 ${who} BPM ${action.value}`, "info"); break; } case "SET_BARS": { document.getElementById("bars-input").value = action.value; renderAll(); const who = actorOf(action); showToast(`🕰 ${who} Compasso add`, "info"); break; } case "SET_TIMESIG_A": document.getElementById("compasso-a-input").value = action.value; renderAll(); showToast("Compasso alt", "info"); break; case "SET_TIMESIG_B": document.getElementById("compasso-b-input").value = action.value; renderAll(); showToast("Compasso alt", "info"); break; // Tracks case "ADD_TRACK": { addTrackToState(); renderPatternEditor(); const who = actorOf(action); showToast(`🥁 Faixa add por ${who}`, "info"); break; } case "REMOVE_LAST_TRACK": { removeLastTrackFromState(); renderPatternEditor(); const who = actorOf(action); showToast(`❌ Faixa remov. por ${who}`, "warning"); break; } case "ADD_AUDIO_LANE": { const id = action.trackId; if (!id) { console.warn("ADD_AUDIO_LANE sem ID."); break; } const exists = appState.audio.tracks.some((t) => t.id === id); if (exists) { console.log(`ADD_AUDIO_LANE ${id} já existe.`); break; } appState.audio.tracks.push({ id: id, name: `Pista ${appState.audio.tracks.length + 1}`, }); renderAll(); const who = actorOf(action); showToast(`🎧 Pista add por ${who}`, "info"); break; } case "REMOVE_AUDIO_CLIP": if (removeAudioClip(action.clipId)) { appState.global.selectedClipId = null; renderAll(); const who = actorOf(action); showToast(`🎚️ Clip remov. por ${who}`, "info"); } break; // Notes case "TOGGLE_NOTE": { const { trackIndex: ti, patternIndex: pi, stepIndex: si, isActive, } = action; const t = appState.pattern.tracks[ti]; if (t) { t.patterns[pi] = t.patterns[pi] || { steps: [] }; t.patterns[pi].steps[si] = isActive; try { updateStepUI(ti, pi, si, isActive); } catch {} if (!isFromSelf) { schedulePatternRerender(); } } const who = actorOf(action); const v = isActive ? "+" : "-"; showToast(`🎯 ${who} ${v} nota ${ti + 1}.${pi + 1}.${si + 1}`, "info"); break; } // Samples case "SET_TRACK_SAMPLE": { const ti = action.trackIndex; const t = appState.pattern.tracks[ti]; if (t) { try { await updateTrackSample(ti, action.filePath); renderPatternEditor(); const who = actorOf(action); const sn = basenameNoExt(action.filePath) || "?"; const tl = trackLabel(t, ti); showToast(`🔊 ${who} trocou ${sn} em ${tl}`, "success"); } catch (err) { console.error("Erro SET_TRACK_SAMPLE:", err); showToast("❌ Erro sample", "error"); } } break; } // Snapshots case "AUDIO_SNAPSHOT_REQUEST": { const clips = appState.audio?.clips?.length || 0, tracks = appState.audio?.tracks?.length || 0; const iHave = clips > 0 || tracks > 0; if (!iHave || isFromSelf) break; if (!window.__lastSnapshotSentAt) window.__lastSnapshotSentAt = 0; const now = Date.now(); if (now - window.__lastSnapshotSentAt < 1500) break; window.__lastSnapshotSentAt = now; try { const snap = getAudioSnapshot(); sendAction({ type: "AUDIO_SNAPSHOT", snapshot: snap, __target: action.__senderId, }); } catch (e) { console.warn("Erro AUDIO_SNAPSHOT_REQUEST:", e); } break; } case "AUDIO_SNAPSHOT": { if (action.__target && action.__target !== socket.id) break; const hasClips = (appState.audio?.clips?.length || 0) > 0; if (hasClips) break; try { await applyAudioSnapshot(action.snapshot); renderAll(); const who = actorOf(action); showToast(`🔁 Sync áudio por ${who}`, "success"); } catch (e) { console.error("Erro AUDIO_SNAPSHOT:", e); showToast("❌ Erro sync áudio", "error"); } break; } // Clip Sync case "ADD_AUDIO_CLIP": { try { const { filePath, trackId, startTimeInSeconds, clipId, name } = action; if ( appState.audio?.clips?.some((c) => String(c.id) === String(clipId)) ) { break; } const trackExists = appState.audio?.tracks?.some( (t) => t.id === trackId ); if (!trackExists) { console.warn( `ADD_AUDIO_CLIP Pista ${trackId} não existe, criando...` ); appState.audio.tracks.push({ id: trackId, name: `Pista ${appState.audio.tracks.length + 1}`, }); } addAudioClipToTimeline( filePath, trackId, startTimeInSeconds, clipId, name ); renderAll(); const who = actorOf(action); const track = appState.audio?.tracks?.find((t) => t.id === trackId); const pista = track?.name || `Pista ${trackId}`; showToast( `🎧 ${who} add “${(name || filePath || "") .split(/[\\/]/) .pop()}” em ${pista}`, "success" ); } catch (e) { console.error("Erro ADD_AUDIO_CLIP:", e); showToast("❌ Erro add clip", "error"); } break; } case "UPDATE_AUDIO_CLIP": { try { if (action.props?.__operation === "slice") { sliceAudioClip(action.clipId, action.props.sliceTimeInTimeline); } else { updateAudioClipProperties(action.clipId, action.props || {}); } renderAll(); const who = actorOf(action); showToast(`✂️ Clip ${action.clipId} att por ${who}`, "info"); } catch (e) { console.error("Erro UPDATE_AUDIO_CLIP:", e); showToast("❌ Erro att clip", "error"); } break; } default: console.warn("Ação desconhecida:", action.type); } } // EXPORTS export { socket };