editando patterns na playlist
Deploy / Deploy (push) Successful in 2m9s Details

This commit is contained in:
JotaChina 2025-12-27 12:20:51 -03:00
parent 4f92c93ab5
commit 8ea53596be
2 changed files with 285 additions and 71 deletions

View File

@ -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;

View File

@ -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,28 +558,56 @@ export function renderAudioEditor() {
// --- RENDERIZAÇÃO DOS CLIPES DE BASSLINE (Blocos Azuis) ---
if (trackData.type === "bassline" && trackData.playlist_clips) {
trackData.playlist_clips.forEach(clip => {
trackData.playlist_clips.forEach((clip) => {
// garante id (sem quebrar projetos antigos)
if (!clip.id) clip.id = _genPlClipId();
const clipDiv = document.createElement("div");
clipDiv.className = "timeline-clip bassline-clip";
clipDiv.dataset.patternIndex = String(trackData.patternIndex ?? 0);
clipDiv.dataset.plClipId = String(clip.id);
// 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;
if (String(clip.id) === String(appState.global.selectedPlaylistClipId)) {
clipDiv.classList.add("selected");
}
// ticks -> steps -> px
const steps = (clip.pos || 0) / PL_TICKS_PER_STEP;
const lengthInSteps = (clip.len || 0) / PL_TICKS_PER_STEP;
const leftPos = steps * stepWidthPx;
const widthDim = lengthInSteps * stepWidthPx;
const widthDim = Math.max(1, lengthInSteps * stepWidthPx);
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)
clipDiv.style.boxSizing = "border-box";
clipDiv.style.cursor = "pointer";
clipDiv.style.zIndex = "5";
clipDiv.title = `${clip.name} (pos:${clip.pos}, len:${clip.len})`;
// 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";
// 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) {
clipDiv.style.position = "absolute"; // garante
const markers = document.createElement("div");
markers.style.position = "absolute";
markers.style.inset = "0";
@ -428,25 +620,11 @@ export function renderAudioEditor() {
transparent 2px,
transparent ${loopPx}px
)`;
// deixa o texto por cima
markers.style.zIndex = "6";
clipDiv.appendChild(markers);
}
const gridStyle = getComputedStyle(grid);
clipDiv.style.backgroundImage = gridStyle.backgroundImage;
clipDiv.style.backgroundSize = gridStyle.backgroundSize;
clipDiv.style.backgroundRepeat = "repeat";
// 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})`;
// label
const label = document.createElement("span");
label.innerText = clip.name;
label.style.fontSize = "0.7rem";
@ -459,6 +637,7 @@ export function renderAudioEditor() {
label.style.zIndex = "7";
clipDiv.appendChild(label);
// duplo clique abre editor (seu comportamento atual)
clipDiv.addEventListener("dblclick", (e) => {
e.stopPropagation();
if (window.openPatternEditor) {
@ -471,6 +650,7 @@ export function renderAudioEditor() {
grid.appendChild(clipDiv);
});
}
});
// === RENDERIZAÇÃO DE CLIPES DE ÁUDIO (Samples) ===
@ -860,7 +1040,22 @@ export function renderAudioEditor() {
pasteItem.style.display = canPaste ? "block" : "none";
if (clipElement) {
if(clipElement.classList.contains("bassline-clip")) {
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;
}