/*
 * Copyright (c) 2013-2026 Meltytech, LLC
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

#include "timelinedock.h"

#include "Logger.h"
#include "abstractproducerwidget.h"
#include "actions.h"
#include "commands/timelinecommands.h"
#include "dialogs/alignaudiodialog.h"
#include "dialogs/durationdialog.h"
#include "dialogs/editmarkerdialog.h"
#include "dialogs/longuitask.h"
#include "dialogs/resourcedialog.h"
#include "jobs/meltjob.h"
#include "mainwindow.h"
#include "models/audiolevelstask.h"
#include "models/multitrackmodel.h"
#include "proxymanager.h"
#include "qmltypes/qmlapplication.h"
#include "qmltypes/qmlutilities.h"
#include "qmltypes/qmlview.h"
#include "qmltypes/thumbnailprovider.h"
#include "settings.h"
#include "shotcut_mlt_properties.h"
#include "util.h"
#include "widgets/blipproducerwidget.h"
#include "widgets/colorbarswidget.h"
#include "widgets/colorproducerwidget.h"
#include "widgets/countproducerwidget.h"
#include "widgets/docktoolbar.h"
#include "widgets/glaxnimateproducerwidget.h"
#include "widgets/noisewidget.h"
#include "widgets/textproducerwidget.h"
#include "widgets/toneproducerwidget.h"

#include <QAction>
#include <QActionGroup>
#include <QClipboard>
#include <QDialogButtonBox>
#include <QGuiApplication>
#include <QMenu>
#include <QProgressBar>
#include <QQmlContext>
#include <QQuickItem>
#include <QQuickWidget>
#include <QSlider>
#include <QToolBar>
#include <QToolButton>
#include <QVBoxLayout>
#include <QWidgetAction>
#include <qlistwidget.h>

static const char *kFileUrlProtocol = "file://";
static const char *kFilesUrlDelimiter = ",file://";
static const int kRecordingTimerIntervalMs = 1000;

namespace {

struct PendingTransitionState
{
    int duration = 0;
    int skippedDuration = 0;
    int rightIn = -1;
    Mlt::Producer rightClip;
};

static bool isTransitionClipboardClip(const Mlt::ClipInfo &info)
{
    return info.producer && info.producer->is_valid()
           && info.producer->get(kShotcutTransitionProperty);
}

static int transitionClipboardDuration(const Mlt::ClipInfo &info)
{
    return qMax(1, info.frame_count);
}

static void clearPendingTransition(PendingTransitionState &state)
{
    state.duration = 0;
    state.rightIn = -1;
    state.rightClip = Mlt::Producer();
}

static void beginPendingTransition(PendingTransitionState &state,
                                   const Mlt::ClipInfo &info,
                                   bool trackSkippedDuration)
{
    state.duration = transitionClipboardDuration(info);
    if (trackSkippedDuration)
        state.skippedDuration += state.duration;
    state.rightIn = -1;
    state.rightClip = Mlt::Producer();
    Mlt::Tractor transition(*info.producer);
    QScopedPointer<Mlt::Producer> transitionRight(transition.track(1));
    if (transitionRight && transitionRight->is_valid()) {
        state.rightIn = transitionRight->get_in();
        state.rightClip = Mlt::Producer(*transitionRight);
    }
}

static bool matchesPendingTransitionRight(PendingTransitionState &state, Mlt::Producer &clip)
{
    return state.duration > 0 && state.rightIn >= 0 && state.rightClip.is_valid()
           && clip.same_clip(state.rightClip);
}

static void applyTransitionAwareInOut(Mlt::Producer &clip,
                                      const Mlt::ClipInfo &info,
                                      bool matchesPendingTransition,
                                      int pendingTransitionRightIn)
{
    if (matchesPendingTransition)
        clip.set_in_and_out(qMin(pendingTransitionRightIn, info.frame_out), info.frame_out);
    else
        clip.set_in_and_out(info.frame_in, info.frame_out);
}

} // namespace

/*!
    \qmltype TimelineDock
    \inqmlmodule org.shotcut.qml
    \brief The timeline dock controller, available as the \c timeline context property.

    \c timeline is injected into the Timeline QML view and exposes properties and
    methods for querying and controlling clip selection, the playhead position,
    the current track, and audio recording.
*/

/*!
    \qmlsignal TimelineDock::currentTrackChanged()
    \brief Emitted when \l currentTrack changes.
*/

/*!
    \qmlsignal TimelineDock::selectionChanged()
    \brief Emitted when the clip \l selection changes.
*/

/*!
    \qmlsignal TimelineDock::positionChanged(int position)
    \brief Emitted when the playhead time \a position changes.
*/

/*!
    \qmlsignal TimelineDock::loopChanged()
    \brief Emitted when the loop region start or end time changes.
*/

/*!
    \qmlsignal TimelineDock::clipOpened(var producer)
    \brief Emitted when a clip is opened for source preview. \a producer is the opened clip's producer.
*/

/*!
    \qmlsignal TimelineDock::showStatusMessage(string message)
    \brief Emitted to request a \a message be shown in the status bar.
*/

/*!
    \qmlsignal TimelineDock::durationChanged()
    \brief Emitted when the total timeline duration changes.
*/

/*!
    \qmlsignal TimelineDock::isRecordingChanged(bool recording)
    \brief Emitted when recording starts or stops. \a recording is \c true when recording begins.
*/

/*!
    \qmlproperty int TimelineDock::position
    \brief The current playhead position in frames. Settable — setting this seeks the player.
*/

/*!
    \qmlproperty bool TimelineDock::isRecording
    \brief \c true when audio recording is in progress. Read-only.
*/

/*!
    \qmlproperty int TimelineDock::loopStart
    \brief The start frame of the current loop region, or \c -1 if no loop is set. Read-only.
*/

/*!
    \qmlproperty int TimelineDock::loopEnd
    \brief The end frame of the current loop region, or \c -1 if no loop is set. Read-only.
*/

/*!
    \qmlmethod bool TimelineDock::isMultitrackSelected()
    \brief Returns \c true if the timeline output is currently selected.
*/

/*!
    \qmlmethod int TimelineDock::selectedTrack()
    \brief Returns the index of the selected track when a single track is selected,
    or \c -1 otherwise.
*/

/*!
    \qmlmethod bool TimelineDock::isFloating()
    \brief Returns \c true if the timeline dock is currently floating (undocked).
*/

TimelineDock::TimelineDock(QWidget *parent)
    : QDockWidget(parent)
    , m_quickView(QmlUtilities::sharedEngine(), this)
    , m_subtitlesModel()
    , m_subtitlesSelectionModel(&m_subtitlesModel)
{
    LOG_DEBUG() << "begin";
    m_selection.selectedTrack = -1;
    m_selection.isMultitrackSelected = false;

    setObjectName("TimelineDock");
    QDockWidget::setWindowTitle(tr("Timeline"));
    resize(400, 300);
    setMinimumSize(QSize(200, 120));
    setAcceptDrops(true);
    QIcon icon = QIcon::fromTheme("view-time-schedule",
                                  QIcon(":/icons/oxygen/32x32/actions/view-time-schedule.png"));
    setWhatsThis("https://forum.shotcut.org/t/about-the-timeline/12952/1");

    toggleViewAction()->setIcon(icon);
    setupActions();

    m_mainMenu = new QMenu(tr("Timeline"), this);
    QMenu *trackOperationsMenu = new QMenu(tr("Track Operations"), this);
    trackOperationsMenu->addAction(Actions["timelineAddAudioTrackAction"]);
    trackOperationsMenu->addAction(Actions["timelineAddVideoTrackAction"]);
    trackOperationsMenu->addAction(Actions["timelineInsertTrackAction"]);
    trackOperationsMenu->addAction(Actions["timelineRemoveTrackAction"]);
    trackOperationsMenu->addAction(Actions["timelineMoveTrackUpAction"]);
    trackOperationsMenu->addAction(Actions["timelineMoveTrackDownAction"]);
    trackOperationsMenu->addAction(Actions["timelineToggleTrackHiddenAction"]);
    trackOperationsMenu->addAction(Actions["timelineToggleOtherTracksHiddenAction"]);
    trackOperationsMenu->addAction(Actions["timelineToggleTrackLockedAction"]);
    trackOperationsMenu->addAction(Actions["timelineToggleTrackMuteAction"]);
    trackOperationsMenu->addAction(Actions["timelineToggleOtherTracksMuteAction"]);
    trackOperationsMenu->addAction(Actions["timelineToggleTrackBlendingAction"]);
    m_mainMenu->addMenu(trackOperationsMenu);
    QMenu *trackHeightMenu = new QMenu(tr("Track Height"), this);
    trackHeightMenu->addAction(Actions["timelineTracksShorterAction"]);
    trackHeightMenu->addAction(Actions["timelineTracksTallerAction"]);
    trackHeightMenu->addAction(Actions["timelineResetTrackHeightAction"]);
    m_mainMenu->addMenu(trackHeightMenu);
    QMenu *selectionMenu = new QMenu(tr("Selection"), this);
    selectionMenu->addAction(Actions["timelineSelectAllAction"]);
    selectionMenu->addAction(Actions["timelineSelectAllOnTrackAction"]);
    selectionMenu->addAction(Actions["timelineSelectNoneAction"]);
    selectionMenu->addAction(Actions["timelineSelectNextClipAction"]);
    selectionMenu->addAction(Actions["timelineSelectPrevClipAction"]);
    selectionMenu->addAction(Actions["timelineSelectClipAboveAction"]);
    selectionMenu->addAction(Actions["timelineSelectClipBelowAction"]);
    selectionMenu->addAction(Actions["timelineSelectClipUnderPlayheadAction"]);
    selectionMenu->addAction(Actions["timelineCurrentTrackAboveAction"]);
    selectionMenu->addAction(Actions["timelineCurrentTrackBelowAction"]);
    selectionMenu->addAction(Actions["timelineGroupAction"]);
    m_mainMenu->addMenu(selectionMenu);
    QMenu *editMenu = new QMenu(tr("Edit"), this);
    editMenu->addAction(Actions["timelinePasteAction"]);
    editMenu->addAction(Actions["timelineAppendAction"]);
    editMenu->addAction(Actions["timelineOverwriteAction"]);
    editMenu->addAction(Actions["timelineReplaceAction"]);
    editMenu->addAction(Actions["timelineRecordAudioAction"]);
    editMenu->addAction(Actions["playerSetInAction"]);
    editMenu->addAction(Actions["playerSetOutAction"]);
    editMenu->addAction(Actions["timelineNudgeForwardAction"]);
    editMenu->addAction(Actions["timelineNudgeBackwardAction"]);
    editMenu->addAction(Actions["timelineRippleTrimClipInAction"]);
    editMenu->addAction(Actions["timelineRippleTrimClipOutAction"]);
    editMenu->addAction(Actions["timelineSplitAction"]);
    editMenu->addAction(Actions["timelineSplitAllTracksAction"]);
    editMenu->addAction(Actions["timelineApplyCopiedFiltersAction"]);
    editMenu->addAction(Actions["timelineFreezeFrameAction"]);
    m_mainMenu->addMenu(editMenu);
    QMenu *viewMenu = new QMenu(tr("View"), this);
    viewMenu->addAction(Actions["timelineZoomOutAction"]);
    viewMenu->addAction(Actions["timelineZoomInAction"]);
    viewMenu->addAction(Actions["timelineZoomFitAction"]);
    viewMenu->addAction(Actions["timelinePropertiesAction"]);
    m_mainMenu->addMenu(viewMenu);
    QMenu *markerMenu = new QMenu(tr("Marker"), this);
    markerMenu->addAction(Actions["timelineMarkerAction"]);
    markerMenu->addAction(Actions["timelinePrevMarkerAction"]);
    markerMenu->addAction(Actions["timelineNextMarkerAction"]);
    markerMenu->addAction(Actions["timelineDeleteMarkerAction"]);
    markerMenu->addAction(Actions["timelineMarkSelectedClipAction"]);
    markerMenu->addAction(Actions["timelineCycleMarkerColorAction"]);
    m_mainMenu->addMenu(markerMenu);
    Actions.loadFromMenu(m_mainMenu);

    m_clipMenu = new QMenu(tr("Timeline Clip"), this);
    m_clipMenu->addAction(Actions["timelineCutAction"]);
    m_clipMenu->addAction(Actions["timelineCopyAction"]);
    m_clipMenu->addAction(Actions["timelineDeleteAction"]);
    m_clipMenu->addAction(Actions["timelineLiftAction"]);
    m_clipMenu->addAction(Actions["timelineReplaceAction"]);
    m_clipMenu->addAction(Actions["timelineSplitAction"]);
    m_clipMenu->addAction(Actions["timelineGroupAction"]);
    m_clipMenu->addAction(Actions["timelineNudgeForwardAction"]);
    m_clipMenu->addAction(Actions["timelineNudgeBackwardAction"]);
    m_clipMenu->addAction(Actions["timelineMergeWithNextAction"]);
    m_clipMenu->addAction(Actions["timelineDetachAudioAction"]);
    m_clipMenu->addAction(Actions["timelineFreezeFrameAction"]);
    m_clipMenu->addAction(Actions["timelineAlignToReferenceAction"]);
    m_clipMenu->addAction(Actions["timelineApplyCopiedFiltersAction"]);
    m_clipMenu->addAction(Actions["timelineUpdateThumbnailsAction"]);
    m_clipMenu->addAction(Actions["timelineRebuildAudioWaveformAction"]);
    m_clipMenu->addAction(Actions["timelinePropertiesAction"]);
    Actions.loadFromMenu(m_clipMenu);

    QVBoxLayout *vboxLayout = new QVBoxLayout();
    vboxLayout->setSpacing(0);
    vboxLayout->setContentsMargins(0, 0, 0, 0);

    DockToolBar *toolbar = new DockToolBar(tr("Timeline Controls"));
    QToolButton *menuButton = new QToolButton();
    menuButton->setIcon(
        QIcon::fromTheme("show-menu", QIcon(":/icons/oxygen/32x32/actions/show-menu.png")));
    menuButton->setToolTip(tr("Timeline Menu"));
    menuButton->setAutoRaise(true);
    menuButton->setPopupMode(QToolButton::QToolButton::InstantPopup);
    menuButton->setMenu(m_mainMenu);
    toolbar->addWidget(menuButton);
    toolbar->addSeparator();
    toolbar->addAction(Actions["timelineCutAction"]);
    toolbar->addAction(Actions["timelineCopyAction"]);
    toolbar->addAction(Actions["timelinePasteAction"]);
    toolbar->addSeparator();

    const auto action = Actions["timelineNewGenerator"];
    menuButton = new QToolButton();
    menuButton->setIcon(action->icon());
    menuButton->setToolTip(action->toolTip());
    menuButton->setAutoRaise(true);
    menuButton->setPopupMode(QToolButton::QToolButton::InstantPopup);
    menuButton->setMenu(action->menu());
    toolbar->addWidget(menuButton);
    connect(action, &QAction::triggered, this, [=]() {
        action->menu()->popup(menuButton->mapToGlobal(QPoint(0, menuButton->height())));
    });

    toolbar->addAction(Actions["timelineAppendAction"]);
    toolbar->addAction(Actions["timelineDeleteAction"]);
    toolbar->addAction(Actions["timelineLiftAction"]);
    toolbar->addAction(Actions["timelineOverwriteAction"]);
    toolbar->addAction(Actions["timelineSplitAction"]);
    toolbar->addSeparator();
    toolbar->addAction(Actions["timelineMarkerAction"]);
    toolbar->addAction(Actions["timelinePrevMarkerAction"]);
    toolbar->addAction(Actions["timelineNextMarkerAction"]);
    toolbar->addSeparator();
    toolbar->addAction(Actions["timelineSnapAction"]);
    toolbar->addAction(Actions["timelineScrubDragAction"]);
    toolbar->addAction(Actions["timelineRippleAction"]);
    toolbar->addAction(Actions["timelineRippleAllTracksAction"]);
    toolbar->addAction(Actions["timelineRippleMarkersAction"]);
    toolbar->addSeparator();
    toolbar->addAction(Actions["timelineZoomOutAction"]);
    QSlider *zoomSlider = new QSlider();
    zoomSlider->setOrientation(Qt::Horizontal);
    zoomSlider->setMaximumWidth(200);
    zoomSlider->setMinimum(0);
    zoomSlider->setMaximum(300);
    zoomSlider->setValue(100);
    zoomSlider->setTracking(false);
    connect(zoomSlider, &QSlider::valueChanged, this, [&](int value) {
        if (!isVisible() || !m_quickView.rootObject())
            return;
        emit setZoom(value / 100.0);
    });
    connect(&m_model, &MultitrackModel::scaleFactorChanged, zoomSlider, [=]() {
        double value = round(pow(m_model.scaleFactor() - 0.01, 1.0 / 3.0) * 100.0);
        zoomSlider->blockSignals(true);
        zoomSlider->setValue(value);
        zoomSlider->blockSignals(false);
    });
    toolbar->addWidget(zoomSlider);
    toolbar->addAction(Actions["timelineZoomInAction"]);
    toolbar->addAction(Actions["timelineZoomFitAction"]);
    toolbar->addSeparator();
    toolbar->addAction(Actions["timelineRecordAudioAction"]);
    vboxLayout->setMenuBar(toolbar);

    qmlRegisterType<MultitrackModel>("Shotcut.Models", 1, 0, "MultitrackModel");
    qmlRegisterType<MarkersModel>("Shotcut.Models", 1, 0, "MarkersModel");

    QDir importPath = QmlUtilities::qmlDir();
    importPath.cd("modules");
    m_quickView.engine()->addImportPath(importPath.path());
    m_quickView.engine()->addImageProvider(QStringLiteral("thumbnail"), new ThumbnailProvider);
    QmlUtilities::setCommonProperties(m_quickView.rootContext());
    m_quickView.rootContext()->setContextProperty("view", new QmlView(&m_quickView));
    m_quickView.rootContext()->setContextProperty("timeline", this);
    m_quickView.rootContext()->setContextProperty("multitrack", &m_model);
    m_quickView.rootContext()->setContextProperty("markers", &m_markersModel);
    m_quickView.rootContext()->setContextProperty("subtitlesModel", &m_subtitlesModel);
    m_quickView.rootContext()->setContextProperty("subtitlesSelectionModel",
                                                  &m_subtitlesSelectionModel);
    m_quickView.setResizeMode(QQuickWidget::SizeRootObjectToView);
    m_quickView.setClearColor(palette().window().color());
    m_quickView.quickWindow()->setPersistentSceneGraph(false);
#ifndef Q_OS_MAC
    m_quickView.setAttribute(Qt::WA_AcceptTouchEvents);
#endif

    m_selectionSignalTimer.setSingleShot(true);
    m_selectionSignalTimer.setInterval(100);
    connect(&m_selectionSignalTimer, &QTimer::timeout, this, [&]() {
        emit selectionChanged();
        emitSelectedFromSelection();
    });

    connect(&m_quickView, &QQuickWidget::statusChanged, this, [&]() {
        if (m_quickView.status() == QQuickWidget::Ready) {
            connect(m_quickView.rootObject(), SIGNAL(clipClicked()), this, SIGNAL(clipClicked()));
            connect(m_quickView.rootObject(),
                    SIGNAL(timelineRightClicked()),
                    this,
                    SLOT(onTimelineRightClicked()));
            connect(m_quickView.rootObject(),
                    SIGNAL(clipRightClicked()),
                    this,
                    SLOT(onClipRightClicked()));
        }
    });

    connect(&m_model, &MultitrackModel::created, this, [&]() {
        connect(&m_model, &MultitrackModel::modified, this, &TimelineDock::clearSelectionIfInvalid);
        connect(&m_model, &MultitrackModel::appended, this, &TimelineDock::selectClip);
        connect(&m_model, &MultitrackModel::inserted, this, &TimelineDock::selectClip);
        connect(&m_model, &MultitrackModel::overWritten, this, &TimelineDock::selectClip);
        connect(&m_model, &MultitrackModel::rowsInserted, this, &TimelineDock::onRowsInserted);
        connect(&m_model, &MultitrackModel::rowsRemoved, this, &TimelineDock::onRowsRemoved);
        connect(&m_model, &MultitrackModel::rowsMoved, this, &TimelineDock::onRowsMoved);
        connect(&m_model,
                &MultitrackModel::bulkUpdateFinished,
                this,
                &TimelineDock::onBulkUpdateFinished);
        connect(&m_model,
                &MultitrackModel::noMoreEmptyTracks,
                this,
                &TimelineDock::onNoMoreEmptyTracks,
                Qt::QueuedConnection);
        reloadTimelineModels();
    });

    connect(&m_model, &MultitrackModel::aboutToClose, this, [&]() {
        setSelection();
        disconnect(&m_model,
                   &MultitrackModel::modified,
                   this,
                   &TimelineDock::clearSelectionIfInvalid);
        disconnect(&m_model, &MultitrackModel::appended, this, &TimelineDock::selectClip);
        disconnect(&m_model, &MultitrackModel::inserted, this, &TimelineDock::selectClip);
        disconnect(&m_model, &MultitrackModel::overWritten, this, &TimelineDock::selectClip);
        disconnect(&m_model, &MultitrackModel::rowsInserted, this, &TimelineDock::onRowsInserted);
        disconnect(&m_model, &MultitrackModel::rowsRemoved, this, &TimelineDock::onRowsRemoved);
        disconnect(&m_model, &MultitrackModel::rowsMoved, this, &TimelineDock::onRowsMoved);
        disconnect(&m_model,
                   &MultitrackModel::bulkUpdateFinished,
                   this,
                   &TimelineDock::onBulkUpdateFinished);
        disconnect(&m_model,
                   &MultitrackModel::noMoreEmptyTracks,
                   this,
                   &TimelineDock::onNoMoreEmptyTracks);
    });

    connect(&m_model, &MultitrackModel::closed, this, [&]() {
        onMultitrackClosed();
        reloadTimelineModels();
    });

    vboxLayout->addWidget(&m_quickView);

    connect(this,
            SIGNAL(clipMoved(int, int, int, int, bool)),
            SLOT(onClipMoved(int, int, int, int, bool)),
            Qt::QueuedConnection);
    connect(this,
            SIGNAL(transitionAdded(int, int, int, bool)),
            SLOT(onTransitionAdded(int, int, int, bool)),
            Qt::QueuedConnection);
    connect(MLT.videoWidget(),
            SIGNAL(frameDisplayed(const SharedFrame &)),
            this,
            SLOT(onShowFrame(const SharedFrame &)));
    connect(this, SIGNAL(topLevelChanged(bool)), this, SLOT(onTopLevelChanged(bool)));
    connect(this, SIGNAL(warnTrackLocked(int)), SLOT(onWarnTrackLocked()));
    connect(&m_markersModel, SIGNAL(rangesChanged()), this, SIGNAL(markerRangesChanged()));

    connect(this, &QDockWidget::visibilityChanged, this, [&](bool visible) {
        if (visible) {
            // Workaround for QML items going blank when moving the dock
            // Emit some signals to trigger some QML items to redraw
            emit m_model.scaleFactorChanged();
            emit currentTrackChanged();
            emit selectionChanged();
            emit m_subtitlesSelectionModel.selectedTrackModelIndexChanged(
                m_subtitlesSelectionModel.selectedTrackModelIndex());
            emit m_subtitlesSelectionModel.selectedItemsChanged();
        }
    });

    QWidget *dockContentsWidget = new QWidget();
    dockContentsWidget->setLayout(vboxLayout);
    QDockWidget::setWidget(dockContentsWidget);
    LOG_DEBUG() << "end";
}

TimelineDock::~TimelineDock() {}

void TimelineDock::setupActions()
{
    QIcon icon;
    QAction *action;

    action = new QAction(tr("Add Audio Track"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_U));
    connect(action, &QAction::triggered, this, [&]() {
        show();
        raise();
        addAudioTrack();
    });
    Actions.add("timelineAddAudioTrackAction", action);

    action = new QAction(tr("Add Video Track"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_I));
    connect(action, &QAction::triggered, this, [&]() {
        show();
        raise();
        addVideoTrack();
    });
    Actions.add("timelineAddVideoTrackAction", action);

    action = new QAction(tr("Insert Track"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_I));
    connect(action, &QAction::triggered, this, [&]() {
        show();
        raise();
        insertTrack();
    });
    Actions.add("timelineInsertTrackAction", action);

    action = new QAction(tr("Remove Track"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_U));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        removeTrack();
    });
    Actions.add("timelineRemoveTrackAction", action);

    action = new QAction(tr("Move Track Up"), this);
    action->setShortcut(QKeySequence(Qt::SHIFT | Qt::ALT | Qt::Key_Up));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        moveTrackUp();
    });
    Actions.add("timelineMoveTrackUpAction", action);

    action = new QAction(tr("Move Track Down"), this);
    action->setShortcut(QKeySequence(Qt::SHIFT | Qt::ALT | Qt::Key_Down));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        moveTrackDown();
    });
    Actions.add("timelineMoveTrackDownAction", action);

    action = new QAction(tr("Show/Hide Selected Track"), this);
