diff --git a/assets/js/creations/main.js b/assets/js/creations/main.js index 25878e3..fa339cf 100644 --- a/assets/js/creations/main.js +++ b/assets/js/creations/main.js @@ -228,46 +228,27 @@ document.addEventListener("DOMContentLoaded", () => { // ================================================================= // 👇 INÍCIO DA CORREÇÃO (Botão de Sincronia - Agora envia Ação) // ================================================================= - const syncModeBtn = document.getElementById("sync-mode-btn"); // + const syncModeBtn = document.getElementById("sync-mode-btn"); if (syncModeBtn) { - // - // Define o estado inicial (global por padrão) - appState.global.syncMode = "global"; // - syncModeBtn.classList.add("active"); // - syncModeBtn.textContent = "Global"; // + // Só define default se ainda não existir + if (!appState.global.syncMode) { + appState.global.syncMode = "global"; + } + + syncModeBtn.classList.toggle("active", appState.global.syncMode === "global"); + syncModeBtn.textContent = + appState.global.syncMode === "global" ? "Global" : "Local"; syncModeBtn.addEventListener("click", () => { - // - // 1. Determina qual será o *novo* modo const newMode = - appState.global.syncMode === "global" ? "local" : "global"; // + appState.global.syncMode === "global" ? "local" : "global"; - // 2. Envia a ação para sincronizar. O handleActionBroadcast - // cuidará de atualizar o appState, o botão e mostrar o toast. sendAction({ type: "SET_SYNC_MODE", mode: newMode, }); - - // Lógica antiga removida daqui (movida para o handler) - /* - const isNowLocal = appState.global.syncMode === "global"; - appState.global.syncMode = isNowLocal ? "local" : "global"; - syncModeBtn.classList.toggle("active", !isNowLocal); - syncModeBtn.textContent = isNowLocal ? "Local" : "Global"; - showToast( `🎧 Modo de Playback: ${isNowLocal ? "Local" : "Global"}`, "info" ); - */ }); - - // Esconde o botão se não estiver em uma sala (lógica movida do socket.js) - if (!ROOM_NAME) { - // - //syncModeBtn.style.display = 'none'; // REMOVIDO PARA TESTE VISUAL - } } - // ================================================================= - // 👆 FIM DA CORREÇÃO - // ================================================================= // Excluir clipe if (deleteClipBtn) { diff --git a/assets/js/creations/socket.js b/assets/js/creations/socket.js index bb8d4ea..30268a0 100644 --- a/assets/js/creations/socket.js +++ b/assets/js/creations/socket.js @@ -38,7 +38,9 @@ import { handleLocalProjectReset, syncPatternStateToServer, BLANK_PROJECT_XML, + 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"; @@ -170,18 +172,17 @@ socket.on("connect_error", (err) => { ); }); +// ----------------------------------------------------------------------------- +// 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}` ); - // Certifique-se de que esta função existe e faz o que é esperado: - // 1. Fetch dos novos manifestos (metadata/samples-manifest.json etc.) - // 2. Renderiza a interface do navegador de samples loadAndRenderSampleBrowser(); } - // Se houver outras notificações, adicione-as aqui (ex: RELOAD_PROJECTS) }); // ----------------------------------------------------------------------------- @@ -193,11 +194,28 @@ socket.on("load_project_state", async (projectXml) => { if (isLoadingProject) return; isLoadingProject = true; try { - await parseMmpContent(projectXml); // + // 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) { + const parsed = JSON.parse(raw); + const hasLocalAudio = parsed.audioSnapshot?.clips?.length > 0 || + parsed.audioSnapshot?.tracks?.length > 0; + if (hasLocalAudio) { + console.log("Re-aplicando sessão local sobre o XML do servidor..."); + await loadStateFromSession(); + } + } + + } + renderAll(); showToast("🎵 Projeto carregado com sucesso", "success"); - // --- INÍCIO DA CORREÇÃO --- const hasAudio = (appState.audio?.clips?.length || 0) > 0 || (appState.audio?.tracks?.length || 0) > 0; @@ -207,13 +225,12 @@ socket.on("load_project_state", async (projectXml) => { "Projeto XML carregado, sem áudio. Pedindo snapshot de áudio..." ); - // 🔥 FIX CRÍTICO: Libera a trava IMEDIATAMENTE antes de pedir + // 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" }); } - // --- FIM DA CORREÇÃO --- } catch (e) { console.error("Erro ao carregar projeto:", e); @@ -301,9 +318,6 @@ export function sendAction(action) { 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" || @@ -312,10 +326,7 @@ export function sendAction(action) { action.type === "STOP_AUDIO_PLAYBACK" || action.type === "SET_LOOP_STATE" || action.type === "SET_SEEK_TIME" || - action.type === "SET_SYNC_MODE"; // 👈 Adicionado - // ================================================================= - // 👆 FIM DA CORREÇÃO - // ================================================================= + action.type === "SET_SYNC_MODE"; if (inRoom && isTransport) { const leadTimeMs = 200; @@ -375,12 +386,16 @@ export function sendAction(action) { }, 900); } -// HELPERS POP-UPS + +// ----------------------------------------------------------------------------- +// 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 || @@ -389,12 +404,14 @@ function trackLabel(track, idx) { 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 || @@ -421,7 +438,9 @@ function schedulePatternRerender() { }); } -// RECEBER BROADCAST +// ----------------------------------------------------------------------------- +// BROADCAST +// ----------------------------------------------------------------------------- socket.on("feedback", (msg) => { console.log("[Servidor]", msg); showToast(msg, "info"); @@ -444,7 +463,9 @@ socket.on("action_broadcast", (payload) => { handleActionBroadcast(action); }); -// PROCESSAR AÇÕES RECEBIDAS +// ----------------------------------------------------------------------------- +// PROCESSAR AÇÕES +// ----------------------------------------------------------------------------- let isLoadingProject = false; async function handleActionBroadcast(action) { if (!action || !action.type) return; @@ -618,26 +639,7 @@ async function handleActionBroadcast(action) { await parseMmpContent(action.xml); // renderAll(); showToast("🎶 Projeto sync", "success"); - - // --- INÍCIO DA CORREÇÃO --- - // O problema é esta chamada. Ela cria um "eco" (SYNC_PATTERN_STATE) - // que força um segundo 'parseMmpContent' e quebra o estado. - - // REMOVA ou COMENTE este bloco: - /* - if (window.ROOM_NAME) { - console.log( - "LOAD_PROJECT: Sincronizando novo estado com o servidor..." - ); - syncPatternStateToServer(); // - } - */ - - // EM VEZ DISSO, apenas salve o estado na sessão local. - // O `syncPatternStateToServer` agora é responsabilidade do servidor - // (que já recebeu o LOAD_PROJECT) ou de outras ações. saveStateToSession(); - // --- FIM DA CORREÇÃO --- } catch (e) { console.error("Erro LOAD_PROJECT:", e); showToast("❌ Erro projeto", "error"); @@ -661,15 +663,9 @@ async function handleActionBroadcast(action) { case "RESET_ROOM": console.log("Socket: Recebendo comando de RESET_ROOM do servidor."); // - - // --- INÍCIO DA CORREÇÃO --- - // Faltava esta linha para definir quem executou a ação const who = actorOf(action); - // --- FIM DA CORREÇÃO --- - - handleLocalProjectReset(); // - showToast(`🧹 Reset por ${who}`, "warning"); // - + handleLocalProjectReset(); + showToast(`🧹 Reset por ${who}`, "warning"); // Salva o estado localmente também! saveStateToSession(); // break; @@ -768,31 +764,78 @@ async function handleActionBroadcast(action) { // 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(); + } + + // ✔ ADICIONE ISTO + 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, - stepIndex: si, - isActive, + notes, + steps: incomingSteps, } = action; + const t = appState.pattern.tracks[ti]; - if (t) { - t.patterns[pi] = t.patterns[pi] || { steps: [] }; - t.patterns[pi].steps[si] = isActive; + 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 { - updateStepUI(ti, pi, si, isActive); - } catch {} - if (!isFromSelf) { - schedulePatternRerender(); + const xml = generateXmlFromStateExported(); // ← CORREÇÃO + sendAction({ + type: "SYNC_PATTERN_STATE", + xml, + }); + } catch (e) { + console.error("Erro gerando XML UPDATE_PATTERN_NOTES", e); } } - const who = actorOf(action); - const v = isActive ? "+" : "-"; - showToast(`🎯 ${who} ${v} nota ${ti + 1}.${pi + 1}.${si + 1}`, "info"); - // Salva o estado localmente também! + + // 5) Salva no sessionStorage saveStateToSession(); + break; } - // Samples case "SET_TRACK_SAMPLE": { const ti = action.trackIndex; diff --git a/assets/js/creations/state.js b/assets/js/creations/state.js index 17b7e3a..403112e 100644 --- a/assets/js/creations/state.js +++ b/assets/js/creations/state.js @@ -1,9 +1,14 @@ -// js/state.js -import { audioState, initializeAudioState } from "./audio/audio_state.js"; +// state.js (versão conceitual nova) +import { + audioState, + initializeAudioState, + getAudioSnapshot, + applyAudioSnapshot, +} from "./audio/audio_state.js"; import { DEFAULT_VOLUME, DEFAULT_PAN } from "./config.js"; -import * as Tone from "https://esm.sh/tone"; +import * as Tone from "https://esm.sh/tone"; -// --- DEFINIÇÃO DOS ESTADOS INICIAIS --- +// ---------------- ESTADOS INICIAIS ---------------- const patternState = { tracks: [], @@ -38,14 +43,82 @@ const globalState = { export let appState = { global: globalState, pattern: patternState, - audio: audioState, + audio: audioState, // compartilhado com módulo de áudio }; + window.appState = appState; +// ---------------- HELPERS ---------------- + +function makePatternSnapshot() { + return { + tracks: appState.pattern.tracks.map((t) => ({ + id: t.id, + name: t.name, + type: t.type, + samplePath: t.samplePath, + patterns: (t.patterns || []).map((p) => ({ + name: p.name, + steps: p.steps, + notes: p.notes, + pos: p.pos, + })), + activePatternIndex: t.activePatternIndex || 0, + volume: t.volume, + pan: t.pan, + instrumentName: t.instrumentName, + instrumentXml: t.instrumentXml, + })), + activeTrackId: appState.pattern.activeTrackId, + activePatternIndex: appState.pattern.activePatternIndex, + }; +} + + +// ---------------------- +// Helper: existe snapshot local com áudio? +// ---------------------- +export function hasLocalAudioSnapshot() { + if (!window.ROOM_NAME) return false; + const raw = sessionStorage.getItem(`temp_state_${window.ROOM_NAME}`); + if (!raw) return false; + + try { + const data = JSON.parse(raw); + + // se tem tracks ou clips, consideramos snapshot válido + const audio = data.audioSnapshot; + if (!audio) return false; + + const hasTracks = audio.tracks && audio.tracks.length > 0; + const hasClips = audio.clips && audio.clips.length > 0; + + return hasTracks || hasClips; + } catch (e) { + console.warn("hasLocalAudioSnapshot: erro ao analisar snapshot", e); + return false; + } +} + +// ---------------------- +// Helper: checa se um snapshot de áudio é "não vazio" +// ---------------------- +function hasAudioInSnapshot(audioSnapshot) { + if (!audioSnapshot) return false; + + const hasTracks = + Array.isArray(audioSnapshot.tracks) && audioSnapshot.tracks.length > 0; + const hasClips = + Array.isArray(audioSnapshot.clips) && audioSnapshot.clips.length > 0; + + return hasTracks || hasClips; +} + +// ---------------- RESET GERAL ---------------- + export function resetProjectState() { console.log("Executando resetProjectState (Limpeza Profunda)..."); - // 1. Reseta Global Object.assign(appState.global, { sliceToolActive: false, isPlaying: false, @@ -67,180 +140,110 @@ export function resetProjectState() { clipboard: null, lastRulerClickTime: 0, justReset: false, - // syncMode mantemos o que estava + syncMode: appState.global.syncMode ?? "global", }); - // 2. Reseta Pattern Object.assign(appState.pattern, { tracks: [], activeTrackId: null, activePatternIndex: 0, }); - // 3. Reseta Áudio (Remove tudo da memória) - if (appState.audio) { - appState.audio.tracks = []; - appState.audio.clips = []; - appState.audio.audioEditorSeekTime = 0; - } - initializeAudioState(); + initializeAudioState(); } -/** - * SALVAR (Serialization): - * Transforma o estado complexo em um JSON leve (apenas strings e números). - * Remove objetos cíclicos como AudioBuffers e Nodes do Tone.js. - */ +// ---------------- SALVAR SESSÃO LOCAL ---------------- + export function saveStateToSession() { if (!window.ROOM_NAME) return; - // 1. Sanitiza Tracks do Pattern (Instrumentos) - const cleanPatternTracks = appState.pattern.tracks.map((track) => ({ - id: track.id, - name: track.name, - samplePath: track.samplePath, // Caminho do arquivo (String) - patterns: track.patterns, - activePatternIndex: track.activePatternIndex, - volume: track.volume, - pan: track.pan, - instrumentName: track.instrumentName, - instrumentXml: track.instrumentXml, - // Note: NÃO salvamos volumeNode, pannerNode, etc. - })); - - // 2. Sanitiza Clipes de Áudio (Timeline) - // AQUI ESTÁ O SEGREDO: Salvamos apenas o 'filePath', não o buffer de áudio. - const cleanAudioClips = (appState.audio.clips || []).map((clip) => ({ - id: clip.id, - trackId: clip.trackId, - name: clip.name, - filePath: clip.filePath, // O endereço do áudio - startTimeInSeconds: clip.startTimeInSeconds, - durationInSeconds: clip.durationInSeconds, - offset: clip.offset, - pitch: clip.pitch, - originalDuration: clip.originalDuration, - patternData: clip.patternData, // Visualização dos steps (leve) - // Note: NÃO salvamos clip.buffer (que é o áudio pesado) - })); - - const stateToSave = { - pattern: { ...appState.pattern, tracks: cleanPatternTracks }, - audio: { - tracks: appState.audio.tracks || [], // Tracks são apenas containers leves - clips: cleanAudioClips - }, - global: { - bpm: document.getElementById("bpm-input")?.value || 140, - compassoA: document.getElementById("compasso-a-input")?.value || 4, - compassoB: document.getElementById("compasso-b-input")?.value || 4, - bars: document.getElementById("bars-input")?.value || 1, - syncMode: appState.global.syncMode, - }, - }; - try { - const roomName = window.ROOM_NAME || "default_room"; - // Agora o JSON.stringify funciona porque só tem dados simples - sessionStorage.setItem(`temp_state_${roomName}`, JSON.stringify(stateToSave)); + // Pattern “puro” + const patternSnapshot = makePatternSnapshot(); + + // XML original em string + let originalXmlString = null; + if (appState.global.originalXmlDoc) { + const serializer = new XMLSerializer(); + try { + originalXmlString = serializer.serializeToString( + appState.global.originalXmlDoc + ); + } catch (e) { + console.warn("Não consegui serializar originalXmlDoc:", e); + } + } + + const audioSnapshot = getAudioSnapshot(); + + const snapshot = { + version: 1, + global: { + ...appState.global, + playbackIntervalId: null, + originalXmlDoc: originalXmlString, + }, + pattern: patternSnapshot, + audioSnapshot, + }; + + sessionStorage.setItem( + `temp_state_${window.ROOM_NAME}`, + JSON.stringify(snapshot) + ); } catch (e) { - console.error("Erro ao salvar sessão:", e); + console.error("Erro salvando sessão:", e); } } -/** - * CARREGAR (Hydration): - * Lê o JSON leve e RECONSTRÓI os objetos pesados (carrega os arquivos via HTTP). - */ -export async function loadStateFromSession() { - const roomName = window.ROOM_NAME || "default_room"; - const tempStateJSON = sessionStorage.getItem(`temp_state_${roomName}`); +// ---------------- CARREGAR SESSÃO LOCAL ---------------- +// mode: +// - "full" (global + pattern + audio) +// - "audioOnly" (apenas áudio, usado como fallback +// depois de carregar XML do servidor) - if (!tempStateJSON) return false; +export async function loadStateFromSession(mode = "full") { + if (!window.ROOM_NAME) return false; + + const raw = sessionStorage.getItem(`temp_state_${window.ROOM_NAME}`); + if (!raw) return false; - console.log("Hidratando estado da sessão..."); try { - const tempState = JSON.parse(tempStateJSON); + const data = JSON.parse(raw); - // 1. Restaura Pattern Tracks - // Precisamos recriar os Nodes do Tone.js que não foram salvos - appState.pattern.tracks.forEach((liveTrack) => { - const savedTrack = tempState.pattern.tracks.find(t => t.id === liveTrack.id); - if (savedTrack) { - // Copia dados simples - Object.assign(liveTrack, { - name: savedTrack.name, - patterns: savedTrack.patterns, - activePatternIndex: savedTrack.activePatternIndex, - volume: savedTrack.volume, - pan: savedTrack.pan - }); - - // Reconecta Nodes de Áudio (Hidratação) - if (liveTrack.volumeNode) liveTrack.volumeNode.volume.value = Tone.gainToDb(savedTrack.volume); - if (liveTrack.pannerNode) liveTrack.pannerNode.pan.value = savedTrack.pan; - } - }); - - // Sincroniza lista de tracks (remove deletadas) - appState.pattern.tracks = appState.pattern.tracks.filter(liveTrack => - tempState.pattern.tracks.some(t => t.id === liveTrack.id) - ); + // GLOBAL & PATTERN só se for "full" + if (mode === "full") { + if (data.global) { + const { originalXmlDoc: xmlString, ...rest } = data.global; + Object.assign(appState.global, rest); - // 2. Restaura Áudio Timeline (A parte mais importante) - if (tempState.audio) { - appState.audio.tracks = tempState.audio.tracks || []; - - const clipsMetadata = tempState.audio.clips || []; - const loadedClips = []; - - console.log(`Recarregando ${clipsMetadata.length} clips de áudio...`); - - // Para cada clipe salvo, baixamos o áudio novamente usando o filePath - for (const metaClip of clipsMetadata) { - let buffer = null; - if (metaClip.filePath) { - try { - // Tone.Buffer carrega o arquivo .wav/.mp3 da URL - buffer = await new Tone.Buffer(metaClip.filePath).loaded; - } catch (err) { - console.warn(`Arquivo não encontrado: ${metaClip.filePath}`, err); - } + if (xmlString) { + try { + const parser = new DOMParser(); + appState.global.originalXmlDoc = parser.parseFromString( + xmlString, + "application/xml" + ); + } catch (e) { + console.warn("Falha ao reconstituir originalXmlDoc:", e); + } } - - // Recria o objeto completo na memória - loadedClips.push({ - ...metaClip, // Pega id, start, duration, pitch... - buffer: buffer // Anexa o áudio pesado recém-carregado - }); } - appState.audio.clips = loadedClips; - } - // 3. Restaura Global UI - if (tempState.global) { - const g = tempState.global; - const setVal = (id, val) => { const el = document.getElementById(id); if(el) el.value = val; }; - - setVal("bpm-input", g.bpm); - setVal("compasso-a-input", g.compassoA); - setVal("compasso-b-input", g.compassoB); - setVal("bars-input", g.bars); - - if (g.syncMode) { - appState.global.syncMode = g.syncMode; - const btn = document.getElementById("sync-mode-btn"); - if (btn) { - btn.classList.toggle("active", g.syncMode === "global"); - btn.textContent = g.syncMode === "global" ? "Global" : "Local"; - } + if (data.pattern) { + Object.assign(appState.pattern, data.pattern); } } - appState.pattern.activeTrackId = tempState.pattern.activeTrackId; + // ÁUDIO em qualquer modo, se existir + if (hasAudioInSnapshot(data.audioSnapshot)) { + initializeAudioState(); + await applyAudioSnapshot(data.audioSnapshot); + } + return true; } catch (e) { - console.error("Erro crítico ao carregar sessão:", e); + console.error("Erro carregando sessão:", e); return false; } -} \ No newline at end of file +}