diff --git a/assets/css/creator.css b/assets/css/creator.css index b4402d7d..eaa83085 100755 --- a/assets/css/creator.css +++ b/assets/css/creator.css @@ -245,6 +245,25 @@ body.sidebar-hidden .sample-browser { width: 0; min-width: 0; border-right: none /* =============================================== */ /* BEAT EDITOR / STEP SEQUENCER /* =============================================== */ + +.bassline-clip.selected { + outline: 2px solid rgba(255,255,255,0.9); +} + +.pattern-resize-handle { + position: absolute; + top: 0; + width: 8px; + height: 100%; + cursor: ew-resize; + z-index: 10; + background: rgba(255,255,255,0.15); +} + +.pattern-resize-handle.left { left: 0; } +.pattern-resize-handle.right { right: 0; } + + .beat-editor { flex: 1; background-color: var(--bg-editor); border: 1px solid var(--border-color); border-radius: 8px; box-shadow: 0 5px 15px rgba(0, 0, 0, .3); overflow: hidden; diff --git a/assets/js/creations/audio/audio_ui.js b/assets/js/creations/audio/audio_ui.js index 3124a789..e2c8864e 100755 --- a/assets/js/creations/audio/audio_ui.js +++ b/assets/js/creations/audio/audio_ui.js @@ -21,12 +21,176 @@ import { } from "../utils.js"; import { sendAction, sendActionSafe } from "../socket.js"; +// ===================================================== +// Playlist Patterns (Bassline clips) - Drag/Resize/Delete +// ===================================================== +const PL_TICKS_PER_STEP = 12; +const PL_MIN_LEN_TICKS = PL_TICKS_PER_STEP; +const PL_SNAP_TICKS = PL_TICKS_PER_STEP; // por enquanto 1/16; depois ligamos ao Snap do UI + +function _genPlClipId() { + return crypto?.randomUUID?.() || `plc_${Date.now()}_${Math.random().toString(36).slice(2)}`; +} + +function _ticksToPx(ticks, stepWidthPx) { + return (Number(ticks) / PL_TICKS_PER_STEP) * stepWidthPx; +} + +function _pxToTicks(px, stepWidthPx) { + return Math.round((Number(px) / stepWidthPx) * PL_TICKS_PER_STEP); +} + +function _snapTicks(ticks, snap = PL_SNAP_TICKS) { + const s = Math.max(1, Math.floor(snap)); + return Math.round(Number(ticks) / s) * s; +} + +function _ensureGlobalPlaylistSelectionFields() { + if (appState.global.selectedPlaylistClipId === undefined) { + appState.global.selectedPlaylistClipId = null; + } + if (appState.global.selectedPlaylistPatternIndex === undefined) { + appState.global.selectedPlaylistPatternIndex = null; + } +} + +function _findBasslineByPatternIndex(patternIndex) { + return (appState.pattern?.tracks || []).find( + (t) => t.type === "bassline" && Number(t.patternIndex ?? 0) === Number(patternIndex ?? 0) + ); +} + +function _getPlaylistClipModel(patternIndex, clipId) { + const b = _findBasslineByPatternIndex(patternIndex); + if (!b || !Array.isArray(b.playlist_clips)) return null; + return b.playlist_clips.find((c) => String(c.id) === String(clipId)); +} + +function _updatePlaylistClipLocal(patternIndex, clipId, props) { + const b = _findBasslineByPatternIndex(patternIndex); + if (!b) return false; + if (!Array.isArray(b.playlist_clips)) b.playlist_clips = []; + + const c = b.playlist_clips.find((x) => String(x.id) === String(clipId)); + if (!c) return false; + + if (props.pos !== undefined) c.pos = Math.max(0, Math.floor(props.pos)); + if (props.len !== undefined) c.len = Math.max(PL_MIN_LEN_TICKS, Math.floor(props.len)); + + b.playlist_clips.sort((a, d) => (a.pos ?? 0) - (d.pos ?? 0)); + return true; +} + +function _removePlaylistClipLocal(patternIndex, clipId) { + const b = _findBasslineByPatternIndex(patternIndex); + if (!b || !Array.isArray(b.playlist_clips)) return false; + + const before = b.playlist_clips.length; + b.playlist_clips = b.playlist_clips.filter((c) => String(c.id) !== String(clipId)); + const after = b.playlist_clips.length; + + if (appState.global.selectedPlaylistClipId === clipId) { + appState.global.selectedPlaylistClipId = null; + appState.global.selectedPlaylistPatternIndex = null; + } + + return after !== before; +} + +function _addPlaylistClipLocal(patternIndex, posTicks, lenTicks, name) { + const b = _findBasslineByPatternIndex(patternIndex); + if (!b) return false; + if (!Array.isArray(b.playlist_clips)) b.playlist_clips = []; + + const clipId = _genPlClipId(); + b.playlist_clips.push({ + id: clipId, + pos: Math.max(0, Math.floor(posTicks ?? 0)), + len: Math.max(PL_MIN_LEN_TICKS, Math.floor(lenTicks ?? 192)), + name: name || b.name || `Beat/Bassline ${patternIndex}`, + }); + b.playlist_clips.sort((a, d) => (a.pos ?? 0) - (d.pos ?? 0)); + return clipId; +} + +// Tecla Delete para apagar pattern selecionada na playlist (sem mexer no menu de áudio) +let _playlistKeybindInstalled = false; +function _installPlaylistKeybindOnce() { + if (_playlistKeybindInstalled) return; + _playlistKeybindInstalled = true; + + window.addEventListener("keydown", (e) => { + // não atrapalha quando digitando em inputs + const tag = (document.activeElement?.tagName || "").toLowerCase(); + if (tag === "input" || tag === "textarea") return; + + if (e.key !== "Delete" && e.key !== "Backspace") return; + + _ensureGlobalPlaylistSelectionFields(); + const clipId = appState.global.selectedPlaylistClipId; + const patternIndex = appState.global.selectedPlaylistPatternIndex; + + if (!clipId || patternIndex == null) return; + + e.preventDefault(); + const ok = confirm("Excluir esta pattern da playlist?"); + if (!ok) return; + + if (_removePlaylistClipLocal(patternIndex, clipId)) { + sendActionSafe({ type: "REMOVE_PLAYLIST_PATTERN_CLIP", patternIndex, clipId }); + renderAudioEditor(); + restartAudioEditorIfPlaying(); + } + }); +} + +// Função utilitária: Pattern Editor pode chamar isso para inserir na playlist +export function addActivePatternToPlaylistAt(timeSec = null) { + _ensureGlobalPlaylistSelectionFields(); + + const patternIndex = Number(appState.pattern?.activePatternIndex ?? 0); + const secondsPerStep = getSecondsPerStep(); + + // prioridade: tempo passado > último clique na régua > seek atual + const t = + (timeSec != null ? Number(timeSec) : null) ?? + (appState.global.lastRulerClickTime != null ? Number(appState.global.lastRulerClickTime) : null) ?? + Number(appState.audio?.audioEditorSeekTime ?? 0); + + const steps = Math.max(0, Math.round(t / secondsPerStep)); + let posTicks = steps * PL_TICKS_PER_STEP; + posTicks = _snapTicks(posTicks, PL_SNAP_TICKS); + + // len default: 1 compasso em ticks (192) — pode refinar depois + const lenTicks = 192; + + const newId = _addPlaylistClipLocal(patternIndex, posTicks, lenTicks, `Beat/Bassline ${patternIndex}`); + if (newId) { + sendActionSafe({ + type: "ADD_PLAYLIST_PATTERN_CLIP", + patternIndex, + pos: posTicks, + len: lenTicks, + clipId: newId, + name: `Beat/Bassline ${patternIndex}`, + }); + renderAudioEditor(); + restartAudioEditorIfPlaying(); + } +} + +// opcional: expõe no window para botão no editor chamar sem import +window.addActivePatternToPlaylistAt = addActivePatternToPlaylistAt; + export function renderAudioEditor() { const audioEditor = document.querySelector(".audio-editor"); const existingTrackContainer = document.getElementById("audio-track-container"); if (!audioEditor || !existingTrackContainer) return; + _ensureGlobalPlaylistSelectionFields(); + _installPlaylistKeybindOnce(); + // --- Identifica o pai real do container --- const tracksParent = existingTrackContainer.parentElement; @@ -394,83 +558,99 @@ export function renderAudioEditor() { // --- RENDERIZAÇÃO DOS CLIPES DE BASSLINE (Blocos Azuis) --- if (trackData.type === "bassline" && trackData.playlist_clips) { - trackData.playlist_clips.forEach(clip => { - const clipDiv = document.createElement("div"); - clipDiv.className = "timeline-clip bassline-clip"; - - // CONVERSÃO MMP (192 ticks/compasso?) vs StepWidth - // Se stepWidthPx representa 1/16, e cada step tem 12 ticks - const steps = clip.pos / 12; - const lengthInSteps = clip.len / 12; - - const leftPos = steps * stepWidthPx; - const widthDim = lengthInSteps * stepWidthPx; + trackData.playlist_clips.forEach((clip) => { + // garante id (sem quebrar projetos antigos) + if (!clip.id) clip.id = _genPlClipId(); - clipDiv.style.position = "absolute"; - clipDiv.style.left = `${leftPos}px`; - clipDiv.style.width = `${widthDim}px`; - clipDiv.style.height = "100%"; - // ✅ overlay de “marquinhas pretas” (loop do pattern) - const loopSteps = getLoopStepsForBasslineLane(trackData); - const loopPx = loopSteps * stepWidthPx; + const clipDiv = document.createElement("div"); + clipDiv.className = "timeline-clip bassline-clip"; + clipDiv.dataset.patternIndex = String(trackData.patternIndex ?? 0); + clipDiv.dataset.plClipId = String(clip.id); - if (loopPx > 0) { - clipDiv.style.position = "absolute"; // garante - const markers = document.createElement("div"); - markers.style.position = "absolute"; - markers.style.inset = "0"; - markers.style.pointerEvents = "none"; - markers.style.opacity = "0.9"; - markers.style.backgroundImage = `repeating-linear-gradient( - to right, - rgba(0,0,0,0.75) 0px, - rgba(0,0,0,0.75) 2px, - transparent 2px, - transparent ${loopPx}px - )`; - // deixa o texto por cima - markers.style.zIndex = "6"; - clipDiv.appendChild(markers); - } + if (String(clip.id) === String(appState.global.selectedPlaylistClipId)) { + clipDiv.classList.add("selected"); + } - const gridStyle = getComputedStyle(grid); - clipDiv.style.backgroundImage = gridStyle.backgroundImage; - clipDiv.style.backgroundSize = gridStyle.backgroundSize; - clipDiv.style.backgroundRepeat = "repeat"; + // ticks -> steps -> px + const steps = (clip.pos || 0) / PL_TICKS_PER_STEP; + const lengthInSteps = (clip.len || 0) / PL_TICKS_PER_STEP; - // Alinha o padrão do grid com a timeline (não reinicia no começo do bloco) - clipDiv.style.backgroundPosition = `-${leftPos}px 0px`; - clipDiv.style.backgroundColor = "rgba(0, 170, 170, 0.6)"; - clipDiv.style.border = "1px solid #00aaaa"; - clipDiv.style.boxSizing = "border-box"; - clipDiv.style.cursor = "pointer"; - clipDiv.style.zIndex = "5"; - clipDiv.title = `${clip.name} (Pos: ${clip.pos})`; + const leftPos = steps * stepWidthPx; + const widthDim = Math.max(1, lengthInSteps * stepWidthPx); - const label = document.createElement("span"); - label.innerText = clip.name; - label.style.fontSize = "0.7rem"; - label.style.color = "#fff"; - label.style.padding = "4px"; - label.style.pointerEvents = "none"; - label.style.whiteSpace = "nowrap"; - label.style.overflow = "hidden"; - label.style.position = "relative"; - label.style.zIndex = "7"; - clipDiv.appendChild(label); + clipDiv.style.position = "absolute"; + clipDiv.style.left = `${leftPos}px`; + clipDiv.style.width = `${widthDim}px`; + clipDiv.style.height = "100%"; + clipDiv.style.boxSizing = "border-box"; + clipDiv.style.cursor = "pointer"; + clipDiv.style.zIndex = "5"; + clipDiv.title = `${clip.name} (pos:${clip.pos}, len:${clip.len})`; - clipDiv.addEventListener("dblclick", (e) => { - e.stopPropagation(); - if (window.openPatternEditor) { - window.openPatternEditor(trackData); - } else { - console.error("Função window.openPatternEditor não encontrada."); - } - }); + // grid visual igual ao seu (mantém “look”) + const gridStyle = getComputedStyle(grid); + clipDiv.style.backgroundImage = gridStyle.backgroundImage; + clipDiv.style.backgroundSize = gridStyle.backgroundSize; + clipDiv.style.backgroundRepeat = "repeat"; + clipDiv.style.backgroundPosition = `-${leftPos}px 0px`; + clipDiv.style.backgroundColor = "rgba(0, 170, 170, 0.6)"; + clipDiv.style.border = "1px solid #00aaaa"; - grid.appendChild(clipDiv); + // handles de resize (não usa .clip-resize-handle pra não conflitar com áudio) + const leftHandle = document.createElement("div"); + leftHandle.className = "pattern-resize-handle left"; + const rightHandle = document.createElement("div"); + rightHandle.className = "pattern-resize-handle right"; + clipDiv.appendChild(leftHandle); + clipDiv.appendChild(rightHandle); + + // ✅ overlay de “marquinhas pretas” (loop interno da pattern) + const loopSteps = getLoopStepsForBasslineLane(trackData); + const loopPx = loopSteps * stepWidthPx; + if (loopPx > 0) { + const markers = document.createElement("div"); + markers.style.position = "absolute"; + markers.style.inset = "0"; + markers.style.pointerEvents = "none"; + markers.style.opacity = "0.9"; + markers.style.backgroundImage = `repeating-linear-gradient( + to right, + rgba(0,0,0,0.75) 0px, + rgba(0,0,0,0.75) 2px, + transparent 2px, + transparent ${loopPx}px + )`; + markers.style.zIndex = "6"; + clipDiv.appendChild(markers); + } + + // label + const label = document.createElement("span"); + label.innerText = clip.name; + label.style.fontSize = "0.7rem"; + label.style.color = "#fff"; + label.style.padding = "4px"; + label.style.pointerEvents = "none"; + label.style.whiteSpace = "nowrap"; + label.style.overflow = "hidden"; + label.style.position = "relative"; + label.style.zIndex = "7"; + clipDiv.appendChild(label); + + // duplo clique abre editor (seu comportamento atual) + clipDiv.addEventListener("dblclick", (e) => { + e.stopPropagation(); + if (window.openPatternEditor) { + window.openPatternEditor(trackData); + } else { + console.error("Função window.openPatternEditor não encontrada."); + } }); + + grid.appendChild(clipDiv); + }); } + }); // === RENDERIZAÇÃO DE CLIPES DE ÁUDIO (Samples) === @@ -860,9 +1040,24 @@ export function renderAudioEditor() { pasteItem.style.display = canPaste ? "block" : "none"; if (clipElement) { - if(clipElement.classList.contains("bassline-clip")) { - menu.style.display = "none"; - return; + if (clipElement.classList.contains("bassline-clip")) { + const patternIndex = Number(clipElement.dataset.patternIndex ?? 0); + const plClipId = String(clipElement.dataset.plClipId || ""); + + appState.global.selectedPlaylistClipId = plClipId; + appState.global.selectedPlaylistPatternIndex = patternIndex; + + const ok = confirm("Excluir esta pattern da playlist?"); + if (ok) { + if (_removePlaylistClipLocal(patternIndex, plClipId)) { + sendActionSafe({ type: "REMOVE_PLAYLIST_PATTERN_CLIP", patternIndex, clipId: plClipId }); + renderAudioEditor(); + restartAudioEditorIfPlaying(); + } + } + + menu.style.display = "none"; + return; } const clipId = clipElement.dataset.clipId;