#ifdef Q_OS_MAC
    // macOS uses Cmd+H to hide an app and Ctrl+H is intercepted at the OS level,
    // so use shift+H.
    action->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_H));
#else
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_H));
#endif
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        toggleTrackHidden(currentTrack());
    });
    Actions.add("timelineToggleTrackHiddenAction", action);

    action = new QAction(tr("Show/Hide Other Tracks"), this);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        toggleOtherTracksHidden(currentTrack());
    });
    Actions.add("timelineToggleOtherTracksHiddenAction", action);

    action = new QAction(tr("Lock/Unlock Selected Track"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_L));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        setTrackLock(currentTrack(), !isTrackLocked(currentTrack()));
    });
    Actions.add("timelineToggleTrackLockedAction", action);

    action = new QAction(tr("Mute/Unmute Selected Track"), this);
#ifdef Q_OS_MAC
    // macOS uses Cmd+M to minimize an app.
    action->setShortcut(QKeySequence(Qt::META | Qt::Key_M));
#else
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_M));
#endif
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        toggleTrackMute(currentTrack());
    });
    Actions.add("timelineToggleTrackMuteAction", action);

    action = new QAction(tr("Mute/Unmute Other Tracks"), this);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        toggleOtherTracksMute(currentTrack());
    });
    Actions.add("timelineToggleOtherTracksMuteAction", action);

    action = new QAction(tr("Blend/Unblend Selected Track"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_B));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        int trackIndex = currentTrack();
        if (trackIndex != model()->bottomVideoTrackIndex()) {
            bool isComposite = model()
                                   ->data(model()->index(trackIndex),
                                          MultitrackModel::IsCompositeRole)
                                   .toBool();
            setTrackComposite(trackIndex, !isComposite);
        }
    });
    Actions.add("timelineToggleTrackBlendingAction", action);

    action = new QAction(tr("Make Tracks Shorter"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Minus));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        m_model.setTrackHeight(std::max(10, m_model.trackHeight() - 20));
    });
    Actions.add("timelineTracksShorterAction", action);

    action = new QAction(tr("Make Tracks Taller"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Plus));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        m_model.setTrackHeight(m_model.trackHeight() + 20);
    });
    Actions.add("timelineTracksTallerAction", action);

    action = new QAction(tr("Reset Track Height"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Equal));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        m_model.setTrackHeight(50);
    });
    Actions.add("timelineResetTrackHeightAction", action);

    action = new QAction(tr("Select All"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_A));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        selectAll();
    });
    Actions.add("timelineSelectAllAction", action);

    action = new QAction(tr("Select All On Current Track"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_A));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        selectAllOnCurrentTrack();
    });
    Actions.add("timelineSelectAllOnTrackAction", action);

    action = new QAction(tr("Select None"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        setSelection();
        model()->reload();
    });
    Actions.add("timelineSelectNoneAction", action);

    action = new QAction(tr("Select Next Clip"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Right));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        if (selection().isEmpty()) {
            selectClipUnderPlayhead();
        } else if (selection().size() == 1) {
            int newIndex = selection().first().x() + 1;
            if (newIndex < clipCount(-1))
                setSelection(QList<QPoint>() << QPoint(newIndex, selection().first().y()));
        }
    });
    Actions.add("timelineSelectNextClipAction", action);

    action = new QAction(tr("Select Previous Clip"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Left));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        if (selection().isEmpty()) {
            selectClipUnderPlayhead();
        } else if (selection().size() == 1) {
            int newIndex = selection().first().x() - 1;
            if (newIndex >= 0)
                setSelection(QList<QPoint>() << QPoint(newIndex, selection().first().y()));
        }
    });
    Actions.add("timelineSelectPrevClipAction", action);

    action = new QAction(tr("Select Clip Above"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Up));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        if (selection().isEmpty()) {
            selectClipUnderPlayhead();
            return;
        }
        int newClipIndex = -1;
        int trackIndex = currentTrack() - 1;
        if (trackIndex > -1) {
            int navigationPosition = centerOfClip(selection().first().y(), selection().first().x());
            newClipIndex = clipIndexAtPosition(trackIndex, navigationPosition);
        }
        incrementCurrentTrack(-1);
        if (newClipIndex >= 0) {
            newClipIndex = qMin(newClipIndex, clipCount(trackIndex) - 1);
            setSelection(QList<QPoint>() << QPoint(newClipIndex, trackIndex));
        }
    });
    Actions.add("timelineSelectClipAboveAction", action);

    action = new QAction(tr("Select Clip Below"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_Down));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        if (selection().isEmpty()) {
            selectClipUnderPlayhead();
            return;
        }
        int newClipIndex = -1;
        int trackIndex = currentTrack() + 1;
        if (trackIndex < model()->trackList().count()) {
            int navigationPosition = centerOfClip(selection().first().y(), selection().first().x());
            newClipIndex = clipIndexAtPosition(trackIndex, navigationPosition);
        }
        incrementCurrentTrack(1);
        if (newClipIndex >= 0) {
            newClipIndex = qMin(newClipIndex, clipCount(trackIndex) - 1);
            setSelection(QList<QPoint>() << QPoint(newClipIndex, trackIndex));
        }
    });
    Actions.add("timelineSelectClipBelowAction", action);

    action = new QAction(tr("Set Current Track Above"), this);
    action->setShortcut(QKeySequence(Qt::Key_Up));
    action->setShortcutContext(Qt::WidgetWithChildrenShortcut);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        incrementCurrentTrack(-1);
    });
    m_quickView.addAction(action);
    Actions.add("timelineCurrentTrackAboveAction", action);

    action = new QAction(tr("Set Current Track Below"), this);
    action->setShortcut(QKeySequence(Qt::Key_Down));
    action->setShortcutContext(Qt::WidgetWithChildrenShortcut);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        incrementCurrentTrack(1);
    });
    m_quickView.addAction(action);
    Actions.add("timelineCurrentTrackBelowAction", action);

    action = new QAction(tr("Select Clip Under Playhead"), this);
    QList<QKeySequence> clipUnderPlayheadShortcuts;
    clipUnderPlayheadShortcuts << QKeySequence(Qt::CTRL | Qt::Key_Space);
#ifdef Q_OS_MAC
    // Spotlight defaults to Cmd+Space, so also accept Ctrl+Space.
    clipUnderPlayheadShortcuts << QKeySequence(Qt::META | Qt::Key_Space);
