@checksub_team/peaks_timeline
Version:
JavaScript UI component for displaying audio waveforms
1,798 lines (1,481 loc) • 64.3 kB
JavaScript
/**
* @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