// js/audio/audio_ui.js
import { appState } from "../state.js";
import {
addAudioClipToTimeline,
updateAudioClipProperties,
sliceAudioClip,
removeAudioClip,
} from "./audio_state.js";
import {
seekAudioEditor,
restartAudioEditorIfPlaying,
updateTransportLoop,
} from "./audio_audio.js";
import { drawWaveform } from "../waveform.js";
import { PIXELS_PER_BAR, PIXELS_PER_STEP, ZOOM_LEVELS } from "../config.js";
import {
getPixelsPerSecond,
quantizeTime,
getBeatsPerBar,
getSecondsPerStep,
} from "../utils.js";
import { sendAction } from "../socket.js";
export function renderAudioEditor() {
const audioEditor = document.querySelector(".audio-editor");
const existingTrackContainer = document.getElementById("audio-track-container");
if (!audioEditor || !existingTrackContainer) return;
// --- Identifica o pai real do container ---
const tracksParent = existingTrackContainer.parentElement;
// --- CRIAÇÃO E RENDERIZAÇÃO DA RÉGUA ---
let rulerWrapper = tracksParent.querySelector(".ruler-wrapper");
if (!rulerWrapper) {
rulerWrapper = document.createElement("div");
rulerWrapper.className = "ruler-wrapper";
rulerWrapper.innerHTML = `
`;
tracksParent.insertBefore(rulerWrapper, existingTrackContainer);
}
const staticRuler = tracksParent.querySelector("#audio-timeline-ruler");
if (staticRuler && staticRuler.parentElement === tracksParent) {
staticRuler.remove();
}
const oldLoopRegion = tracksParent.querySelector("#loop-region");
const oldPlayhead = tracksParent.querySelector("#playhead");
if(oldLoopRegion) oldLoopRegion.remove();
if(oldPlayhead) oldPlayhead.remove();
const ruler = rulerWrapper.querySelector(".timeline-ruler");
ruler.innerHTML = "";
const pixelsPerSecond = getPixelsPerSecond();
let maxTime = appState.global.loopEndTime || 0;
// áudio (segundos)
(appState.audio.clips || []).forEach((clip) => {
const endTime =
(clip.startTimeInSeconds || 0) + (clip.durationInSeconds || 0);
if (endTime > maxTime) maxTime = endTime;
});
// basslines (ticks -> steps -> segundos)
const TICKS_PER_STEP = 12; // você já usa 12 na renderização do bassline
const secondsPerStep = getSecondsPerStep();
if (appState.pattern?.tracks) {
appState.pattern.tracks.forEach((t) => {
if (t.type !== "bassline" || !Array.isArray(t.playlist_clips)) return;
t.playlist_clips.forEach((c) => {
const endTicks = (c.pos || 0) + (c.len || 0);
const endSteps = endTicks / TICKS_PER_STEP;
const endTimeSec = endSteps * secondsPerStep;
if (endTimeSec > maxTime) maxTime = endTimeSec;
});
});
}
const containerWidth =
tracksParent.clientWidth || existingTrackContainer.offsetWidth;
const contentWidth = maxTime * pixelsPerSecond;
// não encolhe se já tiver sido expandido antes
const previousWidth = appState.audio.timelineWidthPx || 0;
const totalWidth = Math.max(contentWidth, containerWidth, previousWidth, 2000);
appState.audio.timelineWidthPx = totalWidth;
ruler.style.width = `${totalWidth}px`;
const zoomFactor = ZOOM_LEVELS[appState.global.zoomLevelIndex];
const beatsPerBar = getBeatsPerBar();
const stepWidthPx = PIXELS_PER_STEP * zoomFactor;
const beatWidthPx = stepWidthPx * 4;
const barWidthPx = beatWidthPx * beatsPerBar;
if (barWidthPx > 0) {
const numberOfBars = Math.ceil(totalWidth / barWidthPx);
for (let i = 1; i <= numberOfBars; i++) {
const marker = document.createElement("div");
marker.className = "ruler-marker";
marker.textContent = i;
marker.style.left = `${(i - 1) * barWidthPx}px`;
ruler.appendChild(marker);
}
}
const loopRegion = document.createElement("div");
loopRegion.id = "loop-region";
loopRegion.style.left = `${
appState.global.loopStartTime * pixelsPerSecond
}px`;
loopRegion.style.width = `${
(appState.global.loopEndTime - appState.global.loopStartTime) *
pixelsPerSecond
}px`;
loopRegion.innerHTML = ``;
loopRegion.classList.toggle("visible", appState.global.isLoopActive);
ruler.appendChild(loopRegion);
// --- LISTENER DA RÉGUA ---
const newRuler = ruler.cloneNode(true);
ruler.parentNode.replaceChild(newRuler, ruler);
newRuler.addEventListener("mousedown", (e) => {
document.getElementById("timeline-context-menu").style.display = "none";
document.getElementById("ruler-context-menu").style.display = "none";
const currentPixelsPerSecond = getPixelsPerSecond();
const loopHandle = e.target.closest(".loop-handle");
const loopRegionBody = e.target.closest("#loop-region:not(.loop-handle)");
// Drag Handle Loop
if (loopHandle) {
e.preventDefault();
e.stopPropagation();
const handleType = loopHandle.classList.contains("left") ? "left" : "right";
const initialMouseX = e.clientX;
const initialStart = appState.global.loopStartTime;
const initialEnd = appState.global.loopEndTime;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
const deltaTime = deltaX / currentPixelsPerSecond;
let newStart = appState.global.loopStartTime;
let newEnd = appState.global.loopEndTime;
if (handleType === "left") {
newStart = Math.max(0, initialStart + deltaTime);
newStart = Math.min(newStart, appState.global.loopEndTime - 0.1);
appState.global.loopStartTime = newStart;
} else {
newEnd = Math.max(
appState.global.loopStartTime + 0.1,
initialEnd + deltaTime
);
appState.global.loopEndTime = newEnd;
}
updateTransportLoop();
const loopRegionEl = newRuler.querySelector("#loop-region");
if (loopRegionEl) {
loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`;
loopRegionEl.style.width = `${(newEnd - newStart) * currentPixelsPerSecond}px`;
}
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
sendAction({
type: "SET_LOOP_STATE",
isLoopActive: appState.global.isLoopActive,
loopStartTime: appState.global.loopStartTime,
loopEndTime: appState.global.loopEndTime,
});
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// Drag Body Loop
if (loopRegionBody) {
e.preventDefault();
e.stopPropagation();
const initialMouseX = e.clientX;
const initialStart = appState.global.loopStartTime;
const initialEnd = appState.global.loopEndTime;
const initialDuration = initialEnd - initialStart;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
const deltaTime = deltaX / currentPixelsPerSecond;
let newStart = Math.max(0, initialStart + deltaTime);
let newEnd = newStart + initialDuration;
appState.global.loopStartTime = newStart;
appState.global.loopEndTime = newEnd;
updateTransportLoop();
const loopRegionEl = newRuler.querySelector("#loop-region");
if (loopRegionEl)
loopRegionEl.style.left = `${newStart * currentPixelsPerSecond}px`;
};
const onMouseUp = () => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
sendAction({
type: "SET_LOOP_STATE",
isLoopActive: appState.global.isLoopActive,
loopStartTime: appState.global.loopStartTime,
loopEndTime: appState.global.loopEndTime,
});
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// Seek na Régua
e.preventDefault();
const handleSeek = (event) => {
const rect = newRuler.getBoundingClientRect();
const scrollLeft = scrollEl.scrollLeft;
const clickX = event.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / currentPixelsPerSecond;
sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
};
handleSeek(e);
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
const onMouseUpSeek = () => {
document.removeEventListener("mousemove", onMouseMoveSeek);
document.removeEventListener("mouseup", onMouseUpSeek);
};
document.addEventListener("mousemove", onMouseMoveSeek);
document.addEventListener("mouseup", onMouseUpSeek);
});
// Menu Contexto Régua
newRuler.addEventListener("contextmenu", (e) => {
e.preventDefault();
document.getElementById("timeline-context-menu").style.display = "none";
const menu = document.getElementById("ruler-context-menu");
const currentPixelsPerSecond = getPixelsPerSecond();
const rect = newRuler.getBoundingClientRect();
const scrollLeft = newRuler.scrollLeft;
const clickX = e.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const clickTime = absoluteX / currentPixelsPerSecond;
appState.global.lastRulerClickTime = clickTime;
menu.style.display = "block";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
});
// Recriação Container Pistas
const newTrackContainer = existingTrackContainer.cloneNode(false);
tracksParent.replaceChild(newTrackContainer, existingTrackContainer);
// ✅ único scroller horizontal/vertical do editor
const scrollEl = newTrackContainer;
// === RENDERIZAÇÃO DAS PISTAS (LANES) ===
// CORREÇÃO: Junta as pistas de áudio com as Basslines (que estão no Pattern State)
const tracksToRender = [...(appState.audio.tracks || [])];
if (appState.pattern && appState.pattern.tracks) {
appState.pattern.tracks.forEach(pTrack => {
// Adiciona se for bassline e ainda não existir na lista
if (pTrack.type === 'bassline' && !tracksToRender.find(t => t.id === pTrack.id)) {
tracksToRender.push(pTrack);
}
});
}
tracksToRender.forEach((trackData) => {
const audioTrackLane = document.createElement("div");
audioTrackLane.className = "audio-track-lane";
audioTrackLane.dataset.trackId = trackData.id;
audioTrackLane.style.minWidth = `calc(var(--track-info-width) + ${totalWidth}px)`;
// Ícone dinâmico
let iconHTML = '';
if(trackData.type === 'bassline') iconHTML = '';
audioTrackLane.innerHTML = `
`;
newTrackContainer.appendChild(audioTrackLane);
const timelineContainer = audioTrackLane.querySelector(".timeline-container");
const grid = timelineContainer.querySelector(".spectrogram-view-grid");
// Configura variáveis CSS
grid.style.setProperty("--step-width", `${stepWidthPx}px`);
grid.style.setProperty("--beat-width", `${beatWidthPx}px`);
grid.style.setProperty("--bar-width", `${barWidthPx}px`);
// Drag & Drop
timelineContainer.addEventListener("dragover", (e) => {
e.preventDefault();
audioTrackLane.classList.add("drag-over");
});
timelineContainer.addEventListener("dragleave", () =>
audioTrackLane.classList.remove("drag-over")
);
timelineContainer.addEventListener("drop", (e) => {
e.preventDefault();
audioTrackLane.classList.remove("drag-over");
const filePath = e.dataTransfer.getData("text/plain");
if (!filePath) return;
const rect = timelineContainer.getBoundingClientRect();
const dropX = e.clientX - rect.left + scrollEl.scrollLeft;
let startTimeInSeconds = dropX / pixelsPerSecond;
startTimeInSeconds = quantizeTime(startTimeInSeconds);
if (!trackData.id || startTimeInSeconds == null || isNaN(startTimeInSeconds)) return;
const clipId = crypto?.randomUUID?.() || `clip_${Date.now()}_${Math.floor(Math.random() * 1e6)}`;
addAudioClipToTimeline(filePath, trackData.id, startTimeInSeconds, clipId);
try {
sendAction({
type: "ADD_AUDIO_CLIP",
filePath,
trackId: trackData.id,
startTimeInSeconds,
clipId,
name: String(filePath).split(/[\\/]/).pop(),
});
} catch (err) {
console.warn("[SYNC] Falha ao emitir ADD_AUDIO_CLIP", err);
}
});
// --- 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;
clipDiv.style.position = "absolute";
clipDiv.style.left = `${leftPos}px`;
clipDiv.style.width = `${widthDim}px`;
clipDiv.style.height = "100%";
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})`;
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";
clipDiv.appendChild(label);
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) ===
appState.audio.clips.forEach((clip) => {
// Busca a pista correta (pode ser nova ou antiga)
const parentGrid = newTrackContainer.querySelector(
`.audio-track-lane[data-track-id="${clip.trackId}"] .spectrogram-view-grid`
);
if (!parentGrid) return; // Se o clipe aponta pra uma pista que não existe, ignora
const clipElement = document.createElement("div");
clipElement.className = "timeline-clip";
clipElement.dataset.clipId = clip.id;
if (clip.id === appState.global.selectedClipId)
clipElement.classList.add("selected");
if (appState.global.clipboard?.cutSourceId === clip.id)
clipElement.classList.add("cut");
clipElement.style.left = `${
(clip.startTimeInSeconds || 0) * pixelsPerSecond
}px`;
clipElement.style.width = `${
(clip.durationInSeconds || 0) * pixelsPerSecond
}px`;
let pitchStr =
clip.pitch > 0 ? `+${clip.pitch.toFixed(1)}` : `${clip.pitch.toFixed(1)}`;
if (clip.pitch === 0) pitchStr = "";
clipElement.innerHTML = ` ${clip.name} ${pitchStr} `;
if (
clip.patternData &&
Array.isArray(clip.patternData) &&
clip.patternData.length > 0
) {
clipElement.classList.add("pattern-clip");
const totalSteps = clip.patternData[0]?.length || 0;
if (totalSteps > 0) {
const patternViewEl = createPatternViewElement(
clip.patternData,
totalSteps
);
clipElement.appendChild(patternViewEl);
}
}
parentGrid.appendChild(clipElement);
if (clip.buffer) {
const canvas = clipElement.querySelector(".waveform-canvas-clip");
const canvasWidth = (clip.durationInSeconds || 0) * pixelsPerSecond;
if (canvasWidth > 0) {
canvas.width = canvasWidth;
canvas.height = 40;
const audioBuffer = clip.buffer;
const isStretched = clip.pitch !== 0;
const sourceOffset = isStretched ? 0 : clip.offset || 0;
const sourceDuration = isStretched
? clip.originalDuration
: clip.durationInSeconds;
drawWaveform(
canvas,
audioBuffer,
"var(--accent-green)",
sourceOffset,
sourceDuration
);
}
}
clipElement.addEventListener("wheel", (e) => {
e.preventDefault();
const clipToUpdate = appState.audio.clips.find(
(c) => c.id == clipElement.dataset.clipId
);
if (!clipToUpdate) return;
const direction = e.deltaY < 0 ? 1 : -1;
let newPitch = clipToUpdate.pitch + direction;
newPitch = Math.max(-24, Math.min(24, newPitch));
updateAudioClipProperties(clipToUpdate.id, { pitch: newPitch });
try {
sendAction({
type: "UPDATE_AUDIO_CLIP",
clipId: clipToUpdate.id,
props: { pitch: newPitch },
});
} catch (err) {
console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (wheel)", err);
}
renderAudioEditor();
restartAudioEditorIfPlaying();
});
});
// Sync Scroll
function appendRulerMarkers(rulerEl, oldWidth, newWidth, barWidthPx) {
if (!rulerEl || barWidthPx <= 0) return;
const loopEl = rulerEl.querySelector("#loop-region");
const startBar = Math.floor(oldWidth / barWidthPx) + 1;
const endBar = Math.ceil(newWidth / barWidthPx);
for (let i = startBar; i <= endBar; i++) {
const marker = document.createElement("div");
marker.className = "ruler-marker";
marker.textContent = i;
marker.style.left = `${(i - 1) * barWidthPx}px`;
// mantém loop por cima/ordem estável
rulerEl.insertBefore(marker, loopEl || null);
}
}
function applyTimelineWidth(widthPx) {
appState.audio.timelineWidthPx = widthPx;
const r = tracksParent.querySelector(".timeline-ruler");
if (r) r.style.width = `${widthPx}px`;
tracksParent
.querySelectorAll(".spectrogram-view-grid")
.forEach((g) => (g.style.width = `${widthPx}px`));
}
newTrackContainer.addEventListener("scroll", () => {
scrollEl.addEventListener("scroll", () => {
const scrollPos = scrollEl.scrollLeft;
const mainRuler = tracksParent.querySelector(".timeline-ruler");
if (mainRuler && mainRuler.scrollLeft !== scrollPos) {
mainRuler.scrollLeft = scrollPos; // funciona com overflow hidden (scroll programático)
}
// expansão "infinita"
const threshold = 300;
const rightEdge = scrollPos + newTrackContainer.clientWidth;
const currentWidth = appState.audio.timelineWidthPx || totalWidth;
if (rightEdge > currentWidth - threshold) {
const newWidth = Math.ceil(currentWidth * 1.5);
// aplica widths
applyTimelineWidth(newWidth);
// adiciona mais marcadores na régua (sem re-render geral)
const rulerEl = tracksParent.querySelector(".timeline-ruler");
appendRulerMarkers(rulerEl, currentWidth, newWidth, barWidthPx);
}
});
// Event Listener Principal (mousedown)
newTrackContainer.addEventListener("mousedown", (e) => {
document.getElementById("timeline-context-menu").style.display = "none";
document.getElementById("ruler-context-menu").style.display = "none";
const clipElement = e.target.closest(".timeline-clip");
if (!clipElement && e.button !== 2) {
if (appState.global.selectedClipId) {
appState.global.selectedClipId = null;
newTrackContainer
.querySelectorAll(".timeline-clip.selected")
.forEach((c) => c.classList.remove("selected"));
}
}
const currentPixelsPerSecond = getPixelsPerSecond();
const handle = e.target.closest(".clip-resize-handle");
// Slice Tool
if (appState.global.sliceToolActive && clipElement && !clipElement.classList.contains("bassline-clip")) {
e.preventDefault();
e.stopPropagation();
const clipId = clipElement.dataset.clipId;
const timelineContainer = clipElement.closest(".timeline-container");
const rect = timelineContainer.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const absoluteX = clickX + scrollEl.scrollLeft;
let sliceTimeInTimeline = absoluteX / currentPixelsPerSecond;
sliceTimeInTimeline = quantizeTime(sliceTimeInTimeline);
sliceAudioClip(clipId, sliceTimeInTimeline);
try {
sendAction({
type: "UPDATE_AUDIO_CLIP",
clipId,
props: { __operation: "slice", sliceTimeInTimeline },
});
} catch (err) {
console.warn("[SYNC] Falha ao emitir UPDATE_AUDIO_CLIP (slice)", err);
}
renderAudioEditor();
return;
}
// Resize Handle
if (handle) {
e.preventDefault();
e.stopPropagation();
const clipId = clipElement.dataset.clipId;
const clip = appState.audio.clips.find((c) => c.id == clipId);
if (!clip || !clip.buffer) return;
const handleType = handle.classList.contains("left") ? "left" : "right";
const initialMouseX = e.clientX;
const secondsPerStep = getSecondsPerStep();
const initialLeftPx = clipElement.offsetLeft;
const initialWidthPx = clipElement.offsetWidth;
const initialStartTime = clip.startTimeInSeconds;
const initialDuration = clip.durationInSeconds;
const initialOffset = clip.offset || 0;
const initialOriginalDuration = clip.originalDuration || clip.buffer.duration;
const bufferStartTime = initialStartTime - initialOffset;
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - initialMouseX;
if (appState.global.resizeMode === "trim") {
if (handleType === "right") {
let newWidthPx = initialWidthPx + deltaX;
let newDuration = newWidthPx / currentPixelsPerSecond;
let newEndTime = quantizeTime(initialStartTime + newDuration);
newEndTime = Math.max(initialStartTime + secondsPerStep, newEndTime);
const maxEndTime = bufferStartTime + initialOriginalDuration;
newEndTime = Math.min(newEndTime, maxEndTime);
clipElement.style.width = `${(newEndTime - initialStartTime) * currentPixelsPerSecond}px`;
} else if (handleType === "left") {
let newLeftPx = initialLeftPx + deltaX;
let newStartTime = newLeftPx / currentPixelsPerSecond;
newStartTime = quantizeTime(newStartTime);
const minStartTime = initialStartTime + initialDuration - secondsPerStep;
newStartTime = Math.min(newStartTime, minStartTime);
newStartTime = Math.max(bufferStartTime, newStartTime);
const newLeftFinalPx = newStartTime * currentPixelsPerSecond;
const newWidthFinalPx = (initialStartTime + initialDuration - newStartTime) * currentPixelsPerSecond;
clipElement.style.left = `${newLeftFinalPx}px`;
clipElement.style.width = `${newWidthFinalPx}px`;
}
} else if (appState.global.resizeMode === "stretch") {
if (handleType === "right") {
let newWidthPx = initialWidthPx + deltaX;
let newDuration = newWidthPx / currentPixelsPerSecond;
let newEndTime = quantizeTime(initialStartTime + newDuration);
newEndTime = Math.max(initialStartTime + secondsPerStep, newEndTime);
clipElement.style.width = `${(newEndTime - initialStartTime) * currentPixelsPerSecond}px`;
} else if (handleType === "left") {
let newLeftPx = initialLeftPx + deltaX;
let newStartTime = newLeftPx / currentPixelsPerSecond;
newStartTime = quantizeTime(newStartTime);
const minStartTime = initialStartTime + initialDuration - secondsPerStep;
newStartTime = Math.min(newStartTime, minStartTime);
const newLeftFinalPx = newStartTime * currentPixelsPerSecond;
const newWidthFinalPx = (initialStartTime + initialDuration - newStartTime) * currentPixelsPerSecond;
clipElement.style.left = `${newLeftFinalPx}px`;
clipElement.style.width = `${newWidthFinalPx}px`;
}
}
};
const onMouseUp = (upEvent) => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const finalLeftPx = clipElement.offsetLeft;
const finalWidthPx = clipElement.offsetWidth;
const newStartTime = finalLeftPx / currentPixelsPerSecond;
const newDuration = finalWidthPx / currentPixelsPerSecond;
if (appState.global.resizeMode === "trim") {
const newOffset = newStartTime - bufferStartTime;
if(handleType === "right") {
updateAudioClipProperties(clipId, { durationInSeconds: newDuration, pitch: 0 });
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { durationInSeconds: newDuration, pitch: 0 } });
} else {
updateAudioClipProperties(clipId, { startTimeInSeconds: newStartTime, durationInSeconds: newDuration, offset: newOffset, pitch: 0 });
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { startTimeInSeconds: newStartTime, durationInSeconds: newDuration, offset: newOffset, pitch: 0 } });
}
} else {
const newPlaybackRate = initialOriginalDuration / newDuration;
const newPitch = 12 * Math.log2(newPlaybackRate);
if(handleType === "right") {
updateAudioClipProperties(clipId, { durationInSeconds: newDuration, pitch: newPitch, offset: 0 });
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { durationInSeconds: newDuration, pitch: newPitch, offset: 0 } });
} else {
updateAudioClipProperties(clipId, { startTimeInSeconds: newStartTime, durationInSeconds: newDuration, pitch: newPitch, offset: 0 });
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { startTimeInSeconds: newStartTime, durationInSeconds: newDuration, pitch: newPitch, offset: 0 } });
}
}
restartAudioEditorIfPlaying();
renderAudioEditor();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// Drag Clip (Audio apenas)
if (clipElement && !clipElement.classList.contains("bassline-clip")) {
const clipId = clipElement.dataset.clipId;
if (appState.global.selectedClipId !== clipId) {
appState.global.selectedClipId = clipId;
newTrackContainer.querySelectorAll(".timeline-clip.selected").forEach((c) => c.classList.remove("selected"));
clipElement.classList.add("selected");
}
e.preventDefault();
const clickOffsetInClip = e.clientX - clipElement.getBoundingClientRect().left;
clipElement.classList.add("dragging");
let lastOverLane = clipElement.closest(".audio-track-lane");
const onMouseMove = (moveEvent) => {
const deltaX = moveEvent.clientX - e.clientX;
clipElement.style.transform = `translateX(${deltaX}px)`;
const overElement = document.elementFromPoint(moveEvent.clientX, moveEvent.clientY);
const overLane = overElement ? overElement.closest(".audio-track-lane") : null;
if (overLane && overLane !== lastOverLane) {
if (lastOverLane) lastOverLane.classList.remove("drag-over");
overLane.classList.add("drag-over");
lastOverLane = overLane;
}
};
const onMouseUp = (upEvent) => {
clipElement.classList.remove("dragging");
if (lastOverLane) lastOverLane.classList.remove("drag-over");
clipElement.style.transform = "";
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
const finalLane = lastOverLane;
if (!finalLane) return;
const newTrackId = finalLane.dataset.trackId;
const timelineContainer = finalLane.querySelector(".timeline-container");
const wrapperRect = timelineContainer.getBoundingClientRect();
const newLeftPx = upEvent.clientX - wrapperRect.left - clickOffsetInClip + scrollEl.scrollLeft;
const constrainedLeftPx = Math.max(0, newLeftPx);
let newStartTime = constrainedLeftPx / currentPixelsPerSecond;
newStartTime = quantizeTime(newStartTime);
updateAudioClipProperties(clipId, { trackId: newTrackId, startTimeInSeconds: newStartTime });
sendActionSafe({ type: "UPDATE_AUDIO_CLIP", clipId, props: { trackId: newTrackId, startTimeInSeconds: newStartTime } });
renderAudioEditor();
};
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
return;
}
// Seek na Pista
const timelineContainer = e.target.closest(".timeline-container");
if (timelineContainer) {
e.preventDefault();
const handleSeek = (event) => {
const rect = timelineContainer.getBoundingClientRect();
const scrollLeft = scrollEl.scrollLeft;
const clickX = event.clientX - rect.left;
const absoluteX = clickX + scrollLeft;
const newTime = absoluteX / currentPixelsPerSecond;
sendAction({ type: "SET_SEEK_TIME", seekTime: newTime });
};
handleSeek(e);
const onMouseMoveSeek = (moveEvent) => handleSeek(moveEvent);
const onMouseUpSeek = () => {
document.removeEventListener("mousemove", onMouseMoveSeek);
document.removeEventListener("mouseup", onMouseUpSeek);
};
document.addEventListener("mousemove", onMouseMoveSeek);
document.addEventListener("mouseup", onMouseUpSeek);
}
});
// Menu Contexto Pista
newTrackContainer.addEventListener("contextmenu", (e) => {
e.preventDefault();
document.getElementById("ruler-context-menu").style.display = "none";
const menu = document.getElementById("timeline-context-menu");
if (!menu) return;
const clipElement = e.target.closest(".timeline-clip");
const copyItem = document.getElementById("copy-clip");
const cutItem = document.getElementById("cut-clip");
const pasteItem = document.getElementById("paste-clip");
const deleteItem = document.getElementById("delete-clip");
const canPaste = appState.global.clipboard?.type === "audio";
pasteItem.style.display = canPaste ? "block" : "none";
if (clipElement) {
if(clipElement.classList.contains("bassline-clip")) {
menu.style.display = "none";
return;
}
const clipId = clipElement.dataset.clipId;
if (appState.global.selectedClipId !== clipId) {
appState.global.selectedClipId = clipId;
newTrackContainer.querySelectorAll(".timeline-clip.selected").forEach((c) => c.classList.remove("selected"));
clipElement.classList.add("selected");
}
copyItem.style.display = "block";
cutItem.style.display = "block";
deleteItem.style.display = "block";
menu.style.display = "block";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
if (!deleteItem.__synced) {
deleteItem.__synced = true;
deleteItem.addEventListener("click", () => {
const id = appState.global.selectedClipId;
if (!id) return;
const ok = removeAudioClip(id);
sendActionSafe({ type: "REMOVE_AUDIO_CLIP", clipId: id });
if (ok) renderAudioEditor();
menu.style.display = "none";
});
}
} else {
copyItem.style.display = "none";
cutItem.style.display = "none";
deleteItem.style.display = "none";
if (canPaste) {
menu.style.display = "block";
menu.style.left = `${e.clientX}px`;
menu.style.top = `${e.clientY}px`;
} else {
menu.style.display = "none";
}
}
});
},
function sendActionSafe(action) {
try {
sendAction(action);
} catch (err) {
console.warn("[SYNC] Falha ao emitir ação:", action.type, err);
}
});
}
export function updateAudioEditorUI() {
const playBtn = document.getElementById("audio-editor-play-btn");
if (!playBtn) return;
if (appState.global.isAudioEditorPlaying) {
playBtn.classList.remove("fa-play");
playBtn.classList.add("fa-pause");
} else {
playBtn.classList.remove("fa-pause");
playBtn.classList.add("fa-play");
}
}
export function updatePlayheadVisual(pixels) {
document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => {
ph.style.left = `${pixels}px`;
});
}
export function resetPlayheadVisual() {
document.querySelectorAll(".audio-track-lane .playhead").forEach((ph) => {
ph.style.left = "0px";
});
}
function createPatternViewElement(patternData) {
const view = document.createElement("div");
view.className = "pattern-clip-view";
const validTracksData = patternData.filter(
(steps) => Array.isArray(steps) && steps.length > 0
);
const totalSteps = validTracksData.reduce(
(max, steps) => Math.max(max, steps.length),
0
);
if (totalSteps === 0) return view;
validTracksData.forEach((trackSteps) => {
const row = document.createElement("div");
row.className = "pattern-clip-track-row";
const stepWidthPercent = (1 / totalSteps) * 100;
for (let i = 0; i < totalSteps; i++) {
if (trackSteps[i] === true) {
const note = document.createElement("div");
note.className = "pattern-step-note";
note.style.left = `${(i / totalSteps) * 100}%`;
note.style.width = `${stepWidthPercent}%`;
row.appendChild(note);
}
}
view.appendChild(row);
});
return view;
}