#endif
    action->setShortcuts(clipUnderPlayheadShortcuts);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid() || !isVisible())
            return;
        selectClipUnderPlayhead();
    });
    Actions.add("timelineSelectClipUnderPlayheadAction", action);

    action = new QAction(tr("Cu&t"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_X));
    action->setWhatsThis("https://forum.shotcut.org/t/cut-copy-and-paste-editing/12981/1");
    icon = QIcon::fromTheme("edit-cut", QIcon(":/icons/oxygen/32x32/actions/edit-cut.png"));
    action->setIcon(icon);
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        show();
        raise();
        removeSelection(true);
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = m_selection.selectedClips.length() > 0;
        if (enabled && !selection().isEmpty()) {
            int trackIndex = selection().first().y();
            int clipIndex = selection().first().x();
            enabled = !isBlank(trackIndex, clipIndex) && !isTransition(trackIndex, clipIndex);
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineCutAction", action);

    action = new QAction(tr("&Copy"), this);
    QList<QKeySequence> copyShortcuts;
    copyShortcuts << QKeySequence(Qt::CTRL | Qt::Key_C);
    copyShortcuts << QKeySequence(Qt::Key_C);
    action->setShortcuts(copyShortcuts);
    action->setWhatsThis("https://forum.shotcut.org/t/cut-copy-and-paste-editing/12981/1");
    icon = QIcon::fromTheme("edit-copy", QIcon(":/icons/oxygen/32x32/actions/edit-copy.png"));
    action->setIcon(icon);
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        show();
        raise();
        if (selection().isEmpty()) {
            copy(-1, -1);
        } else {
            auto &selected = selection().first();
            copy(selected.y(), selected.x());
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = m_selection.selectedClips.length() > 0;
        if (enabled && !selection().isEmpty()) {
            int trackIndex = selection().first().y();
            int clipIndex = selection().first().x();
            enabled = !isBlank(trackIndex, clipIndex) && !isTransition(trackIndex, clipIndex);
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineCopyAction", action);

    action = new QAction(tr("&Paste"), this);
    QList<QKeySequence> pasteShortcuts;
    pasteShortcuts << QKeySequence(Qt::CTRL | Qt::Key_V);
    pasteShortcuts << QKeySequence(Qt::Key_V);
    action->setShortcuts(pasteShortcuts);
    action->setWhatsThis("https://forum.shotcut.org/t/cut-copy-and-paste-editing/12981/1");
    icon = QIcon::fromTheme("edit-paste", QIcon(":/icons/oxygen/32x32/actions/edit-paste.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        show();
        raise();
        insert(-1);
    });
    Actions.add("timelinePasteAction", action);

    action = new QAction(tr("Nudge Forward"), this);
    action->setShortcut(QKeySequence(Qt::Key_Period));
    action->setEnabled(false);
    action->setWhatsThis("https://forum.shotcut.org/t/nudge-tool/43158/1");
    connect(action, &QAction::triggered, this, [&]() {
        auto selectedClips = selection();
        if (selectedClips.size() == 1) {
            int trackIndex = selectedClips.first().y();
            int clipIndex = selectedClips.first().x();
            bool valid = clipIndex > 0 && !isTransition(trackIndex, clipIndex)
                         && !isBlank(trackIndex, clipIndex);
            if (valid && !Settings.timelineRipple()) {
                valid = (clipIndex == clipCount(trackIndex) - 1)
                        || isBlank(trackIndex, clipIndex + 1);
            }
            if (!valid) {
                emit showStatusMessage(tr("Nudge Forward is not available"));
                return;
            }
            auto clipInfo = m_model.getClipInfo(trackIndex, clipIndex);
            moveClip(trackIndex,
                     trackIndex,
                     clipIndex,
                     clipInfo->start + 1,
                     Settings.timelineRipple());
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        auto selectedClips = selection();
        bool enabled = selectedClips.size() == 1;
        if (enabled) {
            enabled = !isBlank(selectedClips.first().y(), selectedClips.first().x())
                      && !isTransition(selectedClips.first().y(), selectedClips.first().x());
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineNudgeForwardAction", action);

    action = new QAction(tr("Nudge Backward"), this);
    action->setShortcut(QKeySequence(Qt::Key_Comma));
    action->setEnabled(false);
    action->setWhatsThis("https://forum.shotcut.org/t/nudge-tool/43158/1");
    connect(action, &QAction::triggered, this, [&]() {
        auto selectedClips = selection();
        if (selectedClips.size() == 1) {
            int trackIndex = selectedClips.first().y();
            int clipIndex = selectedClips.first().x();
            if (clipIndex <= 0 || isTransition(trackIndex, clipIndex)
                || isBlank(trackIndex, clipIndex) || !isBlank(trackIndex, clipIndex - 1)) {
                emit showStatusMessage(tr("Nudge Backward is not available"));
                return;
            }
            auto clipInfo = m_model.getClipInfo(trackIndex, clipIndex);
            moveClip(trackIndex,
                     trackIndex,
                     clipIndex,
                     clipInfo->start - 1,
                     Settings.timelineRipple());
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        auto selectedClips = selection();
        bool enabled = selectedClips.size() == 1;
        if (enabled) {
            enabled = !isBlank(selectedClips.first().y(), selectedClips.first().x())
                      && !isTransition(selectedClips.first().y(), selectedClips.first().x());
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineNudgeBackwardAction", action);

    action = new QAction(tr("Append"), this);
    action->setShortcut(QKeySequence(Qt::Key_A));
    icon = QIcon::fromTheme("list-add", QIcon(":/icons/oxygen/32x32/actions/list-add.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        show();
        raise();
        append(currentTrack());
    });
    Actions.add("timelineAppendAction", action);

    action = new QAction(tr("Ripple Delete"), this);
    QList<QKeySequence> deleteShortcuts;
    deleteShortcuts << QKeySequence(Qt::Key_X);
    deleteShortcuts << QKeySequence(Qt::SHIFT | Qt::Key_Backspace);
    deleteShortcuts << QKeySequence(Qt::SHIFT | Qt::Key_Delete);
    action->setShortcuts(deleteShortcuts);
    icon = QIcon::fromTheme("list-remove", QIcon(":/icons/oxygen/32x32/actions/list-remove.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        removeSelection();
    });
    Actions.add("timelineDeleteAction", action);

    action = new QAction(tr("Lift"), this);
    action->setShortcut(QKeySequence(Qt::Key_Z));
    QList<QKeySequence> liftShortcuts;
    liftShortcuts << QKeySequence(Qt::Key_Z);
    liftShortcuts << QKeySequence(Qt::Key_Backspace);
    liftShortcuts << QKeySequence(Qt::Key_Delete);
    action->setShortcuts(liftShortcuts);
    icon = QIcon::fromTheme("lift", QIcon(":/icons/oxygen/32x32/actions/lift.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        liftSelection();
    });
    Actions.add("timelineLiftAction", action);

    action = new QAction(tr("Overwrite"), this);
    action->setShortcut(QKeySequence(Qt::Key_B));
    icon = QIcon::fromTheme("overwrite", QIcon(":/icons/oxygen/32x32/actions/overwrite.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        overwrite(currentTrack());
    });
    Actions.add("timelineOverwriteAction", action);

    action = new QAction(tr("Split At Playhead"), this);
    action->setShortcut(QKeySequence(Qt::Key_S));
    icon = QIcon::fromTheme("slice", QIcon(":/icons/oxygen/32x32/actions/slice.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        int clipIndex = -1;
        int trackIndex = -1;
        std::vector<int> tracks;
        std::vector<int> clips;
        // First check if any clips are selected under the playhead
        QList<QPoint> selected = selection();
        if (selected.size() > 0) {
            for (auto c : selected) {
                clipIndex = c.x();
                trackIndex = c.y();
                if (isTrackLocked(trackIndex)) {
                    emit warnTrackLocked(trackIndex);
                    continue;
                }
                if (clipIndexAtPlayhead(trackIndex) == clipIndex && !isBlank(trackIndex, clipIndex)
                    && !isTransition(trackIndex, clipIndex)) {
                    auto info = m_model.getClipInfo(trackIndex, clipIndex);
                    if (info && m_position > info->start
                        && m_position < info->start + info->frame_count) {
                        tracks.push_back(trackIndex);
                        clips.push_back(clipIndex);
                    }
                }
            }
        }
        // Next, choose a clip based on current track
        if (tracks.size() == 0) {
            clipIndex = -1;
            trackIndex = currentTrack();
            chooseClipAtPosition(m_position, trackIndex, clipIndex);
            if (trackIndex < 0 || clipIndex < 0) {
                return;
            } else if (isTrackLocked(trackIndex)) {
                emit warnTrackLocked(trackIndex);
                return;
            } else if (isBlank(trackIndex, clipIndex)) {
                return;
            } else if (isTransition(trackIndex, clipIndex)) {
                emit showStatusMessage(tr("You cannot split a transition."));
                return;
            }
            auto info = m_model.getClipInfo(trackIndex, clipIndex);
            if (info && m_position > info->start && m_position < info->start + info->frame_count) {
                setCurrentTrack(trackIndex);
                tracks.push_back(trackIndex);
                clips.push_back(clipIndex);
            }
        }
        if (tracks.size() > 0) {
            setSelection(); // Avoid filter views becoming out of sync
            split(tracks[0], clips[0], m_position);
        }
    });
    Actions.add("timelineSplitAction", action);

    action = new QAction(tr("Split All Tracks At Playhead"), this);
    action->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_S));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        std::vector<int> tracks;
        std::vector<int> clips;
        for (int trackIndex = 0; trackIndex < m_model.rowCount(); trackIndex++) {
            int clipIndex = clipIndexAtPosition(trackIndex, m_position);
            if (clipIndex < 0 || isBlank(trackIndex, clipIndex)) {
                continue;
            }
            if (isTrackLocked(trackIndex)) {
                continue;
            }
            if (isTransition(trackIndex, clipIndex)) {
                emit showStatusMessage(tr("You cannot split a transition."));
                continue;
            }
            auto info = m_model.getClipInfo(trackIndex, clipIndex);
            if (info && m_position > info->start && m_position < info->start + info->frame_count) {
                tracks.push_back(trackIndex);
                clips.push_back(clipIndex);
            }
        }
        if (tracks.size() > 0) {
            setSelection(); // Avoid filter views becoming out of sync
            MAIN.undoStack()->push(new Timeline::SplitCommand(m_model, tracks, clips, m_position));
        }
    });
    Actions.add("timelineSplitAllTracksAction", action);

    action = new QAction(tr("Replace"), this);
    action->setShortcut(QKeySequence(Qt::Key_R));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        if (MLT.isClip() || selection().isEmpty()) {
            replace(-1, -1);
        } else {
            auto &selected = selection().first();
            replace(selected.y(), selected.x());
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        action->setEnabled(m_selection.selectedClips.length() > 0);
    });
    Actions.add("timelineReplaceAction", action);

    action = new QAction(tr("Create/Edit Marker"), this);
    action->setShortcut(QKeySequence(Qt::Key_M));
    action->setWhatsThis("https://forum.shotcut.org/t/timeline-markers/30535/1");
    icon = QIcon::fromTheme("marker", QIcon(":/icons/oxygen/32x32/actions/marker.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        createOrEditMarker();
    });
    Actions.add("timelineMarkerAction", action);

    action = new QAction(tr("Previous Marker"), this);
    action->setShortcut(QKeySequence(Qt::Key_Less));
    action->setWhatsThis("https://forum.shotcut.org/t/timeline-markers/30535/1");
    icon = QIcon::fromTheme("format-indent-less",
                            QIcon(":/icons/oxygen/32x32/actions/format-indent-less.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        seekPrevMarker();
    });
    Actions.add("timelinePrevMarkerAction", action);

    action = new QAction(tr("Next Marker"), this);
    action->setShortcut(QKeySequence(Qt::Key_Greater));
    action->setWhatsThis("https://forum.shotcut.org/t/timeline-markers/30535/1");
    icon = QIcon::fromTheme("format-indent-more",
                            QIcon(":/icons/oxygen/32x32/actions/format-indent-more.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        seekNextMarker();
    });
    Actions.add("timelineNextMarkerAction", action);

    action = new QAction(tr("Delete Marker"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_M));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        deleteMarker();
    });
    Actions.add("timelineDeleteMarkerAction", action);

    action = new QAction(tr("Cycle Marker Color"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_M));
    connect(action, &QAction::triggered, this, [&]() {
        int markerIndex = m_markersModel.markerIndexForPosition(m_position);
        if (markerIndex >= 0) {
            auto marker = m_markersModel.getMarker(markerIndex);
            auto allColors = m_markersModel.allColors();
            int colorIndex = allColors.indexOf(marker.color);
            if (colorIndex >= 0) {
                colorIndex = (colorIndex + 1) % allColors.size();
                m_markersModel.setColor(markerIndex, allColors[colorIndex]);
            }
        }
    });
    Actions.add("timelineCycleMarkerColorAction", action);

    action = new QAction(tr("Create Marker Around Selected Clip"), this);
    action->setShortcut(QKeySequence(Qt::ALT | Qt::Key_M));
    connect(action, &QAction::triggered, this, [&]() {
        if (!isMultitrackValid())
            return;
        show();
        raise();
        createOrEditSelectionMarker();
    });
    Actions.add("timelineMarkSelectedClipAction", action);

    action = new QAction(tr("Rectangle Selection"), this);
    action->setCheckable(true);
    action->setChecked(Settings.timelineRectangleSelect());
    action->setWhatsThis("https://forum.shotcut.org/t/rectangle-selection/42202/1");
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineRectangleSelect(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineRectangleSelectChanged, action, [=]() {
        action->setChecked(Settings.timelineRectangleSelect());
    });
    Actions.add("timelineRectangleSelectAction", action);

    action = new QAction(tr("Adjust Clip Gain/Volume"), this);
    action->setCheckable(true);
    action->setChecked(Settings.timelineAdjustGain());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineAdjustGain(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineAdjustGainChanged, action, [=]() {
        action->setChecked(Settings.timelineAdjustGain());
    });
    Actions.add("timelineAdjustGainAction", action);

    action = new QAction(tr("Automatically Add Tracks"), this);
    action->setCheckable(true);
    action->setChecked(Settings.timelineAutoAddTracks());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineAutoAddTracks(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineAutoAddTracksChanged, action, [=]() {
        action->setChecked(Settings.timelineAutoAddTracks());
    });
    Actions.add("timelineAutoAddTracksAction", action);

    action = new QAction(tr("Create Transitions on Overlap"), this);
    action->setCheckable(true);
    action->setChecked(Settings.timelineAllowTransitions());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineAllowTransitions(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineAllowTransitionsChanged, action, [=]() {
        action->setChecked(Settings.timelineAllowTransitions());
    });
    Actions.add("timelineAllowTransitionsAction", action);

    action = new QAction(tr("Snap"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_P));
    icon = QIcon::fromTheme("snap", QIcon(":/icons/oxygen/32x32/actions/snap.png"));
    action->setIcon(icon);
    action->setCheckable(true);
    action->setChecked(Settings.timelineSnap());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineSnap(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineSnapChanged, action, [=]() {
        action->setChecked(Settings.timelineSnap());
    });
    Actions.add("timelineSnapAction", action);

    action = new QAction(tr("Scrub While Dragging"), this);
    icon = QIcon::fromTheme("scrub_drag", QIcon(":/icons/oxygen/32x32/actions/scrub_drag.png"));
    action->setIcon(icon);
    action->setCheckable(true);
    action->setChecked(Settings.timelineDragScrub());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineDragScrub(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineDragScrubChanged, action, [=]() {
        action->setChecked(Settings.timelineDragScrub());
    });
    Actions.add("timelineScrubDragAction", action);

    action = new QAction(tr("Ripple"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_R));
    icon = QIcon::fromTheme("target", QIcon(":/icons/oxygen/32x32/actions/target.png"));
    action->setIcon(icon);
    action->setCheckable(true);
    action->setChecked(Settings.timelineRipple());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineRipple(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineRippleChanged, action, [=]() {
        action->setChecked(Settings.timelineRipple());
    });
    Actions.add("timelineRippleAction", action);

    action = new QAction(tr("Ripple All Tracks"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_R));
    action->setWhatsThis("https://forum.shotcut.org/t/timeline-ripple-all-tracks/23701/1");
    icon = QIcon::fromTheme("ripple-all", QIcon(":/icons/oxygen/32x32/actions/ripple-all.png"));
    action->setIcon(icon);
    action->setCheckable(true);
    action->setChecked(Settings.timelineRippleAllTracks());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineRippleAllTracks(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineRippleAllTracksChanged, action, [=]() {
        action->setChecked(Settings.timelineRippleAllTracks());
    });
    Actions.add("timelineRippleAllTracksAction", action);

    action = new QAction(tr("Ripple Markers"), this);
    action->setShortcut(QKeySequence(Qt::ALT | Qt::Key_R));
    action->setWhatsThis("https://forum.shotcut.org/t/timeline-markers/30535/1");
    icon = QIcon::fromTheme("ripple-marker",
                            QIcon(":/icons/oxygen/32x32/actions/ripple-marker.png"));
    action->setIcon(icon);
    action->setCheckable(true);
    action->setChecked(Settings.timelineRippleMarkers());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineRippleMarkers(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineRippleMarkersChanged, action, [=]() {
        action->setChecked(Settings.timelineRippleMarkers());
    });
    Actions.add("timelineRippleMarkersAction", action);

    action = new QAction(tr("Toggle Ripple And All Tracks"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_R));
    connect(action, &QAction::triggered, this, [&]() {
        Settings.setTimelineRippleAllTracks(!Settings.timelineRipple());
        Settings.setTimelineRipple(!Settings.timelineRipple());
    });
    Actions.add("timelineToggleRippleAndAllTracksAction", action);

    action = new QAction(tr("Toggle Ripple, All Tracks, And Markers"), this);
    action->setShortcut(QKeySequence(Qt::ALT | Qt::SHIFT | Qt::Key_R));
    connect(action, &QAction::triggered, this, [&]() {
        Settings.setTimelineRippleAllTracks(!Settings.timelineRipple());
        Settings.setTimelineRipple(!Settings.timelineRipple());
        Settings.setTimelineRippleMarkers(!Settings.timelineRippleMarkers());
    });
    Actions.add("timelineToggleRippleAllTracksAndMarkersAction", action);

    action = new QAction(tr("Show Audio Waveforms"), this);
    action->setCheckable(true);
    action->setChecked(Settings.timelineShowWaveforms());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineShowWaveforms(checked);
        if (!isVisible() || !m_quickView.rootObject())
            return;
        emit refreshWaveforms();
    });
    connect(&Settings, &ShotcutSettings::timelineShowWaveformsChanged, action, [=]() {
        action->setChecked(Settings.timelineShowWaveforms());
    });
    Actions.add("timelineShowWaveformsAction", action);

    action = new QAction(tr("Use Higher Performance Waveforms"), this);
    action->setCheckable(true);
    action->setChecked(Settings.timelineFramebufferWaveform());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineFramebufferWaveform(checked);
        if (!isVisible() || !m_quickView.rootObject())
            return;
        if (Settings.timelineFramebufferWaveform()) {
            m_model.reload();
        }
    });
    connect(&Settings, &ShotcutSettings::timelineFramebufferWaveformChanged, action, [=]() {
        action->setChecked(Settings.timelineFramebufferWaveform());
    });
    Actions.add("timelinePerformanceWaveformsAction", action);

    action = new QAction(tr("Show Video Thumbnails"), this);
    action->setCheckable(true);
    action->setChecked(Settings.timelineShowThumbnails());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineShowThumbnails(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineShowThumbnailsChanged, action, [=]() {
        action->setChecked(Settings.timelineShowThumbnails());
    });
    Actions.add("timelineShowThumbnailsAction", action);

    action = new QAction(tr("No"), this);
    action->setCheckable(true);
    action->setChecked(ShotcutSettings::TimelineScrolling::NoScrolling
                       == Settings.timelineScrolling());
    connect(action, &QAction::triggered, this, [&]() {
        Settings.setTimelineScrolling(ShotcutSettings::TimelineScrolling::NoScrolling);
    });
    connect(&Settings, &ShotcutSettings::timelineScrollingChanged, action, [=]() {
        if (ShotcutSettings::TimelineScrolling::NoScrolling == Settings.timelineScrolling())
            action->setChecked(true);
    });
    Actions.add("timelineScrollingNo", action);

    action = new QAction(tr("Page"), this);
    action->setCheckable(true);
    action->setChecked(ShotcutSettings::TimelineScrolling::PageScrolling
                       == Settings.timelineScrolling());
    connect(action, &QAction::triggered, this, [&]() {
        Settings.setTimelineScrolling(ShotcutSettings::TimelineScrolling::PageScrolling);
    });
    connect(&Settings, &ShotcutSettings::timelineScrollingChanged, action, [=]() {
        if (ShotcutSettings::TimelineScrolling::PageScrolling == Settings.timelineScrolling())
            action->setChecked(true);
    });
    Actions.add("timelineScrollingPage", action);

    action = new QAction(tr("Smooth"), this);
    action->setCheckable(true);
    action->setChecked(ShotcutSettings::TimelineScrolling::SmoothScrolling
                       == Settings.timelineScrolling());
    connect(action, &QAction::triggered, this, [&]() {
        Settings.setTimelineScrolling(ShotcutSettings::TimelineScrolling::SmoothScrolling);
    });
    connect(&Settings, &ShotcutSettings::timelineScrollingChanged, action, [=]() {
        if (ShotcutSettings::TimelineScrolling::SmoothScrolling == Settings.timelineScrolling())
            action->setChecked(true);
    });
    Actions.add("timelineScrollingSmooth", action);

    action = new QAction(tr("Center the Playhead"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_P));
    action->setCheckable(true);
    action->setChecked(ShotcutSettings::TimelineScrolling::CenterPlayhead
                       == Settings.timelineScrolling());
    connect(action, &QAction::triggered, this, [&]() {
        Settings.setTimelineScrolling(ShotcutSettings::TimelineScrolling::CenterPlayhead);
    });
    connect(&Settings, &ShotcutSettings::timelineScrollingChanged, action, [=]() {
        if (ShotcutSettings::TimelineScrolling::CenterPlayhead == Settings.timelineScrolling())
            action->setChecked(true);
    });
    Actions.add("timelineScrollingCenterPlayhead", action);

    action = new QAction(tr("Scroll to Playhead on Zoom"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::ALT | Qt::Key_P));
    action->setCheckable(true);
    action->setChecked(Settings.timelineScrollZoom());
    connect(action, &QAction::triggered, this, [&](bool checked) {
        Settings.setTimelineScrollZoom(checked);
    });
    connect(&Settings, &ShotcutSettings::timelineScrollZoomChanged, action, [=]() {
        action->setChecked(Settings.timelineScrollZoom());
    });
    Actions.add("timelineScrollZoomAction", action);

    action = new QAction(tr("Zoom Timeline Out"), this);
    action->setShortcut(QKeySequence(Qt::Key_Minus));
    icon = QIcon::fromTheme("zoom-out", QIcon(":/icons/oxygen/32x32/actions/zoom-out.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isVisible() || !m_quickView.rootObject())
            return;
        emit zoomOut();
    });
    Actions.add("timelineZoomOutAction", action);

    action = new QAction(tr("Zoom Timeline In"), this);
    action->setShortcut(QKeySequence(Qt::Key_Plus));
    icon = QIcon::fromTheme("zoom-in", QIcon(":/icons/oxygen/32x32/actions/zoom-in.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isVisible() || !m_quickView.rootObject())
            return;
        emit zoomIn();
    });
    Actions.add("timelineZoomInAction", action);

    action = new QAction(tr("Zoom Timeline To Fit"), this);
    action->setShortcut(QKeySequence(Qt::Key_0));
    icon = QIcon::fromTheme("zoom-fit-best",
                            QIcon(":/icons/oxygen/32x32/actions/zoom-fit-best.png"));
    action->setIcon(icon);
    connect(action, &QAction::triggered, this, [&]() {
        if (!isVisible())
            return;
        emit zoomToFit();
    });
    Actions.add("timelineZoomFitAction", action);

    action = new QAction(tr("Generate"), this);
    action->setWhatsThis(
        "https://forum.shotcut.org/t/user-guide-table-of-contents/43285#p-145073-properties-14");
    icon = QIcon::fromTheme("document-new", QIcon(":/icons/oxygen/32x32/actions/document-new.png"));
    action->setIcon(icon);
    {
        QScopedPointer<Mlt::Properties> mltProducers(MLT.repository()->producers());
        QScopedPointer<Mlt::Properties> mltFilters(MLT.repository()->filters());
        auto menu = new QMenu;
        if (mltProducers->get_data("color")) {
            menu->addAction(tr("Color"), this, SLOT(addGenerator()))->setObjectName("color");
            if (mltProducers->get_data("qtext") && mltFilters->get_data("dynamictext")) {
                menu->addAction(tr("Text"), this, SLOT(addGenerator()))->setObjectName("text");
            }
        }
        if (mltProducers->get_data("glaxnimate")) {
            menu->addAction(tr("Drawing/Animation"), this, SLOT(addGenerator()))
                ->setObjectName("glaxnimate");
            menu->addAction(tr("Elements"), this, []() { MAIN.onElementsDockTriggered(); });
        }
        if (mltProducers->get_data("noise")) {
            menu->addAction(tr("Noise"), this, SLOT(addGenerator()))->setObjectName("noise");
        }
        if (mltProducers->get_data("frei0r.test_pat_B")) {
            menu->addAction(tr("Color Bars"), this, SLOT(addGenerator()))
                ->setObjectName("test_pat_B");
        }
        if (mltProducers->get_data("tone")) {
            menu->addAction(tr("Audio Tone"), this, SLOT(addGenerator()))->setObjectName("tone");
        }
        if (mltProducers->get_data("count")) {
            menu->addAction(tr("Count"), this, SLOT(addGenerator()))->setObjectName("count");
        }
        if (mltProducers->get_data("blipflash")) {
            menu->addAction(tr("Blip Flash"), this, SLOT(addGenerator()))->setObjectName("blipflash");
        }
#if LIBMLT_VERSION_INT >= ((7 << 16) + (41 << 8))
        if (mltProducers->get_data("color")) {
            menu->addSeparator();
            menu->addAction(tr("Adjustment Clip"), this, SLOT(addGenerator()))
                ->setObjectName("adjustment");
        }
#endif
        action->setMenu(menu);
    }
    Actions.add("timelineNewGenerator", action, windowTitle());

    action = new QAction(tr("Record Audio"), this);
    action->setWhatsThis("https://forum.shotcut.org/t/record-audio-voice-over/33111/1");
    icon = QIcon::fromTheme("audio-input-microphone",
                            QIcon(":/icons/oxygen/32x32/devices/audio-input-microphone.png"));
    action->setIcon(icon);
    action->setCheckable(true);
    action->setChecked(isRecording());
    connect(action, &QAction::triggered, this, [&]() {
        if (isRecording())
            stopRecording();
        else
            recordAudio();
    });
    connect(this, &TimelineDock::isRecordingChanged, action, [=]() {
        action->setChecked(isRecording());
    });
    Actions.add("timelineRecordAudioAction", action);

    action = new QAction(tr("Properties"), this);
    connect(action, &QAction::triggered, this, [&]() { openProperties(); });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        action->setEnabled(!blankIsSelected());
    });
    Actions.add("timelinePropertiesAction", action);

    action = new QAction(tr("Rejoin With Next Clip"), this);
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        if (m_selection.selectedClips.length() == 1 && !selection().isEmpty()) {
            mergeClipWithNext(selection().first().y(), selection().first().x(), false);
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = false;
        if (m_selection.selectedClips.length() == 1 && !selection().isEmpty()) {
            enabled = mergeClipWithNext(selection().first().y(), selection().first().x(), true);
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineMergeWithNextAction", action);

    action = new QAction(tr("Detach Audio"), this);
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        if (m_selection.selectedClips.length() == 1 && !selection().isEmpty()) {
            detachAudio(selection().first().y(), selection().first().x());
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = false;
        if (m_selection.selectedClips.length() == 1 && !selection().isEmpty()) {
            int trackIndex = selection().first().y();
            int clipIndex = selection().first().x();
            if (trackIndex >= 0 && clipIndex >= 0) {
                QModelIndex modelIndex = m_model.index(clipIndex, 0, m_model.index(trackIndex));
                if (modelIndex.isValid()) {
                    enabled = !modelIndex.data(MultitrackModel::IsBlankRole).toBool()
                              && !modelIndex.data(MultitrackModel::IsTransitionRole).toBool()
                              && !modelIndex.data(MultitrackModel::IsAudioRole).toBool()
                              && (modelIndex.data(MultitrackModel::AudioIndexRole).toInt() > -1
                                  || modelIndex.data(MultitrackModel::AudioIndexRole).toString()
                                         == "all");
                }
            }
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineDetachAudioAction", action);

    action = new QAction(tr("Freeze Frame"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_H));
    connect(action, &QAction::triggered, this, [&] { freezeFrame(); });
    Actions.add("timelineFreezeFrameAction", action);
    action->setEnabled(false);
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = true;
        auto clipIndex = -1;
        auto trackIndex = currentTrack();
        if (!selection().isEmpty()) {
            auto &selected = selection().first();
            trackIndex = selected.y();
            clipIndex = selected.x();
            if (trackIndex < 0)
                trackIndex = currentTrack();
            if (clipIndex < 0)
                clipIndex = clipIndexAtPlayhead(trackIndex);

            auto info = m_model.getClipInfo(trackIndex, clipIndex);
            if (info) {
                std::unique_ptr<Mlt::Link> link(MLT.getLink("timeremap", info->producer));
                enabled = !link
                          && QString::fromLatin1(info->producer->get("mlt_service"))
                                 .startsWith("avformat");
            } else {
                enabled = false;
            }
        }
        action->setEnabled(enabled);
    });
    action = new QAction(tr("Align To Reference Track"), this);
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        if (m_selection.selectedClips.length() > 0) {
            alignSelectedClips();
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = false;
        foreach (auto point, selection()) {
            // At least one selected item must be a valid clip
            if (!isBlank(point.y(), point.x()) && !isTransition(point.y(), point.x())) {
                enabled = true;
                break;
            }
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineAlignToReferenceAction", action);

    action = new QAction(tr("Apply Copied Filters"), this);
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        if (m_selection.selectedClips.length() > 0) {
            applyCopiedFiltersToSelectdClips();
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = false;
        foreach (auto point, selection()) {
            // At least one selected item must be a valid clip
            if (!isBlank(point.y(), point.x()) && !isTransition(point.y(), point.x())) {
                enabled = true;
                break;
            }
        }
        if (enabled) {
            auto s = QGuiApplication::clipboard()->text();
            enabled = s.contains(kShotcutFiltersClipboard);
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineApplyCopiedFiltersAction", action);

    action = new QAction(tr("Update Thumbnails"), this);
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        foreach (auto point, selection()) {
            if (!isBlank(point.y(), point.x()) && !isTransition(point.y(), point.x())) {
                emit updateThumbnails(point.y(), point.x());
            }
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = false;
        if (Settings.timelineShowThumbnails()) {
            foreach (auto point, selection()) {
                // At least one selected item must be a valid clip
                if (!isBlank(point.y(), point.x()) && !isTransition(point.y(), point.x())) {
                    enabled = true;
                    break;
                }
            }
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineUpdateThumbnailsAction", action);

    action = new QAction(tr("Rebuild Audio Waveform"), this);
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        foreach (auto point, selection()) {
            if (!isBlank(point.y(), point.x()) && !isTransition(point.y(), point.x())) {
                remakeAudioLevels(point.y(), point.x());
            }
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        bool enabled = false;
        if (Settings.timelineShowWaveforms()) {
            foreach (auto point, selection()) {
                // At least one selected item must be a valid clip
                if (!isBlank(point.y(), point.x()) && !isTransition(point.y(), point.x())) {
                    enabled = true;
                    break;
                }
            }
        }
        action->setEnabled(enabled);
    });
    Actions.add("timelineRebuildAudioWaveformAction", action);

    action = new QAction(tr("Ripple Trim Clip In"), this);
    action->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_I));
    connect(action, &QAction::triggered, this, [&]() {
        trimClipAtPlayhead(TimelineDock::TrimInPoint, true);
    });
    Actions.add("timelineRippleTrimClipInAction", action);

    action = new QAction(tr("Ripple Trim Clip Out"), this);
    action->setShortcut(QKeySequence(Qt::SHIFT | Qt::Key_O));
    connect(action, &QAction::triggered, this, [&]() {
        trimClipAtPlayhead(TimelineDock::TrimOutPoint, true);
    });
    Actions.add("timelineRippleTrimClipOutAction", action);

    action = new QAction(tr("Group/Ungroup"), this);
    action->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_G));
    action->setEnabled(false);
    connect(action, &QAction::triggered, this, [&]() {
        auto selectedClips = selection();
        if (selectedClips.size() <= 1) {
            LOG_ERROR() << "Not enough clips selected" << selectedClips.size();
            return;
        }
        auto firstClip = m_model.getClipInfo(selectedClips[0].y(), selectedClips[0].x());
        if (firstClip->cut->property_exists(kShotcutGroupProperty)) {
            // First clip is in a group. Need to ungroup
            Timeline::UngroupCommand *ungroupCommand = new Timeline::UngroupCommand(m_model);
            foreach (auto point, selectedClips) {
                ungroupCommand->removeFromGroup(point.y(), point.x());
            }
            MAIN.undoStack()->push(ungroupCommand);
        } else {
            // First clip is not in a group - group
            Timeline::GroupCommand *groupCommand = new Timeline::GroupCommand(m_model);
            foreach (auto point, selectedClips) {
                groupCommand->addToGroup(point.y(), point.x());
            }
            MAIN.undoStack()->push(groupCommand);
        }
    });
    connect(this, &TimelineDock::selectionChanged, action, [=]() {
        action->setEnabled(selection().size() > 1);
    });
    Actions.add("timelineGroupAction", action);
}

int TimelineDock::addTrackIfNeeded(TrackType trackType)
{
    // See if current track matches with no clips at playhead and beyond
    bool addTrack = true;
    auto trackIndex = currentTrack();
    // Hunt for next available track
    while (addTrack && trackIndex >= 0 && trackIndex < m_model.trackList().size()) {
        auto clipIndex = clipIndexAtPosition(trackIndex, position());
        if (clipIndex == -1
            || (isBlank(trackIndex, clipIndex) && clipIndex >= clipCount(trackIndex) - 1)) {
            // This track is available if correct type
            addTrack = m_model.trackList().at(trackIndex).type != trackType;
        }
        if (addTrack)
            // Still looking
            trackIndex += (trackType == AudioTrackType) ? 1 : -1;
    }
    // Add track if needed
    if (addTrack) {
        trackIndex = (trackType == AudioTrackType) ? addAudioTrack() : addVideoTrack();
        setCurrentTrack(trackIndex);
    }
    return trackIndex;
}

void TimelineDock::getSelectionRange(int *start, int *end)
{
    auto selected = selection();
    if (selected.isEmpty()) {
        *start = -1;
        *end = -1;
        return;
    }

    // Find the earliest start and the latest end in the selection
    *start = std::numeric_limits<int>::max();
    *end = std::numeric_limits<int>::min();
    for (const auto &clip : selected) {
        auto info = m_model.getClipInfo(clip.y(), clip.x());
        if (info) {
            if (info->start < *start) {
                *start = info->start;
            }
            if ((info->start + info->frame_count) > *end) {
                *end = info->start + info->frame_count;
            }
        }
    }
}

void TimelineDock::setPosition(int position)
{
    if (!m_model.tractor())
        return;
    if (position <= m_model.tractor()->get_length()) {
        emit seeked(position);
    } else {
        m_position = m_model.tractor()->get_length();
        emit positionChanged(m_position);
    }
}

Mlt::Producer TimelineDock::producerForClip(int trackIndex, int clipIndex)
{
    Mlt::Producer result;
    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (info) {
        result = Mlt::Producer(info->producer);
    }
    return result;
}

int TimelineDock::clipIndexAtPlayhead(int trackIndex)
{
    return clipIndexAtPosition(trackIndex, m_position);
}

int TimelineDock::clipIndexAtPosition(int trackIndex, int position)
{
    int result = -1;
    if (trackIndex < 0)
        trackIndex = currentTrack();
    if (trackIndex >= 0 && trackIndex < m_model.trackList().size()) {
        int i = m_model.trackList().at(trackIndex).mlt_index;
        QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
        if (track) {
            Mlt::Playlist playlist(*track);
            result = playlist.get_clip_index_at(position);
            if (result >= playlist.count())
                result = -1;
        }
    }
    return result;
}

bool TimelineDock::isBlank(int trackIndex, int clipIndex)
{
    return trackIndex >= 0 && clipIndex >= 0
           && m_model.index(clipIndex, 0, m_model.index(trackIndex))
                  .data(MultitrackModel::IsBlankRole)
                  .toBool();
}

bool TimelineDock::clipsAreSelected()
{
    const QList<QPoint> &selection = m_selection.selectedClips;
    return selection.size() > 0 && !isBlank(selection[0].y(), selection[0].x());
}

bool TimelineDock::blankIsSelected()
{
    const QList<QPoint> &selection = m_selection.selectedClips;
    return selection.size() == 1 && isBlank(selection[0].y(), selection[0].x());
}

bool TimelineDock::nothingIsSelected()
{
    const QList<QPoint> &selection = m_selection.selectedClips;
    return selection.size() == 0 && !m_selection.isMultitrackSelected
           && m_selection.selectedTrack == -1;
}

bool TimelineDock::isTransition(int trackIndex, int clipIndex)
{
    return trackIndex >= 0 && clipIndex >= 0
           && m_model.index(clipIndex, 0, m_model.index(trackIndex))
                  .data(MultitrackModel::IsTransitionRole)
                  .toBool();
}

bool TimelineDock::isEmptyTrack(Mlt::Playlist &playlist)
{
    return playlist.count() == 1 && playlist.is_blank(0);
}

void TimelineDock::onWarnTrackLocked()
{
    emit showStatusMessage(tr("This track is locked"));
}

void TimelineDock::emitNonSeekableWarning()
{
    emit showStatusMessage(tr("You cannot add a non-seekable source."));
}

void TimelineDock::emitClipExtendStatus(int cappedOut, int requestedOut, int currentOut)
{
    if (cappedOut < requestedOut) {
        if (cappedOut <= currentOut) {
            emit showStatusMessage(tr("Not enough space to extend the clip"));
        } else {
            emit showStatusMessage(tr("Not enough space; clip extended as far as possible"));
        }
    }
}

void TimelineDock::addTrackIfNeeded(int trackIndex, Mlt::Producer *srcTrack)
{
    const auto n = m_model.trackList().size();
    if (trackIndex >= n) {
        if (m_selection.selectedTrack != -1)
            setSelection();
        if (srcTrack->get_int(kAudioTrackProperty)
            || (n > 0 && m_model.trackList()[n - 1].type == AudioTrackType)) {
            MAIN.undoStack()->push(
                new Timeline::InsertTrackCommand(m_model, trackIndex, AudioTrackType));
        } else {
            MAIN.undoStack()->push(
                new Timeline::InsertTrackCommand(m_model, trackIndex, VideoTrackType));
        }
    }
}

void TimelineDock::chooseClipAtPosition(int position, int &trackIndex, int &clipIndex)
{
    QScopedPointer<Mlt::Producer> clip;

    // Start by checking for a hit at the specified track
    if (trackIndex != -1 && !isTrackLocked(trackIndex)) {
        clipIndex = clipIndexAtPosition(trackIndex, position);
        if (clipIndex != -1 && !isBlank(trackIndex, clipIndex))
            return;
    }

    // Next we try the current track
    trackIndex = currentTrack();
    clipIndex = qMin(clipIndexAtPosition(trackIndex, position), clipCount(trackIndex) - 1);

    if (!isTrackLocked(trackIndex) && clipIndex != -1 && !isBlank(trackIndex, clipIndex)) {
        return;
    }

    // if there was no hit, look through the other tracks
    for (trackIndex = 0; trackIndex < m_model.trackList().size(); (trackIndex)++) {
        if (trackIndex == currentTrack())
            continue;
        if (isTrackLocked(trackIndex))
            continue;
        clipIndex = clipIndexAtPosition(trackIndex, position);
        if (clipIndex != -1 && !isBlank(trackIndex, clipIndex))
            return;
    }

    // As last resort choose blank on current track
    trackIndex = currentTrack();
    if (!isTrackLocked(trackIndex)) {
        clipIndex = clipIndexAtPosition(trackIndex, position);
        if (clipIndex != -1)
            return;
    }

    trackIndex = -1;
    clipIndex = -1;
}

int TimelineDock::clipCount(int trackIndex) const
{
    if (trackIndex < 0)
        trackIndex = currentTrack();
    if (trackIndex >= 0 && trackIndex < m_model.trackList().size()) {
        int i = m_model.trackList().at(trackIndex).mlt_index;
        QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
        if (track) {
            Mlt::Playlist playlist(*track);
            if (playlist.is_valid())
                return playlist.count();
        }
    }
    return 0;
}

void TimelineDock::setCurrentTrack(int currentTrack)
{
    if (currentTrack != m_currentTrack) {
        m_currentTrack = currentTrack;
        emit currentTrackChanged();
    }
}

/*!
    \qmlproperty int TimelineDock::currentTrack
    \brief The zero-based index of the currently focused timeline track. Settable.
*/

int TimelineDock::currentTrack() const
{
    return qBound(0,
                  m_currentTrack,
                  m_model.trackList().isEmpty() ? 0 : m_model.trackList().size() - 1);
}

void TimelineDock::setSelectionFromJS(const QVariantList &list)
{
    QList<QPoint> points;
    for (const auto &v : list) {
        points << v.toPoint();
    }
    setSelection(points);
}

void TimelineDock::setSelection(QList<QPoint> newSelection, int trackIndex, bool isMultitrack)
{
    if (newSelection != selection() || trackIndex != m_selection.selectedTrack
        || isMultitrack != m_selection.isMultitrackSelected) {
        LOG_DEBUG() << "Changing selection to" << newSelection << " trackIndex" << trackIndex
                    << "isMultitrack" << isMultitrack;
        m_selection.selectedClips = newSelection;
        m_selection.selectedTrack = trackIndex;
        m_selection.isMultitrackSelected = isMultitrack;
        // Save the selection in case it will be restored later due to a player tab change.
        m_savedSelectionUuids = selectionUuids();
        m_selectionSignalTimer.start();
    }
}

/*!
    \qmlproperty list<var> TimelineDock::selection
    \brief The current clip selection as a list of \c {point} values where \c x is the
    track index and \c y is the clip index. Settable.
*/

QVariantList TimelineDock::selectionForJS() const
{
    QVariantList result;
    foreach (auto point, selection())
        result << QVariant(point);
    return result;
}

const QList<QPoint> TimelineDock::selection() const
{
    if (!m_quickView.rootObject())
        return QList<QPoint>();
    return m_selection.selectedClips;
}

const QVector<QUuid> TimelineDock::selectionUuids()
{
    QVector<QUuid> result;
    for (const auto &clip : selection()) {
        auto info = m_model.getClipInfo(clip.y(), clip.x());
        if (info && info->cut && info->cut->is_blank()) {
            result << MLT.ensureHasUuid(*info->cut);
        } else if (info && info->producer && info->producer->is_valid()) {
            result << MLT.ensureHasUuid(*info->producer);
        }
    }
    return result;
}

const QList<QPoint> TimelineDock::uuidsToSelection(QVector<QUuid> uuids) const
{
    QList<QPoint> points;
    for (int trackIndex = 0; trackIndex < m_model.trackList().size() && uuids.size() > 0;
         trackIndex++) {
        int i = m_model.trackList().at(trackIndex).mlt_index;
        QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
        if (track) {
            Mlt::Playlist playlist(*track);
            for (int clipIndex = 0; clipIndex < playlist.count() && uuids.size() > 0; clipIndex++) {
                Mlt::ClipInfo *info;
                if ((info = playlist.clip_info(clipIndex))) {
                    for (int i = 0; i < uuids.size(); i++) {
                        if (MLT.uuid(*info->producer) == uuids[i]
                            || MLT.uuid(*info->cut) == uuids[i]) {
                            points << QPoint(clipIndex, trackIndex);
                            uuids.removeAt(i);
                            break;
                        }
                    }
                    delete info;
                }
            }
        }
    }
    return points;
}

void TimelineDock::saveAndClearSelection()
{
    m_savedSelectedTrack = m_selection.selectedTrack;
    m_savedIsMultitrackSelected = m_selection.isMultitrackSelected;
    m_savedSelectionUuids = selectionUuids();
    m_selection.selectedClips = QList<QPoint>();
    m_selection.selectedTrack = -1;
    m_selection.isMultitrackSelected = false;
    emit selectionChanged();
}

/*!
    \qmlmethod void TimelineDock::restoreSelection()
    \brief Restores the previously saved clip selection.
*/

void TimelineDock::restoreSelection()
{
    QList<QPoint> restoredSelection = uuidsToSelection(m_savedSelectionUuids);
    setSelection(restoredSelection, m_savedSelectedTrack, m_savedIsMultitrackSelected);
}

/*!
    \qmlmethod list<var> TimelineDock::getGroupForClip(int trackIndex, int clipIndex)
    \brief Returns the list of all clip coordinates (\c {point} values) in the same group
    as the clip at (\a trackIndex, \a clipIndex).
*/

QVariantList TimelineDock::getGroupForClip(int trackIndex, int clipIndex)
{
    QVariantList result;

    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (!info || !info->cut) {
        return result;
    }
    if (!info->cut->property_exists(kShotcutGroupProperty)) {
        result << QPoint(clipIndex, trackIndex);
    } else {
        int group = info->cut->get_int(kShotcutGroupProperty);
        for (int trackIndex = 0; trackIndex < m_model.trackList().size(); trackIndex++) {
            int i = m_model.trackList().at(trackIndex).mlt_index;
            QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
            if (track) {
                Mlt::Playlist playlist(*track);
                for (int clipIndex = 0; clipIndex < playlist.count(); clipIndex++) {
                    QScopedPointer<Mlt::ClipInfo> info(playlist.clip_info(clipIndex));
                    if (info && info->cut && info->cut->property_exists(kShotcutGroupProperty)
                        && info->cut->get_int(kShotcutGroupProperty) == group) {
                        result << QPoint(clipIndex, trackIndex);
                    }
                }
            }
        }
    }
    return result;
}

void TimelineDock::selectClipUnderPlayhead()
{
    int track = -1, clip = -1;
    chooseClipAtPosition(m_position, track, clip);
    if (clip == -1) {
        if (isTrackLocked(currentTrack())) {
            emit warnTrackLocked(currentTrack());
            return;
        }
        int idx = clipIndexAtPlayhead(-1);
        if (idx == -1)
            setSelection();
        else
            setSelection(QList<QPoint>() << QPoint(idx, track));
        return;
    }

    if (track != -1) {
        setCurrentTrack(track);
        setSelection(QList<QPoint>() << QPoint(clip, track));
    }
}

int TimelineDock::centerOfClip(int trackIndex, int clipIndex)
{
    auto clip = m_model.getClipInfo(trackIndex, clipIndex);
    return clip ? clip->start + clip->frame_count / 2 : -1;
}

bool TimelineDock::isTrackLocked(int trackIndex) const
{
    if (trackIndex < 0 || trackIndex >= m_model.trackList().size())
        return false;
    int i = m_model.trackList().at(trackIndex).mlt_index;
    QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
    return track->get_int(kTrackLockProperty);
}

void TimelineDock::trimClipAtPlayhead(TrimLocation location, bool ripple)
{
    int trackIndex = currentTrack(), clipIndex = -1;
    chooseClipAtPosition(m_position, trackIndex, clipIndex);
    if (trackIndex < 0 || clipIndex < 0)
        return;
    setCurrentTrack(trackIndex);

    int i = m_model.trackList().at(trackIndex).mlt_index;
    QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
    if (!track)
        return;

    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (!info)
        return;

    if (location == TrimInPoint) {
        MAIN.undoStack()->push(new Timeline::TrimClipInCommand(m_model,
                                                               m_markersModel,
                                                               trackIndex,
                                                               clipIndex,
                                                               m_position - info->start,
                                                               ripple));
        if (ripple)
            setPosition(info->start);
        if (m_updateCommand && m_updateCommand->trackIndex() == trackIndex
            && m_updateCommand->clipIndex() == clipIndex)
            m_updateCommand->setPosition(trackIndex,
                                         clipIndex,
                                         m_updateCommand->position() + m_position - info->start);
        emit MAIN.serviceInChanged(m_position - info->start, info->producer);
    } else {
        int delta = info->start + info->frame_count - m_position;
        if (!m_model.trimClipOutValid(trackIndex, clipIndex, delta, ripple))
            return;
        MAIN.undoStack()->push(new Timeline::TrimClipOutCommand(m_model,
                                                                m_markersModel,
                                                                trackIndex,
                                                                clipIndex,
                                                                delta,
                                                                ripple));
        if (m_updateCommand && m_updateCommand->trackIndex() == trackIndex
            && m_updateCommand->clipIndex() == clipIndex)
            m_updateCommand->setPosition(trackIndex, clipIndex, -1);
    }
}

/*!
    \qmlmethod void TimelineDock::openProperties()
    \brief Opens the Properties panel for the currently selected clip. Static method.
*/

void TimelineDock::openProperties()
{
    MAIN.onPropertiesDockTriggered(true);
}

void TimelineDock::emitSelectedChanged(const QVector<int> &roles)
{
    if (selection().isEmpty())
        return;
    auto point = selection().first();
    auto index = model()->makeIndex(point.y(), point.x());
    emit model()->dataChanged(index, index, roles);
}

void TimelineDock::clearSelectionIfInvalid()
{
    if (m_model.isBulkUpdating()) {
        // Defer until the bulk update (e.g. a multi-step History dock jump)
        // finishes, rather than redoing this on every intermediate step.
        m_pendingClearSelectionIfInvalid = true;
        return;
    }
    m_pendingClearSelectionIfInvalid = false;
    QList<QPoint> newSelection;
    foreach (auto clip, selection()) {
        if (clip.x() >= clipCount(clip.y()))
            continue;

        newSelection << QPoint(clip.x(), clip.y());
    }
    setSelection(newSelection, m_selection.selectedTrack, m_selection.isMultitrackSelected);
}

void TimelineDock::onBulkUpdateFinished()
{
    if (m_pendingClearSelectionIfInvalid)
        clearSelectionIfInvalid();
}

/*!
    \qmlmethod void TimelineDock::insertTrack()
    \brief Inserts a new track of the same type as the current track above it.
*/

void TimelineDock::insertTrack()
{
    if (m_selection.selectedTrack != -1)
        setSelection();
    MAIN.undoStack()->push(new Timeline::InsertTrackCommand(m_model, currentTrack()));
}

/*!
    \qmlmethod void TimelineDock::insertAudioTrack()
    \brief Inserts a new audio track above the current track.
*/

void TimelineDock::insertAudioTrack()
{
    if (m_selection.selectedTrack != -1)
        setSelection();
    MAIN.undoStack()->push(
        new Timeline::InsertTrackCommand(m_model, currentTrack(), AudioTrackType));
}

/*!
    \qmlmethod void TimelineDock::insertVideoTrack()
    \brief Inserts a new video track above the current track.
*/

void TimelineDock::insertVideoTrack()
{
    if (m_selection.selectedTrack != -1)
        setSelection();
    MAIN.undoStack()->push(
        new Timeline::InsertTrackCommand(m_model, currentTrack(), VideoTrackType));
}

/*!
    \qmlmethod void TimelineDock::removeTrack()
    \brief Removes the current track (and all its clips) from the timeline.
*/

void TimelineDock::removeTrack()
{
    if (m_model.trackList().size() > 0) {
        int trackIndex = currentTrack();
        MAIN.undoStack()->push(new Timeline::RemoveTrackCommand(m_model, trackIndex));
        if (trackIndex >= m_model.trackList().count())
            setCurrentTrack(m_model.trackList().count() - 1);
    }
}

/*!
    \qmlmethod void TimelineDock::moveTrack(int fromTrackIndex, int toTrackIndex)
    \brief Moves the track at \a fromTrackIndex to \a toTrackIndex.
*/

void TimelineDock::moveTrack(int fromTrackIndex, int toTrackIndex)
{
    const TrackList &trackList = m_model.trackList();
    if (fromTrackIndex >= trackList.size()) {
        LOG_DEBUG() << "From track index out of bounds" << fromTrackIndex;
        return;
    }
    if (toTrackIndex >= trackList.size()) {
        LOG_DEBUG() << "To track index out of bounds" << toTrackIndex;
        return;
    }
    if (trackList[fromTrackIndex].type != trackList[toTrackIndex].type) {
        LOG_DEBUG() << "From/To track types do not match";
        return;
    }
    MAIN.undoStack()->push(new Timeline::MoveTrackCommand(m_model, fromTrackIndex, toTrackIndex));
    setCurrentTrack(toTrackIndex);
}

/*!
    \qmlmethod void TimelineDock::moveTrackUp()
    \brief Moves the current track one position upward.
*/

void TimelineDock::moveTrackUp()
{
    int trackIndex = currentTrack();
    const TrackList &trackList = m_model.trackList();
    if (trackIndex >= trackList.size()) {
        LOG_DEBUG() << "Track Index out of bounds" << trackIndex;
        return;
    }
    if (trackList[trackIndex].type == VideoTrackType) {
        bool topVideo = true;
        foreach (const Track &t, trackList) {
            if (t.type == VideoTrackType && t.number > trackList[trackIndex].number) {
                topVideo = false;
                break;
            }
        }
        if (topVideo) {
            MAIN.showStatusMessage(
                tr("Track %1 was not moved").arg(m_model.getTrackName(trackIndex)));
            return;
        }
    }
    if (trackList[trackIndex].number == 0 && trackList[trackIndex].type == AudioTrackType) {
        MAIN.showStatusMessage(tr("Can not move audio track above video track"));
        return;
    }
    MAIN.undoStack()->push(new Timeline::MoveTrackCommand(m_model, trackIndex, trackIndex - 1));
    setCurrentTrack(trackIndex - 1);
}

/*!
    \qmlmethod void TimelineDock::moveTrackDown()
    \brief Moves the current track one position downward.
*/

void TimelineDock::moveTrackDown()
{
    int trackIndex = currentTrack();
    const TrackList &trackList = m_model.trackList();
    if (trackIndex >= trackList.size()) {
        LOG_DEBUG() << "Track Index out of bounds" << trackIndex;
        return;
    }
    if (trackList[trackIndex].number == 0 && trackList[trackIndex].type == VideoTrackType) {
        MAIN.showStatusMessage(tr("Can not move video track below audio track"));
        return;
    }
    if (trackList[trackIndex].type == AudioTrackType) {
        bool bottomAudio = true;
        foreach (const Track &t, trackList) {
            if (t.type == AudioTrackType && t.number > trackList[trackIndex].number) {
                bottomAudio = false;
                break;
            }
        }
        if (bottomAudio) {
            MAIN.showStatusMessage(
                tr("Track %1 was not moved").arg(m_model.getTrackName(trackIndex)));
            return;
        }
    }
    MAIN.undoStack()->push(new Timeline::MoveTrackCommand(m_model, trackIndex, trackIndex + 1));
    setCurrentTrack(trackIndex + 1);
}

/*!
    \qmlmethod bool TimelineDock::mergeClipWithNext(int trackIndex, int clipIndex, bool dryrun)
    \brief Merges the clip at \a clipIndex with the following clip on \a trackIndex.
    If \a dryrun is \c true the merge is validated but not applied; returns \c true when valid.
*/

bool TimelineDock::mergeClipWithNext(int trackIndex, int clipIndex, bool dryrun)
{
    if (dryrun)
        return m_model.mergeClipWithNext(trackIndex, clipIndex, true);

    MAIN.undoStack()->push(new Timeline::MergeCommand(m_model, trackIndex, clipIndex));

    return true;
}

int TimelineDock::limitClipOut(
    Mlt::Playlist &playlist, int clipIndex, int in, int frameCount, int out)
{
    if (!Settings.timelineRipple() && (clipIndex + 1) < playlist.count()) {
        if (playlist.is_blank(clipIndex + 1))
            out = qMin(out, in + frameCount - 1 + playlist.clip_length(clipIndex + 1));
        else
            out = qMin(out, in + frameCount - 1);
    }
    return out;
}

void TimelineDock::onProducerChanged(Mlt::Producer *after)
{
    int trackIndex = currentTrack();
    if (trackIndex < 0 || selection().isEmpty() || !m_updateCommand || !after || !after->is_valid())
        return;
    trackIndex = selection().first().y();
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    auto resetRippleAll = true;
    int i = m_model.trackList().at(trackIndex).mlt_index;
    QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
    if (track) {
        // Ensure the new XML has same in/out point as selected clip by making
        // a copy of the changed producer and copying the in/out from timeline.
        Mlt::Playlist playlist(*track);
        int clipIndex = selection().first().x();
        QScopedPointer<Mlt::ClipInfo> info(playlist.clip_info(clipIndex));
        if (info) {
            QString oldServiceName = info->producer->get("mlt_service");
            QString newServiceName = after->get("mlt_service");
            if (oldServiceName == "timewarp" || newServiceName == "timewarp") {
                double oldSpeed = oldServiceName != "timewarp"
                                      ? 1.0
                                      : info->producer->get_double("warp_speed");
                double newSpeed = newServiceName != "timewarp" ? 1.0
                                                               : after->get_double("warp_speed");
                double speedRatio = oldSpeed / newSpeed;
                int length = qRound(info->length * speedRatio);
                int in = qMin(qRound(info->frame_in * speedRatio), length - 1);
                int out = qMin(qRound(info->frame_out * speedRatio), length - 1);
                int uncappedOut = out;
                out = limitClipOut(playlist, clipIndex, in, info->frame_count, out);
                emitClipExtendStatus(out, uncappedOut, info->frame_out);
                after->set_in_and_out(in, out);
                // Adjust filters.
                MLT.adjustClipFilters(*after,
                                      info->frame_in,
                                      info->frame_out,
                                      in - info->frame_in,
                                      info->frame_out - out,
                                      0);
                resetRippleAll = false;
            } else if (newServiceName == "qimage" || newServiceName == "pixbuf") {
                int oldLength = info->frame_out - info->frame_in;
                int newLength = after->get_out() - after->get_in();
                int lengthDelta = newLength - oldLength;
                if (lengthDelta != 0) {
                    int in = after->get_in();
                    int requestedOut = after->get_out();
                    int out = limitClipOut(playlist, clipIndex, in, info->frame_count, requestedOut);
                    emitClipExtendStatus(out, requestedOut, info->frame_out);
                    after->set_in_and_out(in, out);
                    // Adjust filters.
                    MLT.adjustClipFilters(*after,
                                          info->frame_in,
                                          info->frame_out,
                                          0,
                                          -lengthDelta,
                                          0);
                }
            } else if (after->property_exists(kNewFrameOutProperty)) {
                int requestedNewOut = qMin(after->get_int(kNewFrameOutProperty),
                                           after->get_length() - 1);
                int in = info->frame_in;
                int newOut
                    = limitClipOut(playlist, clipIndex, in, info->frame_count, requestedNewOut);
                emitClipExtendStatus(newOut, requestedNewOut, info->frame_out);
                if (newOut == info->frame_out && requestedNewOut > info->frame_out) {
                    // No space to extend; skip creating an undo command
                    return;
                }
                // Adjust filters.
                MLT.adjustClipFilters(*after,
                                      info->frame_in,
                                      info->frame_out,
                                      0,
                                      info->frame_out - newOut,
                                      0);
                after->set_in_and_out(in, newOut);
                resetRippleAll = false;
            } else {
                after->set_in_and_out(info->frame_in, info->frame_out);
            }
        }
    }
    QString xmlAfter = MLT.XML(after);
    m_updateCommand->setXmlAfter(xmlAfter);
    if (resetRippleAll)
        m_updateCommand->setRippleAllTracks(false);
    setSelection(); // clearing selection prevents a crash
    MAIN.undoStack()->push(m_updateCommand.release());
}

/*!
    \qmlmethod int TimelineDock::addAudioTrack()
    \brief Appends a new audio track and returns its track index.
*/

int TimelineDock::addAudioTrack()
{
    if (m_selection.selectedTrack != -1)
        setSelection();
    MAIN.undoStack()->push(new Timeline::AddTrackCommand(m_model, false));
    return m_model.trackList().size() - 1;
}

/*!
    \qmlmethod int TimelineDock::addVideoTrack()
    \brief Appends a new video track and returns its track index.
*/

int TimelineDock::addVideoTrack()
{
    if (m_selection.selectedTrack != -1)
        setSelection();
    MAIN.undoStack()->push(new Timeline::AddTrackCommand(m_model, true));
    return 0;
}

/*!
    \qmlmethod void TimelineDock::alignSelectedClips()
    \brief Aligns selected clips to a common track using audio analysis.
*/

void TimelineDock::alignSelectedClips()
{
    auto selection = selectionUuids();
    saveAndClearSelection();
    AlignAudioDialog dialog(tr("Align To Reference Track"), &m_model, selection, this);
    dialog.exec();
    restoreSelection();
}

void TimelineDock::applyCopiedFiltersToSelectdClips()
{
    QString xmlToUse = QGuiApplication::clipboard()->text();
    if (MLT.isMltXml(xmlToUse) && xmlToUse.contains(kShotcutFiltersClipboard)) {
        if (!Settings.proxyEnabled()) {
            ProxyManager::filterXML(xmlToUse, "");
        }
    } else {
        LOG_DEBUG() << "Unable to read copied filters" << xmlToUse;
        return;
    }
    Timeline::ApplyFiltersCommand *command = new Timeline::ApplyFiltersCommand(m_model, xmlToUse);
    foreach (auto i, m_selection.selectedClips) {
        if (!isBlank(i.y(), i.x()) && !isTransition(i.y(), i.x())) {
            command->addClip(i.y(), i.x());
        }
    }
    MAIN.undoStack()->push(command);
}

void TimelineDock::onShowFrame(const SharedFrame &frame)
{
    if (MLT.isMultitrack() && m_model.tractor()) {
        m_model.updateTrackGains(frame.get_position());
        m_model.updateTrackAudioLevels(frame);
        if (m_ignoreNextPositionChange) {
            m_ignoreNextPositionChange = false;
        } else if (m_position != frame.get_position()) {
            m_position = qMin(frame.get_position(), m_model.tractor()->get_length());
            emit positionChanged(m_position);
        }
    }
}

void TimelineDock::onSeeked(int position)
{
    if (MLT.isMultitrack() && m_position != position) {
        m_position = qMin(position, m_model.tractor()->get_length());
        m_model.updateTrackGains(m_position);
        m_model.clearTrackAudioLevels();
        emit positionChanged(m_position);
    }
}

static bool isSystemClipboardValid(const QString &xml)
{
    return MLT.isMltXml(xml) && MAIN.isClipboardNewer() && !xml.contains(kShotcutFiltersClipboard);
}

/*!
    \qmlmethod void TimelineDock::append(int trackIndex)
    \brief Appends the current source clip to the track at \a trackIndex.
*/

void TimelineDock::append(int trackIndex)
{
    if (trackIndex < 0)
        trackIndex = currentTrack();
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    if (MAIN.isSourceClipMyProject())
        return;

    // Use MLT XML on the clipboard if it exists and is newer than source clip.
    QString xmlToUse = QGuiApplication::clipboard()->text();
    if (isSystemClipboardValid(xmlToUse)) {
        if (!Settings.proxyEnabled()) {
            ProxyManager::filterXML(xmlToUse, "");
        }
    } else {
        xmlToUse.clear();
    }

    if (MLT.isSeekableClip() || MLT.savedProducer() || !xmlToUse.isEmpty()) {
        Mlt::Producer producer;
        if (xmlToUse.isEmpty()) {
            Mlt::Producer producer(MLT.isClip() ? MLT.producer() : MLT.savedProducer());
            if (MLT.isLiveProducer(&producer)) {
                LongUiTask::cancel();
                DurationDialog durationDialog(this);
                durationDialog.setDuration(MLT.profile().fps() * 5);
                if (durationDialog.exec() == QDialog::Accepted) {
                    producer.set_in_and_out(0, durationDialog.duration() - 1);
                } else {
                    return;
                }
            } else {
                ProxyManager::generateIfNotExists(producer);
            }
            xmlToUse = MLT.XML(&producer);
        } else {
            producer = Mlt::Producer(MLT.profile(), "xml-string", xmlToUse.toUtf8().constData());
        }
        if (xmlToUse.isEmpty()) {
            return;
        }

        // Insert multiple if the XML is a <tractor> with child <property name="shotcut">1</property>
        // No need to create a track in an empty timeline.
        // This can be a macro of QUndoCommands.
        if (producer.is_valid() && producer.type() == mlt_service_tractor_type
            && producer.get_int(kShotcutXmlProperty)) {
            Mlt::Tractor tractor(producer);
            Mlt::ClipInfo info;
            MAIN.undoStack()->beginMacro(tr("Append multiple to timeline"));
            Mlt::Controller::RefreshBlocker blocker;
            QMap<int, Timeline::GroupCommand *> groupCommands;
            QMap<int, int> groupClipCounts;

            // Loop over each source track
            for (int mltTrackIndex = 0; mltTrackIndex < tractor.count(); mltTrackIndex++) {
                QScopedPointer<Mlt::Producer> srcTrack(tractor.track(mltTrackIndex));
                if (srcTrack) {
                    const auto trackIndex = currentTrack() + mltTrackIndex;
                    addTrackIfNeeded(trackIndex, srcTrack.get());
                    PendingTransitionState pendingTransition;

                    // Insert the clips for this track
                    Mlt::Playlist playlist(*srcTrack);
                    for (int mltClipIndex = 0; mltClipIndex < playlist.count(); mltClipIndex++) {
                        if (!playlist.is_blank(mltClipIndex)) {
                            playlist.clip_info(mltClipIndex, &info);
                            if (isTransitionClipboardClip(info)) {
                                beginPendingTransition(pendingTransition, info, false);
                                continue;
                            }
                            Mlt::Producer clip(info.producer);
                            const bool matchesPendingRight
                                = matchesPendingTransitionRight(pendingTransition, clip);
                            applyTransitionAwareInOut(clip,
                                                      info,
                                                      matchesPendingRight,
                                                      pendingTransition.rightIn);
                            bool lastClip = mltTrackIndex == tractor.count() - 1
                                            && mltClipIndex == playlist.count() - 1;
                            MAIN.undoStack()->push(new Timeline::AppendCommand(m_model,
                                                                               trackIndex,
                                                                               MLT.XML(&clip),
                                                                               false,
                                                                               lastClip));

                            if (pendingTransition.duration > 0) {
                                const int rightClipIndex = clipCount(trackIndex) - 1;
                                const int leftClipIndex = rightClipIndex - 1;
                                if (matchesPendingRight && leftClipIndex >= 0 && rightClipIndex >= 0
                                    && !isBlank(trackIndex, leftClipIndex)
                                    && !isBlank(trackIndex, rightClipIndex)
                                    && !isTransition(trackIndex, leftClipIndex)
                                    && !isTransition(trackIndex, rightClipIndex)
                                    && m_model.addTransitionByTrimOutValid(trackIndex,
                                                                           leftClipIndex,
                                                                           -pendingTransition
                                                                                .duration)) {
                                    MAIN.undoStack()->push(
                                        new Timeline::AddTransitionByTrimOutCommand(m_model,
                                                                                    trackIndex,
                                                                                    leftClipIndex,
                                                                                    -pendingTransition
                                                                                         .duration,
                                                                                    0));
                                }
                                clearPendingTransition(pendingTransition);
                            }
                            // AppendCommand executed immediately; record group membership
                            if (info.cut && info.cut->property_exists(kShotcutGroupProperty)) {
                                int group = info.cut->get_int(kShotcutGroupProperty);
                                int clipIndex = clipCount(trackIndex) - 1;
                                if (clipIndex >= 0 && !isBlank(trackIndex, clipIndex)) {
                                    if (!groupCommands.contains(group))
                                        groupCommands[group] = new Timeline::GroupCommand(m_model);
                                    groupCommands[group]->addToGroup(trackIndex, clipIndex);
                                    groupClipCounts[group]++;
                                }
                            }
                        }
                    }
                }
            }
            // Push a GroupCommand for each copied group that has at least two clips.
            for (auto it = groupCommands.constBegin(); it != groupCommands.constEnd(); ++it) {
                if (groupClipCounts.value(it.key()) >= 2) {
                    MAIN.undoStack()->push(it.value());
                } else {
                    delete it.value();
                }
            }
            MAIN.undoStack()->endMacro();
            MLT.refreshConsumer();

        } else {
            if (m_model.trackList().size() == 0) {
                if (Settings.timelineAutoAddTracks())
                    addAudioTrack();
                addVideoTrack();
            }

            MAIN.undoStack()->push(new Timeline::AppendCommand(m_model, trackIndex, xmlToUse));
        }

        if (m_position < 0) {
            // This happens when pasting in a new session
            MAIN.openCut(new Mlt::Producer(m_model.tractor()));
        }
    } else if (!MLT.isSeekableClip()) {
        emitNonSeekableWarning();
    }
}

/*!
    \qmlmethod void TimelineDock::remove(int trackIndex, int clipIndex, bool ignoreTransition)
    \brief Removes the clip at (\a trackIndex, \a clipIndex) causing downstream clips to shift
    earlier by the clip duration (ripple).
    If \a ignoreTransition is \c true, adjacent transitions are not affected.
*/

void TimelineDock::remove(int trackIndex, int clipIndex, bool ignoreTransition)
{
    if (!m_model.trackList().count())
        return;
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    if (trackIndex < 0 || clipIndex < 0)
        return;

    if (!ignoreTransition && isTransition(trackIndex, clipIndex)) {
        MAIN.undoStack()->beginMacro(tr("Ripple delete transition"));
        auto info = m_model.getClipInfo(trackIndex, clipIndex);
        MAIN.undoStack()->push(
            new Timeline::RemoveCommand(m_model, m_markersModel, trackIndex, clipIndex));
        if (clipIndex > 0 && info->producer && info->producer->is_valid()) {
            // verify the clip after belongs to transition
            Mlt::Tractor transition(*info->producer);
            std::unique_ptr<Mlt::Producer> transitionClip(transition.track(1));
            auto neighborClip = producerForClip(trackIndex, clipIndex);
            if (neighborClip.is_valid() && transitionClip && neighborClip.same_clip(*transitionClip))
                MAIN.undoStack()->push(new Timeline::TrimClipInCommand(m_model,
                                                                       m_markersModel,
                                                                       trackIndex,
                                                                       clipIndex,
                                                                       -info->frame_count,
                                                                       true));
            // verify the clip before belongs to transition
            transitionClip.reset(transition.track(0));
            neighborClip = producerForClip(trackIndex, clipIndex - 1);
            if (neighborClip.is_valid() && transitionClip && neighborClip.same_clip(*transitionClip))
                MAIN.undoStack()->push(new Timeline::TrimClipOutCommand(m_model,
                                                                        m_markersModel,
                                                                        trackIndex,
                                                                        clipIndex - 1,
                                                                        -info->frame_count,
                                                                        true));
        }
        MAIN.undoStack()->endMacro();
    } else {
        Mlt::Producer clip = producerForClip(trackIndex, clipIndex);
        if (clip.is_valid()) {
            MAIN.undoStack()->push(
                new Timeline::RemoveCommand(m_model, m_markersModel, trackIndex, clipIndex));
        }
    }
}

/*!
    \qmlmethod void TimelineDock::lift(int trackIndex, int clipIndex, bool ignoreTransition)
    \brief Lifts (removes content from) the clip at (\a trackIndex, \a clipIndex),
    leaving a blank of the same duration. If \a ignoreTransition is \c true, adjacent
    transitions are not affected.
*/

void TimelineDock::lift(int trackIndex, int clipIndex, bool ignoreTransition)
{
    if (!m_model.trackList().count())
        return;
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    if (trackIndex < 0 || clipIndex < 0)
        return;

    if (!ignoreTransition && isTransition(trackIndex, clipIndex)) {
        MAIN.undoStack()->beginMacro(tr("Lift transition"));
        auto info = m_model.getClipInfo(trackIndex, clipIndex);
        if (clipIndex > 0 && clipIndex + 1 < m_model.rowCount(m_model.index(trackIndex))
            && info->producer && info->producer->is_valid()) {
            // verify the clip after belongs to transition
            Mlt::Tractor transition(*info->producer);
            std::unique_ptr<Mlt::Producer> transitionClip(transition.track(1));
            auto clipBefore = producerForClip(trackIndex, clipIndex - 1);
            auto clipAfter = producerForClip(trackIndex, clipIndex + 1);
            auto duration = info->frame_count;

            MAIN.undoStack()->push(new Timeline::LiftCommand(m_model, trackIndex, clipIndex));

            if (clipBefore.is_valid() && clipAfter.is_valid() && !clipBefore.is_blank()
                && !clipAfter.is_blank())
                duration /= 2;
            if (clipAfter.is_valid() && transitionClip && clipAfter.same_clip(*transitionClip))
                MAIN.undoStack()->push(
                    new Timeline::TrimClipInCommand(m_model,
                                                    m_markersModel,
                                                    trackIndex,
                                                    clipIndex + (clipBefore.is_blank() ? 0 : 1),
                                                    -duration,
                                                    false));
            // verify the clip before belongs to transition
            transitionClip.reset(transition.track(0));
            if (duration < info->frame_count)
                duration += info->frame_count % 2;
            if (clipBefore.is_valid() && transitionClip && clipBefore.same_clip(*transitionClip))
                MAIN.undoStack()->push(new Timeline::TrimClipOutCommand(m_model,
                                                                        m_markersModel,
                                                                        trackIndex,
                                                                        clipIndex - 1,
                                                                        -duration,
                                                                        false));
        } else {
            MAIN.undoStack()->push(new Timeline::LiftCommand(m_model, trackIndex, clipIndex));
        }
        MAIN.undoStack()->endMacro();
    } else {
        Mlt::Producer clip(producerForClip(trackIndex, clipIndex));
        if (clip.is_valid()) {
            if (clip.is_blank())
                return;
            MAIN.undoStack()->push(new Timeline::LiftCommand(m_model, trackIndex, clipIndex));
            setSelection();
        }
    }
}

/*!
    \qmlmethod bool TimelineDock::split(int trackIndex, int clipIndex, int position)
    \brief Splits the clip at (\a trackIndex, \a clipIndex) at the absolute timeline
    frame \a position. Returns \c true if the split was performed.
*/

bool TimelineDock::split(int trackIndex, int clipIndex, int position)
{
    if (!m_model.trackList().count() || trackIndex < 0 || clipIndex < 0)
        return false;
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return false;
    }
    if (isBlank(trackIndex, clipIndex))
        return false;
    if (isTransition(trackIndex, clipIndex)) {
        emit showStatusMessage(tr("You cannot split a transition."));
        return false;
    }
    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (!info || position <= info->start || position >= info->start + info->frame_count)
        return false;
    setSelection(); // Avoid filter views becoming out of sync
    std::vector<int> tracks{trackIndex};
    std::vector<int> clips{clipIndex};
    MAIN.undoStack()->push(new Timeline::SplitCommand(m_model, tracks, clips, position));
    return true;
}

/*!
    \qmlmethod void TimelineDock::removeSelection(bool withCopy)
    \brief Removes all selected clips. If \a withCopy is \c true, the clips are
    copied to the clipboard before removal (cut).
*/

void TimelineDock::removeSelection(bool withCopy)
{
    if (isTrackLocked(currentTrack())) {
        emit warnTrackLocked(currentTrack());
        return;
    }
    if (selection().isEmpty())
        selectClipUnderPlayhead();
    if (selection().isEmpty() || currentTrack() < 0)
        return;

    // Cut
    if (withCopy) {
        auto clip = selection().first();
        copy(clip.y(), clip.x());
        if (selection().size() < 2) {
            remove(clip.y(), clip.x(), false);
            return;
        }
    }

    // Ripple delete
    int n = selection().size();
    if (n > 1) {
        if (withCopy)
            MAIN.undoStack()->beginMacro(tr("Cut %1 from timeline").arg(n));
        else
            MAIN.undoStack()->beginMacro(tr("Remove %1 from timeline").arg(n));
    }
    int trackIndex, clipIndex;
    for (const auto &uuid : selectionUuids()) {
        m_model.findClipByUuid(uuid, trackIndex, clipIndex);
        remove(trackIndex, clipIndex, n > 1);
    }
    if (n > 1)
        MAIN.undoStack()->endMacro();
}

/*!
    \qmlmethod void TimelineDock::liftSelection()
    \brief Removes all selected clips, replacing them with blanks.
*/

void TimelineDock::liftSelection()
{
    if (isTrackLocked(currentTrack())) {
        emit warnTrackLocked(currentTrack());
        return;
    }
    if (selection().isEmpty())
        selectClipUnderPlayhead();
    if (selection().isEmpty())
        return;
    int n = selection().size();
    if (n > 1)
        MAIN.undoStack()->beginMacro(tr("Lift %1 from timeline").arg(n));
    int trackIndex, clipIndex;
    for (const auto &uuid : selectionUuids()) {
        m_model.findClipByUuid(uuid, trackIndex, clipIndex);
        lift(trackIndex, clipIndex, n > 1);
    }
    if (n > 1)
        MAIN.undoStack()->endMacro();
}

/*!
    \qmlmethod void TimelineDock::incrementCurrentTrack(int by)
    \brief Moves the current track focus up or down by \a by tracks.
*/

void TimelineDock::incrementCurrentTrack(int by)
{
    int newTrack = currentTrack();
    if (by < 0)
        newTrack = qMax(0, newTrack + by);
    else
        newTrack = qMin(m_model.trackList().size() - 1, newTrack + by);
    setCurrentTrack(newTrack);
}

/*!
    \qmlmethod void TimelineDock::selectTrackHead(int trackIndex)
    \brief Selects the track and its header of the track at \a trackIndex.
*/

void TimelineDock::selectTrackHead(int trackIndex)
{
    if (trackIndex >= 0) {
        setSelection(QList<QPoint>(), trackIndex);
    }
}

/*!
    \qmlmethod void TimelineDock::selectMultitrack()
    \brief Selects the timeline output, deselecting any clips.
*/

void TimelineDock::selectMultitrack()
{
    setSelection(QList<QPoint>(), -1, true);
}

template<typename T>
static void insertSorted(std::vector<T> &vec, T const &item)
{
    vec.insert(std::upper_bound(vec.begin(), vec.end(), item), item);
}

/*!
    \qmlmethod void TimelineDock::copy(int trackIndex, int clipIndex)
    \brief Copies the clip at (\a trackIndex, \a clipIndex) to the clipboard.
*/

void TimelineDock::copy(int trackIndex, int clipIndex)
{
    auto selected = selection();
    if (selected.size() < 2) {
        if (trackIndex < 0)
            trackIndex = currentTrack();
        if (clipIndex < 0)
            clipIndex = clipIndexAtPlayhead(trackIndex);
        Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);
        auto info = m_model.getClipInfo(trackIndex, clipIndex);
        if (info) {
            QString xml = MLT.XML(info->producer);
            Mlt::Producer p(MLT.profile(), "xml-string", xml.toUtf8().constData());
            p.set_speed(0);
            p.seek(info->frame_in);
            p.set_in_and_out(info->frame_in, info->frame_out);
            MLT.setSavedProducer(&p);
            QGuiApplication::clipboard()->setText(MLT.XML(&p));
            emit clipCopied();
        }
    } else {
        // Determine the track indices
        auto minY = std::numeric_limits<int>::max();
        auto maxY = -1;
        auto minStart = std::numeric_limits<int>::max();
        for (auto &a : selected) {
            minY = std::min(minY, a.y());
            maxY = std::max(maxY, a.y());
            auto info = m_model.getClipInfo(a.y(), a.x());
            if (info)
                minStart = std::min(minStart, info->start);
        }
        // Count how many selected clips belong to each group (only copy group
        // membership when at least two members of the same group are selected).
        QMap<int, int> groupCounts;
        for (auto &a : selected) {
            auto clipInfo = m_model.getClipInfo(a.y(), a.x());
            if (clipInfo && clipInfo->cut && clipInfo->cut->property_exists(kShotcutGroupProperty)) {
                groupCounts[clipInfo->cut->get_int(kShotcutGroupProperty)]++;
            }
        }
        // Create the tracks
        Mlt::Tractor tractor(MLT.profile());
        tractor.set(kShotcutXmlProperty, 1);
        for (int trackIndex = minY, i = 0; trackIndex <= maxY; trackIndex++, i++) {
            Mlt::Playlist playlist(MLT.profile());
            if (m_model.trackList()[trackIndex].type == AudioTrackType) {
                playlist.set("hide", 1);
                playlist.set(kAudioTrackProperty, 1);
            } else {
                playlist.set(kVideoTrackProperty, 1);
            }
            tractor.set_track(playlist, i);

            // Sort all the clips on this track
            std::vector<int> clipIndices;
            for (auto &a : selected) {
                if (a.y() == trackIndex) {
                    clipIndices.insert(std::upper_bound(clipIndices.begin(),
                                                        clipIndices.end(),
                                                        a.x()),
                                       a.x());
                }
            }

            // Add the clips to the tracks
            if (clipIndices.size() > 0) {
                int prevEnd = minStart;
                auto mlt_index = m_model.trackList()[trackIndex].mlt_index;
                QScopedPointer<Mlt::Producer> sourceTrack(m_model.tractor()->track(mlt_index));
                if (sourceTrack) {
                    Mlt::Playlist sourcePlaylist(*sourceTrack);
                    Mlt::ClipInfo info;
                    for (auto clipIndex : clipIndices) {
                        sourcePlaylist.clip_info(clipIndex, &info);
                        playlist.blank(info.start - prevEnd - 1);
                        playlist.append(*info.producer, info.frame_in, info.frame_out);
                        // Carry the group property onto the new playlist entry's cut
                        // so it is serialised into the clipboard XML.
                        if (info.cut && info.cut->property_exists(kShotcutGroupProperty)) {
                            int group = info.cut->get_int(kShotcutGroupProperty);
                            if (groupCounts.value(group) >= 2) {
                                QScopedPointer<Mlt::Producer> newCut(
                                    playlist.get_clip(playlist.count() - 1));
                                if (newCut)
                                    newCut->set(kShotcutGroupProperty, group);
                            }
                        }
                        prevEnd = info.start + info.frame_count;
                    }
                }
            }
        }
        // Put XML in clipboard
        QGuiApplication::clipboard()->setText(MLT.XML(&tractor));
    }
}

