/*
  ==============================================================================

   This file is part of the JUCE framework examples.
   Copyright (c) Raw Material Software Limited

   The code included in this file is provided under the terms of the ISC license
   http://www.isc.org/downloads/software-support-policy/isc-license. Permission
   to use, copy, modify, and/or distribute this software for any purpose with or
   without fee is hereby granted provided that the above copyright notice and
   this permission notice appear in all copies.

   THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
   REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
   AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
   INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
   LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
   OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
   PERFORMANCE OF THIS SOFTWARE.

  ==============================================================================
*/

/*******************************************************************************
 The block below describes the properties of this PIP. A PIP is a short snippet
 of code that can be read by the Projucer and used to generate a JUCE project.

 BEGIN_JUCE_PIP_METADATA

 name:                     ARAPluginDemo
 version:                  1.0.0
 vendor:                   JUCE
 website:                  http://juce.com
 description:              Audio plugin using the ARA API.

 dependencies:             juce_audio_basics, juce_audio_devices, juce_audio_formats,
                           juce_audio_plugin_client, juce_audio_processors,
                           juce_audio_utils, juce_core, juce_data_structures,
                           juce_events, juce_graphics, juce_gui_basics, juce_gui_extra,
                           juce_audio_processors_headless
 exporters:                xcode_mac, vs2022, vs2026

 moduleFlags:              JUCE_STRICT_REFCOUNTEDPOINTER=1

 type:                     AudioProcessor
 mainClass:                ARADemoPluginAudioProcessor
 documentControllerClass:  ARADemoPluginDocumentControllerSpecialisation

 useLocalCopy:             1

 END_JUCE_PIP_METADATA

*******************************************************************************/

#pragma once

#include <ARA_Library/Utilities/ARAPitchInterpretation.h>
#include <ARA_Library/Utilities/ARATimelineConversion.h>

//==============================================================================
class ARADemoPluginAudioModification final : public ARAAudioModification
{
public:
    ARADemoPluginAudioModification (ARAAudioSource* audioSource,
                                    ARA::ARAAudioModificationHostRef hostRef,
                                    const ARAAudioModification* optionalModificationToClone)
        : ARAAudioModification (audioSource, hostRef, optionalModificationToClone)
    {
        if (optionalModificationToClone != nullptr)
            dimmed = static_cast<const ARADemoPluginAudioModification*> (optionalModificationToClone)->dimmed;
    }

    bool isDimmed() const           { return dimmed; }
    void setDimmed (bool shouldDim) { dimmed = shouldDim; }

private:
    bool dimmed = false;
};

//==============================================================================
struct PreviewState
{
    std::atomic<double> previewTime { 0.0 };
    std::atomic<ARAPlaybackRegion*> previewedRegion { nullptr };
};

class SharedTimeSliceThread final : public TimeSliceThread
{
public:
    SharedTimeSliceThread()
        : TimeSliceThread (String (JucePlugin_Name) + " ARA Sample Reading Thread")
    {
        startThread (Priority::high);  // Above default priority so playback is fluent, but below realtime
    }
};

class AsyncConfigurationCallback final : private AsyncUpdater
{
public:
    explicit AsyncConfigurationCallback (std::function<void()> callbackIn)
        : callback (std::move (callbackIn)) {}

    ~AsyncConfigurationCallback() override { cancelPendingUpdate(); }

    template <typename RequiresLock>
    auto withLock (RequiresLock&& fn)
    {
        const SpinLock::ScopedTryLockType scope (processingFlag);
        return fn (scope.isLocked());
    }

    void startConfigure() { triggerAsyncUpdate(); }

private:
    void handleAsyncUpdate() override
    {
        const SpinLock::ScopedLockType scope (processingFlag);
        callback();
    }

    std::function<void()> callback;
    SpinLock processingFlag;
};

static void crossfade (const float* sourceA,
                       const float* sourceB,
                       float aProportionAtStart,
                       float aProportionAtFinish,
                       float* destinationBuffer,
                       int numSamples)
{
    AudioBuffer<float> destination { &destinationBuffer, 1, numSamples };
    destination.copyFromWithRamp (0, 0, sourceA, numSamples, aProportionAtStart, aProportionAtFinish);
    destination.addFromWithRamp (0, 0, sourceB, numSamples, 1.0f - aProportionAtStart, 1.0f - aProportionAtFinish);
}

class Looper
{
public:
    Looper() : inputBuffer (nullptr), pos (loopRange.getStart())
    {
    }

    Looper (const AudioBuffer<float>* buffer, Range<int64> range)
        : inputBuffer (buffer), loopRange (range), pos (range.getStart())
    {
    }

    void writeInto (AudioBuffer<float>& buffer)
    {
        if (loopRange.getLength() == 0)
        {
            buffer.clear();
            return;
        }

        const auto numChannelsToCopy = std::min (inputBuffer->getNumChannels(), buffer.getNumChannels());
        const auto actualCrossfadeLengthSamples = std::min (loopRange.getLength() / 2, (int64) desiredCrossfadeLengthSamples);

        for (auto samplesCopied = 0; samplesCopied < buffer.getNumSamples();)
        {
            const auto [needsCrossfade, samplePosOfNextCrossfadeTransition] = [&]() -> std::pair<bool, int64>
            {
                if (const auto endOfFadeIn = loopRange.getStart() + actualCrossfadeLengthSamples; pos < endOfFadeIn)
                    return { true, endOfFadeIn };

                return { false, loopRange.getEnd() - actualCrossfadeLengthSamples };
            }();

            const auto samplesToNextCrossfadeTransition = samplePosOfNextCrossfadeTransition - pos;
            const auto numSamplesToCopy = std::min (buffer.getNumSamples() - samplesCopied,
                                                    (int) samplesToNextCrossfadeTransition);

            const auto getFadeInGainAtPos = [this, actualCrossfadeLengthSamples] (auto p)
            {
                return jmap ((float) p, (float) loopRange.getStart(), (float) loopRange.getStart() + (float) actualCrossfadeLengthSamples - 1.0f, 0.0f, 1.0f);
            };

            for (int i = 0; i < numChannelsToCopy; ++i)
            {
                if (needsCrossfade)
                {
                    const auto overlapStart = loopRange.getEnd() - actualCrossfadeLengthSamples
                                              + (pos - loopRange.getStart());

                    crossfade (inputBuffer->getReadPointer (i, (int) pos),
                               inputBuffer->getReadPointer (i, (int) overlapStart),
                               getFadeInGainAtPos (pos),
                               getFadeInGainAtPos (pos + numSamplesToCopy),
                               buffer.getWritePointer (i, samplesCopied),
                               numSamplesToCopy);
                }
                else
                {
                    buffer.copyFrom (i, samplesCopied, *inputBuffer, i, (int) pos, numSamplesToCopy);
                }
            }

            samplesCopied += numSamplesToCopy;
            pos += numSamplesToCopy;

            jassert (pos <= loopRange.getEnd() - actualCrossfadeLengthSamples);

            if (pos == loopRange.getEnd() - actualCrossfadeLengthSamples)
                pos = loopRange.getStart();
        }
    }

private:
    static constexpr int desiredCrossfadeLengthSamples = 50;

    const AudioBuffer<float>* inputBuffer;
    Range<int64> loopRange;
    int64 pos;
};

//==============================================================================
// Returns the modified sample range in the output buffer.
inline std::optional<Range<int64>> readPlaybackRangeIntoBuffer (Range<double> playbackRange,
                                                                const ARAPlaybackRegion* playbackRegion,
                                                                AudioBuffer<float>& buffer,
                                                                const std::function<AudioFormatReader* (ARAAudioSource*)>& getReader)
{
    const auto rangeInAudioModificationTime = playbackRange - playbackRegion->getStartInPlaybackTime()
                                                            + playbackRegion->getStartInAudioModificationTime();

    const auto audioModification = playbackRegion->getAudioModification<ARADemoPluginAudioModification>();
    const auto audioSource = audioModification->getAudioSource();
    const auto audioModificationSampleRate = audioSource->getSampleRate();

    const Range<int64_t> sampleRangeInAudioModification {
        ARA::roundSamplePosition (rangeInAudioModificationTime.getStart() * audioModificationSampleRate),
        ARA::roundSamplePosition (rangeInAudioModificationTime.getEnd() * audioModificationSampleRate) - 1
    };

    const auto inputOffset = jlimit ((int64_t) 0, audioSource->getSampleCount(), sampleRangeInAudioModification.getStart());

    // With the output offset it can always be said of the output buffer, that the zeroth element
    // corresponds to beginning of the playbackRange.
    const auto outputOffset = std::max (-sampleRangeInAudioModification.getStart(), (int64_t) 0);

    /* TODO: Handle different AudioSource and playback sample rates.

       The conversion should be done inside a specialized AudioFormatReader so that we could use
       playbackSampleRate everywhere in this function and we could still read `readLength` number of samples
       from the source.

       The current implementation will be incorrect when sampling rates differ.
    */
    const auto readLength = [&]
    {
        const auto sourceReadLength =
            std::min (sampleRangeInAudioModification.getEnd(), audioSource->getSampleCount()) - inputOffset;

        const auto outputReadLength =
            std::min (outputOffset + sourceReadLength, (int64_t) buffer.getNumSamples()) - outputOffset;

        return std::min (sourceReadLength, outputReadLength);
    }();

    if (readLength == 0)
        return Range<int64>();

    auto* reader = getReader (audioSource);

    if (reader != nullptr && reader->read (&buffer, (int) outputOffset, (int) readLength, inputOffset, true, true))
    {
        if (audioModification->isDimmed())
            buffer.applyGain ((int) outputOffset, (int) readLength, 0.25f);

        return Range<int64>::withStartAndLength (outputOffset, readLength);
    }

    return {};
}

class PossiblyBufferedReader
{
public:
    PossiblyBufferedReader() = default;

