mmpSearch/assets/js/creations/socket.js

1583 lines
50 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// js/socket.js — V5.5 (Toast para Sync Mode)
// -------------------relómm------------------------------------------------------
// IMPORTS & STATE
// -----------------------------------------------------------------------------
import { appState, saveStateToSession, loadStateFromSession } from "./state.js";
import {
addTrackToState,
removeLastTrackFromState,
updateTrackSample,
removeTrackById,
} 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,
parseBeatIndexJson,
handleLocalProjectReset,
syncPatternStateToServer,
generateXmlFromStateExported,
} from "./file.js";
import { renderAll, showToast } from "./ui.js"; // showToast()
import { updateStepUI, renderPatternEditor } from "./pattern/pattern_ui.js";
import { PORT_SOCK } from "./config.js";
import { DEFAULT_PROJECT_XML } from "./utils.js"
function _getRackIdFallback() {
// 1) pega do primeiro bassline que já tenha instrumentSourceId
const b = (appState.pattern.tracks || []).find(
(t) => t.type === "bassline" && t.instrumentSourceId
);
if (b?.instrumentSourceId) return b.instrumentSourceId;
// 2) pega do primeiro instrumento do rack (parentBasslineId)
const child = (appState.pattern.tracks || []).find(
(t) => t.type !== "bassline" && t.parentBasslineId
);
if (child?.parentBasslineId) return child.parentBasslineId;
return null;
}
function _nextPatternIndex() {
const bassMax = Math.max(
-1,
...(appState.pattern.tracks || [])
.filter((t) => t.type === "bassline" && Number.isFinite(Number(t.patternIndex)))
.map((t) => Number(t.patternIndex))
);
const nonBassMax = Math.max(
-1,
...(appState.pattern.tracks || [])
.filter((t) => t.type !== "bassline")
.map((t) => (t.patterns?.length || 0) - 1)
);
return Math.max(bassMax, nonBassMax) + 1;
}
// -----------------------------------------------------------------------------
// 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(`https://alice.ufsj.edu.br:${PORT_SOCK}`, {
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;
}
export function sendActionSafe(action) {
try {
sendAction(action);
} catch (err) {
console.warn("[SYNC] Falha ao emitir ação:", action?.type, err);
}
}
// -----------------------------------------------------------------------------
// 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
);
});
// -----------------------------------------------------------------------------
// Atualizar os artefatos da plataforma
// -----------------------------------------------------------------------------
socket.on("system_update", (data) => {
if (data.type === "RELOAD_SAMPLES") {
console.log(
`[System Update] Recebida ordem para recarregar samples: ${data.message}`
);
loadAndRenderSampleBrowser();
}
});
// -----------------------------------------------------------------------------
// 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 {
// 1. Carrega o XML que veio do servidor (pode estar desatualizado)
await parseMmpContent(projectXml);
// 🔥 CORREÇÃO: Força a restauração da sessão LOCAL logo após carregar o XML
// Isso garante que suas alterações locais (BPM, steps) "ganhem" do servidor
if (window.ROOM_NAME) {
const raw = sessionStorage.getItem(`temp_state_${window.ROOM_NAME}`);
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();
showToast("🎵 Projeto carregado com sucesso", "success");
const hasAudio =
(appState.audio?.clips?.length || 0) > 0 ||
(appState.audio?.tracks?.length || 0) > 0;
if (!hasAudio && currentRoom) {
console.log(
"Projeto XML carregado, sem áudio. Pedindo snapshot de áudio..."
);
// Libera a trava IMEDIATAMENTE antes de pedir
// Caso contrário, a resposta chega muito rápido e cai no bloqueio "justReset=true"
appState.global.justReset = false;
sendAction({ type: "AUDIO_SNAPSHOT_REQUEST" });
}
} catch (e) {
console.error("Erro ao carregar projeto:", e);
showToast("❌ Erro ao carregar projeto", "error");
}
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);
});
// -----------------------------------------------------------------------------
// 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 });
} 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;
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";
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);
}
// -----------------------------------------------------------------------------
// 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;
});
}
function _genPlaylistClipId() {
return `plc_${Date.now()}_${Math.random().toString(36).slice(2)}`;
}
function _ensureBasslineForPatternIndex(patternIndex) {
let b = appState.pattern.tracks.find(
(t) => t.type === "bassline" && Number(t.patternIndex) === Number(patternIndex)
);
// fallback: cria se não existir (não quebra nada)
if (!b) {
b = {
id: `bassline_${Date.now()}_${Math.random().toString(36).slice(2)}`,
name: `Beat/Bassline ${patternIndex}`,
type: "bassline",
patternIndex: Number(patternIndex),
playlist_clips: [],
patterns: [],
isMuted: false,
instrumentSourceId: null,
volume: 1,
pan: 0,
instrumentSourceId: _getDefaultRackId(),
};
appState.pattern.tracks.push(b);
}
if (!Array.isArray(b.playlist_clips)) b.playlist_clips = [];
// Isso deixa o modo focado funcionando em patterns recém-criados.
if (!b.instrumentSourceId) b.instrumentSourceId = _getDefaultRackId();
// garante ids nos clips antigos
b.playlist_clips.forEach((c) => {
if (!c.id) c.id = _genPlaylistClipId();
});
return b;
}
function _getDefaultRackId() {
const inst = (appState.pattern.tracks || []).find(
(t) => t.type !== "bassline" && t.parentBasslineId
);
return inst?.parentBasslineId ?? null;
}
// -----------------------------------------------------------------------------
// 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
// -----------------------------------------------------------------------------
const LMMS_BAR_TICKS = 192;
const STEPS_PER_BAR = 16;
// ticks por step (1/16) no LMMS: 192/16 = 12
const TICKS_PER_STEP = LMMS_BAR_TICKS / STEPS_PER_BAR;
function _syncNotesWithStepToggle(pattern, stepIndex, isActive) {
if (!pattern) return;
if (!Array.isArray(pattern.notes)) pattern.notes = [];
const pos = Math.round(stepIndex * TICKS_PER_STEP);
const samePos = (n) => Number(n?.pos) === pos;
if (isActive) {
// se já existe nota nesse step, não duplica
if (pattern.notes.some(samePos)) return;
// tenta reaproveitar "template" de nota pra manter key/vol/pan coerentes
const tpl = pattern.notes[0] || { vol: 100, len: TICKS_PER_STEP, pan: 0, key: 57 };
pattern.notes.push({
vol: tpl.vol ?? 100,
len: tpl.len ?? TICKS_PER_STEP,
pan: tpl.pan ?? 0,
key: tpl.key ?? 57,
pos,
});
pattern.notes.sort((a, b) => Number(a.pos) - Number(b.pos));
} else {
// remove todas as notas nesse step (útil inclusive pra "steps compostos")
pattern.notes = pattern.notes.filter((n) => !samePos(n));
}
}
function _makeEmptyPattern(idx) {
// tenta respeitar bars-input atual, mas nunca menos que 1 bar
const bars = parseInt(document.getElementById("bars-input")?.value, 10) || 1;
const stepsLen = Math.max(STEPS_PER_BAR, bars * STEPS_PER_BAR);
return {
name: `Pattern ${idx + 1}`,
steps: new Array(stepsLen).fill(false),
notes: [],
pos: idx * LMMS_BAR_TICKS,
};
}
function _ensurePatternsUpTo(patternIndex) {
appState.pattern.tracks.forEach((t) => {
if (t.type === "bassline") return;
if (!Array.isArray(t.patterns)) t.patterns = [];
while (t.patterns.length <= patternIndex) {
t.patterns.push(_makeEmptyPattern(t.patterns.length));
}
});
}
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 "CREATE_NEW_PATTERN": {
const patternIndex =
Number.isFinite(Number(action.patternIndex)) ? Number(action.patternIndex) : _nextPatternIndex();
const name = (action.name || "").trim() || `Beat/Bassline ${patternIndex}`;
// 1) garante patterns em todas as tracks reais
_ensurePatternsUpTo(patternIndex);
// 2) cria bassline track container (pattern “da playlist”)
let b = (appState.pattern.tracks || []).find(
(t) => t.type === "bassline" && Number(t.patternIndex) === patternIndex
);
if (!b) {
b = {
id: `bassline_${Date.now()}_${Math.random().toString(36).slice(2)}`,
name,
type: "bassline",
patternIndex,
playlist_clips: [],
patterns: [],
isMuted: false,
instrumentSourceId: _getRackIdFallback(),
volume: 1,
pan: 0,
};
appState.pattern.tracks.push(b);
} else {
b.name = name;
if (!b.instrumentSourceId) b.instrumentSourceId = _getRackIdFallback();
if (!Array.isArray(b.playlist_clips)) b.playlist_clips = [];
}
// 3) renomeia a “coluna” nas patterns exportáveis
(appState.pattern.tracks || []).forEach((t) => {
if (t.type === "bassline") return;
if (t.patterns?.[patternIndex]) t.patterns[patternIndex].name = name;
});
// 4) já seleciona essa pattern (opcional, mas fica UX boa)
appState.pattern.activePatternIndex = patternIndex;
(appState.pattern.tracks || []).forEach((t) => (t.activePatternIndex = patternIndex));
try { schedulePatternRerender(); } catch {}
renderAll();
saveStateToSession();
// 5) persiste no servidor (igual você já faz em outros updates)
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
}
break;
}
case "ADD_PATTERN": {
const who = actorOf(action);
const desiredIndex = Number.isFinite(Number(action.patternIndex))
? Number(action.patternIndex)
: null;
const nonBass = (appState.pattern.tracks || []).filter(t => t.type !== "bassline");
const currentCount = nonBass.reduce((m, t) => Math.max(m, t.patterns?.length ?? 0), 0);
// Próximo índice livre (fim da lista)
const maxFromNonBass = nonBass.reduce(
(m, t) => Math.max(m, (t.patterns?.length || 0) - 1),
-1
);
const maxFromBass = (appState.pattern.tracks || [])
.filter(t => t.type === "bassline" && Number.isFinite(Number(t.patternIndex)))
.reduce((m, t) => Math.max(m, Number(t.patternIndex)), -1);
const nextFree = Math.max(maxFromNonBass, maxFromBass) + 1;
// tenta usar o índice pedido, mas só se ele ainda não existe
let idx = desiredIndex != null ? desiredIndex : nextFree;
const idxAlreadyExists =
idx <= maxFromNonBass ||
(appState.pattern.tracks || []).some(
t => t.type === "bassline" && Number(t.patternIndex) === idx
);
if (idxAlreadyExists) idx = nextFree;
const finalName = String(action.name || `Pattern ${idx + 1}`).trim();
// 1) cria patterns vazios em TODAS as tracks (respeita bars-input)
_ensurePatternsUpTo(idx);
// 2) garante nome/pos estáveis no índice criado
for (const t of nonBass) {
t.patterns[idx] = t.patterns[idx] || _makeEmptyPattern(idx);
t.patterns[idx].name = finalName;
if (t.patterns[idx].pos == null) t.patterns[idx].pos = idx * LMMS_BAR_TICKS;
}
// 3) cria a lane "bassline" (a coluna do pattern)
const b = _ensureBasslineForPatternIndex(idx);
b.patternIndex = idx;
b.name = finalName;
if (!b.instrumentSourceId) b.instrumentSourceId = _getDefaultRackId();
// 4) opcional: já selecionar
if (action.select) {
appState.pattern.activePatternIndex = idx;
appState.pattern.tracks.forEach((track) => (track.activePatternIndex = idx));
}
renderAll();
showToast(` ${who} criou: ${finalName}`, "success");
saveStateToSession();
break;
}
case "REMOVE_LAST_PATTERN": {
const nonBass = (appState.pattern.tracks || []).filter(t => t.type !== "bassline");
const count = nonBass.reduce((m, t) => Math.max(m, t.patterns?.length ?? 0), 0);
const last = count - 1;
if (last <= 0) break;
// remove patterns nas tracks
for (const t of nonBass) t.patterns.pop();
// remove a lane bassline correspondente
appState.pattern.tracks = appState.pattern.tracks.filter(
t => !(t.type === "bassline" && Number(t.patternIndex) === last)
);
// ajusta seleção
const newIdx = Math.min(appState.pattern.activePatternIndex, last - 1);
appState.pattern.activePatternIndex = newIdx;
appState.pattern.tracks.forEach((t) => (t.activePatternIndex = newIdx));
renderAll();
saveStateToSession();
break;
}
case "ADD_PLAYLIST_PATTERN_CLIP": {
const { patternIndex, pos, len, clipId, name } = action;
const b = _ensureBasslineForPatternIndex(patternIndex);
const newClip = {
id: clipId || _genPlaylistClipId(),
pos: Math.max(0, Math.floor(pos ?? 0)),
len: Math.max(12, Math.floor(len ?? 192)),
name: name || b.name || "Pattern",
};
// evita duplicar
if (!b.playlist_clips.some((c) => String(c.id) === String(newClip.id))) {
b.playlist_clips.push(newClip);
b.playlist_clips.sort((a, c) => (a.pos ?? 0) - (c.pos ?? 0));
}
renderAll();
saveStateToSession();
// ✅ sync XML (inclui bbtco depois do patch no file.js)
if (window.ROOM_NAME && isFromSelf) {
try {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} catch {}
}
break;
}
case "UPDATE_PLAYLIST_PATTERN_CLIP": {
const { patternIndex, clipId, pos, len } = action;
const b = _ensureBasslineForPatternIndex(patternIndex);
const c = b.playlist_clips.find((x) => String(x.id) === String(clipId));
if (!c) break;
if (pos !== undefined) c.pos = Math.max(0, Math.floor(pos));
if (len !== undefined) c.len = Math.max(12, Math.floor(len));
b.playlist_clips.sort((a, d) => (a.pos ?? 0) - (d.pos ?? 0));
renderAll();
saveStateToSession();
if (window.ROOM_NAME && isFromSelf) {
try {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} catch {}
}
break;
}
case "REMOVE_PLAYLIST_PATTERN_CLIP": {
const { patternIndex, clipId } = action;
const b = _ensureBasslineForPatternIndex(patternIndex);
b.playlist_clips = b.playlist_clips.filter((c) => String(c.id) !== String(clipId));
// limpa seleção se era o selecionado
if (appState.global.selectedPlaylistClipId === clipId) {
appState.global.selectedPlaylistClipId = null;
appState.global.selectedPlaylistPatternIndex = null;
}
renderAll();
saveStateToSession();
if (window.ROOM_NAME && isFromSelf) {
try {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
} catch {}
}
break;
}
case "SELECT_PLAYLIST_PATTERN_CLIP": {
const { patternIndex, clipId } = action;
appState.global.selectedPlaylistClipId = clipId ?? null;
appState.global.selectedPlaylistPatternIndex =
patternIndex ?? null;
renderAll();
saveStateToSession();
break;
}
case "UPDATE_PATTERN_NOTES": {
const {
trackIndex: ti,
patternIndex: pi,
notes,
steps: incomingSteps,
} = action;
const t = appState.pattern.tracks[ti];
if (!t) break;
if (!t.patterns[pi]) t.patterns[pi] = { steps: [], notes: [] };
t.patterns[pi].notes = Array.isArray(notes) ? notes : [];
if (Array.isArray(incomingSteps)) {
t.patterns[pi].steps = incomingSteps;
}
try {
schedulePatternRerender();
} catch (e) {
console.warn("Erro no render UPDATE_PATTERN_NOTES", e);
}
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
const xml = generateXmlFromStateExported();
sendAction({
type: "SYNC_PATTERN_STATE",
xml,
});
}
saveStateToSession();
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;
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");
}
// Salva o estado localmente também!
saveStateToSession();
break;
}
// Estado Global
case "LOAD_PROJECT": //
isLoadingProject = true;
showToast("📂 Carregando...", "info");
// Esta parte está CORRETA. Sempre limpa o sessionStorage.
if (window.ROOM_NAME) {
try {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
console.log(
"Socket: Estado da sessão local limpo para LOAD_PROJECT."
);
} catch (e) {
console.error("Socket: Falha ao limpar estado da sessão:", e);
}
}
try {
await parseMmpContent(action.xml); //
renderAll();
showToast("🎶 Projeto sync", "success");
saveStateToSession();
} catch (e) {
console.error("Erro LOAD_PROJECT:", e);
showToast("❌ Erro projeto", "error");
}
isLoadingProject = false;
break;
case "SYNC_PATTERN_STATE":
// 🔥 CORREÇÃO CRÍTICA:
// Esta ação serve apenas para o SERVIDOR atualizar o arquivo .json no disco.
// Os clientes online NÃO devem recarregar o XML, pois eles já atualizaram
// o estado via ações atômicas (TOGGLE_NOTE, ADD_TRACK, etc).
// Recarregar aqui causa o "resetProjectState" que mata o áudio.
console.log("Socket: XML salvo no servidor (Sync silencioso).");
// Se quiser garantir, salvamos apenas na sessão local do navegador
// sem mexer na engine de áudio ou na tela.
if (action.xml) {
// Atualiza apenas a string do XML na memória global para referência futura
const parser = new DOMParser();
appState.global.originalXmlDoc = parser.parseFromString(action.xml, "application/xml");
saveStateToSession();
}
break;
case "RESET_ROOM":
console.log("Socket: Recebendo comando de RESET_ROOM do servidor."); //
const who = actorOf(action);
handleLocalProjectReset();
showToast(`🧹 Reset por ${who}`, "warning");
// Salva o estado localmente também!
saveStateToSession(); //
break;
// Configs
case "SET_BPM": {
document.getElementById("bpm-input").value = action.value;
renderAll();
const who = actorOf(action);
showToast(`🕰 ${who} BPM ${action.value}`, "info");
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "SET_BARS": {
document.getElementById("bars-input").value = action.value;
renderAll();
const who = actorOf(action);
showToast(`🕰 ${who} Compasso add`, "info");
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "SET_TIMESIG_A":
document.getElementById("compasso-a-input").value = action.value;
renderAll();
showToast("Compasso alt", "info");
// Salva o estado localmente também!
saveStateToSession();
break;
case "SET_TIMESIG_B":
document.getElementById("compasso-b-input").value = action.value;
renderAll();
showToast("Compasso alt", "info");
// Salva o estado localmente também!
saveStateToSession();
break;
// Tracks
case "ADD_TRACK": {
addTrackToState();
renderPatternEditor();
const who = actorOf(action);
showToast(`🥁 Faixa add por ${who}`, "info");
// 🔥 CORREÇÃO: Avisa o servidor que o XML mudou!
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
}
saveStateToSession();
break;
}
case "REMOVE_TRACK_BY_ID": {
const { trackId } = action;
const removed = removeTrackById(trackId); // Tenta remover pelo ID seguro
if (removed) {
renderPatternEditor();
const who = actorOf(action);
showToast(`❌ Faixa removida por ${who}`, "warning");
// Sincroniza o XML com o servidor para persistir a remoção
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
// Importante: gerar o XML atualizado (sem a faixa)
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
}
saveStateToSession();
} else {
console.warn(`Tentativa de remover track inexistente/fantasma: ${trackId}`);
// Não faz nada, protegendo as faixas locais!
}
break;
}
case "REMOVE_LAST_TRACK": {
removeLastTrackFromState();
renderPatternEditor();
const who = actorOf(action);
showToast(`❌ Faixa remov. por ${who}`, "warning");
// 🔥 CORREÇÃO: Avisa o servidor que o XML mudou (removeu o TripleOscillator, por exemplo)
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
}
saveStateToSession();
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");
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "REMOVE_LAST_AUDIO_LANE": {
// 1. Verifica se tem tracks
if (appState.audio.tracks.length === 0) break;
// 2. Pega a última track
const lastTrack = appState.audio.tracks.pop();
// 3. Remove os clips associados a essa track (Limpeza)
if (lastTrack && lastTrack.id) {
// Filtra mantendo apenas os clips que NÃO pertencem à track removida
appState.audio.clips = appState.audio.clips.filter(clip => clip.trackId !== lastTrack.id);
}
// 4. Atualiza a tela
renderAll();
// 5. Notifica
const who = actorOf(action);
showToast(`🗑️ Pista de áudio removida por ${who}`, "error");
// 6. Salva sessão
saveStateToSession();
break;
}
case "REMOVE_AUDIO_CLIP":
if (removeAudioClip(action.clipId)) {
appState.global.selectedClipId = null;
renderAll();
const who = actorOf(action);
showToast(`🎚️ Clip remov. por ${who}`, "info");
// Salva o estado localmente também!
saveStateToSession();
}
break;
// Notes
case "TOGGLE_NOTE": {
const { trackIndex: ti, patternIndex: pi, stepIndex: si, isActive } = action;
// ✅ garante que o índice exista em todas as tracks (evita “silêncio” na playlist)
_ensurePatternsUpTo(pi);
const t = appState.pattern.tracks[ti];
if (t) {
t.patterns[pi] = t.patterns[pi] || _makeEmptyPattern(pi);
// mantém metadados estáveis p/ export/sync
if (t.patterns[pi].pos == null) t.patterns[pi].pos = pi * LMMS_BAR_TICKS;
if (!t.patterns[pi].name) t.patterns[pi].name = `Pattern ${pi + 1}`;
if (!Array.isArray(t.patterns[pi].notes)) t.patterns[pi].notes = [];
t.patterns[pi].steps[si] = isActive;
_syncNotesWithStepToggle(t.patterns[pi], si, isActive);
// importante: se o Audio Editor estiver tocando, precisa re-schedulear
restartAudioEditorIfPlaying();
try { updateStepUI(ti, pi, si, isActive); } catch {}
if (!isFromSelf) schedulePatternRerender();
}
if (window.ROOM_NAME && isFromSelf) {
const xml = generateXmlFromStateExported();
sendAction({ type: "SYNC_PATTERN_STATE", xml });
}
saveStateToSession();
break;
}
case "UPDATE_PATTERN_NOTES": {
const {
trackIndex: ti,
patternIndex: pi,
notes,
steps: incomingSteps,
} = action;
const t = appState.pattern.tracks[ti];
if (!t) break;
// Garante o pattern
if (!t.patterns[pi]) t.patterns[pi] = { steps: [], notes: [] };
// 1) Atualiza notas
t.patterns[pi].notes = Array.isArray(notes) ? notes : [];
// 2) Se steps vieram junto, atualiza
if (Array.isArray(incomingSteps)) {
t.patterns[pi].steps = incomingSteps;
}
// 3) Atualiza interface
try {
schedulePatternRerender();
} catch (e) {
console.warn("Erro no render UPDATE_PATTERN_NOTES", e);
}
// 4) Sync online somente se fui EU quem enviei
const isFromSelf = action.__senderId === socket.id;
if (window.ROOM_NAME && isFromSelf) {
try {
const xml = generateXmlFromStateExported(); // ← CORREÇÃO
sendAction({
type: "SYNC_PATTERN_STATE",
xml,
});
} catch (e) {
console.error("Erro gerando XML UPDATE_PATTERN_NOTES", e);
}
}
// 5) Salva no sessionStorage
saveStateToSession();
restartAudioEditorIfPlaying();
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");
}
}
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "SET_ACTIVE_PATTERN": {
// índice que veio do seletor global
const { patternIndex } = action;
// ✅ limpar seleção
if (!Number.isInteger(patternIndex)) {
appState.pattern.activePatternIndex = null;
appState.pattern.tracks.forEach((t) => (t.activePatternIndex = null));
renderAll();
saveStateToSession();
break;
}
// fonte de verdade global (muitos pontos da UI usam isso)
appState.pattern.activePatternIndex = patternIndex;
// O correto é iterar em TODAS as tracks e definir o
// novo índice de pattern para CADA UMA delas.
appState.pattern.tracks.forEach((track) => {
track.activePatternIndex = patternIndex;
});
// ✅ garante que TODAS as tracks tenham esse índice disponível
_ensurePatternsUpTo(patternIndex);
try {
const stepsPerBar = 16; // 4/4 em 1/16
const maxSteps = appState.pattern.tracks
.filter(t => t.type !== "bassline")
.map(t => t.patterns?.[patternIndex]?.steps?.length || 0)
.reduce((a, b) => Math.max(a, b), 0);
const barsEl = document.getElementById("bars-input");
if (barsEl && maxSteps > 0) {
barsEl.value = Math.max(1, Math.ceil(maxSteps / stepsPerBar));
}
} catch {}
// Mostra o toast (só uma vez)
if (!isFromSelf) {
const who = actorOf(action);
showToast(`🔄 ${who} mudou para Pattern ${patternIndex + 1}`, "info");
}
// O renderAll() vai redesenhar tudo.
// O redrawSequencer() (que é chamado pelo renderAll)
// vai ler o 'track.activePatternIndex' (agora atualizado)
// para TODAS as tracks e desenhar o pattern correto.
renderAll();
// Salva o estado localmente também!
saveStateToSession();
break;
}
// Snapshots
case "AUDIO_SNAPSHOT_REQUEST": {
// 🛑 BLOQUEADO: Agora o Servidor (backend) é quem manda o snapshot autoritativo.
// Se deixarmos os clientes responderem, eles vão enviar estados antigos ("zumbis").
console.log("Socket: Ignorando pedido de snapshot (Server Authoritative Mode).");
break;
}
case "AUDIO_SNAPSHOT": {
if (action.__target && action.__target !== socket.id) break;
// Removemos a verificação "if (hasClips) break" que impedia
// de carregar um estado vazio se o local já tivesse algo (como as faixas zumbis).
try {
console.log("Socket: Aplicando Snapshot de Áudio Autoritativo...");
await applyAudioSnapshot(action.snapshot);
renderAll();
const who = actorOf(action);
// Só mostra toast se realmente tiver conteúdo, para não spammar na entrada
if ((action.snapshot?.tracks?.length || 0) > 0) {
showToast(`🔁 Sync áudio por ${who}`, "success");
}
} catch (e) {
console.error("Erro AUDIO_SNAPSHOT:", e);
}
// Salva o estado localmente (mesmo que seja vazio!)
saveStateToSession();
break;
}
// Clip Sync
case "ADD_AUDIO_CLIP": {
try {
// --- INÍCIO DA MODIFICAÇÃO (Passo 2) ---
// Agora extraímos o 'patternData' que o main.js enviou.
const {
filePath,
trackId,
startTimeInSeconds,
clipId,
name,
patternData,
} = action;
// --- FIM DA MODIFICAÇÃO ---
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}`,
});
}
// --- INÍCIO DA MODIFICAÇÃO (Passo 2) ---
// Passamos o 'patternData' como o novo (sexto) argumento
// para a função que armazena o clipe no estado.
addAudioClipToTimeline(
filePath,
trackId,
startTimeInSeconds,
clipId,
name,
patternData // <-- AQUI ESTÁ A "PARTITURA"
);
// --- FIM DA MODIFICAÇÃO ---
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");
}
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "LOAD_BEAT_INDEX": {
isLoadingProject = true;
showToast("📂 Carregando beat index...", "info");
if (window.ROOM_NAME) {
sessionStorage.removeItem(`temp_state_${window.ROOM_NAME}`);
}
try {
await parseBeatIndexJson(action.data);
renderAll();
showToast("🎵 Beat index carregado", "success");
saveStateToSession();
} catch (e) {
console.error("Erro LOAD_BEAT_INDEX:", e);
showToast("❌ Erro ao carregar beat index", "error");
}
isLoadingProject = false;
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");
}
// Salva o estado localmente também!
saveStateToSession();
break;
}
case "REMOVE_AUDIO_LANE_BY_ID": { //
const { trackId } = action;
// 1. Encontra o índice da faixa com esse ID específico
const trackIndex = appState.audio.tracks.findIndex(t => t.id === trackId);
if (trackIndex !== -1) {
// 2. Remove a faixa exata do array local
appState.audio.tracks.splice(trackIndex, 1);
// 3. Remove os clips associados a este ID (Limpeza local)
appState.audio.clips = appState.audio.clips.filter(c => c.trackId !== trackId);
renderAll(); // Atualiza a tela
const who = actorOf(action);
// showToast(`🗑️ Pista removida por ${who}`, "warning");
// 4. Salva a sessão para garantir persistência no F5 local
saveStateToSession();
} else {
console.warn(`Tentativa de remover track inexistente: ${trackId}`);
}
break;
}
default:
console.warn("Ação desconhecida:", action.type);
}
}
// EXPORTS
export { socket };