void TimelineDock::emitSelectedFromSelection()
{
    if (!m_model.trackList().count()) {
        if (m_model.tractor())
            selectMultitrack();
        else
            emit selected(nullptr);
        return;
    }

    for (auto &clip : selection()) {
        // We need to set these special properties so time-based filters
        // can get information about the cut while still applying filters
        // to the cut parent.
        int trackIndex = clip.y();
        int clipIndex = clip.x();
        auto info = m_model.getClipInfo(trackIndex, clipIndex);
        if (!info)
            continue;
        auto info2 = m_model.getClipInfo(trackIndex, clipIndex - 1);
        if (info2 && info2->producer && info2->producer->is_valid()
            && info2->producer->get(kShotcutTransitionProperty)) {
            // Factor in a transition left of the clip.
            info->producer->set(kFilterInProperty, info->frame_in - info2->frame_count);
            info->producer->set(kPlaylistStartProperty, info2->start);
        } else {
            info->producer->set(kFilterInProperty, info->frame_in);
            info->producer->set(kPlaylistStartProperty, info->start);
        }
        info2 = m_model.getClipInfo(trackIndex, clipIndex + 1);
        if (info2 && info2->producer && info2->producer->is_valid()
            && info2->producer->get(kShotcutTransitionProperty)) {
            // Factor in a transition right of the clip.
            info->producer->set(kFilterOutProperty, info->frame_out + info2->frame_count);
        } else {
            info->producer->set(kFilterOutProperty, info->frame_out);
        }
        info->producer
            ->set(kMultitrackItemProperty,
                  QStringLiteral("%1:%2").arg(clipIndex).arg(trackIndex).toLatin1().constData());
    }

    if (selection().size() > 1 || nothingIsSelected()) {
        emit selected(nullptr);
        return;
    }

    if (selection().isEmpty() && m_selection.selectedTrack > -1
        && m_selection.selectedTrack < m_model.trackList().count()) {
        int i = m_model.trackList().at(m_selection.selectedTrack).mlt_index;
        std::unique_ptr<Mlt::Producer> producer(m_model.tractor()->track(i));
        if (producer && producer->is_valid()) {
            producer->set(kTrackIndexProperty, m_selection.selectedTrack);
            emit selected(producer.get());
        }
        return;
    }

    if (selection().isEmpty() && m_selection.isMultitrackSelected) {
        emit multitrackSelected();
        emit selected(m_model.tractor());
        return;
    }

    int trackIndex = selection().isEmpty() ? currentTrack() : selection().first().y();
    int clipIndex = selection().isEmpty() ? 0 : selection().first().x();
    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (info && info->producer && info->producer->is_valid()) {
        m_updateCommand.reset(
            new Timeline::UpdateCommand(*this, trackIndex, clipIndex, info->start));
        m_ignoreNextPositionChange = true;
        emit selected(info->producer);
    }

    m_model.tractor()->set(kFilterInProperty, 0);
    m_model.tractor()->set(kFilterOutProperty, m_model.tractor()->get_length() - 1);
}

