326 lines
13 KiB
JavaScript
Executable File
326 lines
13 KiB
JavaScript
Executable File
// js/pattern/pattern_ui.js
|
|
import { appState } from "../state.js";
|
|
import { updateTrackSample } from "./pattern_state.js";
|
|
import { playSample, stopPlayback } from "./pattern_audio.js";
|
|
import { getTotalSteps } from "../utils.js";
|
|
import { sendAction } from '../socket.js';
|
|
import { initializeAudioContext } from '../audio.js';
|
|
import * as Tone from "https://esm.sh/tone";
|
|
|
|
// Função principal de renderização para o editor de patterns
|
|
export function renderPatternEditor() {
|
|
const trackContainer = document.getElementById("track-container");
|
|
trackContainer.innerHTML = "";
|
|
|
|
// 1. LÓGICA DE FILTRAGEM E CONTEXTO
|
|
// Decide quais trilhas mostrar baseando-se no ID de Bassline em foco
|
|
let tracksToRender = [];
|
|
let isFocusedMode = false;
|
|
let contextName = "Song Editor"; // Nome da visualização atual
|
|
|
|
if (appState.pattern.focusedBasslineId) {
|
|
isFocusedMode = true;
|
|
// Mostra apenas os filhos da Bassline selecionada
|
|
tracksToRender = appState.pattern.tracks.filter(t => t.parentBasslineId === appState.pattern.focusedBasslineId);
|
|
|
|
// Busca o nome para exibir no título
|
|
const basslineTrack = appState.pattern.tracks.find(t => t.id === appState.pattern.focusedBasslineId);
|
|
if (basslineTrack) contextName = basslineTrack.name;
|
|
} else {
|
|
// Modo Padrão: Mostra trilhas da raiz (sem pai) e que NÃO são containers de bassline
|
|
tracksToRender = appState.pattern.tracks.filter(t => t.type !== 'bassline' && t.parentBasslineId === null);
|
|
|
|
// Fallback: Se a lista ficar vazia (projeto antigo ou mal formatado), mostra tudo que não é container
|
|
if (tracksToRender.length === 0) {
|
|
tracksToRender = appState.pattern.tracks.filter(t => t.type !== 'bassline');
|
|
}
|
|
}
|
|
|
|
// 2. RENDERIZAÇÃO DO CABEÇALHO DE NAVEGAÇÃO
|
|
const navHeader = document.createElement("div");
|
|
navHeader.className = "editor-nav-header";
|
|
// Estilos inline para garantir aparência imediata (mova para CSS depois se preferir)
|
|
navHeader.style.padding = "8px 12px";
|
|
navHeader.style.backgroundColor = "#2b2b2b";
|
|
navHeader.style.marginBottom = "8px";
|
|
navHeader.style.display = "flex";
|
|
navHeader.style.alignItems = "center";
|
|
navHeader.style.justifyContent = "space-between";
|
|
navHeader.style.color = "#ddd";
|
|
navHeader.style.borderBottom = "1px solid #444";
|
|
navHeader.style.fontSize = "0.9rem";
|
|
|
|
const titleIcon = isFocusedMode ? "fa-th-large" : "fa-music";
|
|
const title = document.createElement("span");
|
|
title.innerHTML = `<i class="fa-solid ${titleIcon}" style="margin-right:8px"></i> <strong>${contextName}</strong>`;
|
|
navHeader.appendChild(title);
|
|
|
|
// Botão Voltar (Aparece apenas se estiver dentro de uma Bassline)
|
|
if (isFocusedMode) {
|
|
const backBtn = document.createElement("button");
|
|
backBtn.className = "control-btn";
|
|
backBtn.innerHTML = `<i class="fa-solid fa-arrow-left"></i> Voltar`;
|
|
backBtn.style.padding = "4px 10px";
|
|
backBtn.style.backgroundColor = "#444";
|
|
backBtn.style.border = "1px solid #555";
|
|
backBtn.style.borderRadius = "4px";
|
|
backBtn.style.color = "white";
|
|
backBtn.style.cursor = "pointer";
|
|
|
|
backBtn.onmouseover = () => backBtn.style.backgroundColor = "#555";
|
|
backBtn.onmouseout = () => backBtn.style.backgroundColor = "#444";
|
|
|
|
backBtn.onclick = () => {
|
|
if (window.exitPatternFocus) {
|
|
window.exitPatternFocus();
|
|
} else {
|
|
console.error("Função window.exitPatternFocus não encontrada no escopo global.");
|
|
}
|
|
};
|
|
navHeader.appendChild(backBtn);
|
|
}
|
|
|
|
trackContainer.appendChild(navHeader);
|
|
|
|
// 3. RENDERIZAÇÃO DAS TRILHAS FILTRADAS
|
|
tracksToRender.forEach((trackData) => {
|
|
// IMPORTANTE: Precisamos encontrar o índice real no array global
|
|
// para que as ações (volume, pan, delete) afetem a trilha certa.
|
|
const originalIndex = appState.pattern.tracks.findIndex(t => t.id === trackData.id);
|
|
|
|
const trackLane = document.createElement("div");
|
|
trackLane.className = "track-lane";
|
|
trackLane.dataset.trackIndex = originalIndex;
|
|
|
|
if (trackData.id === appState.pattern.activeTrackId) {
|
|
trackLane.classList.add('active-track');
|
|
}
|
|
|
|
trackLane.innerHTML = `
|
|
<div class="track-info">
|
|
<i class="fa-solid fa-gear" title="Configurações do Instrumento"></i>
|
|
<div class="track-mute" title="Mute"></div>
|
|
<span class="track-name" title="${trackData.name}">${trackData.name}</span>
|
|
</div>
|
|
<div class="track-controls">
|
|
<div class="knob-container">
|
|
<div class="knob" data-control="volume" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
|
<span>VOL</span>
|
|
</div>
|
|
<div class="knob-container">
|
|
<div class="knob" data-control="pan" data-track-id="${trackData.id}"><div class="knob-indicator"></div></div>
|
|
<span>PAN</span>
|
|
</div>
|
|
</div>
|
|
<div class="step-sequencer-wrapper"></div>
|
|
`;
|
|
|
|
// Eventos de Seleção
|
|
trackLane.addEventListener('click', () => {
|
|
if (appState.pattern.activeTrackId === trackData.id) return;
|
|
// stopPlayback(); // (Opcional: descomente se quiser parar o som ao trocar de track)
|
|
appState.pattern.activeTrackId = trackData.id;
|
|
document.querySelectorAll('.track-lane').forEach(lane => lane.classList.remove('active-track'));
|
|
trackLane.classList.add('active-track');
|
|
updateGlobalPatternSelector();
|
|
redrawSequencer();
|
|
});
|
|
|
|
// Drag and Drop de Samples
|
|
trackLane.addEventListener("dragover", (e) => { e.preventDefault(); trackLane.classList.add("drag-over"); });
|
|
trackLane.addEventListener("dragleave", () => trackLane.classList.remove("drag-over"));
|
|
trackLane.addEventListener("drop", (e) => {
|
|
e.preventDefault();
|
|
trackLane.classList.remove("drag-over");
|
|
const filePath = e.dataTransfer.getData("text/plain");
|
|
if (filePath) {
|
|
sendAction({
|
|
type: 'SET_TRACK_SAMPLE',
|
|
trackIndex: originalIndex,
|
|
filePath: filePath
|
|
});
|
|
}
|
|
});
|
|
|
|
trackContainer.appendChild(trackLane);
|
|
});
|
|
|
|
updateGlobalPatternSelector();
|
|
redrawSequencer();
|
|
}
|
|
|
|
// Renderiza o grid de steps ou piano roll miniatura
|
|
export function redrawSequencer() {
|
|
const totalGridSteps = getTotalSteps();
|
|
|
|
document.querySelectorAll(".step-sequencer-wrapper").forEach((wrapper) => {
|
|
let sequencerContainer = wrapper.querySelector(".step-sequencer");
|
|
if (!sequencerContainer) {
|
|
sequencerContainer = document.createElement("div");
|
|
sequencerContainer.className = "step-sequencer";
|
|
wrapper.appendChild(sequencerContainer);
|
|
} else {
|
|
sequencerContainer.innerHTML = "";
|
|
}
|
|
|
|
// Busca o dado da trilha usando o índice global
|
|
const parentTrackElement = wrapper.closest(".track-lane");
|
|
const trackIndex = parseInt(parentTrackElement.dataset.trackIndex, 10);
|
|
const trackData = appState.pattern.tracks[trackIndex];
|
|
|
|
if (!trackData || !trackData.patterns || trackData.patterns.length === 0) return;
|
|
|
|
const activePatternIndex = trackData.activePatternIndex || 0;
|
|
const activePattern = trackData.patterns[activePatternIndex];
|
|
if (!activePattern) return;
|
|
|
|
// --- DECISÃO: STEP SEQUENCER VS PIANO ROLL ---
|
|
const notes = activePattern.notes || [];
|
|
let renderMode = 'steps';
|
|
if (notes.length > 0) {
|
|
const firstKey = notes[0].key;
|
|
// Se houver variação de nota ou notas longas, usa visualização de Piano Roll
|
|
if (notes.some(n => n.key !== firstKey) || notes.some(n => n.len > 48)) {
|
|
renderMode = 'piano_roll';
|
|
}
|
|
}
|
|
|
|
if (renderMode === 'piano_roll') {
|
|
// --- MODO: MINI PIANO ROLL ---
|
|
sequencerContainer.classList.add('mode-piano');
|
|
const miniView = document.createElement('div');
|
|
miniView.className = 'track-mini-piano-roll';
|
|
miniView.title = "Clique duplo para abrir o Piano Roll completo";
|
|
|
|
miniView.addEventListener('dblclick', (e) => {
|
|
e.stopPropagation();
|
|
if (window.openPianoRoll) window.openPianoRoll(trackData.id);
|
|
});
|
|
|
|
// 48 ticks = 1/16 step.
|
|
const totalTicks = totalGridSteps * 48;
|
|
|
|
activePattern.notes.forEach(note => {
|
|
const noteEl = document.createElement('div');
|
|
noteEl.className = 'mini-note';
|
|
|
|
const leftPercent = Math.min((note.pos / totalTicks) * 100, 100);
|
|
const widthPercent = Math.min((note.len / totalTicks) * 100, 100 - leftPercent);
|
|
|
|
// Mapeia nota MIDI para altura (aprox 4 oitavas visíveis)
|
|
const relativeKey = Math.max(0, Math.min(note.key - 36, 48));
|
|
const topPercent = 100 - ((relativeKey / 48) * 100);
|
|
|
|
noteEl.style.left = `${leftPercent}%`;
|
|
noteEl.style.width = `${widthPercent}%`;
|
|
noteEl.style.top = `${topPercent}%`;
|
|
miniView.appendChild(noteEl);
|
|
});
|
|
sequencerContainer.appendChild(miniView);
|
|
|
|
} else {
|
|
// --- MODO: STEP SEQUENCER (Bateria) ---
|
|
sequencerContainer.classList.remove('mode-piano');
|
|
const patternSteps = activePattern.steps;
|
|
|
|
for (let i = 0; i < totalGridSteps; i++) {
|
|
const stepWrapper = document.createElement("div");
|
|
stepWrapper.className = "step-wrapper";
|
|
|
|
const stepElement = document.createElement("div");
|
|
stepElement.className = "step";
|
|
if (patternSteps[i] === true) stepElement.classList.add("active");
|
|
|
|
// Evento de Toggle
|
|
stepElement.addEventListener("click", (e) => {
|
|
e.stopPropagation();
|
|
initializeAudioContext();
|
|
const isActive = !activePattern.steps[i];
|
|
|
|
sendAction({
|
|
type: 'TOGGLE_NOTE',
|
|
trackIndex,
|
|
patternIndex: activePatternIndex,
|
|
stepIndex: i,
|
|
isActive
|
|
});
|
|
|
|
// Preview Sonoro
|
|
if (isActive) {
|
|
if (trackData.type === 'sampler' && trackData.samplePath) {
|
|
playSample(trackData.samplePath, trackData.id);
|
|
} else if (trackData.type === 'plugin' && trackData.instrument) {
|
|
try { trackData.instrument.triggerAttackRelease("C5", "16n", Tone.now()); } catch(err) {}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Estilização do Grid (Marcadores de Compasso e Beat)
|
|
if (Math.floor(i / 4) % 2 === 0) stepElement.classList.add("step-dark");
|
|
|
|
if (i > 0 && i % 16 === 0) {
|
|
const marker = document.createElement("div");
|
|
marker.className = "step-marker";
|
|
marker.textContent = Math.floor(i / 16) + 1;
|
|
stepWrapper.appendChild(marker);
|
|
}
|
|
|
|
stepWrapper.appendChild(stepElement);
|
|
sequencerContainer.appendChild(stepWrapper);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Ilumina o step atual durante o playback
|
|
export function highlightStep(stepIndex, isActive) {
|
|
if (stepIndex < 0) return;
|
|
document.querySelectorAll(".track-lane").forEach((track) => {
|
|
const stepWrapper = track.querySelector(`.step-sequencer .step-wrapper:nth-child(${stepIndex + 1})`);
|
|
if (stepWrapper) {
|
|
const stepElement = stepWrapper.querySelector(".step");
|
|
if (stepElement) stepElement.classList.toggle("playing", isActive);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Atualiza UI de um step específico (chamado pelo socket)
|
|
export function updateStepUI(trackIndex, patternIndex, stepIndex, isActive) {
|
|
// Redesenhar tudo é mais seguro para garantir que filtros e views (piano vs steps) estejam corretos
|
|
redrawSequencer();
|
|
}
|
|
|
|
// Atualiza o dropdown de patterns na toolbar
|
|
export function updateGlobalPatternSelector() {
|
|
const globalPatternSelector = document.getElementById('global-pattern-selector');
|
|
if (!globalPatternSelector) return;
|
|
|
|
const activeTrackId = appState.pattern.activeTrackId;
|
|
const activeTrack = appState.pattern.tracks.find(t => t.id === activeTrackId);
|
|
|
|
// Tenta pegar a track ativa ou a primeira visível como referência de patterns
|
|
const referenceTrack = activeTrack || appState.pattern.tracks.find(t => !t.isMuted && t.type !== 'bassline');
|
|
|
|
globalPatternSelector.innerHTML = '';
|
|
|
|
if (referenceTrack && referenceTrack.patterns && referenceTrack.patterns.length > 0) {
|
|
referenceTrack.patterns.forEach((pattern, index) => {
|
|
const option = document.createElement('option');
|
|
option.value = index;
|
|
option.textContent = pattern.name;
|
|
globalPatternSelector.appendChild(option);
|
|
});
|
|
|
|
if (activeTrack) {
|
|
globalPatternSelector.value = activeTrack.activePatternIndex || 0;
|
|
} else {
|
|
globalPatternSelector.value = 0;
|
|
}
|
|
globalPatternSelector.disabled = false;
|
|
} else {
|
|
const option = document.createElement('option');
|
|
option.textContent = 'Sem patterns';
|
|
globalPatternSelector.appendChild(option);
|
|
globalPatternSelector.disabled = true;
|
|
}
|
|
} |