import { app } from "../../../scripts/app.js"; const MIN_GRAPH_HEIGHT = 500; const AUDIO_HEIGHT = 44; const LOOP_BAR_H = 18; const HANDLE_W = 6; const BRIDGE_FADE_SEC = 0.005; // 5ms crossfade — short enough to be imperceptible function buildAudioUrl(file) { const params = new URLSearchParams({ filename: file.filename, type: file.type, subfolder: file.subfolder || "", }); return `/view?${params.toString()}`; } app.registerExtension({ name: "RyanOnTheInside.InstantFloatPreview", async beforeRegisterNodeDef(nodeType, nodeData) { if (nodeData.name !== "InstantFloatPreview") return; // --- Serialization: persist loop state across page refresh --- const onSerialize = nodeType.prototype.onSerialize; nodeType.prototype.onSerialize = function (data) { onSerialize?.apply(this, arguments); const loop = this._ifpLoop || this._ifpPendingLoop; if (loop) { data.ifpLoop = { ...loop }; } }; const onConfigure = nodeType.prototype.onConfigure; nodeType.prototype.onConfigure = function (data) { onConfigure?.apply(this, arguments); if (data.ifpLoop) { const saved = data.ifpLoop; if (this._ifpLoop) { this._ifpLoop = saved; } else { // Widget not created yet; stash for _ifpCreateWidget this._ifpPendingLoop = saved; } } }; const onExecuted = nodeType.prototype.onExecuted; nodeType.prototype.onExecuted = function (message) { onExecuted?.apply(this, arguments); // Suppress any default image preview/thumbnail if (this.imgs) this.imgs = null; if (this.imageIndex != null) this.imageIndex = null; const series = message?.series; const title = message?.title?.[0] || "Float Preview"; const rewindSeconds = message?.rewind_seconds?.[0] ?? 2.0; const audioFile = message?.ifp_audio?.[0]; const audioDuration = message?.ifp_audio_duration?.[0]; if (!series || series.length === 0) return; if (!this._ifpContainer) { this._ifpCreateWidget(); } const bpm = message?.bpm?.[0] || 0; this._ifpData = { series, title, bpm }; // Handle audio if (audioFile) { this._ifpHandleAudio(audioFile, audioDuration, rewindSeconds); } else if (this._ifpAudioShown) { this._ifpAudioWrapper.style.display = "none"; this._ifpAudioShown = false; this._ifpAudioDuration = 0; } // Draw after a frame so canvas is laid out requestAnimationFrame(() => { this._ifpDraw(); if (!this._ifpSized) { this._ifpSized = true; const sz = this.computeSize(); sz[0] = Math.max(sz[0], 1680); this.setSize(sz); this.graph?.setDirtyCanvas(true, true); } }); }; nodeType.prototype._ifpCreateWidget = function () { const container = document.createElement("div"); container.style.cssText = "display:flex;flex-direction:column;gap:0;min-height:" + MIN_GRAPH_HEIGHT + "px;"; const canvas = document.createElement("canvas"); canvas.style.cssText = "width:100%;flex:1;display:block;cursor:default;min-height:" + MIN_GRAPH_HEIGHT + "px;"; container.appendChild(canvas); const audioWrapper = document.createElement("div"); audioWrapper.style.cssText = "width:100%;display:none;flex-shrink:0;"; container.appendChild(audioWrapper); // Single audio element for native controls and normal playback. // Gapless swaps are handled via an AudioBufferSourceNode bridge. const audioEl = document.createElement("audio"); audioEl.controls = true; audioEl.preload = "auto"; audioEl.style.cssText = "width:100%;display:block;"; audioWrapper.appendChild(audioEl); this._ifpContainer = container; this._ifpCanvas = canvas; this._ifpAudioWrapper = audioWrapper; this._ifpAudioEl = audioEl; this._ifpData = null; this._ifpRafId = null; this._ifpAudioShown = false; this._ifpAudioDuration = 0; this._ifpSized = false; this._ifpHasAudio = false; // Loop state this._ifpLoop = { enabled: false, start: 0, end: 1 }; // Restore saved loop state from onConfigure if it ran before widget creation if (this._ifpPendingLoop) { this._ifpLoop = this._ifpPendingLoop; this._ifpPendingLoop = null; } this._ifpDrag = null; // Swap / bridge state this._ifpSwapping = false; this._ifpSwapAbort = null; this._ifpBridgeSource = null; this._ifpBridgeStartCtxTime = 0; this._ifpBridgeOffset = 0; // Web Audio API state (lazy-init on first audio load) this._ifpAudioCtx = null; this._ifpElementGain = null; this._ifpBridgeGain = null; const self = this; const widget = this.addDOMWidget("instant_float_preview", "custom", container, { serialize: false, hideOnZoom: false, }); widget.computeSize = function () { const audioH = self._ifpAudioShown ? AUDIO_HEIGHT : 0; return [self.size[0], MIN_GRAPH_HEIGHT + audioH + 4]; }; // Redraw when the canvas element is resized (handles node resize + recovery from drag) this._ifpResizeObserver = new ResizeObserver(() => { this._ifpDraw(); }); this._ifpResizeObserver.observe(canvas); // Canvas mouse events for loop bar canvas.addEventListener("mousedown", (e) => this._ifpOnMouseDown(e)); canvas.addEventListener("mousemove", (e) => this._ifpOnMouseMove(e)); canvas.addEventListener("mouseup", (e) => this._ifpOnMouseUp(e)); canvas.addEventListener("mouseleave", (e) => this._ifpOnMouseUp(e)); canvas.addEventListener("dblclick", (e) => this._ifpOnDblClick(e)); // Audio event listeners on the single element audioEl.addEventListener("timeupdate", () => { if (this._ifpSwapping) return; this._ifpCheckLoop(); if (!this._ifpRafId) this._ifpDraw(); }); audioEl.addEventListener("play", () => { // Resume AudioContext if suspended (browser autoplay policy) if (this._ifpAudioCtx?.state === "suspended") { this._ifpAudioCtx.resume(); } if (!this._ifpSwapping) this._ifpStartRaf(); }); audioEl.addEventListener("pause", () => { if (this._ifpSwapping) return; this._ifpStopRaf(); this._ifpDraw(); }); audioEl.addEventListener("ended", () => { if (this._ifpSwapping) return; this._ifpStopRaf(); this._ifpDraw(); }); }; // --- Web Audio API setup (called once, lazily) --- // Creates the audio graph: //