void TimelineDock::remakeAudioLevels(int trackIndex, int clipIndex, bool force)
{
    if (Settings.timelineShowWaveforms()) {
        QModelIndex modelIndex = m_model.index(clipIndex, 0, m_model.index(trackIndex));
        auto info = m_model.getClipInfo(trackIndex, clipIndex);
        if (info)
            AudioLevelsTask::start(*info->producer, &m_model, modelIndex, force);
    }
}

void TimelineDock::commitTrimCommand()
{
    if (m_trimCommand && (m_trimDelta || m_transitionDelta)) {
        if (m_undoHelper)
            m_trimCommand->setUndoHelper(m_undoHelper.release());
        MAIN.undoStack()->push(m_trimCommand.release());
    }
    m_trimDelta = 0;
    m_transitionDelta = 0;
    emit trimEnded();
}

void TimelineDock::onRowsInserted(const QModelIndex &parent, int first, int last)
{
    // Adjust selected clips for changed indices.
    if (-1 == m_selection.selectedTrack) {
        QList<QPoint> newSelection;
        int n = last - first + 1;
        if (parent.isValid()) {
            foreach (auto i, m_selection.selectedClips) {
                if (parent.row() != i.y()) {
                    newSelection << QPoint(i.x(), i.y());
                    continue;
                }
                if (i.x() < first)
                    newSelection << QPoint(i.x(), parent.row());
                else
                    newSelection << QPoint(i.x() + n, parent.row());
            }
        } else {
            foreach (auto i, m_selection.selectedClips) {
                if (i.y() < first)
                    newSelection << QPoint(i.x(), i.y());
                else
                    newSelection << QPoint(i.x(), i.y() + n);
            }
        }
        setSelection(newSelection);
        if (!parent.isValid())
            model()->reload(true);
    }
}

void TimelineDock::onRowsRemoved(const QModelIndex &parent, int first, int last)
{
    Q_UNUSED(parent)
    // Adjust selected clips for changed indices.
    if (-1 == m_selection.selectedTrack) {
        QList<QPoint> newSelection;
        int n = last - first + 1;
        if (parent.isValid()) {
            foreach (auto i, m_selection.selectedClips) {
                if (parent.row() != i.y()) {
                    newSelection << QPoint(i.x(), i.y());
                    continue;
                }
                if (i.x() < first)
                    newSelection << QPoint(i.x(), parent.row());
                else if (i.x() > last)
                    newSelection << QPoint(i.x() - n, parent.row());
            }
        } else {
            foreach (auto i, m_selection.selectedClips) {
                if (i.y() < first)
                    newSelection << QPoint(i.x(), i.y());
                else if (i.y() > last)
                    newSelection << QPoint(i.x(), i.y() - n);
            }
        }
        setSelection(newSelection);
        if (!parent.isValid())
            model()->reload(true);
    }
}

void TimelineDock::onRowsMoved(
    const QModelIndex &parent, int start, int end, const QModelIndex &destination, int row)
{
    Q_UNUSED(parent)
    Q_UNUSED(start)
    Q_UNUSED(end)
    Q_UNUSED(destination)
    Q_UNUSED(row)
    // Workaround issue in timeline qml that clip selection becomes inconsistent with the model
    // Clear the selection and reload the model to trigger reset of the selected clips in the UI
    QList<QPoint> newSelection;
    setSelection(newSelection);
}

/*!
    \qmlmethod void TimelineDock::detachAudio(int trackIndex, int clipIndex)
    \brief Detaches the audio of the clip at (\a trackIndex, \a clipIndex) to an audio track.
*/

void TimelineDock::detachAudio(int trackIndex, int clipIndex)
{
    if (!m_model.trackList().count())
        return;
    Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);
    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (info && info->producer && info->producer->is_valid() && !info->producer->is_blank()
        && info->producer->get("audio_index") && info->producer->get_int("audio_index") >= 0) {
        if (!info->producer->property_exists(kDefaultAudioIndexProperty)) {
            info->producer->set(kDefaultAudioIndexProperty, info->producer->get_int("audio_index"));
        }
        Mlt::Producer clip(MLT.profile(),
                           "xml-string",
                           MLT.XML(info->producer).toUtf8().constData());
        clip.set_in_and_out(info->frame_in, info->frame_out);
        MAIN.undoStack()->push(new Timeline::DetachAudioCommand(*this,
                                                                trackIndex,
                                                                clipIndex,
                                                                info->start,
                                                                MLT.XML(&clip)));
    }
}

/*!
    \qmlmethod void TimelineDock::selectAll()
    \brief Selects all clips on all tracks.
*/

void TimelineDock::selectAll()
{
    QList<QPoint> selection;
    for (int y = 0; y < m_model.rowCount(); y++) {
        for (int x = 0; x < m_model.rowCount(m_model.index(y)); x++) {
            if (!isBlank(y, x) && !isTrackLocked(y))
                selection << QPoint(x, y);
        }
    }
    setSelection(selection);
}

/*!
    \qmlmethod void TimelineDock::selectAllOnCurrentTrack()
    \brief Selects all clips on the current track.
*/

void TimelineDock::selectAllOnCurrentTrack()
{
    int y = currentTrack();
    QList<QPoint> selection;
    if (y > -1 && y < m_model.rowCount()) {
        for (int x = 0; x < m_model.rowCount(m_model.index(y)); x++) {
            if (!isBlank(y, x) && !isTrackLocked(y))
                selection << QPoint(x, y);
        }
    }
    setSelection(selection);
}

void TimelineDock::onProducerModified()
{
    // The clip name may have changed.
    emitSelectedChanged(QVector<int>() << MultitrackModel::NameRole << MultitrackModel::CommentRole);
}

/*!
    \qmlmethod void TimelineDock::replace(int trackIndex, int clipIndex, string xml)
    \brief Replaces the clip at (\a trackIndex, \a clipIndex) with the producer described by \a xml.
    If \a xml is empty, uses the current source clip.
*/

void TimelineDock::replace(int trackIndex, int clipIndex, const QString &xml)
{
    if (xml.isEmpty() && !MLT.isClip() && !MLT.savedProducer()) {
        emit showStatusMessage(tr("There is nothing in the Source player."));
        return;
    }
    if (!m_model.trackList().count() || MAIN.isSourceClipMyProject())
        return;
    if (trackIndex < 0)
        trackIndex = currentTrack();
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    if (clipIndex < 0)
        clipIndex = clipIndexAtPlayhead(trackIndex);
    Mlt::Producer producer(producerForClip(trackIndex, clipIndex));
    if (producer.is_valid() && producer.type() == mlt_service_tractor_type) {
        emit showStatusMessage(tr("You cannot replace a transition."));
        return;
    }
    if (MLT.isSeekableClip() || MLT.savedProducer() || !xml.isEmpty()) {
        Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);
        QString xmlToUse = !xml.isEmpty() ? xml
                                          : MLT.XML(MLT.isClip() ? nullptr : MLT.savedProducer());
        MAIN.undoStack()->push(
            new Timeline::ReplaceCommand(m_model, trackIndex, clipIndex, xmlToUse));
    } else if (!MLT.isSeekableClip()) {
        emitNonSeekableWarning();
    }
}

/*!
    \qmlmethod void TimelineDock::createOrEditMarker()
    \brief Creates a new marker at the playhead, or opens the edit dialog if one already exists there.
*/

void TimelineDock::createOrEditMarker()
{
    if (!m_model.trackList().count() || MLT.producer()->get_length() <= 1)
        return;
    int index = m_markersModel.markerIndexForPosition(m_position);
    if (index >= 0) {
        editMarker(index);
        return;
    }
    createMarker();
}

/*!
    \qmlmethod void TimelineDock::createOrEditSelectionMarker()
    \brief Creates a range marker spanning the current clip selection, or edits the existing one.
*/

void TimelineDock::createOrEditSelectionMarker()
{
    auto selected = selection();
    if (!m_model.trackList().count() || MLT.producer()->get_length() <= 1 || selected.isEmpty()) {
        emit showStatusMessage(tr("Select a clip in the timeline to create a marker around it"));
        return;
    }

    // Find the earliest start and the latest end in the selection
    int start = -1;
    int end = -1;
    getSelectionRange(&start, &end);

    if (start > -1) {
        int index = m_markersModel.markerIndexForRange(start, end);
        if (index >= 0) {
            editMarker(index);
            return;
        } else {
            Markers::Marker marker;
            marker.text = QStringLiteral("Marker %1").arg(m_markersModel.uniqueKey() + 1);
            marker.color = Settings.markerColor();
            marker.start = start;
            marker.end = end;
            m_markersModel.append(marker);
            emit showStatusMessage(tr("Added marker: \"%1\".").arg(marker.text));
            return;
        }
    }
}

/*!
    \qmlmethod void TimelineDock::createMarker()
    \brief Creates a new marker at the current playhead position.
*/

void TimelineDock::createMarker()
{
    if (!m_model.trackList().count() || MLT.producer()->get_length() <= 1)
        return;
    int index = m_markersModel.markerIndexForPosition(m_position);
    if (index >= 0) {
        return;
    }
    Markers::Marker marker;
    marker.text = QStringLiteral("Marker %1").arg(m_markersModel.uniqueKey() + 1);
    marker.color = Settings.markerColor();
    marker.start = position();
    marker.end = position();
    m_markersModel.append(marker);
    emit showStatusMessage(tr("Added marker: \"%1\". Hold %2 and drag to create a range")
                               .arg(marker.text, QmlApplication::OS() == "macOS" ? "⌘" : "Ctrl"));
}

/*!
    \qmlmethod void TimelineDock::editMarker(int markerIndex)
    \brief Opens the edit dialog for the marker at \a markerIndex.
*/

void TimelineDock::editMarker(int markerIndex)
{
    Markers::Marker marker = m_markersModel.getMarker(markerIndex);
    EditMarkerDialog dialog(this,
                            marker.text,
                            marker.color,
                            marker.start,
                            marker.end,
                            m_model.tractor()->get_length() - 1);
    dialog.setWindowModality(QmlApplication::dialogModality());
    if (dialog.exec() == QDialog::Accepted) {
        marker.text = dialog.getText();
        marker.color = dialog.getColor();
        marker.start = dialog.getStart();
        marker.end = dialog.getEnd();
        m_markersModel.update(markerIndex, marker);
    }
}

/*!
    \qmlmethod void TimelineDock::deleteMarker(int markerIndex)
    \brief Deletes the marker at \a markerIndex, or the marker at the playhead if \a markerIndex is \c -1.
*/

void TimelineDock::deleteMarker(int markerIndex)
{
    if (markerIndex < 0) {
        markerIndex = m_markersModel.markerIndexForPosition(m_position);
    }
    if (markerIndex >= 0) {
        m_markersModel.remove(markerIndex);
    }
}

/*!
    \qmlmethod void TimelineDock::seekNextMarker()
    \brief Seeks the playhead to the next marker after the current position.
*/

void TimelineDock::seekNextMarker()
{
    int nextPos = m_markersModel.nextMarkerPosition(m_position);
    if (nextPos >= 0) {
        setPosition(nextPos);
        emit markerSeeked(m_markersModel.markerIndexForPosition(nextPos));
    }
}

/*!
    \qmlmethod void TimelineDock::seekPrevMarker()
    \brief Seeks the playhead to the previous marker before the current position.
*/

void TimelineDock::seekPrevMarker()
{
    int prevPos = m_markersModel.prevMarkerPosition(m_position);
    if (prevPos >= 0) {
        setPosition(prevPos);
        emit markerSeeked(m_markersModel.markerIndexForPosition(prevPos));
    }
}

void TimelineDock::onFilterModelChanged()
{
    if (m_updateCommand) {
        m_updateCommand->setPosition(-1, -1, -1);
    }
}

/*!
    \qmlmethod void TimelineDock::trimClipIn(bool ripple)
    \brief Trims the in-point of the clip under the playhead to the current time position.
    If \a ripple is \c true, downstream clips shift accordingly.
*/

void TimelineDock::trimClipIn(bool ripple)
{
    trimClipAtPlayhead(TimelineDock::TrimInPoint, ripple);
}

/*!
    \qmlmethod void TimelineDock::trimClipOut(bool ripple)
    \brief Trims the out-point of the clip under the playhead to the current time position.
    If \a ripple is \c true, downstream clips shift accordingly.
*/

void TimelineDock::trimClipOut(bool ripple)
{
    trimClipAtPlayhead(TimelineDock::TrimOutPoint, ripple);
}

void TimelineDock::initLoad()
{
    load(false);
}

void TimelineDock::handleDrop(int trackIndex, int position, QString xmlOrUrls)
{
    if (xmlOrUrls.startsWith(kFileUrlProtocol)) {
        QList<QUrl> urls;
        auto strings = xmlOrUrls.split(kFilesUrlDelimiter);
        for (auto &s : strings) {
#ifdef Q_OS_WIN
            if (!s.startsWith(kFileUrlProtocol)) {
                s.prepend(kFileUrlProtocol);
            }
#endif
            urls << s;
        }

        // Use QTimer to workaround stupid drag from Windows Explorer bug
        QTimer::singleShot(0, this, [=]() {
            auto xml = xmlOrUrls;
            int i = 0, count = urls.size();

            // Handle drop from file manager to empty project.
            if (!MLT.producer() || !MLT.producer()->is_valid()) {
                QUrl url = xml.split(kFilesUrlDelimiter).first();
                Mlt::Properties properties;
                properties.set(kShotcutSkipConvertProperty, 1);
                if (!MAIN.open(Util::removeFileScheme(url), &properties, false /* play */))
                    MAIN.open(Util::removeFileScheme(url, false), &properties, false /* play */);
            }

            LongUiTask longTask(QObject::tr("Drop Files"));
            Mlt::Playlist playlist(MLT.profile());
            ResourceDialog dialog(this);
            for (const auto &path : Util::sortedFileList(urls)) {
                if (MAIN.isSourceClipMyProject(path, /* withDialog */ false))
                    continue;
                if (MLT.checkFile(path)) {
                    MAIN.showStatusMessage(QObject::tr("Failed to open ").append(path));
                    continue;
                }
                longTask.reportProgress(Util::baseName(path), i++, count);
                Mlt::Producer p;
                if (path.endsWith(".mlt") || path.endsWith(".xml")) {
                    p = Util::openMltVirtualClip(path);
                    if (!p.is_valid()) {
                        MAIN.showStatusMessage(tr("Failed to open ").append(path));
                        continue;
                    }
                } else {
                    p = Mlt::Producer(MLT.profile(), path.toUtf8().constData());
                }
                if (p.is_valid()) {
                    if (!qstrcmp(p.get("mlt_service"), "avformat") && !p.get_int("seekable")) {
                        MAIN.showStatusMessage(QObject::tr("Not adding non-seekable file: ")
                                               + Util::baseName(path));
                        continue;
                    }
                    Mlt::Producer *producer = MLT.setupNewProducer(&p);
                    producer->set(kShotcutSkipConvertProperty, 1);
                    if (MLT.isLiveProducer(producer)) {
                        LongUiTask::cancel();
                        DurationDialog durationDialog(this);
                        durationDialog.setDuration(MLT.profile().fps() * 5);
                        if (durationDialog.exec() == QDialog::Accepted) {
                            producer->set_in_and_out(0, durationDialog.duration() - 1);
                        } else {
                            continue;
                        }
                    } else {
                        ProxyManager::generateIfNotExists(*producer);
                    }
                    playlist.append(*producer);
                    dialog.add(producer);
                    delete producer;
                }
            }
            xml = MLT.XML(&playlist);
            if (Settings.showConvertClipDialog() && dialog.producerCount() > 1
                && dialog.hasTroubleClips()) {
                dialog.selectTroubleClips();
                dialog.setWindowTitle(tr("Dropped Files"));
                longTask.cancel();
                dialog.exec();
            } else if (Settings.showConvertClipDialog() && dialog.producerCount() == 1) {
                Mlt::Producer producer = dialog.producer(0);
                QString convertAdvice = Util::getConversionAdvice(&producer);
                if (!convertAdvice.isEmpty()) {
                    longTask.cancel();
                    Util::offerSingleFileConversion(convertAdvice, &producer, this);
                }
            }
            insertOrOverwriteDrop(trackIndex, position, xml);
        });
    } else {
        insertOrOverwriteDrop(trackIndex, position, xmlOrUrls);
    }
}

void TimelineDock::insertOrOverwriteDrop(int trackIndex, int position, const QString &xml)
{
    auto autoAddTracks = Settings.timelineAutoAddTracks();
    auto emptyTimeline = m_model.trackList().size() == 0;
    Settings.setTimelineAutoAddTracks(false);
    if (Settings.timelineRipple()) {
        insert(trackIndex, position, xml, false);
    } else {
        overwrite(trackIndex, position, xml, false);
    }
    Settings.setTimelineAutoAddTracks(autoAddTracks);
    if (autoAddTracks) {
        QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
        if (emptyTimeline)
            addAudioTrack();
        m_model.checkForEmptyTracks(trackIndex);
    }
}

void TimelineDock::freezeFrame()
{
    auto clipIndex = -1;
    auto trackIndex = currentTrack();
    if (selection().isEmpty()) {
        chooseClipAtPosition(m_position, trackIndex, clipIndex);
        if (trackIndex < 0 || clipIndex < 0) {
            return;
        } else if (isBlank(trackIndex, clipIndex)) {
            return;
        } else if (isTransition(trackIndex, clipIndex)) {
            emit showStatusMessage(tr("You cannot freeze a frame of a transition."));
            return;
        }
    } else {
        auto &selected = selection().first();
        trackIndex = selected.y();
        clipIndex = selected.x();
        if (trackIndex < 0)
            trackIndex = currentTrack();
        if (clipIndex < 0)
            clipIndex = clipIndexAtPlayhead(trackIndex);
    }
    Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);

    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (info && m_position >= info->start && m_position < info->start + info->frame_count) {
        std::unique_ptr<Mlt::Link> linkExists(MLT.getLink("timeremap", info->producer));
        if (linkExists
            || !QString::fromLatin1(info->producer->get("mlt_service")).startsWith("avformat")) {
            emit showStatusMessage(tr("Freeze Frame is not available."));
            return;
        }

        setCurrentTrack(trackIndex);

        QString xml = MLT.XML(info->producer);
        Mlt::Producer p(MLT.profile(), "xml-string", xml.toUtf8().constData());
        const auto fps = qRound(MLT.profile().fps());

        info->frame_in += m_position - info->start;
        info->frame_out = info->frame_in + fps - 1;
        p.set("length", p.frames_to_time(qRound(MLT.profile().fps() * 3600 * 4), mlt_time_clock));
        p.set_in_and_out(info->frame_in, info->frame_out);
        MLT.adjustFilters(p);

        Mlt::Link link("timeremap");
        link.set_profile(MLT.profile());
        link.set(kShotcutFilterProperty, "speedForward");
        link.set_in_and_out(info->frame_in, info->frame_out);
        link.anim_set("speed_map", 0, 0, 0, mlt_keyframe_discrete);

        Mlt::Chain chain(p);
        chain.attach(link);
        int normalizerCount = 0;
        for (int i = 0; i < chain.link_count(); i++) {
            std::unique_ptr<Mlt::Link> link{chain.link(i)};
            if (link->is_valid() && link->get_int("_loader")) {
                normalizerCount++;
            } else {
                break;
            }
        }
        chain.move_link(chain.link_count() - 1, normalizerCount);

        MAIN.undoStack()->beginMacro(tr("Insert Freeze Frame"));
        insert(-1, m_position, MLT.XML(&chain), false);
        MAIN.undoStack()->endMacro();
    } else {
        emit showStatusMessage(tr("The play head is not over the selected clip."));
    }
}

void TimelineDock::onLoopChanged(int start, int end)
{
    if (MLT.isMultitrack()) {
        m_loopStart = start;
        m_loopEnd = end;
    } else {
        m_loopStart = -1;
        m_loopEnd = -1;
    }
    emit loopChanged();
}

/*!
    \qmlmethod void TimelineDock::setTrackName(int trackIndex, string value)
    \brief Renames the track at \a trackIndex to \a value.
*/

void TimelineDock::setTrackName(int trackIndex, const QString &value)
{
    MAIN.undoStack()->push(new Timeline::NameTrackCommand(m_model, trackIndex, value));
}

/*!
    \qmlmethod void TimelineDock::toggleTrackMute(int trackIndex)
    \brief Toggles mute on the track at \a trackIndex.
*/

void TimelineDock::toggleTrackMute(int trackIndex)
{
    MAIN.undoStack()->push(new Timeline::MuteTrackCommand(m_model, trackIndex));
}

/*!
    \qmlmethod void TimelineDock::toggleOtherTracksMute(int trackIndex)
    \brief Mutes all tracks except \a trackIndex (solo), or unmutes all if already soloed.
*/

void TimelineDock::toggleOtherTracksMute(int trackIndex)
{
    int trackCount = m_model.rowCount();
    if (trackCount == 1 || trackIndex < 0 || trackIndex >= trackCount) {
        return;
    }
    MAIN.undoStack()->beginMacro(tr("Toggle other tracks mute"));
    for (int otherTrackIndex = 0; otherTrackIndex < trackCount; otherTrackIndex++) {
        if (otherTrackIndex != trackIndex) {
            MAIN.undoStack()->push(new Timeline::MuteTrackCommand(m_model, otherTrackIndex));
        }
    }
    MAIN.undoStack()->endMacro();
}

bool TimelineDock::setTrackGain(int trackIndex, double gain)
{
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return false;
    }
    if (trackIndex < 0 || trackIndex >= m_model.rowCount())
        return false;

    MAIN.undoStack()->push(new Timeline::ChangeTrackGainCommand(m_model, trackIndex, gain));
    emit gainChanged(gain);
    return true;
}

/*!
    \qmlmethod void TimelineDock::toggleTrackHidden(int trackIndex)
    \brief Toggles visibility of the video track at \a trackIndex.
*/

void TimelineDock::toggleTrackHidden(int trackIndex)
{
    MAIN.undoStack()->push(new Timeline::HideTrackCommand(m_model, trackIndex));
}

/*!
    \qmlmethod void TimelineDock::toggleOtherTracksHidden(int trackIndex)
    \brief Hides all video tracks except \a trackIndex, or shows all if already in that state.
*/

void TimelineDock::toggleOtherTracksHidden(int trackIndex)
{
    int trackCount = m_model.rowCount();
    if (trackCount == 1 || trackIndex < 0 || trackIndex >= trackCount) {
        return;
    }
    MAIN.undoStack()->beginMacro(tr("Toggle other tracks hidden"));
    for (int otherTrackIndex = 0; otherTrackIndex < trackCount; otherTrackIndex++) {
        if (otherTrackIndex != trackIndex
            && m_model.trackList().at(otherTrackIndex).type == VideoTrackType) {
            MAIN.undoStack()->push(new Timeline::HideTrackCommand(m_model, otherTrackIndex));
        }
    }
    MAIN.undoStack()->endMacro();
}

/*!
    \qmlmethod void TimelineDock::setTrackComposite(int trackIndex, bool composite)
    \brief Sets whether the video track at \a trackIndex composites (\a composite) over lower tracks.
*/

void TimelineDock::setTrackComposite(int trackIndex, bool composite)
{
    MAIN.undoStack()->push(new Timeline::CompositeTrackCommand(m_model, trackIndex, composite));
}

/*!
    \qmlmethod void TimelineDock::setTrackLock(int trackIndex, bool lock)
    \brief Locks (\a lock = \c true) or unlocks the track at \a trackIndex.
*/

void TimelineDock::setTrackLock(int trackIndex, bool lock)
{
    MAIN.undoStack()->push(new Timeline::LockTrackCommand(m_model, trackIndex, lock));
}

/*!
    \qmlmethod bool TimelineDock::moveClip(int fromTrack, int toTrack, int clipIndex, int position, bool ripple)
    \brief Moves the clip at (\a fromTrack, \a clipIndex) to \a position on \a toTrack.
    If \a ripple is \c true, downstream clips shift to fill the gap. Returns \c true on success.
*/

bool TimelineDock::moveClip(int fromTrack, int toTrack, int clipIndex, int position, bool ripple)
{
    if (toTrack >= 0 && clipIndex >= 0) {
        int length = 0;
        int i = m_model.trackList().at(fromTrack).mlt_index;
        QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
        if (track->is_valid()) {
            Mlt::Playlist playlist(*track.data());
            length = playlist.clip_length(clipIndex);
        }
        i = m_model.trackList().at(toTrack).mlt_index;
        track.reset((m_model.tractor()->track(i)));
        if (track->is_valid()) {
            Mlt::Playlist playlist(*track.data());
            if (m_model.isTransition(playlist, playlist.get_clip_index_at(position))
                || m_model.isTransition(playlist,
                                        playlist.get_clip_index_at(position + length - 1))) {
                return false;
            }
            if (!ripple && !Settings.timelineAllowTransitions()) {
                int startIdx = playlist.get_clip_index_at(position);
                int endIdx = playlist.get_clip_index_at(position + length - 1);
                for (int idx = startIdx; idx <= endIdx; idx++) {
                    if (idx >= 0 && idx < playlist.count() && !playlist.is_blank(idx)) {
                        if (fromTrack == toTrack && idx == clipIndex)
                            continue;
                        return false;
                    }
                }
            }
        }
    }
    if (selection().size() <= 1 && Settings.timelineAllowTransitions()
        && m_model.addTransitionValid(fromTrack, toTrack, clipIndex, position, ripple)) {
        emit transitionAdded(fromTrack, clipIndex, position, ripple);
        if (m_updateCommand)
            m_updateCommand->setPosition(toTrack, clipIndex, position);
    } else if (selection().size() <= 1 && Settings.timelineAllowTransitions()
               && m_model.transitionOverlapValid(fromTrack, toTrack, clipIndex, position, ripple)) {
        emit showStatusMessage(tr("You cannot make a transition with an adjustment clip."));
        return false;
    } else {
        // Check for locked tracks
        auto trackDelta = toTrack - fromTrack;
        for (const auto &clip : selection()) {
            auto trackIndex = clip.y() + trackDelta;
            if (isTrackLocked(clip.y())) {
                emit warnTrackLocked(clip.y());
                return false;
            }
            if (isTrackLocked(trackIndex)) {
                emit warnTrackLocked(trackIndex);
                return false;
            }
        }

        // Workaround bug #326 moving clips between tracks stops allowing drag-n-drop
        // into Timeline, which appeared with Qt 5.6 upgrade.
        emit clipMoved(fromTrack, toTrack, clipIndex, position, ripple);
        if (m_updateCommand)
            m_updateCommand->setPosition(toTrack, clipIndex, position);
    }
    return true;
}

void TimelineDock::onClipMoved(int fromTrack, int toTrack, int clipIndex, int position, bool ripple)
{
    int n = selection().size();
    if (n > 0) {
        // determine the position delta
        for (const auto &clip : selection()) {
            if (clip.y() == fromTrack && clip.x() == clipIndex) {
                auto info = m_model.getClipInfo(clip.y(), clip.x());
                if (info) {
                    position -= info->start;
                    break;
                }
            }
        }
        // A move that nets no change has nothing to do or undo.
        if (toTrack == fromTrack && position == 0)
            return;
        auto command = new Timeline::MoveClipCommand(*this, toTrack - fromTrack, position, ripple);
        for (const auto &clip : selection()) {
            command->addClip(clip.y(), clip.x());
        }
        setSelection();
        if (fromTrack == toTrack)
            disconnect(&m_model, &MultitrackModel::noMoreEmptyTracks, this, nullptr);
        MAIN.undoStack()->push(command);
        if (fromTrack == toTrack)
            connect(&m_model,
                    &MultitrackModel::noMoreEmptyTracks,
                    this,
                    &TimelineDock::onNoMoreEmptyTracks,
                    Qt::QueuedConnection);
    }
}

bool TimelineDock::trimClipIn(
    int trackIndex, int clipIndex, int oldClipIndex, int delta, bool ripple, bool roll)
{
    emit trimStarted();
    if (dynamic_cast<Timeline::RemoveTransitionByTrimInCommand *>(m_trimCommand.get())) {
        if (delta < 0) {
            // Do not trim past the removed transition
            return false;
        } else {
            // Untrimming - Restore the transition
            dynamic_cast<Timeline::RemoveTransitionByTrimInCommand *>(m_trimCommand.get())->undo();
            m_trimCommand.release();
            m_undoHelper.release();
            delta += m_trimDelta;
            m_trimDelta = 0;
            clipIndex += 1;
        }
    }
    if (!ripple && !roll && m_model.addTransitionByTrimInValid(trackIndex, clipIndex, delta)) {
        if (!Settings.timelineAllowTransitions())
            return false;
        clipIndex = m_model.addTransitionByTrimIn(trackIndex, clipIndex, delta);
        m_transitionDelta += delta;
        m_trimCommand.reset(new Timeline::AddTransitionByTrimInCommand(*this,
                                                                       trackIndex,
                                                                       clipIndex - 1,
                                                                       m_transitionDelta,
                                                                       m_trimDelta,
                                                                       false));
        if (m_updateCommand && m_updateCommand->trackIndex() == trackIndex
            && m_updateCommand->clipIndex() == clipIndex)
            m_updateCommand->setPosition(trackIndex, clipIndex, -1);
    } else if (!ripple && !roll
               && m_model.removeTransitionByTrimInValid(trackIndex, clipIndex, delta)) {
        Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);
        std::unique_ptr<Mlt::ClipInfo> clipInfo = m_model.getClipInfo(trackIndex, clipIndex - 1);
        QString xml = MLT.XML(clipInfo->producer);
        m_model.liftClip(trackIndex, clipIndex - 1);
        if (delta < 0) {
            m_model.trimClipIn(trackIndex, clipIndex, -clipInfo->length, false, false);
            m_trimDelta += -clipInfo->length;
        } else if (delta > 0) {
            m_model.trimClipOut(trackIndex, clipIndex - 2, -clipInfo->length, false, false);
            m_transitionDelta = 0;
        }
        m_trimCommand.reset(new Timeline::RemoveTransitionByTrimInCommand(m_model,
                                                                          trackIndex,
                                                                          clipIndex - 1,
                                                                          m_trimDelta,
                                                                          xml,
                                                                          false));
        if (m_updateCommand && m_updateCommand->trackIndex() == trackIndex
            && m_updateCommand->clipIndex() == clipIndex)
            m_updateCommand->setPosition(trackIndex, clipIndex - 1, -1);
    } else if (!ripple && !roll && m_model.trimTransitionOutValid(trackIndex, clipIndex, delta)) {
        m_model.trimTransitionOut(trackIndex, clipIndex, delta);
        m_trimDelta += delta;
        m_trimCommand.reset(new Timeline::TrimTransitionOutCommand(m_model,
                                                                   trackIndex,
                                                                   clipIndex,
                                                                   m_trimDelta,
                                                                   false));
    } else if (m_model.trimClipInValid(trackIndex, clipIndex, delta, ripple || roll)) {
        if (!m_undoHelper) {
            m_undoHelper.reset(new UndoHelper(m_model));
            if (ripple)
                m_undoHelper->setHints(UndoHelper::RestoreTracks);
            m_undoHelper->recordBeforeState(
                Settings.timelineRippleAllTracks() ? QSet<int>() : QSet<int>{trackIndex});
        }
        if (roll && delta < 0
            && m_model.trimClipOutValid(trackIndex, clipIndex - 1, -delta, false)) {
            m_model.trimClipOut(trackIndex, clipIndex - 1, -delta, false, false);
            clipIndex += 1; // A blank was added
        }
        clipIndex = m_model.trimClipIn(trackIndex,
                                       clipIndex,
                                       delta,
                                       ripple,
                                       Settings.timelineRippleAllTracks());
        if (roll && delta > 0
            && m_model.trimClipOutValid(trackIndex, clipIndex - 2, -delta, false)) {
            m_model.trimClipOut(trackIndex, clipIndex - 2, -delta, false, false);
        }

        m_trimDelta += delta;
        m_trimCommand.reset(new Timeline::TrimClipInCommand(
            m_model, m_markersModel, trackIndex, oldClipIndex, m_trimDelta, ripple, false));
        if (m_updateCommand && m_updateCommand->trackIndex() == trackIndex
            && m_updateCommand->clipIndex() == clipIndex)
            m_updateCommand->setPosition(trackIndex, clipIndex, m_updateCommand->position() + delta);
    } else
        return false;

    // Update duration in properties
    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (info && !info->producer->get_int(kShotcutSequenceProperty))
        emit durationChanged();

    return true;
}

bool TimelineDock::trimClipOut(int trackIndex, int clipIndex, int delta, bool ripple, bool roll)
{
    emit trimStarted();
    if (dynamic_cast<Timeline::RemoveTransitionByTrimOutCommand *>(m_trimCommand.get())) {
        if (delta < 0) {
            // Do not trim past the removed transition
            return false;
        } else {
            // Untrimming - Restore the transition
            dynamic_cast<Timeline::RemoveTransitionByTrimOutCommand *>(m_trimCommand.get())->undo();
            m_trimCommand.release();
            m_undoHelper.release();
            delta += m_trimDelta;
            m_trimDelta = 0;
        }
    }
    if (!ripple && !roll && m_model.addTransitionByTrimOutValid(trackIndex, clipIndex, delta)) {
        if (!Settings.timelineAllowTransitions())
            return false;
        m_model.addTransitionByTrimOut(trackIndex, clipIndex, delta);
        m_transitionDelta += delta;
        m_trimCommand.reset(new Timeline::AddTransitionByTrimOutCommand(m_model,
                                                                        trackIndex,
                                                                        clipIndex,
                                                                        m_transitionDelta,
                                                                        m_trimDelta,
                                                                        false));
        if (m_updateCommand && m_updateCommand->trackIndex() == trackIndex
            && m_updateCommand->clipIndex() == clipIndex)
            m_updateCommand->setPosition(trackIndex, clipIndex, -1);
    } else if (!ripple && !roll
               && m_model.removeTransitionByTrimOutValid(trackIndex, clipIndex, delta)) {
        Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);
        std::unique_ptr<Mlt::ClipInfo> clipInfo = m_model.getClipInfo(trackIndex, clipIndex + 1);
        QString xml = MLT.XML(clipInfo->producer);
        m_model.liftClip(trackIndex, clipIndex + 1);
        if (delta < 0) {
            m_model.trimClipOut(trackIndex, clipIndex, -clipInfo->length, false, false);
            m_trimDelta += -clipInfo->length;
        } else if (delta > 0) {
            m_model.trimClipIn(trackIndex, clipIndex + 2, -clipInfo->length, false, false);
            m_transitionDelta = 0;
        }
        m_trimCommand.reset(new Timeline::RemoveTransitionByTrimOutCommand(m_model,
                                                                           trackIndex,
                                                                           clipIndex + 1,
                                                                           m_trimDelta,
                                                                           xml,
                                                                           false));
        if (m_updateCommand && m_updateCommand->trackIndex() == trackIndex
            && m_updateCommand->clipIndex() == clipIndex)
            m_updateCommand->setPosition(trackIndex, clipIndex, -1);
    } else if (!ripple && !roll && m_model.trimTransitionInValid(trackIndex, clipIndex, delta)) {
        m_model.trimTransitionIn(trackIndex, clipIndex, delta);
        m_trimDelta += delta;
        m_trimCommand.reset(new Timeline::TrimTransitionInCommand(m_model,
                                                                  trackIndex,
                                                                  clipIndex,
                                                                  m_trimDelta,
                                                                  false));
    } else if (m_model.trimClipOutValid(trackIndex, clipIndex, delta, ripple || roll)) {
        if (!m_undoHelper) {
            m_undoHelper.reset(new UndoHelper(m_model));
            if (!ripple)
                m_undoHelper->setHints(UndoHelper::SkipXML);
            m_undoHelper->recordBeforeState(
                Settings.timelineRippleAllTracks() ? QSet<int>() : QSet<int>{trackIndex});
        }
        if (roll && delta < 0 && m_model.trimClipInValid(trackIndex, clipIndex + 1, -delta, false)) {
            m_model.trimClipIn(trackIndex, clipIndex + 1, -delta, false, false);
        }
        m_model.trimClipOut(trackIndex, clipIndex, delta, ripple, Settings.timelineRippleAllTracks());
        if (roll && delta > 0 && m_model.trimClipInValid(trackIndex, clipIndex + 2, -delta, false)) {
            m_model.trimClipIn(trackIndex, clipIndex + 2, -delta, false, false);
        }

        m_trimDelta += delta;
        m_trimCommand.reset(new Timeline::TrimClipOutCommand(
            m_model, m_markersModel, trackIndex, clipIndex, m_trimDelta, ripple, false));
        if (m_updateCommand && m_updateCommand->trackIndex() == trackIndex
            && m_updateCommand->clipIndex() == clipIndex)
            m_updateCommand->setPosition(trackIndex, clipIndex, -1);
    } else
        return false;

    // Update duration in properties
    auto info = m_model.getClipInfo(trackIndex, clipIndex);
    if (info && !info->producer->get_int(kShotcutSequenceProperty))
        emit durationChanged();

    return true;
}

bool TimelineDock::resizeTransition(int trackIndex, int clipIndex, int delta)
{
    emit trimStarted();
    if (m_model.resizeTransitionValid(trackIndex, clipIndex, delta)) {
        m_model.trimTransitionIn(trackIndex, clipIndex - 1, -delta);
        m_model.trimTransitionOut(trackIndex, clipIndex + 1, -delta);
        m_trimDelta += delta;
        m_trimCommand.reset(new Timeline::ResizeTransitionCommand(m_model,
                                                                  trackIndex,
                                                                  clipIndex,
                                                                  m_trimDelta,
                                                                  false));
        return true;
    }
    return false;
}

/*!
    \qmlmethod void TimelineDock::insert(int trackIndex, int position, string xml, bool seek)
    \brief Inserts the producer described by \a xml at time \a position on \a trackIndex,
    rippling downstream clips. Pass \c -1 for \a position to use the playhead.
    If \a seek is \c true, moves the playhead to the end of the inserted clip.
*/

void TimelineDock::insert(int trackIndex, int position, const QString &xml, bool seek)
{
    // Validations
    if (trackIndex < 0)
        trackIndex = currentTrack();
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    if (xml.contains(MAIN.fileName()) && MAIN.isSourceClipMyProject())
        return;

    // Use MLT XML on the clipboard if it exists and is newer than source clip.
    QString xmlToUse = QGuiApplication::clipboard()->text();
    if (isSystemClipboardValid(xmlToUse)) {
        if (!Settings.proxyEnabled()) {
            ProxyManager::filterXML(xmlToUse, "");
        }
    } else {
        xmlToUse.clear();
    }

    if (MLT.isSeekableClip() || MLT.savedProducer() || !xml.isEmpty() || !xmlToUse.isEmpty()) {
        Mlt::Producer producer;
        if (xmlToUse.isEmpty() && xml.isEmpty()) {
            Mlt::Producer producer(MLT.isClip() ? MLT.producer() : MLT.savedProducer());
            if (MLT.isLiveProducer(&producer)) {
                LongUiTask::cancel();
                DurationDialog durationDialog(this);
                durationDialog.setDuration(MLT.profile().fps() * 5);
                if (durationDialog.exec() == QDialog::Accepted) {
                    producer.set_in_and_out(0, durationDialog.duration() - 1);
                } else {
                    return;
                }
            } else {
                ProxyManager::generateIfNotExists(producer);
            }
            xmlToUse = MLT.XML(&producer);
        } else if (!xml.isEmpty()) {
            xmlToUse = xml;
        } else {
            producer = Mlt::Producer(MLT.profile(), "xml-string", xmlToUse.toUtf8().constData());
        }
        if (xmlToUse.isEmpty()) {
            return;
        }
        if (position < 0) {
            position = qMax(m_position, 0);
        }

        // Insert multiple if the XML is a <tractor> with child <property name="shotcut">1</property>
        // No need to create a track in an empty timeline.
        // This can be a macro of QUndoCommands.
        if (producer.is_valid() && producer.type() == mlt_service_tractor_type
            && producer.get_int(kShotcutXmlProperty)) {
            Mlt::Tractor tractor(producer);
            Mlt::ClipInfo info;
            MAIN.undoStack()->beginMacro(tr("Insert multiple into timeline"));
            Mlt::Controller::RefreshBlocker blocker;

            // Loop over each source track
            for (int mltTrackIndex = 0; mltTrackIndex < tractor.count(); mltTrackIndex++) {
                QScopedPointer<Mlt::Producer> srcTrack(tractor.track(mltTrackIndex));
                if (srcTrack) {
                    const auto trackIndex = currentTrack() + mltTrackIndex;
                    addTrackIfNeeded(trackIndex, srcTrack.get());
                    PendingTransitionState pendingTransition;

                    // Insert the clips for this track
                    Mlt::Playlist playlist(*srcTrack);
                    for (int mltClipIndex = 0; mltClipIndex < playlist.count(); mltClipIndex++) {
                        if (!playlist.is_blank(mltClipIndex)) {
                            playlist.clip_info(mltClipIndex, &info);
                            if (isTransitionClipboardClip(info)) {
                                beginPendingTransition(pendingTransition, info, true);
                                continue;
                            }
                            Mlt::Producer clip(info.producer);
                            const bool matchesPendingRight
                                = matchesPendingTransitionRight(pendingTransition, clip);
                            applyTransitionAwareInOut(clip,
                                                      info,
                                                      matchesPendingRight,
                                                      pendingTransition.rightIn);
                            const int insertPosition = position + info.start
                                                       - pendingTransition.skippedDuration;
                            bool lastClip = mltTrackIndex == tractor.count() - 1
                                            && mltClipIndex == playlist.count() - 1;
                            MAIN.undoStack()->push(new Timeline::InsertCommand(m_model,
                                                                               m_markersModel,
                                                                               trackIndex,
                                                                               insertPosition,
                                                                               MLT.XML(&clip),
                                                                               lastClip));

                            if (pendingTransition.duration > 0) {
                                const int rightClipIndex = clipIndexAtPosition(trackIndex,
                                                                               insertPosition);
                                const int leftClipIndex = rightClipIndex - 1;
                                if (matchesPendingRight && leftClipIndex >= 0 && rightClipIndex >= 0
                                    && !isBlank(trackIndex, leftClipIndex)
                                    && !isBlank(trackIndex, rightClipIndex)
                                    && !isTransition(trackIndex, leftClipIndex)
                                    && !isTransition(trackIndex, rightClipIndex)
                                    && m_model.addTransitionByTrimOutValid(trackIndex,
                                                                           leftClipIndex,
                                                                           -pendingTransition
                                                                                .duration)) {
                                    MAIN.undoStack()->push(
                                        new Timeline::AddTransitionByTrimOutCommand(m_model,
                                                                                    trackIndex,
                                                                                    leftClipIndex,
                                                                                    -pendingTransition
                                                                                         .duration,
                                                                                    0));
                                }
                                clearPendingTransition(pendingTransition);
                            }
                        }
                    }
                }
            }
            // Restore groups: find each inserted clip by position and create GroupCommands.
            QMap<int, Timeline::GroupCommand *> groupCommands;
            QMap<int, int> groupClipCounts;
            for (int mltTrackIndex = 0; mltTrackIndex < tractor.count(); mltTrackIndex++) {
                QScopedPointer<Mlt::Producer> srcTrack(tractor.track(mltTrackIndex));
                if (srcTrack) {
                    const auto destTrackIndex = currentTrack() + mltTrackIndex;
                    Mlt::Playlist srcPlaylist(*srcTrack);
                    Mlt::ClipInfo clipInfo;
                    int skippedTransitionDuration = 0;
                    for (int mltClipIndex = 0; mltClipIndex < srcPlaylist.count(); mltClipIndex++) {
                        if (!srcPlaylist.is_blank(mltClipIndex)) {
                            srcPlaylist.clip_info(mltClipIndex, &clipInfo);
                            if (isTransitionClipboardClip(clipInfo)) {
                                skippedTransitionDuration += transitionClipboardDuration(clipInfo);
                                continue;
                            }
                            if (clipInfo.cut
                                && clipInfo.cut->property_exists(kShotcutGroupProperty)) {
                                int group = clipInfo.cut->get_int(kShotcutGroupProperty);
                                const int destPosition = position + clipInfo.start
                                                         - skippedTransitionDuration;
                                int destClipIndex = clipIndexAtPosition(destTrackIndex,
                                                                        destPosition);
                                if (destClipIndex >= 0 && !isBlank(destTrackIndex, destClipIndex)) {
                                    if (!groupCommands.contains(group))
                                        groupCommands[group] = new Timeline::GroupCommand(m_model);
                                    groupCommands[group]->addToGroup(destTrackIndex, destClipIndex);
                                    groupClipCounts[group]++;
                                }
                            }
                        }
                    }
                }
            }
            for (auto it = groupCommands.constBegin(); it != groupCommands.constEnd(); ++it) {
                if (groupClipCounts.value(it.key()) >= 2) {
                    MAIN.undoStack()->push(it.value());
                } else {
                    delete it.value();
                }
            }
            MAIN.undoStack()->endMacro();

        } else {
            if (m_model.trackList().size() == 0) {
                position = 0;
                if (Settings.timelineAutoAddTracks())
                    addAudioTrack();
                addVideoTrack();
            }
            MAIN.undoStack()->push(new Timeline::InsertCommand(m_model,
                                                               m_markersModel,
                                                               trackIndex,
                                                               position,
                                                               xmlToUse,
                                                               seek));
        }
        if (m_position < 0) {
            // This happens when pasting in a new session
            MAIN.openCut(new Mlt::Producer(m_model.tractor()));
        }
    } else if (!MLT.isSeekableClip()) {
        emitNonSeekableWarning();
    }
}

void TimelineDock::selectClip(int trackIndex, int clipIndex)
{
    setSelection(QList<QPoint>() << QPoint(clipIndex, trackIndex));
}

void TimelineDock::onMultitrackClosed()
{
    stopRecording();
    m_position = -1;
    m_ignoreNextPositionChange = false;
    m_trimDelta = 0;
    m_transitionDelta = 0;
    setSelection();
    emit setZoom(1.0);
}

void TimelineDock::reloadTimelineModels()
{
    m_markersModel.load(m_model.tractor());
    m_subtitlesModel.load(m_model.tractor());
}

/*!
    \qmlmethod void TimelineDock::overwrite(int trackIndex, int position, string xml, bool seek)
    \brief Overwrites at time \a position on \a trackIndex with the producer described by \a xml.
    Pass \c -1 for \a position to use the playhead.
    If \a seek is \c true, moves the playhead to the end of the overwritten region.
*/

void TimelineDock::overwrite(int trackIndex, int position, const QString &xml, bool seek)
{
    // Validations
    if (trackIndex < 0)
        trackIndex = currentTrack();
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    if (xml.contains(MAIN.fileName()) && MAIN.isSourceClipMyProject())
        return;

    // Use MLT XML on the clipboard if it exists and is newer than source clip.
    QString xmlToUse = QGuiApplication::clipboard()->text();
    if (isSystemClipboardValid(xmlToUse)) {
        if (!Settings.proxyEnabled()) {
            ProxyManager::filterXML(xmlToUse, "");
        }
    } else {
        xmlToUse.clear();
    }

    if (MLT.isSeekableClip() || MLT.savedProducer() || !xml.isEmpty() || !xmlToUse.isEmpty()) {
        Mlt::Producer producer;
        if (xmlToUse.isEmpty() && xml.isEmpty()) {
            Mlt::Producer producer(MLT.isClip() ? MLT.producer() : MLT.savedProducer());
            if (MLT.isLiveProducer(&producer)) {
                LongUiTask::cancel();
                DurationDialog durationDialog(this);
                durationDialog.setDuration(MLT.profile().fps() * 5);
                if (durationDialog.exec() == QDialog::Accepted) {
                    producer.set_in_and_out(0, durationDialog.duration() - 1);
                } else {
                    return;
                }
            } else {
                ProxyManager::generateIfNotExists(producer);
            }
            xmlToUse = MLT.XML(&producer);
        } else if (!xml.isEmpty()) {
            if (MLT.isLiveProducer()) {
                LongUiTask::cancel();
                DurationDialog durationDialog(this);
                durationDialog.setDuration(MLT.profile().fps() * 5);
                if (durationDialog.exec() == QDialog::Accepted) {
                    MLT.producer()->set_in_and_out(0, durationDialog.duration() - 1);
                    xmlToUse = MLT.XML();
                } else {
                    xmlToUse = xml;
                }
            } else {
                xmlToUse = xml;
            }
        } else {
            producer = Mlt::Producer(MLT.profile(), "xml-string", xmlToUse.toUtf8().constData());
        }
        if (position < 0) {
            position = qMax(m_position, 0);
        }

        // Overwrite multiple if the XML is a <tractor> with child <property name="shotcut">1</property>
        // No need to create a track in an empty timeline.
        // This can be a macro of QUndoCommands.
        if (producer.is_valid() && producer.type() == mlt_service_tractor_type
            && producer.get_int(kShotcutXmlProperty)) {
            Mlt::Tractor tractor(producer);
            Mlt::ClipInfo info;
            MAIN.undoStack()->beginMacro(tr("Overwrite multiple onto timeline"));
            Mlt::Controller::RefreshBlocker blocker;

            // Loop over each source track
            for (int mltTrackIndex = 0; mltTrackIndex < tractor.count(); mltTrackIndex++) {
                QScopedPointer<Mlt::Producer> srcTrack(tractor.track(mltTrackIndex));
                if (srcTrack) {
                    const auto trackIndex = currentTrack() + mltTrackIndex;
                    addTrackIfNeeded(trackIndex, srcTrack.get());
                    PendingTransitionState pendingTransition;

                    // Insert the clips for this track
                    Mlt::Playlist playlist(*srcTrack);
                    for (int mltClipIndex = 0; mltClipIndex < playlist.count(); mltClipIndex++) {
                        if (!playlist.is_blank(mltClipIndex)) {
                            playlist.clip_info(mltClipIndex, &info);
                            if (isTransitionClipboardClip(info)) {
                                beginPendingTransition(pendingTransition, info, true);
                                continue;
                            }
                            Mlt::Producer clip(info.producer);
                            const bool matchesPendingRight
                                = matchesPendingTransitionRight(pendingTransition, clip);
                            applyTransitionAwareInOut(clip,
                                                      info,
                                                      matchesPendingRight,
                                                      pendingTransition.rightIn);
                            const int overwritePosition = position + info.start
                                                          - pendingTransition.skippedDuration;
                            MAIN.undoStack()->push(new Timeline::OverwriteCommand(m_model,
                                                                                  trackIndex,
                                                                                  overwritePosition,
                                                                                  MLT.XML(&clip),
                                                                                  false));

                            if (pendingTransition.duration > 0) {
                                const int rightClipIndex = clipIndexAtPosition(trackIndex,
                                                                               overwritePosition);
                                const int leftClipIndex = rightClipIndex - 1;
                                if (matchesPendingRight && leftClipIndex >= 0 && rightClipIndex >= 0
                                    && !isBlank(trackIndex, leftClipIndex)
                                    && !isBlank(trackIndex, rightClipIndex)
                                    && !isTransition(trackIndex, leftClipIndex)
                                    && !isTransition(trackIndex, rightClipIndex)
                                    && m_model.addTransitionByTrimOutValid(trackIndex,
                                                                           leftClipIndex,
                                                                           -pendingTransition
                                                                                .duration)) {
                                    MAIN.undoStack()->push(
                                        new Timeline::AddTransitionByTrimOutCommand(m_model,
                                                                                    trackIndex,
                                                                                    leftClipIndex,
                                                                                    -pendingTransition
                                                                                         .duration,
                                                                                    0));
                                }
                                clearPendingTransition(pendingTransition);
                            }
                        }
                    }
                }
            }
            // Restore groups: find each overwritten clip by position and create GroupCommands.
            QMap<int, Timeline::GroupCommand *> groupCommands;
            QMap<int, int> groupClipCounts;
            for (int mltTrackIndex = 0; mltTrackIndex < tractor.count(); mltTrackIndex++) {
                QScopedPointer<Mlt::Producer> srcTrack(tractor.track(mltTrackIndex));
                if (srcTrack) {
                    const auto destTrackIndex = currentTrack() + mltTrackIndex;
                    Mlt::Playlist srcPlaylist(*srcTrack);
                    Mlt::ClipInfo clipInfo;
                    int skippedTransitionDuration = 0;
                    for (int mltClipIndex = 0; mltClipIndex < srcPlaylist.count(); mltClipIndex++) {
                        if (!srcPlaylist.is_blank(mltClipIndex)) {
                            srcPlaylist.clip_info(mltClipIndex, &clipInfo);
                            if (isTransitionClipboardClip(clipInfo)) {
                                skippedTransitionDuration += transitionClipboardDuration(clipInfo);
                                continue;
                            }
                            if (clipInfo.cut
                                && clipInfo.cut->property_exists(kShotcutGroupProperty)) {
                                int group = clipInfo.cut->get_int(kShotcutGroupProperty);
                                const int destPosition = position + clipInfo.start
                                                         - skippedTransitionDuration;
                                int destClipIndex = clipIndexAtPosition(destTrackIndex,
                                                                        destPosition);
                                if (destClipIndex >= 0 && !isBlank(destTrackIndex, destClipIndex)) {
                                    if (!groupCommands.contains(group))
                                        groupCommands[group] = new Timeline::GroupCommand(m_model);
                                    groupCommands[group]->addToGroup(destTrackIndex, destClipIndex);
                                    groupClipCounts[group]++;
                                }
                            }
                        }
                    }
                }
            }
            for (auto it = groupCommands.constBegin(); it != groupCommands.constEnd(); ++it) {
                if (groupClipCounts.value(it.key()) >= 2) {
                    MAIN.undoStack()->push(it.value());
                } else {
                    delete it.value();
                }
            }
            MAIN.undoStack()->endMacro();

        } else {
            if (m_model.trackList().size() == 0) {
                position = 0;
                if (Settings.timelineAutoAddTracks())
                    addAudioTrack();
                addVideoTrack();
            }
            MAIN.undoStack()->push(
                new Timeline::OverwriteCommand(m_model, trackIndex, position, xmlToUse, seek));
        }
        if (m_position < 0) {
            // This happens when pasting in a new session
            MAIN.openCut(new Mlt::Producer(m_model.tractor()));
        }
    } else if (!MLT.isSeekableClip()) {
        emitNonSeekableWarning();
    }
}

