UNPKG

@checksub_team/peaks_timeline

Version:

JavaScript UI component for displaying audio waveforms

1,798 lines (1,481 loc) 64.3 kB
/** * @file * * Defines the {@link SourceGroup} class. * * @module source-group */ define([ './waveform-builder', './waveform-shape', './loader', './invoker', '../utils', 'konva' ], function( WaveformBuilder, WaveformShape, Loader, Invoker, Utils, Konva) { 'use strict'; var SPACING_BETWEEN_PREVIEW_AND_BORDER_RATIO = 0.15; var SPACING_BETWEEN_PREVIEWS = 1.5; var CORNER_RADIUS = 8; var INDICATOR_RADIUS = 4; // px var PREVIEW_CREATE_CHUNK = 12; // number of preview tiles to add per idle slice (increased for better throughput) // Shared invoker for all source groups to coordinate updates var sharedInvoker = new Invoker(); /** * Creates a source group for the given source. * * @class * @alias SourceGroup * * @param {Source} source * @param {Peaks} peaks * @param {SourcesLayer} layer * @param {WaveformOverview|WaveformZoomView} view */ function SourceGroup(source, peaks, layer, view) { this._source = source; this._peaks = peaks; this._layer = layer; this._view = view; this._indicators = {}; var self = this; this._width = this._view.timeToPixels(source.endTime - source.startTime); var heights = SourceGroup.getHeights(source, peaks); this._unwrappedHeight = heights.unwrapped; this._wrappedHeight = heights.wrapped; this._height = heights.current; this._borderWidth = this._source.borderWidth || 0; this._currentTimeToPixelsScaleUsed = this._view.getTimeToPixelsScale(); this._selected = this._source.selected; this._hovered = false; this._isDragged = false; this._isHandleDragged = false; this._destroyed = false; // Optional Konva element used as a drag "ghost" preview. this._dragGhost = null; // Performance: track if draw is needed this._drawScheduled = false; this._previewList = []; // internal queue state for async preview creation this._previewBuildQueue = new Set(); // Track pending idle callbacks for cleanup this._pendingIdleCallbacks = new Set(); this._markersGroup = this._createMarkers(); this._group = new Konva.Group({ x: this._view.timeToPixels(source.startTime), sourceId: this._source.id, draggable: this._source.draggable, dragBoundFunc: function() { return self._layer.onSourcesGroupDrag(this); }, clipFunc: function(ctx) { self.drawSourceShape(ctx, null); } }); this._group.on('dragstart', this._onDragStart.bind(this)); this._group.on('dragend', this._onDragEnd.bind(this)); this._cursor = null; this._group.on('mouseenter', this._onHoverStart.bind(this)); this._group.on('mouseleave', this._onHoverEnd.bind(this)); this._group.on('mouseover', function() { if (self._source.draggable) { self._view.setCursor(self._cursor || 'pointer'); } if (self._view.getCurrentMode() === 'cut') { self.toggleDragging(false); self.toggleResizing(false); } }); this._group.on('mouseout', function() { self._view.setCursor('default'); if (self._view.getCurrentMode() === 'cut') { self.toggleDragging(true); self.toggleResizing(true); } }); this._group.add(new Konva.Group()); if (this._borderWidth) { this._border = new Konva.Shape({ fill: this._source.borderColor, sceneFunc: function(ctx, shape) { self.drawSourceShape(ctx, shape); } }); this._group.getChildren()[0].add(this._border); } this._addHandles(); this.setWrapping(source.wrapped); this.setSelected(); this._indicatorsGroup = new Konva.Group(); this.addToContent(this._indicatorsGroup); this.createIndicators(); this.setLoadingState(this._source.loading); } SourceGroup.prototype._onHoverStart = function() { this._hovered = true; this._view.setHoveredElement(this); if (!this._source.loading) { this._showButtons(); } this._scheduleBatchDraw(); }; SourceGroup.prototype._onHoverEnd = function() { this._hovered = false; this._manualHover = false; this._disableManualHoverTracking(); this._view.setHoveredElement(null); this._hideButtons(); this._scheduleBatchDraw(); }; SourceGroup.prototype._enableManualHoverTracking = function() { if (this._manualHoverTrackingEnabled) { return; } if (!this._group || this._destroyed) { return; } var stage = this._group.getStage && this._group.getStage(); if (!stage) { return; } this._manualHoverTrackingEnabled = true; this._manualHoverNamespace = '.manualHover.' + this._source.id; this._manualHoverMoveHandler = function() { this._manageManualHoverStop(); }.bind(this); stage.on('mousemove' + this._manualHoverNamespace, this._manualHoverMoveHandler); stage.on('touchmove' + this._manualHoverNamespace, this._manualHoverMoveHandler); }; SourceGroup.prototype._disableManualHoverTracking = function() { if (!this._manualHoverTrackingEnabled) { return; } var stage = this._group && this._group.getStage && this._group.getStage(); if (stage && this._manualHoverNamespace) { stage.off(this._manualHoverNamespace); } this._manualHoverTrackingEnabled = false; this._manualHoverMoveHandler = null; this._manualHoverNamespace = null; }; SourceGroup.prototype._onDragStart = function(element) { this._isDragged = true; this._layer.onSourcesGroupDragStart(element); }; SourceGroup.prototype._onDragEnd = function(element) { this._isDragged = false; this._manageManualHoverStop(); this._layer.onSourcesGroupDragEnd(element); }; SourceGroup.prototype._manageManualHoverStop = function() { if (!this._manualHover) { return; } var pointer = this._view.getPointerPosition(); if (pointer) { var absPos = this._group.absolutePosition(); var inside = ( pointer.x >= absPos.x && pointer.x <= absPos.x + this._width && pointer.y >= absPos.y && pointer.y <= absPos.y + this._height ); if (!inside) { this.stopHover(); } } }; SourceGroup.prototype.isHovered = function() { return this._hovered; }; SourceGroup.prototype.isActive = function() { return this._isDragged || this._isHandleDragged; }; SourceGroup.prototype.isDragged = function() { return this._isDragged; }; SourceGroup.prototype.addToContent = function(newChild) { if (this._source.wrapped) { this._wrap.add(newChild); } else { this._unwrap.add(newChild); } }; SourceGroup.prototype._updateHandles = function() { var handleWidth = Math.min(this._peaks.options.sourceHandleWidth, this._width / 2); this._leftHandle.width(handleWidth); this._rightHandle.width(handleWidth); this._rightHandle.x(this._width - handleWidth); }; SourceGroup.prototype._onSourceGroupHandleDrag = function(draggedElement, leftHandle) { return this._layer.onSourceHandleDrag(draggedElement, leftHandle); }; SourceGroup.prototype.update = function() { const startPixel = this._view.timeToPixels(this._source.startTime); const endPixel = this._view.timeToPixels(this._source.endTime); const frameOffset = this._view.timeToPixels(this._view.getTimeOffset()); const newTimeToPixelsScale = this._view.getTimeToPixelsScale(); // When sources are being moved (dragged as blocks), their Konva positions are // controlled by the drag handler so they follow the cursor. // Auto-scroll triggers frequent `updateSources()` calls; avoid overwriting // the drag position here to prevent jitter. if (!this._isDragged) { this._group.x(startPixel - frameOffset); } const newWidth = endPixel - startPixel; if (newWidth !== this._width) { this._width = newWidth; // the zoom was changed if (newTimeToPixelsScale !== this._currentTimeToPixelsScaleUsed) { this._currentTimeToPixelsScaleUsed = newTimeToPixelsScale; this._updateMarkers(); } else { // the zoom was not changed, but the source was resized const newTitle = Utils.removeLineBreaks(this._source.getVisibleTitle()); if (this._wrappedTitle && this._wrappedTitle.text() !== newTitle) { this._wrap.add(this._createTitle(true)); } if (this._unwrappedTitle && this._unwrappedTitle.text() !== newTitle) { this._unwrap.add(this._createTitle(false)); } } this._updateHandles(); this._updateVolumeSlider(); this._updateButtons(); this._updateLoadingOverlay(); // update unwrap this.updatePreviews(); } // Keep the drag ghost in sync automatically while dragging. // This lets SourcesLayer avoid manually updating ghosts. if (this._isDragged) { this.createDragGhost(); this.updateDragGhost(); } }; SourceGroup.prototype.setWrapping = function(wrap, forceCreate, notify) { if (wrap) { this._removeUnwrap(); this._height = this._wrappedHeight; this._addWrap(forceCreate); } else { this._removeWrap(); this._height = this._unwrappedHeight; this._addUnwrap(forceCreate); } this.setHandlesWrapping(wrap); if (notify) { this._peaks.emit('source.wrappingChanged', this); } }; SourceGroup.prototype.setHandlesWrapping = function(wrap) { if (wrap) { this._leftHandle.height(this._wrappedHeight); this._rightHandle.height(this._wrappedHeight); } else { this._leftHandle.height(this._unwrappedHeight); this._rightHandle.height(this._unwrappedHeight); } }; SourceGroup.prototype._onHandleDragStart = function(leftHandle) { this._isHandleDragged = true; this._hideButtons(); this._layer.onSourceHandleDragStart(this, leftHandle); }; SourceGroup.prototype._onHandleDragEnd = function() { this._isHandleDragged = false; this._showButtons(); this._layer.onSourceHandleDragEnd(this); }; SourceGroup.prototype._addHandles = function(forceCreate) { var self = this; var handleWidth = Math.min(this._peaks.options.sourceHandleWidth, this._width / 2); if (!this._leftHandle || forceCreate) { this._leftHandle = new Konva.Rect({ x: 0, width: handleWidth, height: this._unwrappedHeight, visible: true, draggable: this._source.resizable, dragBoundFunc: function() { return self._onSourceGroupHandleDrag(this, true); } }); this._leftHandle.on('dragstart', function(event) { event.cancelBubble = true; self._onHandleDragStart(true); }); this._leftHandle.on('dragend', function(event) { event.cancelBubble = true; self._onHandleDragEnd(event); }); if (this._source.resizable) { this._leftHandle.on('mouseover', function() { self._cursor = 'ew-resize'; }); this._leftHandle.on('mouseout', function() { self._cursor = null; }); } } if (!this._rightHandle || forceCreate) { this._rightHandle = new Konva.Rect({ x: this._width - handleWidth, width: handleWidth, height: this._unwrappedHeight, visible: true, draggable: this._source.resizable, dragBoundFunc: function() { return self._onSourceGroupHandleDrag(this, false); } }); this._rightHandle.on('dragstart', function(event) { event.cancelBubble = true; self._onHandleDragStart(false); }); this._rightHandle.on('dragend', function(event) { event.cancelBubble = true; self._onHandleDragEnd(event); }); if (this._source.resizable) { this._rightHandle.on('mouseover', function() { self._cursor = 'ew-resize'; }); this._rightHandle.on('mouseout', function() { self._cursor = null; }); } } this._group.add(this._leftHandle); this._group.add(this._rightHandle); }; SourceGroup.prototype.toggleDragging = function(bool) { var background; if (this._wrap) { background = this._wrap.getChildren(function(node) { return node.getClassName() === 'Shape'; })[0]; if (background) { background.draggable(bool); } } if (this._unwrap) { background = this._unwrap.getChildren(function(node) { return node.getClassName() === 'Shape'; })[0]; if (background) { background.draggable(bool); } } }; SourceGroup.prototype.toggleResizing = function(bool) { if (this._leftHandle) { this._leftHandle.draggable(bool); } if (this._rightHandle) { this._rightHandle.draggable(bool); } }; SourceGroup.prototype.drawSourceShape = function(ctx, shape, addBorderWidth, fill) { var offset = addBorderWidth ? this._borderWidth : 0; var radius = !Utils.isNullOrUndefined(this._source.borderRadius) ? this._source.borderRadius : Math.max( 1, Math.min( this._width / 2, Math.min( CORNER_RADIUS, this._height / 2 ) ) ); var x = Math.max( 0, -(this._group.x() + 2 * radius) ); var width = Math.min( this._width - x, this._view.getWidth() + 4 * radius - Math.max( 0, this._group.x() ) ); var xWidth = x + width; if (width > 0) { ctx.beginPath(); ctx.moveTo(x + radius, offset); ctx.lineTo(xWidth - radius, offset); ctx.quadraticCurveTo(xWidth - offset, offset, xWidth - offset, radius); ctx.lineTo(xWidth - offset, this._height - radius); ctx.quadraticCurveTo( xWidth - offset, this._height - offset, xWidth - radius, this._height - offset ); ctx.lineTo(x + radius, this._height - offset); ctx.quadraticCurveTo(x + offset, this._height - offset, x + offset, this._height - radius); ctx.lineTo(x + offset, radius); ctx.quadraticCurveTo(x + offset, offset, x + radius, offset); ctx.closePath(); if (fill) { var backgroundColor; if (this._selected) { backgroundColor = this._source.selectedBackgroundColor; } else if (this._hovered && this._view.getCurrentMode() !== 'cut') { backgroundColor = this._source.hoverBackgroundColor; } else { backgroundColor = this._source.backgroundColor; } if (this._source.shouldShowWarning()) { var gradient = ctx.createLinearGradient(0, 0, this._width, 0); if (this._source.mediaEndTime < this._source.duration) { var rightStopPosition = Math.max(1 - (this._source.warningWidth / this._width), 0.5); gradient.addColorStop(rightStopPosition, backgroundColor); gradient.addColorStop(1, this._source.warningColor); } if (this._source.mediaStartTime > 0) { var leftStopPosition = Math.min(this._source.warningWidth / this._width, 0.5); gradient.addColorStop(0, this._source.warningColor); gradient.addColorStop(leftStopPosition, backgroundColor); } ctx.fillStyle = gradient; ctx.fill(); } else { ctx.fillStyle = backgroundColor; ctx.fill(); } } if (shape) { ctx.fillStrokeShape(shape); } } }; SourceGroup.prototype._addUnwrap = function(forceCreate) { if (!this._unwrap || forceCreate) { this._unwrap = this._createUnwrap(); } this._group.getChildren()[0].add(this._unwrap); }; SourceGroup.prototype._createUnwrap = function() { var self = this; var unwrap = new Konva.Group({ width: this._width, height: this._unwrappedHeight, clipFunc: function(ctx) { self.drawSourceShape(ctx, null, true); } }); var background = new Konva.Group(); background.add(new Konva.Shape({ sceneFunc: function(ctx, shape) { self.drawSourceShape(ctx, shape, true, true); } })); unwrap.add(background); unwrap.add(this._markersGroup); if (this._source.volumeRange && this._source.volume !== undefined) { unwrap.add(this._getVolumeSlider()); } if (this._source.buttons.length > 0) { unwrap.add(this._getButtons()); } unwrap.add(this._createTitle(false)); return unwrap; }; SourceGroup.prototype._addToUnwrap = function(element, inForeground) { if (inForeground) { this._unwrap.add(element); } else { this._unwrap.getChildren()[0].add(element); } }; SourceGroup.prototype._removeUnwrap = function() { if (this._unwrap) { this._unwrap.remove(); } }; SourceGroup.prototype._addWrap = function(forceCreate) { if (!this._wrap || forceCreate) { this._wrap = this._createWrap(); } this._group.getChildren()[0].add(this._wrap); }; SourceGroup.prototype._createWrap = function() { var self = this; var wrap = new Konva.Group({ width: this._width, height: this._wrappedHeight, clipFunc: function(ctx) { self.drawSourceShape(ctx, null, true); } }); var background = new Konva.Group(); background.add(new Konva.Shape({ sceneFunc: function(ctx, shape) { self.drawSourceShape(ctx, shape, true, true); } })); wrap.add(background); wrap.add(this._markersGroup); if (this._source.volumeRange && this._source.volume !== undefined) { wrap.add(this._getVolumeSlider()); } if (this._source.buttons.length > 0) { wrap.add(this._getButtons()); } wrap.add(this._createTitle(true)); return wrap; }; SourceGroup.prototype._addToWrap = function(element, inForeground) { if (inForeground) { this._wrap.add(element); } else { this._wrap.getChildren()[0].add(element); } }; SourceGroup.prototype._removeWrap = function() { if (this._wrap) { this._wrap.remove(); } }; SourceGroup.prototype._createTitle = function(isWrap) { var self = this; var defaultWidth; var y = (this._source.textPosition === 'bottom') ? Math.max( (isWrap ? this._wrappedHeight : this._unwrappedHeight) - this._source.textFontSize - this._peaks.options.sourceTextYOffset, this._peaks.options.sourceTextYOffset ) : this._peaks.options.sourceTextYOffset; var defaultXOffset = this._peaks.options.sourceTextXOffset; var maxXOffset = this._width - 2 * defaultXOffset; if (isWrap) { if (this._wrappedTitle) { this._wrappedTitle.destroy(); this._wrappedTitle = null; } } else { if (this._unwrappedTitle) { this._unwrappedTitle.destroy(); this._unwrappedTitle = null; } } var title = new Konva.Text({ x: defaultXOffset, y: y, text: Utils.removeLineBreaks(this._source.getVisibleTitle()), textAlign: 'left', verticalAlign: 'middle', fontSize: this._source.textFontSize, fontFamily: this._source.textFont, fill: this._source.textColor, wrap: 'none', ellipsis: true, listening: false, sceneFunc: function(context, shape) { var absX = this.absolutePosition().x; if (self._source.textAutoScroll) { this.offsetX(Math.max(Math.min(0, absX - defaultXOffset), -(maxXOffset - shape.width()))); } defaultWidth = defaultWidth ? defaultWidth : shape.width(); shape.width(Math.min(self._width - 10, defaultWidth)); if (self._source.textBackgroundColor) { context.fillStyle = self._source.textBackgroundColor; context.fillRect(-5, -5, shape.width() + 10, shape.height() ? shape.height() + 10 : 0); } shape._sceneFunc(context); } }); if (isWrap) { this._wrappedTitle = title; } else { this._unwrappedTitle = title; } return title; }; SourceGroup.prototype.getWidth = function() { return this._width; }; SourceGroup.prototype.getAbsoluteY = function() { return this._group.absolutePosition().y; }; SourceGroup.prototype.x = function(value) { if (typeof value !== 'number') { return this._group.x(); } return this._group.x(value); }; SourceGroup.prototype.y = function(value) { if (typeof value !== 'number') { return this._group.y(); } return this._group.y(value); }; SourceGroup.prototype.absolutePosition = function(value) { if (value) { return this._group.absolutePosition(value); } return this._group.absolutePosition(); }; SourceGroup.prototype.getSource = function() { return this._source; }; SourceGroup.prototype.startHover = function() { this._manualHover = true; this._enableManualHoverTracking(); this._group.fire('mouseenter', { evt: new MouseEvent('mouseenter') }, true); }; SourceGroup.prototype.stopHover = function() { this._group.fire('mouseleave', { evt: new MouseEvent('mouseleave') }, true); }; SourceGroup.prototype.setDragging = function(isDragging) { this._isDragged = isDragging; // Ghost lifecycle is tied to dragging state. if (isDragging) { this.createDragGhost(); } else { this.destroyDragGhost(); } }; SourceGroup.prototype.startDrag = function() { return this._group.startDrag(); }; SourceGroup.prototype.stopDrag = function() { return this._group.stopDrag(); }; SourceGroup.prototype.moveTo = function(group) { this._group.moveTo(group); }; SourceGroup.prototype.moveToTop = function() { this._group.moveToTop(); }; SourceGroup.prototype.isDescendantOf = function(group) { return group.isAncestorOf(this._group); }; SourceGroup.prototype.getParent = function() { return this._group.getParent(); }; SourceGroup.prototype.remove = function() { this._group.remove(); }; SourceGroup.prototype.addImagePreview = function(content, url, redraw) { var preview = { type: 'image', group: new Konva.Group({ height: this._unwrappedHeight, listening: false }) }; var imageData = this._layer.getLoadedData(url); if (!imageData) { imageData = new Image(); var self = this; imageData.onload = function() { self._layer.setLoadedData(url, this); preview.loaded = true; self._createImagePreview(preview, imageData, redraw); }; imageData.src = content; } else { preview.loaded = true; this._createImagePreview(preview, imageData, redraw); } }; SourceGroup.prototype.addVideoPreview = function(content, url, redraw) { var preview = { type: 'video', group: new Konva.Group({ y: this._source.binaryUrl && this._source.previewUrl ? this._source.binaryHeight : 0, height: this._source.binaryUrl && this._source.previewUrl ? this._source.previewHeight : this._unwrappedHeight, listening: false }) }; var imageData = this._layer.getLoadedData(url); if (!imageData) { var video = document.createElement('video'); var self = this; video.onloadeddata = function() { this.currentTime = this.duration / 2; var canvas = document.createElement('canvas'); canvas.width = this.videoWidth; canvas.height = this.videoHeight; canvas.getContext('2d').drawImage(this, 0, 0, canvas.width, canvas.height); imageData = new Image(); imageData.onload = function() { self._layer.setLoadedData(url, this); preview.loaded = true; self._createImagePreview(preview, imageData, redraw); }; imageData.src = canvas.toDataURL(); }; video.src = content; } else { preview.loaded = true; this._createImagePreview(preview, imageData, redraw); } }; SourceGroup.prototype.addAudioPreview = function(type, content, url, redraw) { var preview = { type: 'audio', group: new Konva.Group({ height: this._source.binaryUrl && this._source.previewUrl ? this._source.binaryHeight : this._unwrappedHeight, listening: false }), url: url }; var self = this; var audioData = this._layer.getLoadedData(url); if (!audioData) { var waveformBuilder = new WaveformBuilder(this._peaks); var options = Object.assign({},this._peaks.options); if (type === 'audio') { options.objectUrl = content; } else { options.dataUri = content; } waveformBuilder.init(options, function(err, originalWaveformData) { if (self._destroyed) { return; } if (err) { throw err; } var newScale = originalWaveformData.sample_rate / self._view.getTimeToPixelsMaxZoom(); if (newScale > originalWaveformData.scale) { self._minScale = newScale; } else { self._minScale = originalWaveformData.scale; } self._view.setTimeToPixelsMaxZoom(originalWaveformData.sample_rate / self._minScale); self._layer.setLoadedData(url, originalWaveformData); self._layer.setLoadedData( url + '-scaled', { data: originalWaveformData, scale: originalWaveformData.sample_rate / self._minScale } ); preview.loaded = true; self._createAudioPreview(preview, redraw); }); } else { preview.loaded = true; this._createAudioPreview(preview, redraw); } }; SourceGroup.prototype._getWaveformData = function() { return this._source && this._source.waveformData ? this._source.waveformData : null; }; SourceGroup.prototype._hasWaveformData = function() { return Boolean(this._getWaveformData()); }; SourceGroup.prototype._removeWaveformDataPreview = function() { if (!this._previewList || this._previewList.length === 0) { return; } var removed = false; this._previewList = this._previewList.filter(function(preview) { if (preview && preview.type === 'waveformData') { if (preview.group && typeof preview.group.destroy === 'function') { preview.group.destroy(); } removed = true; return false; } return true; }); if (removed) { this._scheduleBatchDraw(); } }; SourceGroup.prototype.addWaveformDataPreview = function(redraw) { if (!this._hasWaveformData()) { return; } // If the source starts wrapped, _unwrap may not exist yet. We still build // previews into the unwrap container so they appear when the source is // expanded later. if (!this._unwrap) { this._unwrap = this._createUnwrap(); } // Avoid duplicates. var existing = this._previewList.filter(function(preview) { return preview && preview.type === 'waveformData'; }); if (existing.length > 0) { if (redraw) { this._scheduleBatchDraw(); } return; } var preview = { type: 'waveformData', group: new Konva.Group({ height: (this._source.previewUrl ? this._source.binaryHeight : this._unwrappedHeight), listening: false }) }; var self = this; var waveform = new WaveformShape({ view: this._view, color: this._source.color, height: preview.group.height(), waveformDataFunc: function() { return self._createWaveformDataPointsIterator(); } }); preview.group.add(waveform); this._addToUnwrap(preview.group); if (redraw) { this._scheduleBatchDraw(); } this._previewList.push(preview); }; SourceGroup.prototype.updateWaveformDataPreview = function(redraw) { if (!this._hasWaveformData()) { this._removeWaveformDataPreview(); return; } var existing = this._previewList.filter(function(preview) { return preview && preview.type === 'waveformData'; }); if (existing.length === 0) { this.addWaveformDataPreview(redraw); return; } // Height may depend on previewUrl presence. var expectedHeight = (this._source.previewUrl ? this._source.binaryHeight : this._unwrappedHeight); existing.forEach(function(preview) { if (preview.group && typeof preview.group.height === 'function') { preview.group.height(expectedHeight); } }); if (redraw) { this._scheduleBatchDraw(); } }; SourceGroup.prototype._createAudioPreview = function(preview, redraw) { var url = preview.url; // Resample the waveform data for the current zoom level if needed var scaledData = this._layer.getLoadedData(url + '-scaled'); var currentScale = this._view.getTimeToPixelsScale(); if (scaledData && scaledData.scale !== currentScale) { var originalData = this._layer.getLoadedData(url); if (originalData) { this._layer.setLoadedData(url + '-scaled', { data: originalData.resample({ scale: originalData.sample_rate / currentScale }), scale: currentScale }); } } var self = this; var waveform = new WaveformShape({ view: this._view, color: this._source.color, height: preview.group.height(), waveformDataFunc: function() { return self._createWaveformPointsIterator(url); } }); preview.group.add(waveform); this._addToUnwrap(preview.group); if (redraw) { this._scheduleBatchDraw(); } this._previewList.push(preview); }; SourceGroup.prototype._createWaveformPointsIterator = function(url) { var loaded = this._layer.getLoadedData(url + '-scaled'); var waveformData = loaded && loaded.data; if (!waveformData) { return { next: function() { return { done: true }; } }; } var view = this._view; var source = this._source; var groupX = this._group && typeof this._group.x === 'function' ? this._group.x() : 0; var groupWidth = this._width; var viewWidth = view.getWidth(); var startPixel = 0; var startOffset = 0; var endPixel = Math.min(viewWidth, waveformData.length); var targetSpeed = 1.0; if (source) { targetSpeed = source.targetSpeed || 1.0; // Determine which part of the source is visible in the view based on // its current on-canvas geometry (supports dragging without relying on // startTime/endTime). var hiddenLeftPixels = Math.max(-groupX, 0); var hiddenRightPixels = Math.max(groupX + groupWidth - viewWidth, 0); startPixel = Math.floor( (view.timeToPixels(source.mediaStartTime) + hiddenLeftPixels) * targetSpeed ); startOffset = view.timeToPixels(source.mediaStartTime); endPixel = Math.min( Math.ceil( (view.timeToPixels(source.mediaEndTime) - hiddenRightPixels) * targetSpeed ), waveformData.length ); } if (startPixel < 0) { startPixel = 0; } if (endPixel < startPixel) { endPixel = startPixel; } var channels = waveformData.channels; var channelData = new Array(channels); for (var c = 0; c < channels; c++) { channelData[c] = waveformData.channel(c); } var x = startPixel; return { next: function() { if (x >= endPixel) { return { done: true }; } var min = new Array(channels); var max = new Array(channels); for (var i = 0; i < channels; i++) { min[i] = channelData[i].min_sample(x); max[i] = channelData[i].max_sample(x); } var value = { x: x / targetSpeed - startOffset + 0.5, min: min, max: max }; x++; return { done: false, value: value }; } }; }; SourceGroup.prototype._createWaveformDataPointsIterator = function() { var custom = this._getWaveformData(); if (!custom || typeof custom !== 'object') { return { next: function() { return { done: true }; } }; } var bucketSizeSec = custom.bucketSizeSec; var startTimeSec = custom.startTimeSec; var minRaw = custom.min; var maxRaw = custom.max; if (!Utils.isValidTime(bucketSizeSec) || bucketSizeSec <= 0) { return { next: function() { return { done: true }; } }; } if (!Utils.isValidTime(startTimeSec)) { // Fallback to current model mediaStartTime if missing. startTimeSec = this._source && Utils.isValidTime(this._source.mediaStartTime) ? this._source.mediaStartTime : 0; } // Normalize to channel arrays. var minByChannel; var maxByChannel; var channels = 1; if (Array.isArray(minRaw) && minRaw.length > 0 && Array.isArray(minRaw[0])) { minByChannel = minRaw; channels = minRaw.length; } else { minByChannel = [Array.isArray(minRaw) ? minRaw : []]; channels = 1; } if (Array.isArray(maxRaw) && maxRaw.length > 0 && Array.isArray(maxRaw[0])) { maxByChannel = maxRaw; channels = Math.max(channels, maxRaw.length); } else { maxByChannel = [Array.isArray(maxRaw) ? maxRaw : []]; } // Ensure arrays exist for all channels. for (var c = 0; c < channels; c++) { if (!minByChannel[c]) { minByChannel[c] = []; } if (!maxByChannel[c]) { maxByChannel[c] = []; } } // Determine visible time range for this source, including drag offsets. var view = this._view; var source = this._source; var groupX = this._group && typeof this._group.x === 'function' ? this._group.x() : 0; var groupWidth = this._width; var viewWidth = view.getWidth(); var hiddenLeftPixels = Math.max(-groupX, 0); var hiddenRightPixels = Math.max(groupX + groupWidth - viewWidth, 0); // Base media start time for x-axis alignment. var baseMediaStartTime = (source && Utils.isValidTime(source.mediaStartTime)) ? source.mediaStartTime : startTimeSec; // Translate pixel visibility into time range in the media timeline. var visibleMediaStartTime = baseMediaStartTime; var visibleMediaEndTime = (source && Utils.isValidTime(source.mediaEndTime)) ? source.mediaEndTime : (baseMediaStartTime + (source.endTime - source.startTime)); // Account for the portion hidden outside the viewport (during dragging/scrolling). visibleMediaStartTime += view.pixelsToTime(hiddenLeftPixels); visibleMediaEndTime -= view.pixelsToTime(hiddenRightPixels); if (visibleMediaEndTime < visibleMediaStartTime) { visibleMediaEndTime = visibleMediaStartTime; } // Map time range to bucket indices. var startIndex = Math.max(0, Math.floor((visibleMediaStartTime - startTimeSec) / bucketSizeSec)); var endIndex = Math.max(startIndex, Math.ceil((visibleMediaEndTime - startTimeSec) / bucketSizeSec)); // Clamp endIndex to available data. var maxLen = 0; for (var cc = 0; cc < channels; cc++) { maxLen = Math.max(maxLen, minByChannel[cc].length, maxByChannel[cc].length); } endIndex = Math.min(endIndex, maxLen); // Convert float amplitudes (-1..1) to signed 8-bit-ish range (-128..127). function toInt8Amplitude(v) { if (!Utils.isNumber(v) || !Number.isFinite(v)) { return null; } if (v <= -1) { return -128; } if (v >= 1) { return 127; } // Use 127 as positive peak to match common signed 8-bit scaling. return Math.round(v * 127); } var startOffsetPx = view.timeToPixels(baseMediaStartTime); var xBucket = startIndex; return { next: function() { while (xBucket < endIndex) { var min = new Array(channels); var max = new Array(channels); var hasAny = false; for (var i = 0; i < channels; i++) { var minV = toInt8Amplitude(minByChannel[i][xBucket]); var maxV = toInt8Amplitude(maxByChannel[i][xBucket]); // Draw what we have: if one side is missing, fall back to the other. if (minV === null && maxV !== null) { minV = maxV; } if (maxV === null && minV !== null) { maxV = minV; } if (minV === null && maxV === null) { min[i] = 0; max[i] = 0; } else { min[i] = minV; max[i] = maxV; hasAny = true; } } var bucketTime = startTimeSec + xBucket * bucketSizeSec; var xPx = view.timeToPixels(bucketTime) - startOffsetPx + 0.5; xBucket++; if (hasAny) { return { done: false, value: { x: xPx, min: min, max: max } }; } } return { done: true }; } }; }; SourceGroup.prototype.getAudioPreview = function() { return this._previewList.filter(function(preview) { return preview.type === 'audio'; }); }; SourceGroup.prototype.setSelected = function() { this._selected = this._source.selected; if (this._border) { if (this._selected) { this._border.fill(this._source.selectedBorderColor); this._borderWidth = this._peaks.options.sourceSelectedBorderWidth; } else { this._border.fill(this._source.borderColor); this._borderWidth = this._source.borderWidth; } } else { if (this._unwrap) { // update unwrap var unwrap_background = this._unwrap.getChildren(function(node) { return node.getClassName() === 'Shape'; })[0]; if (unwrap_background) { if (this._selected) { unwrap_background.stroke(this._source.selectedBorderColor); unwrap_background.strokeWidth(this._peaks.options.sourceSelectedBorderWidth); } else { unwrap_background.strokeWidth(0); } } } if (this._wrap) { // update wrap var wrap_background = this._wrap.getChildren(function(node) { return node.getClassName() === 'Shape'; })[0]; if (wrap_background) { if (this._selected) { wrap_background.stroke(this._source.selectedBorderColor); wrap_background.strokeWidth(this._peaks.options.sourceSelectedBorderWidth); } else { wrap_background.strokeWidth(0); } } } } }; SourceGroup.prototype.updatePreviews = function() { var self = this; this._previewList.forEach(function(preview) { if (preview.loaded) { switch (preview.type) { case 'video': case 'image': // image or video preview if (self._unwrappedHeight !== preview.imageData.referenceHeight) { preview.imageData.referenceHeight = preview.group.height(); preview.imageData.borderSpacing = SPACING_BETWEEN_PREVIEW_AND_BORDER_RATIO * preview.imageData.referenceHeight; preview.imageData.height = preview.imageData.referenceHeight - (2 * preview.imageData.borderSpacing); preview.imageData.width = preview.imageData.height * preview.imageData.dimRatio; preview.imageData.imageSpacing = preview.imageData.width * SPACING_BETWEEN_PREVIEWS; } var interImageSpacing = preview.imageData.width + preview.imageData.imageSpacing; var imageNumber; if (self._width > preview.imageData.borderSpacing) { imageNumber = Math.trunc( (self._width - preview.imageData.borderSpacing) / interImageSpacing ) + 1; } else { imageNumber = 0; } self._ensureImagePreviewCount(preview, imageNumber, interImageSpacing); } } }); }; /** * Schedules a batch draw using RAF to coalesce multiple draw requests. * This is more efficient than calling _batchDraw directly. */ SourceGroup.prototype._scheduleBatchDraw = function() { if (this._destroyed || this._drawScheduled) { return; } this._drawScheduled = true; var self = this; sharedInvoker.scheduleFrame(function() { self._drawScheduled = false; if (!self._destroyed) { self._batchDraw(); } }); }; SourceGroup.prototype._batchDraw = function() { var layer = this._group && this._group.getLayer && this._group.getLayer(); if (layer && typeof layer.batchDraw === 'function') { layer.batchDraw(); } }; // Utility to schedule work during idle time, with tracking for cleanup SourceGroup.prototype._scheduleIdle = function(fn) { var self = this; var id = Utils.scheduleIdle(function(deadline) { self._pendingIdleCallbacks.delete(id); fn(deadline); }, { timeout: 50 }); this._pendingIdleCallbacks.add(id); return id; }; SourceGroup.prototype._ensureImagePreviewCount = function(preview, targetCount, interImageSpacing) { var imageList = preview.group.getChildren(); var currentCount = imageList.length; for (var i = 0; i < Math.min(currentCount, targetCount); i++) { imageList[i].visible(true); } for (var j = targetCount; j < currentCount; j++) { imageList[j].visible(false); } if (currentCount >= targetCount || this._previewBuildQueue.has(preview)) { this._scheduleBatchDraw(); return; } this._previewBuildQueue.add(preview); var self = this; var nextIndex = currentCount; function buildChunk() { var added = 0; while (nextIndex < targetCount && added < PREVIEW_CREATE_CHUNK) { var imagePreview = new Konva.Image({ x: preview.imageData.borderSpacing + nextIndex * interImageSpacing, y: preview.imageData.borderSpacing, image: preview.imageData.image, width: preview.imageData.width, height: preview.imageData.height, listening: false, visible: true }); preview.group.add(imagePreview); nextIndex += 1; added += 1; } self._scheduleBatchDraw(); if (nextIndex < targetCount) { self._scheduleIdle(buildChunk); } else { self._previewBuildQueue.delete(preview); } } this._scheduleIdle(buildChunk); }; SourceGroup.prototype._createImagePreview = function(preview, image, redraw) { preview.imageData = { image: image, referenceHeight: null, dimRatio: null, borderSpacing: null, height: null, width: null, imageSpacing: null }; preview.imageData.referenceHeight = preview.group.height(); preview.imageData.dimRatio = image.width / image.height; preview.imageData.borderSpacing = SPACING_BETWEEN_PREVIEW_AND_BORDER_RATIO * preview.imageData.referenceHeight; preview.imageData.height = preview.imageData.referenceHeight - (2 * preview.imageData.borderSpacing); preview.imageData.width = preview.imageData.height * preview.imageData.dimRatio; preview.imageData.imageSpacing = preview.imageData.width * SPACING_BETWEEN_PREVIEWS; this._addToUnwrap(preview.group); var interImageSpacing = preview.imageData.width + preview.imageData.imageSpacing; var targetCount = 0; if (this._width > preview.imageData.borderSpacing) { targetCount = Math.trunc((this._width - preview.imageData.borderSpacing) / interImageSpacing) + 1; } this._ensureImagePreviewCount(preview, targetCount, interImageSpacing); if (redraw) { this._scheduleBatchDraw(); } this._previewList.push(preview); }; SourceGroup.prototype.setLoadingState = function(isLoading) { if (isLoading && !this._loadingOverlay) { this._createLoadingOverlay(); } else if (!isLoading && this._loadingOverlay) { this._removeLoadingOverlay(); } if (this._loadingOverlay) { this._loadingOverlay.visible(isLoading); } }; SourceGroup.prototype._createLoadingOverlay = function() { this._loadingOverlay = new Konva.Group({ x: 0, y: 0, width: this._width, height: this._height, listening: false }); // Semi-transparent background var loadingBackground = new Konva.Rect({ x: 0, y: 0, width: this._width, height: this._height, fill: 'rgba(0, 0, 0, 0.7)' }); this._loader = new Loader(); this._loader.x(this._width / 2); this._loader.y(this._height / 2); // Add overlay to the main group this._loadingOverlay.add(loadingBackground); this._loader.addTo(this._loadingOverlay); this.addToContent(this._loadingOverlay); }; SourceGroup.prototype._removeLoadingOverlay = function() { if (this._loadingOverlay) { if (this._loader) { this._loader.destroy(); this._loader = null; } this._loadingOverlay.destroy(); this._loadingOverlay = null; } }; SourceGroup.prototype._updateLoadingOverlay = function() { if (this._loadingOverlay) { var self = this; this._loadingOverlay.width(self._width); this._loadingOverlay.height(self._height); this._loadingOverlay.getChildren().forEach(function(child) { if (child instanceof Konva.Rect) { child.width(self._width); child.height(self._height); } else { child.x(self._width / 2); } }); } }; SourceGroup.prototype.isWrapped = function() { return this._source.wrapped; }; SourceGroup.prototype.getCurrentHeight = function() { return this._height; }; /** * Creates the drag ghost preview element if it does not exist. * The ghost shows where the source will be placed when released. */ SourceGroup.prototype.createDragGhost = function() { if (this._dragGhost) { return this._dragGhost; } var frameOffset = this._view.getFrameOffset(); var x = this._view.timeToPixels(this._source.startTime) - frameOffset; var width = this._view.timeToPixels(this._source.endTime - this._source.startTime); var height = this.getCurrentHeight(); var y = this.getAbsoluteY(); this._dragGhost = new Konva.Rect({ x: x, y: y, width: width, height: height, fill: this._source.backgroundColor, opacity: 0.4, stroke: this._source.selectedBorderColor, strokeWidth: 2, dash: [8, 4], cornerRadius: 8, listening: false }); // Add to the main sources layer (not the group) so it stays behind sources. this._layer.add(this._dragGhost); this._dragGhost.moveToBottom(); // Ensure initial Y snaps to the current line position. this.updateDragGhost(); return this._dragGhost; }; /** * Updates the drag ghost preview position and size. * * @param {Object} lineGroupsById Map of lineId -> Konva.Group */ SourceGroup.prototype.updateDragGhost = function(lineGroupsById) { if (!this._dragGhost) { return; } // Allow callers to omit the lookup; resolve via the owning layer. if (!lineGroupsById && this._layer && typeof this._layer.getLineGroups === 'function') { var lineGroups = this._layer.getLineGroups(); if (lineGroups && typeof lineGroups.getLineGroupsById === 'function') { lineGroupsById = lineGroups.getLineGroupsById(); } } var frameOffset = this._view.getFrameOffset(); var x = this._view.timeToPixels(this._source.startTime) - frameOffset; var width = this._view.timeToPixels(this._source.endTime - this._source.startTime); this._dragGhost.x(x); this._dragGhost.width(width); this._dragGhost.height(this.getCurrentHeight()); if (lineGroupsById) { var lineGroup = lineGroupsById[this._source.lineId]; if (lineGroup) { this._dragGhost.y(lineGroup.y()); } } }; SourceGroup.prototype.destroyDragGhost = function() { if (this._dragGhost) { this._dragGhost.destroy(); this._dragGhost = null; } }; SourceGroup.prototype.getHeights = function() { return { unwrapped: this._unwrappedHeight, wrapped: this._wrappedHeight, current: this._height }; }; SourceGroup.prototype.setVisible = function(boolean) { this._group.visible(boolean); }; SourceGroup.prototype.setListening = function(boolean) { this._group.listening(boolean); }; SourceGroup.prototype.isVisible = function() { return this._group.visible(); }; SourceGroup.prototype.isCuttable = function() { return this._source.cuttable; }; SourceGroup.prototype.isDeletable = function() { return this._source.deletable; }; SourceGroup.prototype.getLine = function() { return this._source.lineId; }; SourceGroup.prototype.getAbsoluteBoundingBox = function() { var stageContainer = this._group.getStage().containe