editando patterns na playlist
Deploy / Deploy (push) Successful in 2m9s
Details
Deploy / Deploy (push) Successful in 2m9s
Details
This commit is contained in:
parent
4f92c93ab5
commit
8ea53596be
|
|
@ -245,6 +245,25 @@ body.sidebar-hidden .sample-browser { width: 0; min-width: 0; border-right: none
|
||||||
/* =============================================== */
|
/* =============================================== */
|
||||||
/* BEAT EDITOR / STEP SEQUENCER
|
/* 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 {
|
.beat-editor {
|
||||||
flex: 1; background-color: var(--bg-editor); border: 1px solid var(--border-color);
|
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;
|
border-radius: 8px; box-shadow: 0 5px 15px rgba(0, 0, 0, .3); overflow: hidden;
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,176 @@ import {
|
||||||
} from "../utils.js";
|
} from "../utils.js";
|
||||||
import { sendAction, sendActionSafe } from "../socket.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() {
|
export function renderAudioEditor() {
|
||||||
const audioEditor = document.querySelector(".audio-editor");
|
const audioEditor = document.querySelector(".audio-editor");
|
||||||
const existingTrackContainer = document.getElementById("audio-track-container");
|
const existingTrackContainer = document.getElementById("audio-track-container");
|
||||||
|
|
||||||
if (!audioEditor || !existingTrackContainer) return;
|
if (!audioEditor || !existingTrackContainer) return;
|
||||||
|
|
||||||
|
_ensureGlobalPlaylistSelectionFields();
|
||||||
|
_installPlaylistKeybindOnce();
|
||||||
|
|
||||||
// --- Identifica o pai real do container ---
|
// --- Identifica o pai real do container ---
|
||||||
const tracksParent = existingTrackContainer.parentElement;
|
const tracksParent = existingTrackContainer.parentElement;
|
||||||
|
|
||||||
|
|
@ -394,83 +558,99 @@ export function renderAudioEditor() {
|
||||||
|
|
||||||
// --- RENDERIZAÇÃO DOS CLIPES DE BASSLINE (Blocos Azuis) ---
|
// --- RENDERIZAÇÃO DOS CLIPES DE BASSLINE (Blocos Azuis) ---
|
||||||
if (trackData.type === "bassline" && trackData.playlist_clips) {
|
if (trackData.type === "bassline" && trackData.playlist_clips) {
|
||||||
trackData.playlist_clips.forEach(clip => {
|
trackData.playlist_clips.forEach((clip) => {
|
||||||
const clipDiv = document.createElement("div");
|
// garante id (sem quebrar projetos antigos)
|
||||||
clipDiv.className = "timeline-clip bassline-clip";
|
if (!clip.id) clip.id = _genPlClipId();
|
||||||
|
|
||||||
// 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;
|
|
||||||
|
|
||||||
clipDiv.style.position = "absolute";
|
const clipDiv = document.createElement("div");
|
||||||
clipDiv.style.left = `${leftPos}px`;
|
clipDiv.className = "timeline-clip bassline-clip";
|
||||||
clipDiv.style.width = `${widthDim}px`;
|
clipDiv.dataset.patternIndex = String(trackData.patternIndex ?? 0);
|
||||||
clipDiv.style.height = "100%";
|
clipDiv.dataset.plClipId = String(clip.id);
|
||||||
// ✅ overlay de “marquinhas pretas” (loop do pattern)
|
|
||||||
const loopSteps = getLoopStepsForBasslineLane(trackData);
|
|
||||||
const loopPx = loopSteps * stepWidthPx;
|
|
||||||
|
|
||||||
if (loopPx > 0) {
|
if (String(clip.id) === String(appState.global.selectedPlaylistClipId)) {
|
||||||
clipDiv.style.position = "absolute"; // garante
|
clipDiv.classList.add("selected");
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const gridStyle = getComputedStyle(grid);
|
// ticks -> steps -> px
|
||||||
clipDiv.style.backgroundImage = gridStyle.backgroundImage;
|
const steps = (clip.pos || 0) / PL_TICKS_PER_STEP;
|
||||||
clipDiv.style.backgroundSize = gridStyle.backgroundSize;
|
const lengthInSteps = (clip.len || 0) / PL_TICKS_PER_STEP;
|
||||||
clipDiv.style.backgroundRepeat = "repeat";
|
|
||||||
|
|
||||||
// Alinha o padrão do grid com a timeline (não reinicia no começo do bloco)
|
const leftPos = steps * stepWidthPx;
|
||||||
clipDiv.style.backgroundPosition = `-${leftPos}px 0px`;
|
const widthDim = Math.max(1, lengthInSteps * stepWidthPx);
|
||||||
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 label = document.createElement("span");
|
clipDiv.style.position = "absolute";
|
||||||
label.innerText = clip.name;
|
clipDiv.style.left = `${leftPos}px`;
|
||||||
label.style.fontSize = "0.7rem";
|
clipDiv.style.width = `${widthDim}px`;
|
||||||
label.style.color = "#fff";
|
clipDiv.style.height = "100%";
|
||||||
label.style.padding = "4px";
|
clipDiv.style.boxSizing = "border-box";
|
||||||
label.style.pointerEvents = "none";
|
clipDiv.style.cursor = "pointer";
|
||||||
label.style.whiteSpace = "nowrap";
|
clipDiv.style.zIndex = "5";
|
||||||
label.style.overflow = "hidden";
|
clipDiv.title = `${clip.name} (pos:${clip.pos}, len:${clip.len})`;
|
||||||
label.style.position = "relative";
|
|
||||||
label.style.zIndex = "7";
|
|
||||||
clipDiv.appendChild(label);
|
|
||||||
|
|
||||||
clipDiv.addEventListener("dblclick", (e) => {
|
// grid visual igual ao seu (mantém “look”)
|
||||||
e.stopPropagation();
|
const gridStyle = getComputedStyle(grid);
|
||||||
if (window.openPatternEditor) {
|
clipDiv.style.backgroundImage = gridStyle.backgroundImage;
|
||||||
window.openPatternEditor(trackData);
|
clipDiv.style.backgroundSize = gridStyle.backgroundSize;
|
||||||
} else {
|
clipDiv.style.backgroundRepeat = "repeat";
|
||||||
console.error("Função window.openPatternEditor não encontrada.");
|
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) ===
|
// === RENDERIZAÇÃO DE CLIPES DE ÁUDIO (Samples) ===
|
||||||
|
|
@ -860,9 +1040,24 @@ export function renderAudioEditor() {
|
||||||
pasteItem.style.display = canPaste ? "block" : "none";
|
pasteItem.style.display = canPaste ? "block" : "none";
|
||||||
|
|
||||||
if (clipElement) {
|
if (clipElement) {
|
||||||
if(clipElement.classList.contains("bassline-clip")) {
|
if (clipElement.classList.contains("bassline-clip")) {
|
||||||
menu.style.display = "none";
|
const patternIndex = Number(clipElement.dataset.patternIndex ?? 0);
|
||||||
return;
|
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;
|
const clipId = clipElement.dataset.clipId;
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue