mmpSearch/assets/js/creations/socket.js

806 lines
26 KiB
JavaScript

// 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 };