void TimelineDock::appendFromPlaylist(Mlt::Playlist *playlist, bool skipProxy, bool emptyTrack)
{
    int trackIndex = currentTrack();
    if (trackIndex >= 0 && emptyTrack) {
        if (trackIndex < m_model.trackList().size()) {
            int i = m_model.trackList().at(trackIndex).mlt_index;
            QScopedPointer<Mlt::Producer> producer(m_model.tractor()->track(i));
            if (producer) {
                Mlt::Playlist track(*producer);
                if (!isEmptyTrack(track)) {
                    trackIndex = addVideoTrack();
                }
            }
        }
    }
    if (trackIndex < 0) {
        trackIndex = 0;
    }

    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    // Workaround a bug with first slide of slideshow animation not working.
    if (skipProxy) {
        // Initialize the multitrack with a bogus clip and remove it.
        Mlt::Producer producer(playlist->get_clip(0));
        auto clipIndex = m_model.appendClip(trackIndex, producer);
        if (clipIndex >= 0)
            m_model.removeClip(trackIndex, clipIndex, false);
    }
    disconnect(&m_model, &MultitrackModel::appended, this, &TimelineDock::selectClip);
    auto autoAddTracks = Settings.timelineAutoAddTracks();
    auto emptyTimeline = m_model.trackList().size() == 0;
    Settings.setTimelineAutoAddTracks(false);
    MAIN.undoStack()->push(
        new Timeline::AppendCommand(m_model, trackIndex, MLT.XML(playlist), skipProxy));
    connect(&m_model,
            &MultitrackModel::appended,
            this,
            &TimelineDock::selectClip,
            Qt::QueuedConnection);
    Settings.setTimelineAutoAddTracks(autoAddTracks);
    if (autoAddTracks && emptyTimeline)
        addAudioTrack();
    m_model.checkForEmptyTracks(trackIndex);
}

/*!
    \qmlmethod bool TimelineDock::changeGain(int trackIndex, int clipIndex, double gain)
    \brief Sets the audio \a gain of the clip at (\a trackIndex, \a clipIndex).
    Returns \c true on success.
*/

bool TimelineDock::changeGain(int trackIndex, int clipIndex, double gain)
{
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return false;
    }
    Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);

    // ignore request and return false if there are keyframes
    auto producer = producerForClip(trackIndex, clipIndex);
    std::unique_ptr<Mlt::Filter> filter(MLT.getFilter("audioGain", &producer));
    if (filter && filter->is_valid()) {
        Mlt::Animation anim = filter->get_animation("level");
        if (anim.is_valid() && anim.key_count() > 1)
            return false;
    }

    MAIN.undoStack()->push(new Timeline::ChangeGainCommand(m_model, trackIndex, clipIndex, gain));
    emit gainChanged(gain);
    return true;
}

/*!
    \qmlmethod void TimelineDock::fadeIn(int trackIndex, int clipIndex, int duration)
    \brief Sets the fade-in \a duration (frames) for the clip at (\a trackIndex, \a clipIndex).
    Pass \c -1 for \a clipIndex to use the clip under the playhead.
*/

void TimelineDock::fadeIn(int trackIndex, int clipIndex, int duration)
{
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    if (duration < 0)
        return;
    Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);
    MAIN.undoStack()->push(new Timeline::FadeInCommand(m_model, trackIndex, clipIndex, duration));
    emit fadeInChanged(duration);
}

/*!
    \qmlmethod void TimelineDock::fadeOut(int trackIndex, int clipIndex, int duration)
    \brief Sets the fade-out \a duration (frames) for the clip at (\a trackIndex, \a clipIndex).
    Pass \c -1 for \a clipIndex to use the clip under the playhead.
*/

void TimelineDock::fadeOut(int trackIndex, int clipIndex, int duration)
{
    if (isTrackLocked(trackIndex)) {
        emit warnTrackLocked(trackIndex);
        return;
    }
    if (duration < 0)
        return;
    Q_ASSERT(trackIndex >= 0 && clipIndex >= 0);
    MAIN.undoStack()->push(new Timeline::FadeOutCommand(m_model, trackIndex, clipIndex, duration));
    emit fadeOutChanged(duration);
}

/*!
    \qmlmethod void TimelineDock::seekPreviousEdit()
    \brief Seeks the playhead to the nearest edit point (clip boundary) before the current position.
*/

void TimelineDock::seekPreviousEdit()
{
    if (!MLT.isMultitrack())
        return;
    if (!m_model.tractor())
        return;

    int newPosition = -1;
    int n = m_model.tractor()->count();
    for (int i = 0; i < n; i++) {
        QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
        if (track) {
            Mlt::Playlist playlist(*track);
            // Skip empty tracks (tracks with only one blank clip)
            if (isEmptyTrack(playlist))
                continue;
            int clipIndex = playlist.get_clip_index_at(m_position);
            if (clipIndex >= 0 && m_position == playlist.clip_start(clipIndex))
                --clipIndex;
            if (clipIndex >= 0)
                newPosition = qMax(newPosition, playlist.clip_start(clipIndex));
        }
    }
    if (newPosition != m_position)
        setPosition(newPosition);
}

/*!
    \qmlmethod void TimelineDock::seekNextEdit()
    \brief Seeks the playhead to the nearest edit point (clip boundary) after the current position.
*/

void TimelineDock::seekNextEdit()
{
    if (!MLT.isMultitrack())
        return;
    if (!m_model.tractor())
        return;

    int newPosition = std::numeric_limits<int>::max();
    int n = m_model.tractor()->count();
    for (int i = 0; i < n; i++) {
        QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(i));
        if (track) {
            Mlt::Playlist playlist(*track);
            // Skip empty tracks (tracks with only one blank clip)
            if (isEmptyTrack(playlist))
                continue;
            int clipIndex = playlist.get_clip_index_at(m_position) + 1;
            if (clipIndex < playlist.count())
                newPosition = qMin(newPosition, playlist.clip_start(clipIndex));
            else if (clipIndex == playlist.count())
                newPosition = qMin(newPosition,
                                   playlist.clip_start(clipIndex) + playlist.clip_length(clipIndex));
        }
    }
    if (newPosition != m_position)
        setPosition(newPosition);
}

/*!
    \qmlmethod void TimelineDock::seekInPoint(int clipIndex)
    \brief Seeks the playhead to the in-point of the clip at \a clipIndex on the current track.
*/

void TimelineDock::seekInPoint(int clipIndex)
{
    if (!MLT.isMultitrack())
        return;
    if (!m_model.tractor())
        return;
    if (clipIndex < 0)
        return;

    int mltTrackIndex = m_model.trackList().at(currentTrack()).mlt_index;
    QScopedPointer<Mlt::Producer> track(m_model.tractor()->track(mltTrackIndex));
    if (track) {
        Mlt::Playlist playlist(*track);
        if (m_position != playlist.clip_start(clipIndex))
            setPosition(playlist.clip_start(clipIndex));
    }
}

void TimelineDock::dragEnterEvent(QDragEnterEvent *event)
{
    LOG_DEBUG() << event->mimeData()->hasFormat(Mlt::XmlMimeType);
    if (event->mimeData()->hasFormat(Mlt::XmlMimeType)) {
        MLT.pause();
        event->acceptProposedAction();
    }
}

void TimelineDock::dragMoveEvent(QDragMoveEvent *event)
{
    emit dragging(event->position(), event->mimeData()->text().toInt());
}

void TimelineDock::dragLeaveEvent(QDragLeaveEvent *event)
{
    Q_UNUSED(event);
    emit dropped();
}

void TimelineDock::dropEvent(QDropEvent *event)
{
    if (event->mimeData()->hasFormat(Mlt::XmlMimeType)) {
        int trackIndex = currentTrack();
        if (trackIndex >= 0) {
            emit dropAccepted(QString::fromUtf8(event->mimeData()->data(Mlt::XmlMimeType)));
            event->acceptProposedAction();
        }
    }
    emit dropped();
}

bool TimelineDock::event(QEvent *event)
{
    bool result = QDockWidget::event(event);
    if (event->type() == QEvent::PaletteChange || event->type() == QEvent::StyleChange)
        load(true);
    return result;
}

void TimelineDock::keyPressEvent(QKeyEvent *event)
{
    QDockWidget::keyPressEvent(event);
    if (!event->isAccepted())
        MAIN.keyPressEvent(event);
}

void TimelineDock::keyReleaseEvent(QKeyEvent *event)
{
    QDockWidget::keyReleaseEvent(event);
    if (!event->isAccepted())
        MAIN.keyReleaseEvent(event);
}

void TimelineDock::load(bool force)
{
    if (m_quickView.source().isEmpty() || force) {
        int saveCurrentTrack = -1;
        if (!m_quickView.source().isEmpty())
            saveCurrentTrack = currentTrack();
        QDir sourcePath = QmlUtilities::qmlDir();
        sourcePath.cd("views");
        sourcePath.cd("timeline");
        m_quickView.setFocusPolicy(isFloating() ? Qt::NoFocus : Qt::StrongFocus);
        m_quickView.setSource(QUrl::fromLocalFile(sourcePath.filePath("timeline.qml")));
        if (force && Settings.timelineShowWaveforms())
            m_model.reload();
        if (saveCurrentTrack != -1)
            setCurrentTrack(saveCurrentTrack);
    } else if (Settings.timelineShowWaveforms()) {
        m_model.reload();
    }
}

void TimelineDock::onTopLevelChanged(bool floating)
{
    m_quickView.setFocusPolicy(floating ? Qt::NoFocus : Qt::StrongFocus);
}

void TimelineDock::onTransitionAdded(int trackIndex, int clipIndex, int position, bool ripple)
{
    setSelection(); // cleared
    Timeline::AddTransitionCommand *command
        = new Timeline::AddTransitionCommand(*this, trackIndex, clipIndex, position, ripple);
    MAIN.undoStack()->push(command);
    // Select the transition.
    setSelection(QList<QPoint>() << QPoint(command->getTransitionIndex(), trackIndex));
}

void TimelineDock::onTimelineRightClicked()
{
    m_mainMenu->popup(QCursor::pos());
}

void TimelineDock::onClipRightClicked()
{
    m_clipMenu->popup(QCursor::pos());
}

void TimelineDock::onNoMoreEmptyTracks(bool isAudio)
{
    if (Settings.timelineAutoAddTracks()) {
        if (isAudio) {
            addAudioTrack();
        } else {
            addVideoTrack();
            incrementCurrentTrack(1);
        }
    }
}

void TimelineDock::addGenerator(QWidget *widget)
{
    auto dialog = new QDialog(this);
    dialog->resize(426, 288);
    dialog->setWindowModality(QmlApplication::dialogModality());
    auto vlayout = new QVBoxLayout(dialog);
    vlayout->addWidget(widget);
    auto buttonBox = new QDialogButtonBox(dialog);
    buttonBox->setOrientation(Qt::Horizontal);
    buttonBox->setStandardButtons(QDialogButtonBox::Cancel | QDialogButtonBox::Ok);
    vlayout->addWidget(buttonBox);
    connect(buttonBox, &QDialogButtonBox::accepted, dialog, &QDialog::accept);
    connect(buttonBox, &QDialogButtonBox::rejected, dialog, &QDialog::reject);
    connect(dialog, &QDialog::finished, this, [=](int result) {
        if (QDialog::Rejected == result)
            return;
        const auto name = widget->objectName();
        auto dialog = dynamic_cast<AbstractProducerWidget *>(widget);
        if (QLatin1String("GlaxnimateProducerWidget") == name) {
            auto glax = qobject_cast<GlaxnimateProducerWidget *>(widget);
            glax->setLaunchOnNew(false);
        }

        auto &profile = MLT.profile();
        auto producer = dialog->newProducer(profile);
        if (!(producer && producer->is_valid())) {
            delete producer;
            return;
        }
        if (!profile.is_explicit()) {
            profile.from_producer(*producer);
            profile.set_width(Util::coerceMultiple(profile.width()));
            profile.set_height(Util::coerceMultiple(profile.height()));
        }
        MLT.updatePreviewProfile();
        MLT.setPreviewScale(Settings.playerPreviewScale());
        if (QDialog::Accepted == result) {
            auto trackType = (QLatin1String("ToneProducerWidget") == name) ? AudioTrackType
                                                                           : VideoTrackType;
            auto trackIndex = addTrackIfNeeded(trackType);
            overwrite(trackIndex, -1, MLT.XML(producer), false);
            delete producer;
        }
        if (QLatin1String("TextProducerWidget") == name) {
            MAIN.onFiltersDockTriggered();
        } else {
            MAIN.onPropertiesDockTriggered();
        }
    });
    if (QLatin1String("NoiseWidget") == widget->objectName()) {
        emit dialog->finished(QDialog::Accepted);
    } else {
        dialog->show();
    }
}

void TimelineDock::addGenerator()
{
    if (sender()->objectName() == "color")
        addGenerator(new ColorProducerWidget(this));
    else if (sender()->objectName() == "text")
        addGenerator(new TextProducerWidget(this));
    else if (sender()->objectName() == "glaxnimate")
        addGenerator(new GlaxnimateProducerWidget(this));
    else if (sender()->objectName() == "noise")
        addGenerator(new NoiseWidget(this));
    else if (sender()->objectName() == "test_pat_B")
        addGenerator(new ColorBarsWidget(this));
    else if (sender()->objectName() == "tone")
        addGenerator(new ToneProducerWidget(this));
    else if (sender()->objectName() == "count")
        addGenerator(new CountProducerWidget(this));
    else if (sender()->objectName() == "blipflash")
        addGenerator(new BlipProducerWidget(this));
    else if (sender()->objectName() == "adjustment")
        addAdjustmentClip();
}

void TimelineDock::addAdjustmentClip()
{
    auto &profile = MLT.profile();
    Mlt::Producer producer(profile, "color:0");
    if (!producer.is_valid())
        return;
    producer.set("mlt_image_format", "rgba");
    producer.set("meta.fx_cut", 1);
    producer.set(kShotcutProducerProperty, "adjustment");
    producer.set(kShotcutCaptionProperty, tr("Adjustment Clip").toUtf8().constData());
    MLT.setDurationFromDefault(&producer);
    auto trackIndex = addTrackIfNeeded(VideoTrackType);
    overwrite(trackIndex, -1, MLT.XML(&producer), false);
}

class FindProducersByHashParser : public Mlt::Parser
{
private:
    QString m_hash;
    QList<Mlt::Producer> m_producers;

public:
    FindProducersByHashParser(const QString &hash)
        : Mlt::Parser()
        , m_hash(hash)
    {}

    QList<Mlt::Producer> &producers() { return m_producers; }

    int on_start_filter(Mlt::Filter *) { return 0; }
    int on_start_producer(Mlt::Producer *producer)
    {
        if (producer->is_cut() && Util::getHash(producer->parent()) == m_hash)
            m_producers << Mlt::Producer(producer);
        return 0;
    }
    int on_end_producer(Mlt::Producer *) { return 0; }
    int on_start_playlist(Mlt::Playlist *) { return 0; }
    int on_end_playlist(Mlt::Playlist *) { return 0; }
    int on_start_tractor(Mlt::Tractor *) { return 0; }
    int on_end_tractor(Mlt::Tractor *) { return 0; }
    int on_start_multitrack(Mlt::Multitrack *) { return 0; }
    int on_end_multitrack(Mlt::Multitrack *) { return 0; }
    int on_start_track() { return 0; }
    int on_end_track() { return 0; }
    int on_end_filter(Mlt::Filter *) { return 0; }
    int on_start_transition(Mlt::Transition *) { return 0; }
    int on_end_transition(Mlt::Transition *) { return 0; }
    int on_start_chain(Mlt::Chain *) { return 0; }
    int on_end_chain(Mlt::Chain *) { return 0; }
    int on_start_link(Mlt::Link *) { return 0; }
    int on_end_link(Mlt::Link *) { return 0; }
};

void TimelineDock::replaceClipsWithHash(const QString &hash, Mlt::Producer &producer)
{
    FindProducersByHashParser parser(hash);
    parser.start(*model()->tractor());
    auto n = parser.producers().size();
    if (n > 1)
        MAIN.undoStack()->beginMacro(tr("Replace %n timeline clips", nullptr, n));
    for (auto &clip : parser.producers()) {
        int trackIndex = -1;
        int clipIndex = -1;
        // lookup the current track and clip index by UUID
        auto info = m_model.findClipByUuid(QUuid::fromString(clip.parent().get(kUuidProperty)),
                                           trackIndex,
                                           clipIndex);

        if (info && info->producer->is_valid() && trackIndex >= 0 && clipIndex >= 0
            && info->producer->type() != mlt_service_tractor_type) {
            if (producer.get_int(kIsProxyProperty) && info->producer->get_int(kIsProxyProperty)) {
                // Not much to do on a proxy clip but change its resource
                info->producer->set(kOriginalResourceProperty, producer.get("resource"));
                auto caption = Util::baseName(ProxyManager::resource(*info->producer), true);
                if (!::qstrcmp(info->producer->get("mlt_service"), "timewarp")) {
                    caption
                        = QStringLiteral("%1 (%2x)").arg(caption, info->producer->get("warp_speed"));
                }
                info->producer->set(kShotcutCaptionProperty, caption.toUtf8().constData());
            } else {
                int in = clip.get_in();
                int out = clip.get_out();

                // Factor in a transition left of the clip.
                auto info2 = m_model.getClipInfo(trackIndex, clipIndex - 1);
                if (info2 && info2->producer && info2->producer->is_valid()
                    && info2->producer->get(kShotcutTransitionProperty)) {
                    in -= info2->frame_count;
                }
                // Factor in a transition right of the clip.
                info2 = m_model.getClipInfo(trackIndex, clipIndex + 1);
                if (info2 && info2->producer && info2->producer->is_valid()
                    && info2->producer->get(kShotcutTransitionProperty)) {
                    out += info2->frame_count;
                }
                Util::applyCustomProperties(producer, *info->producer, in, out);

                replace(trackIndex, clipIndex, MLT.XML(&producer));
            }
        }
    }
    if (n > 1)
        MAIN.undoStack()->endMacro();
}

/*!
    \qmlmethod void TimelineDock::recordAudio()
    \brief Starts audio recording into the current track at the playhead position.
*/

void TimelineDock::recordAudio()
{
    // Get the file name.
    auto filename = QmlApplication::getNextProjectFile("voiceover-.flac");
    if (filename.isEmpty()) {
        QString path = Settings.savePath();
        path.append("/%1.flac");
        path = path.arg(tr("voiceover"));
        auto nameFilter = QStringLiteral("FLAC (*.flac);;") + tr("All Files (*)");
        filename = QFileDialog::getSaveFileName(this,
                                                tr("Record Audio"),
                                                path,
                                                nameFilter,
                                                nullptr,
                                                Util::getFileDialogOptions());
    }
    if (filename.isEmpty()) {
        return;
    }
    if (!filename.endsWith(".flac")) {
        filename += ".flac";
    }
    auto info = QFileInfo(filename);
    Settings.setSavePath(info.path());
    MAIN.undoStack()->beginMacro(tr("Record Audio: %1").arg(info.fileName()));

    // Add renamed color clip to audio track.
    auto trackIndex = addTrackIfNeeded(AudioTrackType);
    auto clip = Mlt::Producer(MLT.profile(), "color:");
    clip.set(kShotcutCaptionProperty, info.fileName().toUtf8().constData());
    clip.set(kShotcutDetailProperty, filename.toUtf8().constData());
    clip.set(kBackgroundCaptureProperty, 1);
    clip.set("length", std::numeric_limits<int>::max());
    clip.set_in_and_out(0, 0);
    overwrite(trackIndex, -1, MLT.XML(&clip), false);
    m_recordingTrackIndex = trackIndex;
    m_recordingClipIndex = clipIndexAtPosition(trackIndex, position());

    // Start ffmpeg background job.
#if defined(Q_OS_MAC)
    QStringList args{"avformat:avfoundation:none:" + Settings.audioInput()};
    args << "-consumer"
         << "avformat:" + filename << "video_off=1"
         << "strict=-2";
    m_recordJob.reset(
        new MeltJob("vo", args, MLT.profile().frame_rate_num(), MLT.profile().frame_rate_den()));
#else
#if defined(Q_OS_WIN)
    QStringList args{"-f", "dshow", "-i", "audio=" + Settings.audioInput()};
#else
    QStringList args{"-f", "pulse", "-name", "Shotcut", "-i", Settings.audioInput()};
#endif
    args << "-flush_packets"
         << "1"
         << "-y" << filename;
    m_recordJob.reset(new FfmpegJob("vo", args, false, QThread::HighPriority));
#endif
    m_recordJob->setTarget(filename);
    connect(m_recordJob.get(), SIGNAL(started()), SLOT(onRecordStarted()));
    connect(m_recordJob.get(),
            SIGNAL(finished(AbstractJob *, bool)),
            SLOT(onRecordFinished(AbstractJob *, bool)));
    m_recordJob->start();
    m_isRecording = true;
    emit isRecordingChanged(m_isRecording);
}

void TimelineDock::onRecordStarted()
{
    // Use a timer to increase length of color clip.
    m_recordingTimer.setInterval(kRecordingTimerIntervalMs);
    connect(&m_recordingTimer, SIGNAL(timeout()), this, SLOT(updateRecording()));
    m_recordingTime = QDateTime::currentDateTime();
    m_recordingTimer.start();

    // Start playback.
    MLT.play();
}

void TimelineDock::updateRecording()
{
    const auto seconds = m_recordingTime.secsTo(QDateTime::currentDateTime());
    const auto out = qRound(MLT.profile().fps() * seconds);
    const auto info = m_model.getClipInfo(m_recordingTrackIndex, m_recordingClipIndex);
    if (info) {
        auto delta = info->frame_out - out;
        if (delta < 0) {
            m_model.trimClipOut(m_recordingTrackIndex, m_recordingClipIndex, delta, false, false);
        }
    }
    if (seconds % 3 == 1) {
        MAIN.showStatusMessage(tr("Audio Recording In Progress"), 2);
    }
}

void TimelineDock::onRecordFinished(AbstractJob *, bool success)
{
    if (!success) {
        stopRecording();
#if defined(Q_OS_UNIX) && !defined(Q_OS_MAC)
        Settings.setAudioInput(QString()); // saved input likely no longer valid
        emit showStatusMessage(tr("Record Audio error: check PulseAudio settings"));
#elif !defined(Q_OS_MAC)
        Settings.setAudioInput(QString()); // saved input likely no longer valid
        emit showStatusMessage(
            tr("Record Audio error: choose File > Open Other > Audio/Video Device"));
#endif
    }
}

/*!
    \qmlmethod void TimelineDock::stopRecording()
    \brief Stops an in-progress audio recording.
*/

void TimelineDock::stopRecording()
{
    m_recordingTimer.stop();

    if (m_isRecording) {
        m_isRecording = false;
        emit isRecordingChanged(m_isRecording);

        // Stop ffmpeg job.
        if (m_recordJob && m_recordJob->state() != QProcess::NotRunning) {
            m_recordJob->stop();

            // Stop playback.
            MLT.pause();

            // Wait for ffmpeg to flush the recording.
            LongUiTask longTask(tr("Record Audio"));
            longTask.setMinimumDuration(500);
            QFuture<int> future = QtConcurrent::run([]() {
                QThread::msleep(3000);
                return 0;
            });
            longTask.wait<int>(tr("Saving audio recording..."), future);
        }

        // Replace color clip.
        auto info = m_model.getClipInfo(m_recordingTrackIndex, m_recordingClipIndex);
        if (info && info->producer && info->producer->is_valid()) {
            Mlt::Producer clip(MLT.profile(), info->producer->get(kShotcutDetailProperty));
            lift(m_recordingTrackIndex, m_recordingClipIndex, true);
            if (clip.is_valid()) {
                overwrite(m_recordingTrackIndex, info->start, MLT.XML(&clip), false);
            }
        }
        MAIN.undoStack()->endMacro();
    }
}