    explicit PossiblyBufferedReader (std::unique_ptr<BufferingAudioReader> readerIn)
        : setTimeoutFn ([ptr = readerIn.get()] (int ms) { ptr->setReadTimeout (ms); }),
          reader (std::move (readerIn))
    {}

    explicit PossiblyBufferedReader (std::unique_ptr<AudioFormatReader> readerIn)
        : setTimeoutFn(),
          reader (std::move (readerIn))
    {}

    void setReadTimeout (int ms)
    {
        NullCheckedInvocation::invoke (setTimeoutFn, ms);
    }

    AudioFormatReader* get() const { return reader.get(); }

private:
    std::function<void (int)> setTimeoutFn;
    std::unique_ptr<AudioFormatReader> reader;
};

struct ProcessingLockInterface
{
    virtual ~ProcessingLockInterface() = default;
    virtual ScopedTryReadLock getProcessingLock() = 0;
};

//==============================================================================
class PlaybackRenderer final : public ARAPlaybackRenderer
{
public:
    PlaybackRenderer (ARA::PlugIn::DocumentController* dc, ProcessingLockInterface& lockInterfaceIn)
        : ARAPlaybackRenderer (dc), lockInterface (lockInterfaceIn) {}

    void prepareToPlay (double sampleRateIn,
                        int maximumSamplesPerBlockIn,
                        int numChannelsIn,
                        AudioProcessor::ProcessingPrecision,
                        AlwaysNonRealtime alwaysNonRealtime) override
    {
        numChannels = numChannelsIn;
        sampleRate = sampleRateIn;
        maximumSamplesPerBlock = maximumSamplesPerBlockIn;
        tempBuffer.reset (new AudioBuffer<float> (numChannels, maximumSamplesPerBlock));

        useBufferedAudioSourceReader = alwaysNonRealtime == AlwaysNonRealtime::no;

        for (const auto playbackRegion : getPlaybackRegions())
        {
            auto audioSource = playbackRegion->getAudioModification()->getAudioSource();

            if (audioSourceReaders.find (audioSource) == audioSourceReaders.end())
            {
                auto reader = std::make_unique<ARAAudioSourceReader> (audioSource);

                if (! useBufferedAudioSourceReader)
                {
                    audioSourceReaders.emplace (audioSource,
                                                PossiblyBufferedReader { std::move (reader) });
                }
                else
                {
                    const auto readAheadSize = jmax (4 * maximumSamplesPerBlock,
                                                     roundToInt (2.0 * sampleRate));
                    audioSourceReaders.emplace (audioSource,
                                                PossiblyBufferedReader { std::make_unique<BufferingAudioReader> (reader.release(),
                                                                                                                 *sharedTimesliceThread,
                                                                                                                 readAheadSize) });
                }
            }
        }
    }

    void releaseResources() override
    {
        audioSourceReaders.clear();
        tempBuffer.reset();
    }

    bool processBlock (AudioBuffer<float>& buffer,
                       AudioProcessor::Realtime realtime,
                       const AudioPlayHead::PositionInfo& positionInfo) noexcept override
    {
        const auto lock = lockInterface.getProcessingLock();

        if (! lock.isLocked())
            return true;

        const auto numSamples = buffer.getNumSamples();
        jassert (numSamples <= maximumSamplesPerBlock);
        jassert (numChannels == buffer.getNumChannels());
        jassert (realtime == AudioProcessor::Realtime::no || useBufferedAudioSourceReader);
        const auto timeInSamples = positionInfo.getTimeInSamples().orFallback (0);
        const auto isPlaying = positionInfo.getIsPlaying();

        bool success = true;
        bool didRenderAnyRegion = false;

        if (isPlaying)
        {
            const auto blockRange = Range<int64>::withStartAndLength (timeInSamples, numSamples);

            for (const auto& playbackRegion : getPlaybackRegions())
            {
                // Evaluate region borders in song time, calculate sample range to render in song time.
                // Note that this example does not use head- or tailtime, so the includeHeadAndTail
                // parameter is set to false here - this might need to be adjusted in actual plug-ins.
                const auto playbackSampleRange = playbackRegion->getSampleRange (sampleRate, ARAPlaybackRegion::IncludeHeadAndTail::no);
                auto renderRange = blockRange.getIntersectionWith (playbackSampleRange);

                if (renderRange.isEmpty())
                    continue;

                // Evaluate region borders in modification/source time and calculate offset between
                // song and source samples, then clip song samples accordingly
                // (if an actual plug-in supports time stretching, this must be taken into account here).
                Range<int64> modificationSampleRange { playbackRegion->getStartInAudioModificationSamples(),
                                                       playbackRegion->getEndInAudioModificationSamples() };
                const auto modificationSampleOffset = modificationSampleRange.getStart() - playbackSampleRange.getStart();

                renderRange = renderRange.getIntersectionWith (modificationSampleRange.movedToStartAt (playbackSampleRange.getStart()));

                if (renderRange.isEmpty())
                    continue;

                // Get the audio source for the region and find the reader for that source.
                // This simplified example code only produces audio if sample rate and channel count match -
                // a robust plug-in would need to do conversion, see ARA SDK documentation.
                const auto audioSource = playbackRegion->getAudioModification()->getAudioSource();
                const auto readerIt = audioSourceReaders.find (audioSource);

                if (std::make_tuple (audioSource->getChannelCount(), audioSource->getSampleRate()) != std::make_tuple (numChannels, sampleRate)
                    || (readerIt == audioSourceReaders.end()))
                {
                    success = false;
                    continue;
                }

                auto& reader = readerIt->second;
                reader.setReadTimeout (realtime == AudioProcessor::Realtime::no ? 100 : 0);

                // Calculate buffer offsets.
                const int numSamplesToRead = (int) renderRange.getLength();
                const int startInBuffer = (int) (renderRange.getStart() - blockRange.getStart());
                auto startInSource = renderRange.getStart() + modificationSampleOffset;

                // Read samples:
                // first region can write directly into output, later regions need to use local buffer.
                auto& readBuffer = (didRenderAnyRegion) ? *tempBuffer : buffer;

                if (! reader.get()->read (&readBuffer, startInBuffer, numSamplesToRead, startInSource, true, true))
                {
                    success = false;
                    continue;
                }

                // Apply dim if enabled
                if (playbackRegion->getAudioModification<ARADemoPluginAudioModification>()->isDimmed())
                    readBuffer.applyGain (startInBuffer, numSamplesToRead, 0.25f);  // dim by about 12 dB

                // Mix output of all regions
                if (didRenderAnyRegion)
                {
                    // Mix local buffer into the output buffer.
                    for (int c = 0; c < numChannels; ++c)
                        buffer.addFrom (c, startInBuffer, *tempBuffer, c, startInBuffer, numSamplesToRead);
                }
                else
                {
                    // Clear any excess at start or end of the region.
                    if (startInBuffer != 0)
                        buffer.clear (0, startInBuffer);

                    const int endInBuffer = startInBuffer + numSamplesToRead;
                    const int remainingSamples = numSamples - endInBuffer;

                    if (remainingSamples != 0)
                        buffer.clear (endInBuffer, remainingSamples);

                    didRenderAnyRegion = true;
                }
            }
        }

        // If no playback or no region did intersect, clear buffer now.
        if (! didRenderAnyRegion)
            buffer.clear();

        return success;
    }

    using ARAPlaybackRenderer::processBlock;

private:
    //==============================================================================
    ProcessingLockInterface& lockInterface;
    SharedResourcePointer<SharedTimeSliceThread> sharedTimesliceThread;
    std::map<ARAAudioSource*, PossiblyBufferedReader> audioSourceReaders;
    bool useBufferedAudioSourceReader = true;
    int numChannels = 2;
    double sampleRate = 48000.0;
    int maximumSamplesPerBlock = 128;
    std::unique_ptr<AudioBuffer<float>> tempBuffer;
};

class EditorRenderer final : public ARAEditorRenderer,
                             private ARARegionSequence::Listener
{
public:
    EditorRenderer (ARA::PlugIn::DocumentController* documentController,
                    const PreviewState* previewStateIn,
                    ProcessingLockInterface& lockInterfaceIn)
        : ARAEditorRenderer (documentController),
          lockInterface (lockInterfaceIn),
          previewState (previewStateIn)
    {
        jassert (previewState != nullptr);
    }

    ~EditorRenderer() override
    {
        for (const auto& rs : regionSequences)
            rs->removeListener (this);
    }

    void didAddPlaybackRegionToRegionSequence (ARARegionSequence*, ARAPlaybackRegion*) override
    {
        asyncConfigCallback.startConfigure();
    }

    void didAddRegionSequence (ARA::PlugIn::RegionSequence* rs) noexcept override
    {
        auto* sequence = static_cast<ARARegionSequence*> (rs);
        sequence->addListener (this);
        regionSequences.insert (sequence);
        asyncConfigCallback.startConfigure();
    }

    void willRemoveRegionSequence (ARA::PlugIn::RegionSequence* rs) noexcept override
    {
        auto* rsToRemove = static_cast<ARARegionSequence*> (rs);
        rsToRemove->removeListener (this);
        regionSequences.erase (rsToRemove);
    }

    void didAddPlaybackRegion (ARA::PlugIn::PlaybackRegion*) noexcept override
    {
        asyncConfigCallback.startConfigure();
    }

    /*  An ARA host could be using either the `addPlaybackRegion()` or `addRegionSequence()` interface
        so we need to check the other side of both.

        The callback must have a signature of `bool (ARAPlaybackRegion*)`
    */
    template <typename Callback>
    void forEachPlaybackRegion (Callback&& cb)
    {
        for (const auto& playbackRegion : getPlaybackRegions())
            if (! cb (playbackRegion))
                return;

        for (const auto& regionSequence : getRegionSequences())
            for (const auto& playbackRegion : regionSequence->getPlaybackRegions())
                if (! cb (playbackRegion))
                    return;
    }

    void prepareToPlay (double sampleRateIn,
                        int maximumExpectedSamplesPerBlock,
                        int numChannels,
                        AudioProcessor::ProcessingPrecision,
                        AlwaysNonRealtime alwaysNonRealtime) override
    {
        sampleRate = sampleRateIn;
        previewBuffer = std::make_unique<AudioBuffer<float>> (numChannels, (int) (2 * sampleRateIn));

        ignoreUnused (maximumExpectedSamplesPerBlock, alwaysNonRealtime);
    }

    void releaseResources() override
    {
        audioSourceReaders.clear();
    }

    void reset() override
    {
        previewBuffer->clear();
    }

    bool processBlock (AudioBuffer<float>& buffer,
                       AudioProcessor::Realtime realtime,
                       const AudioPlayHead::PositionInfo& positionInfo) noexcept override
    {
        ignoreUnused (realtime);

        const auto lock = lockInterface.getProcessingLock();

        if (! lock.isLocked())
            return true;

        return asyncConfigCallback.withLock ([&] (bool locked)
        {
            if (! locked)
                return true;

            const auto fadeOutIfNecessary = [this, &buffer]
            {
                if (std::exchange (wasPreviewing, false))
                {
                    previewLooper.writeInto (buffer);
                    const auto fadeOutStart = std::max (0, buffer.getNumSamples() - 50);
                    buffer.applyGainRamp (fadeOutStart, buffer.getNumSamples() - fadeOutStart, 1.0f, 0.0f);
                }
            };

            if (positionInfo.getIsPlaying())
            {
                fadeOutIfNecessary();
                return true;
            }

            if (const auto previewedRegion = previewState->previewedRegion.load())
            {
                const auto regionIsAssignedToEditor = [&]()
                {
                    bool regionIsAssigned = false;

                    forEachPlaybackRegion ([&previewedRegion, &regionIsAssigned] (const auto& region)
                    {
                        if (region == previewedRegion)
                        {
                            regionIsAssigned = true;
                            return false;
                        }

                        return true;
                    });

                    return regionIsAssigned;
                }();

                if (regionIsAssignedToEditor)
                {
                    const auto previewTime = previewState->previewTime.load();
                    const auto previewDimmed = previewedRegion->getAudioModification<ARADemoPluginAudioModification>()
                                                              ->isDimmed();

                    if (! exactlyEqual (lastPreviewTime, previewTime)
                        || ! exactlyEqual (lastPlaybackRegion, previewedRegion)
                        || ! exactlyEqual (lastPreviewDimmed, previewDimmed))
                    {
                        Range<double> previewRangeInPlaybackTime { previewTime - 0.25, previewTime + 0.25 };
                        previewBuffer->clear();
                        const auto rangeInOutput = readPlaybackRangeIntoBuffer (previewRangeInPlaybackTime,
                                                                                previewedRegion,
                                                                                *previewBuffer,
                                                                                [this] (auto* source) -> auto*
                                                                                {
                                                                                    const auto iter = audioSourceReaders.find (source);
                                                                                    return iter != audioSourceReaders.end() ? iter->second.get() : nullptr;
                                                                                });

                        if (rangeInOutput)
                        {
                            lastPreviewTime = previewTime;
                            lastPlaybackRegion = previewedRegion;
                            lastPreviewDimmed = previewDimmed;
                            previewLooper = Looper (previewBuffer.get(), *rangeInOutput);
                        }
                    }
                    else
                    {
                        previewLooper.writeInto (buffer);

                        if (! std::exchange (wasPreviewing, true))
                        {
                            const auto fadeInLength = std::min (50, buffer.getNumSamples());
                            buffer.applyGainRamp (0, fadeInLength, 0.0f, 1.0f);
                        }
                    }
                }
            }
            else
            {
                fadeOutIfNecessary();
            }

            return true;
        });
    }

    using ARAEditorRenderer::processBlock;

private:
    void configure()
    {
        forEachPlaybackRegion ([this, maximumExpectedSamplesPerBlock = 1000] (const auto& playbackRegion)
        {
            const auto audioSource = playbackRegion->getAudioModification()->getAudioSource();

            if (audioSourceReaders.find (audioSource) == audioSourceReaders.end())
            {
                audioSourceReaders[audioSource] = std::make_unique<BufferingAudioReader> (
                        new ARAAudioSourceReader (playbackRegion->getAudioModification()->getAudioSource()),
                        *timeSliceThread,
                        std::max (4 * maximumExpectedSamplesPerBlock, (int) sampleRate));
            }

            return true;
        });
    }

    ProcessingLockInterface& lockInterface;
    const PreviewState* previewState = nullptr;
    AsyncConfigurationCallback asyncConfigCallback { [this] { configure(); } };
    double lastPreviewTime = 0.0;
    ARAPlaybackRegion* lastPlaybackRegion = nullptr;
    bool lastPreviewDimmed = false;
    bool wasPreviewing = false;
    std::unique_ptr<AudioBuffer<float>> previewBuffer;
    Looper previewLooper;

    double sampleRate = 48000.0;
    SharedResourcePointer<SharedTimeSliceThread> timeSliceThread;
    std::map<ARAAudioSource*, std::unique_ptr<BufferingAudioReader>> audioSourceReaders;

    std::set<ARARegionSequence*> regionSequences;
};

//==============================================================================
class ARADemoPluginDocumentControllerSpecialisation final : public ARADocumentControllerSpecialisation,
                                                            private ProcessingLockInterface
{
public:
    using ARADocumentControllerSpecialisation::ARADocumentControllerSpecialisation;

    PreviewState previewState;

protected:
    void willBeginEditing (ARADocument*) override
    {
        processBlockLock.enterWrite();
    }

    void didEndEditing (ARADocument*) override
    {
        processBlockLock.exitWrite();
    }

    ARAAudioModification* doCreateAudioModification (ARAAudioSource* audioSource,
                                                     ARA::ARAAudioModificationHostRef hostRef,
                                                     const ARAAudioModification* optionalModificationToClone) noexcept override
    {
        return new ARADemoPluginAudioModification (audioSource,
                                                   hostRef,
                                                   static_cast<const ARADemoPluginAudioModification*> (optionalModificationToClone));
    }

    ARAPlaybackRenderer* doCreatePlaybackRenderer() noexcept override
    {
        return new PlaybackRenderer (getDocumentController(), *this);
    }

    EditorRenderer* doCreateEditorRenderer() noexcept override
    {
        return new EditorRenderer (getDocumentController(), &previewState, *this);
    }

    bool doRestoreObjectsFromStream (ARAInputStream& input,
                                     const ARARestoreObjectsFilter* filter) noexcept override
    {
        // Start reading data from the archive, starting with the number of audio modifications in the archive
        const auto numAudioModifications = input.readInt64();

        // Loop over stored audio modification data
        for (int64 i = 0; i < numAudioModifications; ++i)
        {
            const auto progressVal = (float) i / (float) numAudioModifications;
            getDocumentController()->getHostArchivingController()->notifyDocumentUnarchivingProgress (progressVal);

            // Read audio modification persistent ID and analysis result from archive
            const String persistentID = input.readString();
            const bool dimmed = input.readBool();

            // Find audio modification to restore the state to (drop state if not to be loaded)
            auto audioModification = filter->getAudioModificationToRestoreStateWithID<ARADemoPluginAudioModification> (persistentID.getCharPointer());

            if (audioModification == nullptr)
                continue;

            const bool dimChanged = (dimmed != audioModification->isDimmed());
            audioModification->setDimmed (dimmed);

            // If the dim state changed, send a sample content change notification without notifying the host
            if (dimChanged)
            {
                audioModification->notifyContentChanged (ARAContentUpdateScopes::samplesAreAffected(), false);

                for (auto playbackRegion : audioModification->getPlaybackRegions())
                    playbackRegion->notifyContentChanged (ARAContentUpdateScopes::samplesAreAffected(), false);
            }
        }

        getDocumentController()->getHostArchivingController()->notifyDocumentUnarchivingProgress (1.0f);

        return ! input.failed();
    }

    bool doStoreObjectsToStream (ARAOutputStream& output, const ARAStoreObjectsFilter* filter) noexcept override
    {
        // This example implementation only deals with audio modification states
        const auto& audioModificationsToPersist { filter->getAudioModificationsToStore<ARADemoPluginAudioModification>() };

        const auto reportProgress = [archivingController = getDocumentController()->getHostArchivingController()] (float p)
        {
            archivingController->notifyDocumentArchivingProgress (p);
        };

        const ScopeGuard scope { [&reportProgress] { reportProgress (1.0f); } };

        // Write the number of audio modifications we are persisting
        const auto numAudioModifications = audioModificationsToPersist.size();

        if (! output.writeInt64 ((int64) numAudioModifications))
            return false;

        // For each audio modification to persist, persist its ID followed by whether it's dimmed
        for (size_t i = 0; i < numAudioModifications; ++i)
        {
            // Write persistent ID and dim state
            if (! output.writeString (audioModificationsToPersist[i]->getPersistentID()))
                return false;

            if (! output.writeBool (audioModificationsToPersist[i]->isDimmed()))
                return false;

            const auto progressVal = (float) i / (float) numAudioModifications;
            reportProgress (progressVal);
        }

        return true;
    }

private:
    ScopedTryReadLock getProcessingLock() override
    {
        return ScopedTryReadLock { processBlockLock };
    }

    ReadWriteLock processBlockLock;
};

struct PlayHeadState
{
    void update (const Optional<AudioPlayHead::PositionInfo>& info)
    {
        if (info.hasValue())
        {
            isPlaying.store (info->getIsPlaying(), std::memory_order_relaxed);
            timeInSeconds.store (info->getTimeInSeconds().orFallback (0), std::memory_order_relaxed);
            isLooping.store (info->getIsLooping(), std::memory_order_relaxed);
            const auto loopPoints = info->getLoopPoints();

            if (loopPoints.hasValue())
            {
                loopPpqStart = loopPoints->ppqStart;
                loopPpqEnd = loopPoints->ppqEnd;
            }
        }
        else
        {
            isPlaying.store (false, std::memory_order_relaxed);
            isLooping.store (false, std::memory_order_relaxed);
        }
    }

    std::atomic<bool> isPlaying { false },
                      isLooping { false };
    std::atomic<double> timeInSeconds { 0.0 },
                        loopPpqStart  { 0.0 },
                        loopPpqEnd    { 0.0 };
};

//==============================================================================
class ARADemoPluginAudioProcessorImpl : public AudioProcessor,
                                        private AudioProcessorARAExtension
{
public:
    //==============================================================================
    ARADemoPluginAudioProcessorImpl()
        : AudioProcessor (getBusesProperties())
    {}

    ~ARADemoPluginAudioProcessorImpl() override = default;

    //==============================================================================
    void prepareToPlay (double sampleRate, int samplesPerBlock) override
    {
        playHeadState.update (nullopt);
        prepareToPlayForARA (sampleRate, samplesPerBlock, getMainBusNumOutputChannels(), getProcessingPrecision());
    }

    void releaseResources() override
    {
        playHeadState.update (nullopt);
        releaseResourcesForARA();
    }

    bool isBusesLayoutSupported (const BusesLayout& layouts) const override
    {
        if (layouts.getMainOutputChannelSet() != AudioChannelSet::mono()
            && layouts.getMainOutputChannelSet() != AudioChannelSet::stereo())
            return false;

        return true;
    }

    void processBlock (AudioBuffer<float>& buffer, MidiBuffer& midiMessages) override
    {
        ignoreUnused (midiMessages);

        ScopedNoDenormals noDenormals;

        auto* audioPlayHead = getPlayHead();
        playHeadState.update (audioPlayHead->getPosition());

        if (! processBlockForARA (buffer, isRealtime(), audioPlayHead))
            processBlockBypassed (buffer, midiMessages);
    }

    using AudioProcessor::processBlock;

    //==============================================================================
    const String getName() const override                             { return "ARAPluginDemo"; }
    bool acceptsMidi() const override                                 { return true; }
    bool producesMidi() const override                                { return true; }

    double getTailLengthSeconds() const override
    {
        double tail;
        if (getTailLengthSecondsForARA (tail))
            return tail;

        return 0.0;
    }

    //==============================================================================
    int getNumPrograms() override                                     { return 0; }
    int getCurrentProgram() override                                  { return 0; }
    void setCurrentProgram (int) override                             {}
    const String getProgramName (int) override                        { return "None"; }
    void changeProgramName (int, const String&) override              {}

    //==============================================================================
    void getStateInformation (MemoryBlock&) override                  {}
    void setStateInformation (const void*, int) override              {}

    AudioProcessorARAExtension* getARAClientExtensions() override { return this; }

    PlayHeadState playHeadState;

private:
    //==============================================================================
    static BusesProperties getBusesProperties()
    {
        return BusesProperties().withInput  ("Input",  AudioChannelSet::stereo(), true)
            .withOutput ("Output", AudioChannelSet::stereo(), true);
    }

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ARADemoPluginAudioProcessorImpl)
};

//==============================================================================
class TimeToViewScaling
{
public:
    class Listener
    {
    public:
        virtual ~Listener() = default;

        virtual void zoomLevelChanged (double newPixelPerSecond) = 0;
    };

    void addListener (Listener* l)    { listeners.add (l); }
    void removeListener (Listener* l) { listeners.remove (l); }

    TimeToViewScaling() = default;

    void zoom (double factor)
    {
        zoomLevelPixelPerSecond = jlimit (minimumZoom, minimumZoom * 32, zoomLevelPixelPerSecond * factor);
        setZoomLevel (zoomLevelPixelPerSecond);
    }

    void setZoomLevel (double pixelPerSecond)
    {
        zoomLevelPixelPerSecond = pixelPerSecond;
        listeners.call ([this] (Listener& l) { l.zoomLevelChanged (zoomLevelPixelPerSecond); });
    }

    int getXForTime (double time) const
    {
        return roundToInt (time * zoomLevelPixelPerSecond);
    }

    double getTimeForX (int x) const
    {
        return x / zoomLevelPixelPerSecond;
    }

private:
    static constexpr auto minimumZoom = 10.0;

    double zoomLevelPixelPerSecond = minimumZoom * 4;
    ListenerList<Listener> listeners;
};

class RulersView final : public Component,
                         public SettableTooltipClient,
                         private Timer,
                         private TimeToViewScaling::Listener,
                         private ARAMusicalContext::Listener
{
public:
    class CycleMarkerComponent final : public Component
    {
        void paint (Graphics& g) override
        {
            g.setColour (Colours::yellow.darker (0.2f));
            const auto bounds = getLocalBounds().toFloat();
            g.drawRoundedRectangle (bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight(), 6.0f, 2.0f);
        }
    };

    RulersView (PlayHeadState& playHeadStateIn, TimeToViewScaling& timeToViewScalingIn, ARADocument& document)
        : playHeadState (playHeadStateIn), timeToViewScaling (timeToViewScalingIn), araDocument (document)
    {
        timeToViewScaling.addListener (this);

        addChildComponent (cycleMarker);
        cycleMarker.setInterceptsMouseClicks (false, false);

        setTooltip ("Double-click to start playback, click to stop playback or to reposition, drag horizontal range to set cycle.");

        startTimerHz (30);
    }

    ~RulersView() override
    {
        stopTimer();

        timeToViewScaling.removeListener (this);
        selectMusicalContext (nullptr);
    }

    void paint (Graphics& g) override
    {
        auto drawBounds = g.getClipBounds();
        const auto drawStartTime = timeToViewScaling.getTimeForX (drawBounds.getX());
        const auto drawEndTime   = timeToViewScaling.getTimeForX (drawBounds.getRight());

        const auto bounds = getLocalBounds();

        g.setColour (getLookAndFeel().findColour (ResizableWindow::backgroundColourId));
        g.fillRect (bounds);
        g.setColour (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).contrasting());
        g.drawRect (bounds);

        const auto rulerHeight = bounds.getHeight() / 3;
        g.drawRect (drawBounds.getX(), rulerHeight, drawBounds.getRight(), rulerHeight);
        g.setFont (FontOptions (12.0f));

        const int lightLineWidth = 1;
        const int heavyLineWidth = 3;

        if (selectedMusicalContext != nullptr)
        {
            const ARA::PlugIn::HostContentReader<ARA::kARAContentTypeTempoEntries> tempoReader (selectedMusicalContext);
            const ARA::TempoConverter<decltype (tempoReader)> tempoConverter (tempoReader);

            // chord ruler: one rect per chord, skipping empty "no chords"
            const auto chordBounds = drawBounds.removeFromTop (rulerHeight);
            const ARA::PlugIn::HostContentReader<ARA::kARAContentTypeSheetChords> chordsReader (selectedMusicalContext);

            if (tempoReader && chordsReader)
            {
                const ARA::ChordInterpreter interpreter (true);
                for (auto itChord = chordsReader.begin(); itChord != chordsReader.end(); ++itChord)
                {
                    if (interpreter.isNoChord (*itChord))
                        continue;

                    const auto chordStartTime = (itChord == chordsReader.begin()) ? 0 : tempoConverter.getTimeForQuarter (itChord->position);

                    if (chordStartTime >= drawEndTime)
                        break;

                    auto chordRect = chordBounds;
                    chordRect.setLeft (timeToViewScaling.getXForTime (chordStartTime));

                    if (std::next (itChord) != chordsReader.end())
                    {
                        const auto nextChordStartTime = tempoConverter.getTimeForQuarter (std::next (itChord)->position);

                        if (nextChordStartTime < drawStartTime)
                            continue;

                        chordRect.setRight (timeToViewScaling.getXForTime (nextChordStartTime));
                    }

                    g.drawRect (chordRect);
                    g.drawText (convertARAString (interpreter.getNameForChord (*itChord).c_str()),
                                chordRect.withTrimmedLeft (2),
                                Justification::centredLeft);
                }
            }

            // beat ruler: evaluates tempo and bar signatures to draw a line for each beat
            const ARA::PlugIn::HostContentReader<ARA::kARAContentTypeBarSignatures> barSignaturesReader (selectedMusicalContext);

            if (barSignaturesReader)
            {
                const ARA::BarSignaturesConverter<decltype (barSignaturesReader)> barSignaturesConverter (barSignaturesReader);

                const double beatStart = barSignaturesConverter.getBeatForQuarter (tempoConverter.getQuarterForTime (drawStartTime));
                const double beatEnd   = barSignaturesConverter.getBeatForQuarter (tempoConverter.getQuarterForTime (drawEndTime));
                const int endBeat = roundToInt (std::floor (beatEnd));
                RectangleList<int> rects;

                for (int beat = roundToInt (std::ceil (beatStart)); beat <= endBeat; ++beat)
                {
                    const auto quarterPos = barSignaturesConverter.getQuarterForBeat (beat);
                    const int x = timeToViewScaling.getXForTime (tempoConverter.getTimeForQuarter (quarterPos));
                    const auto barSignature = barSignaturesConverter.getBarSignatureForQuarter (quarterPos);
                    const int lineWidth = (approximatelyEqual (quarterPos, barSignature.position)) ? heavyLineWidth : lightLineWidth;
                    const int beatsSinceBarStart = roundToInt (barSignaturesConverter.getBeatDistanceFromBarStartForQuarter (quarterPos));
                    const int lineHeight = (beatsSinceBarStart == 0) ? rulerHeight : rulerHeight / 2;
                    rects.addWithoutMerging (Rectangle<int> (x - lineWidth / 2, 2 * rulerHeight - lineHeight, lineWidth, lineHeight));
                }

                g.fillRectList (rects);
            }
        }

        // time ruler: one tick for each second
        {
            RectangleList<int> rects;

            for (auto time = std::floor (drawStartTime); time <= drawEndTime; time += 1.0)
            {
                const int lineWidth  = (std::fmod (time, 60.0) <= 0.001) ? heavyLineWidth : lightLineWidth;
                const int lineHeight = (std::fmod (time, 10.0) <= 0.001) ? rulerHeight : rulerHeight / 2;
                rects.addWithoutMerging (Rectangle<int> (timeToViewScaling.getXForTime (time) - lineWidth / 2,
                                                         bounds.getHeight() - lineHeight,
                                                         lineWidth,
                                                         lineHeight));
            }

            g.fillRectList (rects);
        }
    }

    void mouseDrag (const MouseEvent& m) override
    {
        isDraggingCycle = true;

        auto cycleRect = getBounds();
        cycleRect.setLeft  (jmin (m.getMouseDownX(), m.x));
        cycleRect.setRight (jmax (m.getMouseDownX(), m.x));
        cycleMarker.setBounds (cycleRect);
    }

    void mouseUp (const MouseEvent& m) override
    {
        auto playbackController = araDocument.getDocumentController()->getHostPlaybackController();

        if (playbackController != nullptr)
        {
            const auto startTime = timeToViewScaling.getTimeForX (jmin (m.getMouseDownX(), m.x));
            const auto endTime   = timeToViewScaling.getTimeForX (jmax (m.getMouseDownX(), m.x));

            if (playHeadState.isPlaying.load (std::memory_order_relaxed))
                playbackController->requestStopPlayback();
            else
                playbackController->requestSetPlaybackPosition (startTime);

            if (isDraggingCycle)
                playbackController->requestSetCycleRange (startTime, endTime - startTime);
        }

        isDraggingCycle = false;
    }

    void mouseDoubleClick (const MouseEvent&) override
    {
        if (auto* playbackController = araDocument.getDocumentController()->getHostPlaybackController())
        {
            if (! playHeadState.isPlaying.load (std::memory_order_relaxed))
                playbackController->requestStartPlayback();
        }
    }

    void selectMusicalContext (ARAMusicalContext* newSelectedMusicalContext)
    {
        if (auto* oldSelection = std::exchange (selectedMusicalContext, newSelectedMusicalContext);
            oldSelection != selectedMusicalContext)
        {
            if (oldSelection != nullptr)
                oldSelection->removeListener (this);

            if (selectedMusicalContext != nullptr)
                selectedMusicalContext->addListener (this);

            repaint();
        }
    }

    void zoomLevelChanged (double) override
    {
        repaint();
    }

    void doUpdateMusicalContextContent (ARAMusicalContext*, ARAContentUpdateScopes) override
    {
        repaint();
    }

private:
    void updateCyclePosition()
    {
        if (selectedMusicalContext != nullptr)
        {
            const ARA::PlugIn::HostContentReader<ARA::kARAContentTypeTempoEntries> tempoReader (selectedMusicalContext);
            const ARA::TempoConverter<decltype (tempoReader)> tempoConverter (tempoReader);

            const auto loopStartTime = tempoConverter.getTimeForQuarter (playHeadState.loopPpqStart.load (std::memory_order_relaxed));
            const auto loopEndTime   = tempoConverter.getTimeForQuarter (playHeadState.loopPpqEnd.load   (std::memory_order_relaxed));

            auto cycleRect = getBounds();
            cycleRect.setLeft  (timeToViewScaling.getXForTime (loopStartTime));
            cycleRect.setRight (timeToViewScaling.getXForTime (loopEndTime));
            cycleMarker.setVisible (true);
            cycleMarker.setBounds (cycleRect);
        }
        else
        {
            cycleMarker.setVisible (false);
        }
    }

    void timerCallback() override
    {
        if (! isDraggingCycle)
            updateCyclePosition();
    }

private:
    PlayHeadState& playHeadState;
    TimeToViewScaling& timeToViewScaling;
    ARADocument& araDocument;
    ARAMusicalContext* selectedMusicalContext = nullptr;
    CycleMarkerComponent cycleMarker;
    bool isDraggingCycle = false;
};

class RulersHeader final : public Component
{
public:
    RulersHeader()
    {
        chordsLabel.setText ("Chords", NotificationType::dontSendNotification);
        addAndMakeVisible (chordsLabel);

        barsLabel.setText ("Bars", NotificationType::dontSendNotification);
        addAndMakeVisible (barsLabel);

        timeLabel.setText ("Time", NotificationType::dontSendNotification);
        addAndMakeVisible (timeLabel);
    }

    void resized() override
    {
        auto bounds = getLocalBounds();
        const auto rulerHeight = bounds.getHeight() / 3;

        for (auto* label : { &chordsLabel, &barsLabel, &timeLabel })
            label->setBounds (bounds.removeFromTop (rulerHeight));
    }

    void paint (Graphics& g) override
    {
        auto bounds = getLocalBounds();
        const auto rulerHeight = bounds.getHeight() / 3;
        g.setColour (getLookAndFeel().findColour (ResizableWindow::backgroundColourId));
        g.fillRect (bounds);
        g.setColour (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).contrasting());
        g.drawRect (bounds);
        bounds.removeFromTop (rulerHeight);
        g.drawRect (bounds.removeFromTop (rulerHeight));
    }

private:
    Label chordsLabel, barsLabel, timeLabel;
};

//==============================================================================
struct WaveformCache final : private ARAAudioSource::Listener
{
    WaveformCache() : thumbnailCache (20)
    {
    }

    ~WaveformCache() override
    {
        for (const auto& entry : thumbnails)
        {
            entry.first->removeListener (this);
        }
    }

    //==============================================================================
    void willDestroyAudioSource (ARAAudioSource* audioSource) override
    {
        removeAudioSource (audioSource);
    }

    AudioThumbnail& getOrCreateThumbnail (ARAAudioSource* audioSource)
    {
        const auto iter = thumbnails.find (audioSource);

        if (iter != std::end (thumbnails))
            return *iter->second;

        auto thumb = std::make_unique<AudioThumbnail> (128, dummyManager, thumbnailCache);
        auto& result = *thumb;

        ++hash;
        thumb->setReader (new ARAAudioSourceReader (audioSource), hash);

        audioSource->addListener (this);
        thumbnails.emplace (audioSource, std::move (thumb));
        return result;
    }

private:
    void removeAudioSource (ARAAudioSource* audioSource)
    {
        audioSource->removeListener (this);
        thumbnails.erase (audioSource);
    }

    int64 hash = 0;
    AudioFormatManager dummyManager;
    AudioThumbnailCache thumbnailCache;
    std::map<ARAAudioSource*, std::unique_ptr<AudioThumbnail>> thumbnails;
};

class PlaybackRegionView final : public Component,
                                 public ChangeListener,
                                 public SettableTooltipClient,
                                 private ARAAudioSource::Listener,
                                 private ARAPlaybackRegion::Listener,
                                 private ARAEditorView::Listener
{
public:
    PlaybackRegionView (ARAEditorView& editorView, ARAPlaybackRegion& region, WaveformCache& cache)
        : araEditorView (editorView), playbackRegion (region), waveformCache (cache), previewRegionOverlay (*this)
    {
        auto* audioSource = playbackRegion.getAudioModification()->getAudioSource();

        waveformCache.getOrCreateThumbnail (audioSource).addChangeListener (this);

        audioSource->addListener (this);
        playbackRegion.addListener (this);
        araEditorView.addListener (this);
        addAndMakeVisible (previewRegionOverlay);

        setTooltip ("Double-click to toggle dim state of the region, click and hold to prelisten region near click.");
    }

    ~PlaybackRegionView() override
    {
        auto* audioSource = playbackRegion.getAudioModification()->getAudioSource();

        audioSource->removeListener (this);
        playbackRegion.removeListener (this);
        araEditorView.removeListener (this);

        waveformCache.getOrCreateThumbnail (audioSource).removeChangeListener (this);
    }

    void mouseDown (const MouseEvent& m) override
    {
        const auto relativeTime = (double) m.getMouseDownX() / getLocalBounds().getWidth();
        const auto previewTime = playbackRegion.getStartInPlaybackTime()
                                 + relativeTime * playbackRegion.getDurationInPlaybackTime();
        auto& previewState = ARADocumentControllerSpecialisation::getSpecialisedDocumentController<ARADemoPluginDocumentControllerSpecialisation> (playbackRegion.getDocumentController())->previewState;
        previewState.previewTime.store (previewTime);
        previewState.previewedRegion.store (&playbackRegion);
        previewRegionOverlay.update();
    }

    void mouseUp (const MouseEvent&) override
    {
        auto& previewState = ARADocumentControllerSpecialisation::getSpecialisedDocumentController<ARADemoPluginDocumentControllerSpecialisation> (playbackRegion.getDocumentController())->previewState;
        previewState.previewTime.store (0.0);
        previewState.previewedRegion.store (nullptr);
        previewRegionOverlay.update();
    }

    void mouseDoubleClick (const MouseEvent&) override
    {
        // Set the dim flag on our region's audio modification when double-clicked
        auto audioModification = playbackRegion.getAudioModification<ARADemoPluginAudioModification>();
        audioModification->setDimmed (! audioModification->isDimmed());

        // Send a content change notification for the modification and all associated playback regions
        audioModification->notifyContentChanged (ARAContentUpdateScopes::samplesAreAffected(), true);
        for (auto region : audioModification->getPlaybackRegions())
            region->notifyContentChanged (ARAContentUpdateScopes::samplesAreAffected(), true);
    }

    void changeListenerCallback (ChangeBroadcaster*) override
    {
        repaint();
    }

    void didEnableAudioSourceSamplesAccess (ARAAudioSource*, bool) override
    {
        repaint();
    }

    void willUpdatePlaybackRegionProperties (ARAPlaybackRegion*,
                                             ARAPlaybackRegion::PropertiesPtr newProperties) override
    {
        if (playbackRegion.getName() != newProperties->name
            || playbackRegion.getColor() != newProperties->color)
        {
            repaint();
        }
    }

    void didUpdatePlaybackRegionContent (ARAPlaybackRegion*, ARAContentUpdateScopes) override
    {
        repaint();
    }

    void onNewSelection (const ARAViewSelection& viewSelection) override
    {
        const auto& selectedPlaybackRegions = viewSelection.getPlaybackRegions();
        const bool selected = std::find (selectedPlaybackRegions.begin(), selectedPlaybackRegions.end(), &playbackRegion) != selectedPlaybackRegions.end();
        if (selected != isSelected)
        {
            isSelected = selected;
            repaint();
        }
    }

    void paint (Graphics& g) override
    {
        g.fillAll (Colour { convertOptionalARAColour (playbackRegion.getEffectiveColor()) });

        const auto* audioModification = playbackRegion.getAudioModification<ARADemoPluginAudioModification>();
        g.setColour (audioModification->isDimmed() ? Colours::darkgrey.darker() : Colours::darkgrey.brighter());

        if (audioModification->getAudioSource()->isSampleAccessEnabled())
        {
            auto& thumbnail = waveformCache.getOrCreateThumbnail (playbackRegion.getAudioModification()->getAudioSource());
            thumbnail.drawChannels (g,
                                    getLocalBounds(),
                                    playbackRegion.getStartInAudioModificationTime(),
                                    playbackRegion.getEndInAudioModificationTime(),
                                    1.0f);
        }
        else
        {
            g.setFont (FontOptions (12.0f));
            g.drawText ("Audio Access Disabled", getLocalBounds(), Justification::centred);
        }

        g.setColour (Colours::white.withMultipliedAlpha (0.9f));
        g.setFont (FontOptions (12.0f));
        g.drawText (convertOptionalARAString (playbackRegion.getEffectiveName()),
                    getLocalBounds(),
                    Justification::topLeft);

        if (audioModification->isDimmed())
            g.drawText ("DIMMED", getLocalBounds(), Justification::bottomLeft);

        g.setColour (isSelected ? Colours::white : Colours::black);
        g.drawRect (getLocalBounds());
    }

    void resized() override
    {
        repaint();
    }

private:
    class PreviewRegionOverlay final : public Component
    {
        static constexpr auto previewLength = 0.5;

    public:
        PreviewRegionOverlay (PlaybackRegionView& ownerIn) : owner (ownerIn)
        {
        }

        void update()
        {
            const auto& previewState = owner.getDocumentController()->previewState;

            if (previewState.previewedRegion.load() == &owner.playbackRegion)
            {
                const auto previewStartTime = previewState.previewTime.load() - owner.playbackRegion.getStartInPlaybackTime();
                const auto pixelPerSecond = owner.getWidth() / owner.playbackRegion.getDurationInPlaybackTime();

                setBounds (roundToInt ((previewStartTime - previewLength / 2) * pixelPerSecond),
                           0,
                           roundToInt (previewLength * pixelPerSecond),
                           owner.getHeight());

                setVisible (true);
            }
            else
            {
                setVisible (false);
            }

            repaint();
        }

        void paint (Graphics& g) override
        {
            g.setColour (Colours::yellow.withAlpha (0.5f));
            g.fillRect (getLocalBounds());
        }

    private:
        PlaybackRegionView& owner;
    };

    ARADemoPluginDocumentControllerSpecialisation* getDocumentController() const
    {
        return ARADocumentControllerSpecialisation::getSpecialisedDocumentController<ARADemoPluginDocumentControllerSpecialisation> (playbackRegion.getDocumentController());
    }

    ARAEditorView& araEditorView;
    ARAPlaybackRegion& playbackRegion;
    WaveformCache& waveformCache;
    PreviewRegionOverlay previewRegionOverlay;
    bool isSelected = false;
};

class RegionSequenceView final : public Component,
                                 public ChangeBroadcaster,
                                 private TimeToViewScaling::Listener,
                                 private ARARegionSequence::Listener,
                                 private ARAPlaybackRegion::Listener
{
public:
    RegionSequenceView (ARAEditorView& editorView, TimeToViewScaling& scaling, ARARegionSequence& rs, WaveformCache& cache)
        : araEditorView (editorView), timeToViewScaling (scaling), regionSequence (rs), waveformCache (cache)
    {
        regionSequence.addListener (this);

        for (auto* playbackRegion : regionSequence.getPlaybackRegions())
            createAndAddPlaybackRegionView (playbackRegion);

        updatePlaybackDuration();

        timeToViewScaling.addListener (this);
    }

    ~RegionSequenceView() override
    {
        timeToViewScaling.removeListener (this);

        regionSequence.removeListener (this);

        for (const auto& it : playbackRegionViews)
            it.first->removeListener (this);
    }

    //==============================================================================
    // ARA Document change callback overrides
    void willUpdateRegionSequenceProperties (ARARegionSequence*,
                                             ARARegionSequence::PropertiesPtr newProperties) override
    {
        if (regionSequence.getColor() != newProperties->color)
        {
            for (auto& pbr : playbackRegionViews)
                pbr.second->repaint();
        }
    }

    void willRemovePlaybackRegionFromRegionSequence (ARARegionSequence*,
                                                     ARAPlaybackRegion* playbackRegion) override
    {
        playbackRegion->removeListener (this);
        removeChildComponent (playbackRegionViews[playbackRegion].get());
        playbackRegionViews.erase (playbackRegion);
        updatePlaybackDuration();
    }

    void didAddPlaybackRegionToRegionSequence (ARARegionSequence*, ARAPlaybackRegion* playbackRegion) override
    {
        createAndAddPlaybackRegionView (playbackRegion);
        updatePlaybackDuration();
    }

    void willDestroyPlaybackRegion (ARAPlaybackRegion* playbackRegion) override
    {
        playbackRegion->removeListener (this);
        removeChildComponent (playbackRegionViews[playbackRegion].get());
        playbackRegionViews.erase (playbackRegion);
        updatePlaybackDuration();
    }

    void didUpdatePlaybackRegionProperties (ARAPlaybackRegion*) override
    {
        updatePlaybackDuration();
    }

    void zoomLevelChanged (double) override
    {
        resized();
    }

    void resized() override
    {
        for (auto& pbr : playbackRegionViews)
        {
            const auto playbackRegion = pbr.first;
            pbr.second->setBounds (
                getLocalBounds()
                    .withTrimmedLeft (timeToViewScaling.getXForTime (playbackRegion->getStartInPlaybackTime()))
                    .withWidth (timeToViewScaling.getXForTime (playbackRegion->getDurationInPlaybackTime())));
        }
    }

    auto getPlaybackDuration() const noexcept
    {
        return playbackDuration;
    }

private:
    void createAndAddPlaybackRegionView (ARAPlaybackRegion* playbackRegion)
    {
        playbackRegionViews[playbackRegion] = std::make_unique<PlaybackRegionView> (araEditorView,
                                                                                    *playbackRegion,
                                                                                    waveformCache);
        playbackRegion->addListener (this);
        addAndMakeVisible (*playbackRegionViews[playbackRegion]);
    }

    void updatePlaybackDuration()
    {
        const auto iter = std::max_element (
            playbackRegionViews.begin(),
            playbackRegionViews.end(),
            [] (const auto& a, const auto& b) { return a.first->getEndInPlaybackTime() < b.first->getEndInPlaybackTime(); });

        playbackDuration = iter != playbackRegionViews.end() ? iter->first->getEndInPlaybackTime()
                                                             : 0.0;

        sendChangeMessage();
    }

    ARAEditorView& araEditorView;
    TimeToViewScaling& timeToViewScaling;
    ARARegionSequence& regionSequence;
    WaveformCache& waveformCache;
    std::unordered_map<ARAPlaybackRegion*, std::unique_ptr<PlaybackRegionView>> playbackRegionViews;
    double playbackDuration = 0.0;
};

class ZoomControls final : public Component
{
public:
    ZoomControls()
    {
        addAndMakeVisible (zoomInButton);
        addAndMakeVisible (zoomOutButton);
    }

    void setZoomInCallback  (std::function<void()> cb)   { zoomInButton.onClick  = std::move (cb); }
    void setZoomOutCallback (std::function<void()> cb)   { zoomOutButton.onClick = std::move (cb); }

    void resized() override
    {
        FlexBox fb;
        fb.justifyContent = FlexBox::JustifyContent::flexEnd;

        for (auto* button : { &zoomInButton, &zoomOutButton })
            fb.items.add (FlexItem (*button).withMinHeight (30.0f).withMinWidth (30.0f).withMargin ({ 5, 5, 5, 0 }));

        fb.performLayout (getLocalBounds());
    }

private:
    TextButton zoomInButton { "+" }, zoomOutButton { "-" };
};

class PlayheadPositionLabel final : public Label,
                                    private Timer
{
public:
    PlayheadPositionLabel (PlayHeadState& playHeadStateIn)
        : playHeadState (playHeadStateIn)
    {
        startTimerHz (30);
    }

    ~PlayheadPositionLabel() override
    {
        stopTimer();
    }

    void selectMusicalContext (ARAMusicalContext* newSelectedMusicalContext)
    {
        selectedMusicalContext = newSelectedMusicalContext;
    }

private:
    void timerCallback() override
    {
        const auto timePosition = playHeadState.timeInSeconds.load (std::memory_order_relaxed);

        auto text = timeToTimecodeString (timePosition);

        if (playHeadState.isPlaying.load (std::memory_order_relaxed))
            text += " (playing)";
        else
            text += " (stopped)";

        if (selectedMusicalContext != nullptr)
        {
            const ARA::PlugIn::HostContentReader<ARA::kARAContentTypeTempoEntries> tempoReader (selectedMusicalContext);
            const ARA::PlugIn::HostContentReader<ARA::kARAContentTypeBarSignatures> barSignaturesReader (selectedMusicalContext);

            if (tempoReader && barSignaturesReader)
            {
                const ARA::TempoConverter<decltype (tempoReader)> tempoConverter (tempoReader);
                const ARA::BarSignaturesConverter<decltype (barSignaturesReader)> barSignaturesConverter (barSignaturesReader);
                const auto quarterPosition = tempoConverter.getQuarterForTime (timePosition);
                const auto barIndex = barSignaturesConverter.getBarIndexForQuarter (quarterPosition);
                const auto beatDistance = barSignaturesConverter.getBeatDistanceFromBarStartForQuarter (quarterPosition);
                const auto quartersPerBeat = 4.0 / (double) barSignaturesConverter.getBarSignatureForQuarter (quarterPosition).denominator;
                const auto beatIndex = (int) beatDistance;
                const auto tickIndex = juce::roundToInt ((beatDistance - beatIndex) * quartersPerBeat * 960.0);

                text += newLine;
                text += String::formatted ("bar %d | beat %d | tick %03d", (barIndex >= 0) ? barIndex + 1 : barIndex, beatIndex + 1, tickIndex + 1);
                text += "  -  ";

                const ARA::PlugIn::HostContentReader<ARA::kARAContentTypeSheetChords> chordsReader (selectedMusicalContext);

                if (chordsReader && chordsReader.getEventCount() > 0)
                {
                    const auto begin = chordsReader.begin();
                    const auto end = chordsReader.end();
                    auto it = begin;

                    while (it != end && it->position <= quarterPosition)
                        ++it;

                    if (it != begin)
                        --it;

                    const ARA::ChordInterpreter interpreter (true);
                    text += "chord ";
                    text += String (interpreter.getNameForChord (*it));
                }
                else
                {
                    text += "(no chords provided)";
                }
            }
        }

        setText (text, NotificationType::dontSendNotification);
    }

    // Copied from AudioPluginDemo.h: quick-and-dirty function to format a timecode string
    static String timeToTimecodeString (double seconds)
    {
        auto millisecs = roundToInt (seconds * 1000.0);
        auto absMillisecs = std::abs (millisecs);

        return String::formatted ("%02d:%02d:%02d.%03d",
                                  millisecs / 3600000,
                                  (absMillisecs / 60000) % 60,
                                  (absMillisecs / 1000)  % 60,
                                  absMillisecs % 1000);
    }

    PlayHeadState& playHeadState;
    ARAMusicalContext* selectedMusicalContext = nullptr;
};

class TrackHeader final : public Component,
                          private ARARegionSequence::Listener,
                          private ARAEditorView::Listener
{
public:
    TrackHeader (ARAEditorView& editorView, ARARegionSequence& regionSequenceIn)
        : araEditorView (editorView), regionSequence (regionSequenceIn)
    {
        updateTrackName (regionSequence.getName());
        onNewSelection (araEditorView.getViewSelection());

        addAndMakeVisible (trackNameLabel);

        regionSequence.addListener (this);
        araEditorView.addListener (this);
    }

    ~TrackHeader() override
    {
        araEditorView.removeListener (this);
        regionSequence.removeListener (this);
    }

    void willUpdateRegionSequenceProperties (ARARegionSequence*, ARARegionSequence::PropertiesPtr newProperties) override
    {
        if (regionSequence.getName() != newProperties->name)
            updateTrackName (newProperties->name);
        if (regionSequence.getColor() != newProperties->color)
            repaint();
    }

    void resized() override
    {
        trackNameLabel.setBounds (getLocalBounds().reduced (2));
    }

    void paint (Graphics& g) override
    {
        const auto backgroundColour = getLookAndFeel().findColour (ResizableWindow::backgroundColourId);
        g.setColour (isSelected ? backgroundColour.brighter() : backgroundColour);
        g.fillRoundedRectangle (getLocalBounds().reduced (2).toFloat(), 6.0f);
        g.setColour (backgroundColour.contrasting());
        g.drawRoundedRectangle (getLocalBounds().reduced (2).toFloat(), 6.0f, 1.0f);

        if (auto colour = regionSequence.getColor())
        {
            g.setColour (Colour { convertARAColourARGB (colour) });
            g.fillRect (getLocalBounds().removeFromTop (16).reduced (6));
            g.fillRect (getLocalBounds().removeFromBottom (16).reduced (6));
        }
    }

    void onNewSelection (const ARAViewSelection& viewSelection) override
    {
        const auto& selectedRegionSequences = viewSelection.getRegionSequences();
        const bool selected = std::find (selectedRegionSequences.begin(), selectedRegionSequences.end(), &regionSequence) != selectedRegionSequences.end();

        if (selected != isSelected)
        {
            isSelected = selected;
            repaint();
        }
    }

private:
    void updateTrackName (ARA::ARAUtf8String optionalName)
    {
        trackNameLabel.setText (optionalName ? optionalName : "No track name",
                                NotificationType::dontSendNotification);
    }

    ARAEditorView& araEditorView;
    ARARegionSequence& regionSequence;
    Label trackNameLabel;
    bool isSelected = false;
};

constexpr auto trackHeight = 60;

class VerticalLayoutViewportContent final : public Component
{
public:
    void resized() override
    {
        auto bounds = getLocalBounds();

        for (auto* component : getChildren())
        {
            component->setBounds (bounds.removeFromTop (trackHeight));
            component->resized();
        }
    }
};

class VerticalLayoutViewport final : public Viewport
{
public:
    VerticalLayoutViewport()
    {
        setViewedComponent (&content, false);
    }

    void paint (Graphics& g) override
    {
        g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).brighter());
    }

    std::function<void (Rectangle<int>)> onVisibleAreaChanged;

    VerticalLayoutViewportContent content;

private:
    void visibleAreaChanged (const Rectangle<int>& newVisibleArea) override
    {
        NullCheckedInvocation::invoke (onVisibleAreaChanged, newVisibleArea);
    }
};

class OverlayComponent final : public Component,
                               private Timer,
                               private TimeToViewScaling::Listener
{
public:
    class PlayheadMarkerComponent final : public Component
    {
        void paint (Graphics& g) override { g.fillAll (Colours::yellow.darker (0.2f)); }
    };

    OverlayComponent (PlayHeadState& playHeadStateIn, TimeToViewScaling& timeToViewScalingIn)
        : playHeadState (playHeadStateIn), timeToViewScaling (timeToViewScalingIn)
    {
        addChildComponent (playheadMarker);
        setInterceptsMouseClicks (false, false);
        startTimerHz (30);

        timeToViewScaling.addListener (this);
    }

    ~OverlayComponent() override
    {
        timeToViewScaling.removeListener (this);

        stopTimer();
    }

    void resized() override
    {
        updatePlayHeadPosition();
    }

    void setHorizontalOffset (int offset)
    {
        horizontalOffset = offset;
    }

    void setSelectedTimeRange (std::optional<ARA::ARAContentTimeRange> timeRange)
    {
        selectedTimeRange = timeRange;
        repaint();
    }

    void zoomLevelChanged (double) override
    {
        updatePlayHeadPosition();
        repaint();
    }

    void paint (Graphics& g) override
    {
        if (selectedTimeRange)
        {
            auto bounds = getLocalBounds();
            bounds.setLeft (timeToViewScaling.getXForTime (selectedTimeRange->start));
            bounds.setRight (timeToViewScaling.getXForTime (selectedTimeRange->start + selectedTimeRange->duration));
            g.setColour (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).brighter().withAlpha (0.3f));
            g.fillRect (bounds);
            g.setColour (Colours::whitesmoke.withAlpha (0.5f));
            g.drawRect (bounds);
        }
    }

private:
    void updatePlayHeadPosition()
    {
        if (playHeadState.isPlaying.load (std::memory_order_relaxed))
        {
            const auto markerX = timeToViewScaling.getXForTime (playHeadState.timeInSeconds.load (std::memory_order_relaxed));
            const auto playheadLine = getLocalBounds().withTrimmedLeft ((int) (markerX - markerWidth / 2.0) - horizontalOffset)
                                                      .removeFromLeft ((int) markerWidth);
            playheadMarker.setVisible (true);
            playheadMarker.setBounds (playheadLine);
        }
        else
        {
            playheadMarker.setVisible (false);
        }
    }

    void timerCallback() override
    {
        updatePlayHeadPosition();
    }

    static constexpr double markerWidth = 2.0;

    PlayHeadState& playHeadState;
    TimeToViewScaling& timeToViewScaling;
    int horizontalOffset = 0;
    std::optional<ARA::ARAContentTimeRange> selectedTimeRange;
    PlayheadMarkerComponent playheadMarker;
};

class DocumentView final : public Component,
                           public ChangeListener,
                           public ARAMusicalContext::Listener,
                           private ARADocument::Listener,
                           private ARAEditorView::Listener
{
public:
    DocumentView (ARAEditorView& editorView, PlayHeadState& playHeadState)
        : araEditorView (editorView),
          araDocument (*editorView.getDocumentController()->getDocument<ARADocument>()),
          rulersView (playHeadState, timeToViewScaling, araDocument),
          overlay (playHeadState, timeToViewScaling),
          playheadPositionLabel (playHeadState)
    {
        if (araDocument.getMusicalContexts().size() > 0)
            selectMusicalContext (araDocument.getMusicalContexts().front());

        addAndMakeVisible (rulersHeader);

        viewport.content.addAndMakeVisible (rulersView);

        viewport.onVisibleAreaChanged = [this] (const auto& r)
        {
            viewportHeightOffset = r.getY();
            overlay.setHorizontalOffset (r.getX());
            resized();
        };

        addAndMakeVisible (viewport);
        addAndMakeVisible (overlay);
        addAndMakeVisible (playheadPositionLabel);

        zoomControls.setZoomInCallback  ([this] { zoom (2.0); });
        zoomControls.setZoomOutCallback ([this] { zoom (0.5); });
        addAndMakeVisible (zoomControls);

        invalidateRegionSequenceViews();

        araDocument.addListener (this);
        araEditorView.addListener (this);
    }

    ~DocumentView() override
    {
        araEditorView.removeListener (this);
        araDocument.removeListener (this);
        selectMusicalContext (nullptr);
    }

    //==============================================================================
    // ARADocument::Listener overrides
    void didAddMusicalContextToDocument (ARADocument*, ARAMusicalContext* musicalContext) override
    {
        if (selectedMusicalContext == nullptr)
            selectMusicalContext (musicalContext);
    }

    void willDestroyMusicalContext (ARAMusicalContext* musicalContext) override
    {
        if (selectedMusicalContext == musicalContext)
            selectMusicalContext (nullptr);
    }

    void didReorderRegionSequencesInDocument (ARADocument*) override
    {
        invalidateRegionSequenceViews();
    }

    void didAddRegionSequenceToDocument (ARADocument*, ARARegionSequence*) override
    {
        invalidateRegionSequenceViews();
    }

    void willRemoveRegionSequenceFromDocument (ARADocument*, ARARegionSequence* regionSequence) override
    {
        removeRegionSequenceView (regionSequence);
    }

    void didEndEditing (ARADocument*) override
    {
        rebuildRegionSequenceViews();
        update();
    }

    //==============================================================================
    void changeListenerCallback (ChangeBroadcaster*) override
    {
        update();
    }

    //==============================================================================
    // ARAEditorView::Listener overrides
    void onNewSelection (const ARAViewSelection& viewSelection) override
    {
        auto getNewSelectedMusicalContext = [&viewSelection]() -> ARAMusicalContext*
        {
            if (! viewSelection.getRegionSequences().empty())
                return viewSelection.getRegionSequences<ARARegionSequence>().front()->getMusicalContext();
            else if (! viewSelection.getPlaybackRegions().empty())
                return viewSelection.getPlaybackRegions<ARAPlaybackRegion>().front()->getRegionSequence()->getMusicalContext();

            return nullptr;
        };

        if (auto* newSelectedMusicalContext = getNewSelectedMusicalContext())
            if (newSelectedMusicalContext != selectedMusicalContext)
                selectMusicalContext (newSelectedMusicalContext);

        if (const auto timeRange = viewSelection.getTimeRange())
            overlay.setSelectedTimeRange (*timeRange);
        else
            overlay.setSelectedTimeRange (std::nullopt);
    }

    void onHideRegionSequences (const std::vector<ARARegionSequence*>& regionSequences) override
    {
        hiddenRegionSequences = regionSequences;
        invalidateRegionSequenceViews();
    }

    //==============================================================================
    void paint (Graphics& g) override
    {
        g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId).darker());
    }

    void resized() override
    {
        auto bounds = getLocalBounds();

        FlexBox fb;
        fb.justifyContent = FlexBox::JustifyContent::spaceBetween;
        fb.items.add (FlexItem (playheadPositionLabel).withWidth (450.0f).withMinWidth (250.0f));
        fb.items.add (FlexItem (zoomControls).withMinWidth (80.0f));
        fb.performLayout (bounds.removeFromBottom (40));

        auto headerBounds = bounds.removeFromLeft (headerWidth);
        rulersHeader.setBounds (headerBounds.removeFromTop (trackHeight));
        layOutVertically (headerBounds, trackHeaders, viewportHeightOffset);

        viewport.setBounds (bounds);
        overlay.setBounds (bounds.reduced (1));

        const auto width = jmax (timeToViewScaling.getXForTime (timelineLength), viewport.getWidth());
        const auto height = (int) (regionSequenceViews.size() + 1) * trackHeight;
        viewport.content.setSize (width, height);
        viewport.content.resized();
    }

    //==============================================================================
    static constexpr int headerWidth = 120;

private:
    struct RegionSequenceViewKey
    {
        explicit RegionSequenceViewKey (ARARegionSequence* regionSequence)
            : orderIndex (regionSequence->getOrderIndex()), sequence (regionSequence)
        {
        }

        bool operator< (const RegionSequenceViewKey& other) const
        {
            return std::tie (orderIndex, sequence) < std::tie (other.orderIndex, other.sequence);
        }

        ARA::ARAInt32 orderIndex;
        ARARegionSequence* sequence;
    };

    void selectMusicalContext (ARAMusicalContext* newSelectedMusicalContext)
    {
        if (auto oldContext = std::exchange (selectedMusicalContext, newSelectedMusicalContext);
            oldContext != selectedMusicalContext)
        {
            if (oldContext != nullptr)
                oldContext->removeListener (this);

            if (selectedMusicalContext != nullptr)
                selectedMusicalContext->addListener (this);

            rulersView.selectMusicalContext (selectedMusicalContext);
            playheadPositionLabel.selectMusicalContext (selectedMusicalContext);
        }
    }

    void zoom (double factor)
    {
        timeToViewScaling.zoom (factor);
        update();
    }

    template <typename T>
    void layOutVertically (Rectangle<int> bounds, T& components, int verticalOffset = 0)
    {
        bounds = bounds.withY (bounds.getY() - verticalOffset).withHeight (bounds.getHeight() + verticalOffset);

        for (auto& component : components)
        {
            component.second->setBounds (bounds.removeFromTop (trackHeight));
            component.second->resized();
        }
    }

    void update()
    {
        timelineLength = 0.0;

        for (const auto& view : regionSequenceViews)
            timelineLength = std::max (timelineLength, view.second->getPlaybackDuration());

        resized();
    }

    void addTrackViews (ARARegionSequence* regionSequence)
    {
        const auto insertIntoMap = [] (auto& map, auto key, auto value) -> auto&
        {
            auto it = map.insert ({ std::move (key), std::move (value) });
            return *(it.first->second);
        };

        auto& regionSequenceView = insertIntoMap (
            regionSequenceViews,
            RegionSequenceViewKey { regionSequence },
            std::make_unique<RegionSequenceView> (araEditorView, timeToViewScaling, *regionSequence, waveformCache));

        regionSequenceView.addChangeListener (this);
        viewport.content.addAndMakeVisible (regionSequenceView);

        auto& trackHeader = insertIntoMap (trackHeaders,
                                           RegionSequenceViewKey { regionSequence },
                                           std::make_unique<TrackHeader> (araEditorView, *regionSequence));

        addAndMakeVisible (trackHeader);
    }

    void removeRegionSequenceView (ARARegionSequence* regionSequence)
    {
        const auto& view = regionSequenceViews.find (RegionSequenceViewKey { regionSequence });

        if (view != regionSequenceViews.cend())
        {
            removeChildComponent (view->second.get());
            regionSequenceViews.erase (view);
        }

        invalidateRegionSequenceViews();
    }

    void invalidateRegionSequenceViews()
    {
        regionSequenceViewsAreValid = false;
        rebuildRegionSequenceViews();
    }

    void rebuildRegionSequenceViews()
    {
        if (! regionSequenceViewsAreValid && ! araDocument.getDocumentController()->isHostEditingDocument())
        {
            for (auto& view : regionSequenceViews)
                removeChildComponent (view.second.get());

            regionSequenceViews.clear();

            for (auto& view : trackHeaders)
                removeChildComponent (view.second.get());

            trackHeaders.clear();

            for (auto* regionSequence : araDocument.getRegionSequences())
                if (std::find (hiddenRegionSequences.begin(), hiddenRegionSequences.end(), regionSequence) == hiddenRegionSequences.end())
                    addTrackViews (regionSequence);

            update();

            regionSequenceViewsAreValid = true;
        }
    }

    ARAEditorView& araEditorView;
    ARADocument& araDocument;

    bool regionSequenceViewsAreValid = false;

    TimeToViewScaling timeToViewScaling;
    double timelineLength = 0.0;

    ARAMusicalContext* selectedMusicalContext = nullptr;

    std::vector<ARARegionSequence*> hiddenRegionSequences;

    WaveformCache waveformCache;
    std::map<RegionSequenceViewKey, std::unique_ptr<TrackHeader>> trackHeaders;
    std::map<RegionSequenceViewKey, std::unique_ptr<RegionSequenceView>> regionSequenceViews;
    RulersHeader rulersHeader;
    RulersView rulersView;
    VerticalLayoutViewport viewport;
    OverlayComponent overlay;
    ZoomControls zoomControls;
    PlayheadPositionLabel playheadPositionLabel;
    TooltipWindow tooltip;

    int viewportHeightOffset = 0;
};


class ARADemoPluginProcessorEditor final : public AudioProcessorEditor,
                                           private AudioProcessorEditorARAExtension
{
public:
    explicit ARADemoPluginProcessorEditor (ARADemoPluginAudioProcessorImpl& p)
        : AudioProcessorEditor (&p),
          AudioProcessorEditorARAExtension (&p)
    {
        if (auto* editorView = getARAEditorView())
            documentView = std::make_unique<DocumentView> (*editorView, p.playHeadState);

        addAndMakeVisible (documentView.get());

        // ARA requires that plugin editors are resizable to support tight integration
        // into the host UI
        setResizable (true, false);
        setSize (800, 300);
    }

    //==============================================================================
    void paint (Graphics& g) override
    {
        g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId));

        if (! isARAEditorView())
        {
            g.setColour (Colours::white);
            g.setFont (FontOptions (15.0f));
            g.drawFittedText ("ARA host isn't detected. This plugin only supports ARA mode",
                              getLocalBounds(),
                              Justification::centred,
                              1);
        }
    }

    void resized() override
    {
        if (documentView != nullptr)
            documentView->setBounds (getLocalBounds());
    }

    AudioProcessorEditorARAExtension* getARAClientExtensions() override { return this; }

private:
    std::unique_ptr<Component> documentView;

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (ARADemoPluginProcessorEditor)
};

class ARADemoPluginAudioProcessor final : public ARADemoPluginAudioProcessorImpl
{
public:
    bool hasEditor() const override               { return true; }
    AudioProcessorEditor* createEditor() override { return new ARADemoPluginProcessorEditor (*this); }
};
