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
|
||||
/* =============================================== */
|
||||
|
||||
.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;
|
||||
|
|
|
|||
|
|
@ -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) ===
|
||||
|
|
@ -861,6 +1041,21 @@ export function renderAudioEditor() {
|
|||
|
||||
if (clipElement) {
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue