1
0
Fork 0
mirror of https://github.com/juce-framework/JUCE.git synced 2026-01-09 23:34:20 +00:00

WebViewPluginDemo: Add demo of WebBrowserComponent with a React GUI

This commit is contained in:
attila 2023-09-16 19:20:41 +02:00 committed by Anthony Nicholls
parent ee0aac2c75
commit bc6295d7b5
12 changed files with 2147 additions and 32 deletions

View file

@ -86,11 +86,26 @@ inline File getExamplesDirectory() noexcept
#endif
}
inline std::unique_ptr<InputStream> createAssetInputStream (const char* resourcePath)
enum class AssertAssetExists
{
no,
yes
};
inline std::unique_ptr<InputStream> createAssetInputStream (const char* resourcePath,
[[maybe_unused]] AssertAssetExists assertExists = AssertAssetExists::yes)
{
#if JUCE_ANDROID
ZipFile apkZip (File::getSpecialLocation (File::invokedExecutableFile));
return std::unique_ptr<InputStream> (apkZip.createStreamForEntry (apkZip.getIndexOfFileName ("assets/" + String (resourcePath))));
const auto fileIndex = apkZip.getIndexOfFileName ("assets/" + String (resourcePath));
if (fileIndex == -1)
{
jassert (assertExists == AssertAssetExists::no);
return {};
}
return std::unique_ptr<InputStream> (apkZip.createStreamForEntry (fileIndex));
#else
#if JUCE_IOS
auto assetsDir = File::getSpecialLocation (File::currentExecutableFile)
@ -106,7 +121,12 @@ inline std::unique_ptr<InputStream> createAssetInputStream (const char* resource
#endif
auto resourceFile = assetsDir.getChildFile (resourcePath);
jassert (resourceFile.existsAsFile());
if (! resourceFile.existsAsFile())
{
jassert (assertExists == AssertAssetExists::no);
return {};
}
return resourceFile.createInputStream();
#endif

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>WebViewPluginDemo</title>
<style type="text/css">
body {
background-color: white;
}
</style>
</head>
<body>
<h1>WebViewPluginDemo</h1>
<p>
This document is a placeholder for the GUI component of the
WebViewPluginDemo.
</p>
<p>
To build the fully fledged user interface you need to install
<a href="https://nodejs.org">node.js</a>
</p>
<p>
Then navigate into the
<code>examples/GUI/WebViewPluginDemoGUI</code> directory inside your JUCE
directory, and issue the following commands.
</p>
<pre>
npm install
npm run build
npm run zip
</pre
>
<p>
This will build the full GUI package and place it in the
<code>Assets</code> directory.
</p>
<p>After this, rebuild and restart this demo.</p>
</body>
</html>

View file

@ -51,6 +51,30 @@ function(_juce_add_pips)
list(REMOVE_ITEM headers "${CMAKE_CURRENT_SOURCE_DIR}/ReaperEmbeddedViewPluginDemo.h")
endif()
if((CMAKE_SYSTEM_NAME STREQUAL "Windows")
AND EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/WebViewPluginDemo.h")
if(NOT ("${JUCE_CMAKE_UTILS_DIR}" IN_LIST CMAKE_MODULE_PATH))
list(APPEND CMAKE_MODULE_PATH "${JUCE_CMAKE_UTILS_DIR}")
endif()
find_package(WebView2 QUIET)
if(NOT WebView2_FOUND)
list(REMOVE_ITEM headers "${CMAKE_CURRENT_SOURCE_DIR}/WebViewPluginDemo.h")
message(WARNING "The WebViewPluginDemo was not enabled because WebView2 wasn't found"
" in the the local NuGet folder"
"\n"
"To install NuGet and the WebView2 package containing the statically linked library, "
"open a PowerShell and issue the following commands"
"\n"
"> Register-PackageSource -provider NuGet -name nugetRepository -location https://www.nuget.org/api/v2\n"
"> Install-Package NuGet.CommandLine -Scope CurrentUser\n"
"> Install-Package Microsoft.Web.WebView2 -Scope CurrentUser -RequiredVersion 1.0.1901.177\n")
endif()
endif()
foreach(header IN ITEMS ${headers})
juce_add_pip(${header} added_target)
target_link_libraries(${added_target} PUBLIC

View file

@ -86,11 +86,26 @@ inline File getExamplesDirectory() noexcept
#endif
}
inline std::unique_ptr<InputStream> createAssetInputStream (const char* resourcePath)
enum class AssertAssetExists
{
no,
yes
};
inline std::unique_ptr<InputStream> createAssetInputStream (const char* resourcePath,
[[maybe_unused]] AssertAssetExists assertExists = AssertAssetExists::yes)
{
#if JUCE_ANDROID
ZipFile apkZip (File::getSpecialLocation (File::invokedExecutableFile));
return std::unique_ptr<InputStream> (apkZip.createStreamForEntry (apkZip.getIndexOfFileName ("assets/" + String (resourcePath))));
const auto fileIndex = apkZip.getIndexOfFileName ("assets/" + String (resourcePath));
if (fileIndex == -1)
{
jassert (assertExists == AssertAssetExists::no);
return {};
}
return std::unique_ptr<InputStream> (apkZip.createStreamForEntry (fileIndex));
#else
#if JUCE_IOS
auto assetsDir = File::getSpecialLocation (File::currentExecutableFile)
@ -106,7 +121,12 @@ inline std::unique_ptr<InputStream> createAssetInputStream (const char* resource
#endif
auto resourceFile = assetsDir.getChildFile (resourcePath);
jassert (resourceFile.existsAsFile());
if (! resourceFile.existsAsFile())
{
jassert (assertExists == AssertAssetExists::no);
return {};
}
return resourceFile.createInputStream();
#endif

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>WebViewPluginDemo</title>
<style type="text/css">
body {
background-color: white;
}
</style>
</head>
<body>
<h1>WebViewPluginDemo</h1>
<p>
This document is a placeholder for the GUI component of the
WebViewPluginDemo.
</p>
<p>
To build the fully fledged user interface you need to install
<a href="https://nodejs.org">node.js</a>
</p>
<p>
Then navigate into the
<code>examples/GUI/WebViewPluginDemoGUI</code> directory inside your JUCE
directory, and issue the following commands.
</p>
<pre>
npm install
npm run build
npm run zip
</pre
>
<p>
This will build the full GUI package and place it in the
<code>Assets</code> directory.
</p>
<p>After this, rebuild and restart this demo.</p>
</body>
</html>

View file

@ -0,0 +1,624 @@
/*
==============================================================================
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: WebViewPluginDemo
version: 1.0.0
vendor: JUCE
website: http://juce.com
description: Filtering audio plugin using an HTML/JS user interface
dependencies: juce_audio_basics, juce_audio_devices, juce_audio_formats,
juce_audio_plugin_client, juce_audio_processors, juce_dsp,
juce_audio_utils, juce_core, juce_data_structures,
juce_events, juce_graphics, juce_gui_basics, juce_gui_extra
exporters: xcode_mac, vs2022, linux_make, androidstudio, xcode_iphone
moduleFlags: JUCE_STRICT_REFCOUNTEDPOINTER=1, JUCE_USE_WIN_WEBVIEW2_WITH_STATIC_LINKING=1
type: AudioProcessor
mainClass: WebViewPluginAudioProcessorWrapper
useLocalCopy: 1
END_JUCE_PIP_METADATA
*******************************************************************************/
#pragma once
#include "../Assets/DemoUtilities.h"
namespace ID
{
#define PARAMETER_ID(str) static const ParameterID str { #str, 1 };
PARAMETER_ID (cutoffFreqHz)
PARAMETER_ID (mute)
PARAMETER_ID (filterType)
#undef PARAMETER_ID
}
class CircularBuffer
{
public:
CircularBuffer (int numChannels, int numSamples)
: buffer (data, (size_t) numChannels, (size_t) numSamples)
{
}
template <typename T>
void push (dsp::AudioBlock<T> b)
{
jassert (b.getNumChannels() == buffer.getNumChannels());
const auto trimmed = b.getSubBlock ( b.getNumSamples()
- std::min (b.getNumSamples(), buffer.getNumSamples()));
const auto bufferLength = (int64) buffer.getNumSamples();
for (auto samplesRemaining = (int64) trimmed.getNumSamples(); samplesRemaining > 0;)
{
const auto writeOffset = writeIx % bufferLength;
const auto numSamplesToWrite = std::min (samplesRemaining, bufferLength - writeOffset);
auto destSubBlock = buffer.getSubBlock ((size_t) writeOffset, (size_t) numSamplesToWrite);
const auto sourceSubBlock = trimmed.getSubBlock (trimmed.getNumSamples() - (size_t) samplesRemaining,
(size_t) numSamplesToWrite);
destSubBlock.copyFrom (sourceSubBlock);
samplesRemaining -= numSamplesToWrite;
writeIx += numSamplesToWrite;
}
}
template <typename T>
void push (Span<T> s)
{
auto* ptr = s.begin();
dsp::AudioBlock<T> b (&ptr, 1, s.size());
push (b);
}
void read (int64 readIx, dsp::AudioBlock<float> output) const
{
const auto numChannelsToUse = std::min (buffer.getNumChannels(), output.getNumChannels());
jassert (output.getNumChannels() == buffer.getNumChannels());
const auto bufferLength = (int64) buffer.getNumSamples();
for (auto outputOffset = (size_t) 0; outputOffset < output.getNumSamples();)
{
const auto inputOffset = (size_t) ((readIx + (int64) outputOffset) % bufferLength);
const auto numSamplesToRead = std::min (output.getNumSamples() - outputOffset,
(size_t) bufferLength - inputOffset);
auto destSubBlock = output.getSubBlock (outputOffset, numSamplesToRead)
.getSubsetChannelBlock (0, numChannelsToUse);
destSubBlock.copyFrom (buffer.getSubBlock (inputOffset, numSamplesToRead)
.getSubsetChannelBlock (0, numChannelsToUse));
outputOffset += numSamplesToRead;
}
}
int64 getWriteIndex() const noexcept { return writeIx; }
private:
HeapBlock<char> data;
dsp::AudioBlock<float> buffer;
int64 writeIx = 0;
};
class SpectralBars
{
public:
static constexpr int getNumBars() noexcept
{
return analysisWindowWidth / 2;
}
template <typename T>
void push (Span<T> data)
{
buffer.push (data);
}
void compute (Span<float> output)
{
auto* ptr = output.begin();
auto result = dsp::AudioBlock<float> (&ptr, 1, output.size());
result.clear();
auto analysisData = fftTmp.getSubBlock (0, analysisWindowWidth);
for (int i = 0; i < numAnalysisWindows; ++i)
{
buffer.read (0 + i * analysisWindowOverlap, analysisData);
fft.performFrequencyOnlyForwardTransform (fftTmp.getChannelPointer (0), true);
result.add (analysisData);
}
result.multiplyBy (1.0f / numAnalysisWindows);
}
static inline constexpr int64 fftOrder = 5;
static inline constexpr int64 analysisWindowWidth = 1 << fftOrder;
static inline constexpr int numAnalysisWindows = 16;
static inline constexpr int analysisWindowOverlap = analysisWindowWidth / 2;
private:
dsp::FFT fft { fftOrder };
HeapBlock<char> fftTmpData;
dsp::AudioBlock<float> fftTmp { fftTmpData, 1, (size_t) (2 * fft.getSize()) };
CircularBuffer buffer { 1, (int) analysisWindowWidth
+ (numAnalysisWindows - 1) * analysisWindowOverlap };
};
//==============================================================================
class WebViewPluginAudioProcessor : public AudioProcessor
{
public:
//==============================================================================
WebViewPluginAudioProcessor (AudioProcessorValueTreeState::ParameterLayout layout);
//==============================================================================
void prepareToPlay (double sampleRate, int samplesPerBlock) override;
void releaseResources() override {}
bool isBusesLayoutSupported (const BusesLayout& layouts) const override;
void processBlock (AudioBuffer<float>&, MidiBuffer&) override;
using AudioProcessor::processBlock;
//==============================================================================
const String getName() const override { return JucePlugin_Name; }
bool acceptsMidi() const override { return false; }
bool producesMidi() const override { return false; }
bool isMidiEffect() const override { return false; }
double getTailLengthSeconds() const override { return 0.0; }
//==============================================================================
int getNumPrograms() override { return 1; }
int getCurrentProgram() override { return 0; }
void setCurrentProgram (int) override {}
const String getProgramName (int) override { return {}; }
void changeProgramName (int, const String&) override {}
//==============================================================================
void getStateInformation (MemoryBlock& destData) override;
void setStateInformation (const void* data, int sizeInBytes) override;
struct Parameters
{
public:
explicit Parameters (AudioProcessorValueTreeState::ParameterLayout& layout)
: cutoffFreqHz (addToLayout<AudioParameterFloat> (layout,
ID::cutoffFreqHz,
"Cutoff",
NormalisableRange<float> { 200.0f, 14000.0f, 1.0f, 0.5f },
11000.0f,
AudioParameterFloatAttributes{}.withLabel ("Hz"))),
mute (addToLayout<AudioParameterBool> (layout, ID::mute, "Mute", false)),
filterType (addToLayout<AudioParameterChoice> (layout,
ID::filterType,
"Filter type",
StringArray { "Low-pass", "High-pass", "Band-pass" },
0))
{
}
AudioParameterFloat& cutoffFreqHz;
AudioParameterBool& mute;
AudioParameterChoice& filterType;
private:
template <typename Param>
static void add (AudioProcessorParameterGroup& group, std::unique_ptr<Param> param)
{
group.addChild (std::move (param));
}
template <typename Param>
static void add (AudioProcessorValueTreeState::ParameterLayout& group, std::unique_ptr<Param> param)
{
group.add (std::move (param));
}
template <typename Param, typename Group, typename... Ts>
static Param& addToLayout (Group& layout, Ts&&... ts)
{
auto param = std::make_unique<Param> (std::forward<Ts> (ts)...);
auto& ref = *param;
add (layout, std::move (param));
return ref;
}
};
Parameters parameters;
AudioProcessorValueTreeState state;
std::vector<float> spectrumData = [] { return std::vector<float> (16, 0.0f); }();
SpinLock spectrumDataLock;
SpectralBars spectralBars;
dsp::LadderFilter<float> filter;
private:
//==============================================================================
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (WebViewPluginAudioProcessor)
};
//==============================================================================
WebViewPluginAudioProcessor::WebViewPluginAudioProcessor (AudioProcessorValueTreeState::ParameterLayout layout)
: AudioProcessor (BusesProperties()
#if ! JucePlugin_IsMidiEffect
#if ! JucePlugin_IsSynth
.withInput ("Input", juce::AudioChannelSet::stereo(), true)
#endif
.withOutput ("Output", juce::AudioChannelSet::stereo(), true)
#endif
),
parameters (layout),
state (*this, nullptr, "STATE", std::move (layout))
{
}
//==============================================================================
void WebViewPluginAudioProcessor::prepareToPlay (double sampleRate, int samplesPerBlock)
{
const auto channels = std::max (getTotalNumInputChannels(), getTotalNumOutputChannels());
if (channels == 0)
return;
filter.prepare ({ sampleRate, (uint32_t) samplesPerBlock, (uint32_t) channels });
filter.reset();
}
bool WebViewPluginAudioProcessor::isBusesLayoutSupported (const BusesLayout& layouts) const
{
if (layouts.getMainOutputChannelSet() != juce::AudioChannelSet::mono()
&& layouts.getMainOutputChannelSet() != juce::AudioChannelSet::stereo())
return false;
if (layouts.getMainOutputChannelSet() != layouts.getMainInputChannelSet())
return false;
return true;
}
void WebViewPluginAudioProcessor::processBlock (juce::AudioBuffer<float>& buffer,
juce::MidiBuffer&)
{
juce::ScopedNoDenormals noDenormals;
const auto totalNumInputChannels = getTotalNumInputChannels();
const auto totalNumOutputChannels = getTotalNumOutputChannels();
for (auto i = totalNumInputChannels; i < totalNumOutputChannels; ++i)
buffer.clear (i, 0, buffer.getNumSamples());
filter.setCutoffFrequencyHz (parameters.cutoffFreqHz.get());
const auto filterMode = [this]
{
switch (parameters.filterType.getIndex())
{
case 0:
return dsp::LadderFilter<float>::Mode::LPF12;
case 1:
return dsp::LadderFilter<float>::Mode::HPF12;
default:
return dsp::LadderFilter<float>::Mode::BPF12;
}
}();
filter.setMode (filterMode);
auto outBlock = dsp::AudioBlock<float> { buffer }.getSubsetChannelBlock (0, (size_t) getTotalNumOutputChannels());
if (parameters.mute.get())
outBlock.clear();
filter.process (dsp::ProcessContextReplacing<float> (outBlock));
spectralBars.push (Span { buffer.getReadPointer (0), (size_t) buffer.getNumSamples() });
{
const SpinLock::ScopedTryLockType lock (spectrumDataLock);
if (! lock.isLocked())
return;
spectralBars.compute ({ spectrumData.data(), spectrumData.size() });
}
}
//==============================================================================
void WebViewPluginAudioProcessor::getStateInformation (juce::MemoryBlock& destData)
{
juce::ignoreUnused (destData);
}
void WebViewPluginAudioProcessor::setStateInformation (const void* data, int sizeInBytes)
{
juce::ignoreUnused (data, sizeInBytes);
}
extern const String localDevServerAddress;
std::optional<WebBrowserComponent::Resource> getResource (const String& url);
//==============================================================================
struct SinglePageBrowser : WebBrowserComponent
{
using WebBrowserComponent::WebBrowserComponent;
// Prevent page loads from navigating away from our single page web app
bool pageAboutToLoad (const String& newURL) override;
};
//==============================================================================
class WebViewPluginAudioProcessorEditor : public AudioProcessorEditor, private Timer
{
public:
explicit WebViewPluginAudioProcessorEditor (WebViewPluginAudioProcessor&);
std::optional<WebBrowserComponent::Resource> getResource (const String& url);
//==============================================================================
void paint (Graphics&) override;
void resized() override;
void timerCallback() override
{
static constexpr size_t numFramesBuffered = 5;
SpinLock::ScopedLockType lock { processorRef.spectrumDataLock };
Array<var> frame;
for (size_t i = 1; i < processorRef.spectrumData.size(); ++i)
frame.add (processorRef.spectrumData[i]);
spectrumDataFrames.push_back (std::move (frame));
while (spectrumDataFrames.size() > numFramesBuffered)
spectrumDataFrames.pop_front();
static int64 callbackCounter = 0;
if ( spectrumDataFrames.size() == numFramesBuffered
&& callbackCounter++ % (int64) numFramesBuffered)
{
webComponent.emitEventIfBrowserIsVisible ("spectrumData", var{});
}
}
private:
WebViewPluginAudioProcessor& processorRef;
WebSliderRelay cutoffSliderRelay { webComponent, "cutoffSlider" };
WebToggleButtonRelay muteToggleRelay { webComponent, "muteToggle" };
WebComboBoxRelay filterTypeComboRelay { webComponent, "filterTypeCombo" };
SinglePageBrowser webComponent { WebBrowserComponent::Options{}
.withBackend (WebBrowserComponent::Options::Backend::webview2)
.withWinWebView2Options (WebBrowserComponent::Options::WinWebView2{}
.withUserDataFolder (File::getSpecialLocation (File::SpecialLocationType::tempDirectory)))
.withNativeIntegrationEnabled()
.withOptionsFrom (cutoffSliderRelay)
.withOptionsFrom (muteToggleRelay)
.withOptionsFrom (filterTypeComboRelay)
.withNativeFunction ("sayHello", [](auto& var, auto complete)
{
complete ("Hello " + var[0].toString());
})
.withResourceProvider ([this] (const auto& url)
{
return getResource (url);
},
URL { localDevServerAddress }.getOrigin()) };
WebSliderParameterAttachment cutoffAttachment;
WebToggleButtonParameterAttachment muteAttachment;
WebComboBoxParameterAttachment filterTypeAttachment;
std::deque<Array<var>> spectrumDataFrames;
JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (WebViewPluginAudioProcessorEditor)
};
static ZipFile* getZipFile()
{
static auto stream = createAssetInputStream ("webviewplugin-gui_1.0.0.zip", AssertAssetExists::no);
if (stream == nullptr)
return nullptr;
static ZipFile f { stream.get(), false };
return &f;
}
static const char* getMimeForExtension (const String& extension)
{
static const std::unordered_map<String, const char*> mimeMap =
{
{ { "htm" }, "text/html" },
{ { "html" }, "text/html" },
{ { "txt" }, "text/plain" },
{ { "jpg" }, "image/jpeg" },
{ { "jpeg" }, "image/jpeg" },
{ { "svg" }, "image/svg+xml" },
{ { "ico" }, "image/vnd.microsoft.icon" },
{ { "json" }, "application/json" },
{ { "png" }, "image/png" },
{ { "css" }, "text/css" },
{ { "map" }, "application/json" },
{ { "js" }, "text/javascript" },
{ { "woff2" }, "font/woff2" }
};
if (const auto it = mimeMap.find (extension.toLowerCase()); it != mimeMap.end())
return it->second;
jassertfalse;
return "";
}
static String getExtension (String filename)
{
return filename.fromLastOccurrenceOf (".", false, false);
}
static auto streamToVector (InputStream& stream)
{
std::vector<std::byte> result ((size_t) stream.getTotalLength());
stream.setPosition (0);
[[maybe_unused]] const auto bytesRead = stream.read (result.data(), result.size());
jassert (bytesRead == (ssize_t) result.size());
return result;
}
std::optional<WebBrowserComponent::Resource> WebViewPluginAudioProcessorEditor::getResource (const String& url)
{
const auto urlToRetrive = url == "/" ? String { "index.html" }
: url.fromFirstOccurrenceOf ("/", false, false);
if (auto* archive = getZipFile())
{
if (auto* entry = archive->getEntry (urlToRetrive))
{
auto stream = rawToUniquePtr (archive->createStreamForEntry (*entry));
auto v = streamToVector (*stream);
auto mime = getMimeForExtension (getExtension (entry->filename).toLowerCase());
return WebBrowserComponent::Resource { std::move (v),
std::move (mime) };
}
}
if (urlToRetrive == "index.html")
{
auto fallbackIndexHtml = createAssetInputStream ("webviewplugin-gui-fallback.html");
return WebBrowserComponent::Resource { streamToVector (*fallbackIndexHtml),
String { "text/html" } };
}
if (urlToRetrive == "data.txt")
{
WebBrowserComponent::Resource resource;
static constexpr char testData[] = "testdata";
MemoryInputStream stream { testData, numElementsInArray (testData) - 1, false };
return WebBrowserComponent::Resource { streamToVector (stream), String { "text/html" } };
}
if (urlToRetrive == "spectrumData.json")
{
Array<var> frames;
for (const auto& frame : spectrumDataFrames)
frames.add (frame);
DynamicObject::Ptr d (new DynamicObject());
d->setProperty ("timeResolutionMs", getTimerInterval());
d->setProperty ("frames", std::move (frames));
const auto s = JSON::toString (d.get());
MemoryInputStream stream { s.getCharPointer(), s.getNumBytesAsUTF8(), false };
return WebBrowserComponent::Resource { streamToVector (stream), String { "application/json" } };
}
return std::nullopt;
}
#if JUCE_ANDROID
// The localhost is available on this address to the emulator
const String localDevServerAddress = "http://10.0.2.2:3000/";
#else
const String localDevServerAddress = "http://localhost:3000/";
#endif
bool SinglePageBrowser::pageAboutToLoad (const String& newURL)
{
return newURL == localDevServerAddress || newURL == getResourceProviderRoot();
}
//==============================================================================
WebViewPluginAudioProcessorEditor::WebViewPluginAudioProcessorEditor (WebViewPluginAudioProcessor& p)
: AudioProcessorEditor (&p), processorRef (p),
cutoffAttachment (*processorRef.state.getParameter (ID::cutoffFreqHz.getParamID()),
cutoffSliderRelay,
processorRef.state.undoManager),
muteAttachment (*processorRef.state.getParameter (ID::mute.getParamID()),
muteToggleRelay,
processorRef.state.undoManager),
filterTypeAttachment (*processorRef.state.getParameter (ID::filterType.getParamID()),
filterTypeComboRelay,
processorRef.state.undoManager)
{
addAndMakeVisible (webComponent);
// webComponent.goToURL (localDevServerAddress);
webComponent.goToURL (WebBrowserComponent::getResourceProviderRoot());
setSize (500, 500);
startTimerHz (20);
}
//==============================================================================
void WebViewPluginAudioProcessorEditor::paint (Graphics& g)
{
// (Our component is opaque, so we must completely fill the background with a solid colour)
g.fillAll (getLookAndFeel().findColour (ResizableWindow::backgroundColourId));
}
void WebViewPluginAudioProcessorEditor::resized()
{
webComponent.setBounds (getLocalBounds());
}
class WebViewPluginAudioProcessorWrapper : public WebViewPluginAudioProcessor
{
public:
WebViewPluginAudioProcessorWrapper() : WebViewPluginAudioProcessor ({})
{}
bool hasEditor() const override { return true; }
AudioProcessorEditor* createEditor() override { return new WebViewPluginAudioProcessorEditor (*this); }
};

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{
"name": "ladder-filter-gui",
"version": "0.1.0",
"name": "webviewplugin-gui",
"version": "1.0.0",
"private": true,
"dependencies": {
"@emotion/react": "^11.11.1",
@ -11,6 +11,7 @@
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"juce-framework-frontend": "file:../../../modules/juce_gui_extra/native/javascript",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
@ -20,7 +21,8 @@
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
"eject": "react-scripts eject",
"zip": "npm-build-zip --destination=../../Assets"
},
"eslintConfig": {
"extends": [
@ -42,6 +44,7 @@
},
"devDependencies": {
"eslint": "^8.43.0",
"eslint-plugin-react": "^7.32.2"
"eslint-plugin-react": "^7.32.2",
"npm-build-zip": "^1.0.4"
}
}

View file

@ -1,23 +1,416 @@
import "@fontsource/roboto/300.css";
import "@fontsource/roboto/400.css";
import "@fontsource/roboto/500.css";
import "@fontsource/roboto/700.css";
import Box from "@mui/material/Container";
import Checkbox from "@mui/material/Checkbox";
import Typography from "@mui/material/Typography";
import Container from "@mui/material/Container";
import Slider from "@mui/material/Slider";
import Button from "@mui/material/Button";
import CardActions from "@mui/material/CardActions";
import Snackbar from "@mui/material/Snackbar";
import IconButton from "@mui/material/IconButton";
import CloseIcon from "@mui/icons-material/Close";
import InputLabel from "@mui/material/InputLabel";
import MenuItem from "@mui/material/MenuItem";
import FormControl from "@mui/material/FormControl";
import Select from "@mui/material/Select";
import FormGroup from "@mui/material/FormGroup";
import FormControlLabel from "@mui/material/FormControlLabel";
import { React, useState, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import * as Juce from "juce-framework-frontend";
import "./App.css";
import { React } from "react";
function JuceSlider({ identifier, title }) {
JuceSlider.propTypes = {
identifier: PropTypes.string,
title: PropTypes.string,
};
const sliderState = Juce.getSliderState(identifier);
const [value, setValue] = useState(sliderState.getNormalisedValue());
const [properties, setProperties] = useState(sliderState.properties);
const handleChange = (event, newValue) => {
sliderState.setNormalisedValue(newValue);
setValue(newValue);
};
const mouseDown = () => {
sliderState.sliderDragStarted();
};
const changeCommitted = (event, newValue) => {
sliderState.setNormalisedValue(newValue);
sliderState.sliderDragEnded();
};
useEffect(() => {
const valueListenerId = sliderState.valueChangedEvent.addListener(() => {
setValue(sliderState.getNormalisedValue());
});
const propertiesListenerId = sliderState.propertiesChangedEvent.addListener(
() => setProperties(sliderState.properties)
);
return function cleanup() {
sliderState.valueChangedEvent.removeListener(valueListenerId);
sliderState.propertiesChangedEvent.removeListener(propertiesListenerId);
};
});
function calculateValue() {
return sliderState.getScaledValue();
}
return (
<Box>
<Typography sx={{ mt: 1.5 }}>
{properties.name}: {sliderState.getScaledValue()} {properties.label}
</Typography>
<Slider
aria-label={title}
value={value}
scale={calculateValue}
onChange={handleChange}
min={0}
max={1}
step={1 / (properties.numSteps - 1)}
onChangeCommitted={changeCommitted}
onMouseDown={mouseDown}
/>
</Box>
);
}
function JuceCheckbox({ identifier }) {
JuceCheckbox.propTypes = {
identifier: PropTypes.string,
};
const checkboxState = Juce.getToggleState(identifier);
const [value, setValue] = useState(checkboxState.getValue());
const [properties, setProperties] = useState(checkboxState.properties);
const handleChange = (event) => {
checkboxState.setValue(event.target.checked);
setValue(event.target.checked);
};
useEffect(() => {
const valueListenerId = checkboxState.valueChangedEvent.addListener(() => {
setValue(checkboxState.getValue());
});
const propertiesListenerId =
checkboxState.propertiesChangedEvent.addListener(() =>
setProperties(checkboxState.properties)
);
return function cleanup() {
checkboxState.valueChangedEvent.removeListener(valueListenerId);
checkboxState.propertiesChangedEvent.removeListener(propertiesListenerId);
};
});
const cb = <Checkbox checked={value} onChange={handleChange} />;
return (
<Box>
<FormGroup>
<FormControlLabel control={cb} label={properties.name} />
</FormGroup>
</Box>
);
}
function JuceComboBox({ identifier }) {
JuceComboBox.propTypes = {
identifier: PropTypes.string,
};
const comboBoxState = Juce.getComboBoxState(identifier);
const [value, setValue] = useState(comboBoxState.getChoiceIndex());
const [properties, setProperties] = useState(comboBoxState.properties);
const handleChange = (event) => {
comboBoxState.setChoiceIndex(event.target.value);
setValue(event.target.value);
};
useEffect(() => {
const valueListenerId = comboBoxState.valueChangedEvent.addListener(() => {
setValue(comboBoxState.getChoiceIndex());
});
const propertiesListenerId =
comboBoxState.propertiesChangedEvent.addListener(() => {
setProperties(comboBoxState.properties);
});
return function cleanup() {
comboBoxState.valueChangedEvent.removeListener(valueListenerId);
comboBoxState.propertiesChangedEvent.removeListener(propertiesListenerId);
};
});
return (
<Box>
<FormControl fullWidth>
<InputLabel id={identifier}>{properties.name}</InputLabel>
<Select
labelId={identifier}
value={value}
label={properties.name}
onChange={handleChange}
>
{properties.choices.map((choice, i) => (
<MenuItem value={i} key={i}>
{choice}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
);
}
const sayHello = Juce.getNativeFunction("sayHello");
const SpectrumDataReceiver_eventId = "spectrumData";
function interpolate(a, b, s) {
let result = new Array(a.length).fill(0);
for (const [i, val] of a.entries()) result[i] += (1 - s) * val;
for (const [i, val] of b.entries()) result[i] += s * val;
return result;
}
function mod(dividend, divisor) {
const quotient = Math.floor(dividend / divisor);
return dividend - divisor * quotient;
}
class SpectrumDataReceiver {
constructor(bufferLength) {
this.bufferLength = bufferLength;
this.buffer = new Array(this.bufferLength);
this.readIndex = 0;
this.writeIndex = 0;
this.lastTimeStampMs = 0;
this.timeResolutionMs = 0;
let self = this;
this.spectrumDataRegistrationId = window.__JUCE__.backend.addEventListener(
SpectrumDataReceiver_eventId,
() => {
fetch(Juce.getBackendResourceAddress("spectrumData.json"))
.then((response) => response.text())
.then((text) => {
const data = JSON.parse(text);
if (self.timeResolutionMs == 0) {
self.timeResolutionMs = data.timeResolutionMs;
// We want to stay behind the write index by a full batch plus one
// so that we can keep reading buffered frames until we receive the
// new batch
self.readIndex = -data.frames.length - 1;
self.buffer.fill(new Array(data.frames[0].length).fill(0));
}
for (const f of data.frames)
self.buffer[mod(self.writeIndex++, self.bufferLength)] = f;
});
}
);
}
getBufferItem(index) {
return this.buffer[mod(index, this.buffer.length)];
}
getLevels(timeStampMs) {
if (this.timeResolutionMs == 0) return null;
const previousTimeStampMs = this.lastTimeStampMs;
this.lastTimeStampMs = timeStampMs;
if (previousTimeStampMs == 0) return this.buffer[0];
const timeAdvance =
(timeStampMs - previousTimeStampMs) / this.timeResolutionMs;
this.readIndex += timeAdvance;
const integralPart = Math.floor(this.readIndex);
const fractionalPart = this.readIndex - integralPart;
return interpolate(
this.getBufferItem(integralPart),
this.getBufferItem(integralPart + 1),
fractionalPart
);
}
unregister() {
window.__JUCE__.backend.removeEventListener(
this.spectrumDataRegistrationId
);
}
}
function FreqBandInfo() {
const canvasRef = useRef(null);
let dataReceiver = null;
let isActive = true;
// eslint-disable-next-line no-unused-vars
const render = (timeStampMs) => {
const canvas = canvasRef.current;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
var grd = ctx.createLinearGradient(0, 0, 0, canvas.height);
grd.addColorStop(0, "#1976d2");
grd.addColorStop(1, "#dae9f8");
ctx.fillStyle = grd;
if (dataReceiver != null) {
const levels = dataReceiver.getLevels(timeStampMs);
if (levels != null) {
const numBars = levels.length;
const barWidth = canvas.width / numBars;
const barHeight = canvas.height;
for (const [i, l] of levels.entries()) {
ctx.fillRect(
i * barWidth,
barHeight - l * barHeight,
barWidth,
l * barHeight
);
}
}
}
if (isActive) window.requestAnimationFrame(render);
};
useEffect(() => {
dataReceiver = new SpectrumDataReceiver(10);
isActive = true;
window.requestAnimationFrame(render);
return function cleanup() {
isActive = false;
dataReceiver.unregister();
};
});
const canvasStyle = {
marginLeft: "0",
marginRight: "0",
marginTop: "1em",
display: "block",
width: "94%",
bottom: "0",
position: "absolute",
};
return (
<Box>
<canvas height={90} style={canvasStyle} ref={canvasRef}></canvas>
</Box>
);
}
function App() {
const [open, setOpen] = useState(false);
const [snackbarMessage, setMessage] = useState("No message received yet");
const openSnackbar = () => {
setOpen(true);
};
const handleClose = (event, reason) => {
if (reason === "clickaway") {
return;
}
setOpen(false);
};
const action = (
<>
<IconButton
size="small"
aria-label="close"
color="inherit"
onClick={handleClose}
>
<CloseIcon fontSize="small" />
</IconButton>
</>
);
return (
<div className="App">
<header className="App-header">
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
<div>
<Container>
<JuceSlider identifier="cutoffSlider" title="Cutoff" />
</Container>
<CardActions style={{ justifyContent: "center" }}>
<Button
variant="contained"
sx={{ marginTop: 2 }}
onClick={() => {
sayHello("JUCE").then((result) => {
setMessage(result);
openSnackbar();
});
}}
>
Learn React
</a>
</header>
Call backend function
</Button>
</CardActions>
<CardActions style={{ justifyContent: "center" }}>
<Button
variant="contained"
sx={{ marginTop: 2 }}
onClick={() => {
fetch(Juce.getBackendResourceAddress("data.txt"))
.then((response) => response.text())
.then((text) => {
setMessage("Data fetched: " + text);
openSnackbar();
});
}}
>
Fetch data from backend
</Button>
</CardActions>
<JuceCheckbox identifier="muteToggle" />
<br></br>
<JuceComboBox identifier="filterTypeCombo" />
<FreqBandInfo></FreqBandInfo>
<Snackbar
open={open}
autoHideDuration={6000}
onClose={handleClose}
message={snackbarMessage}
action={action}
/>
</div>
);
}

View file

@ -5,6 +5,7 @@ body {
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: white;
}
code {

View file

@ -86,11 +86,26 @@ inline File getExamplesDirectory() noexcept
#endif
}
inline std::unique_ptr<InputStream> createAssetInputStream (const char* resourcePath)
enum class AssertAssetExists
{
no,
yes
};
inline std::unique_ptr<InputStream> createAssetInputStream (const char* resourcePath,
[[maybe_unused]] AssertAssetExists assertExists = AssertAssetExists::yes)
{
#if JUCE_ANDROID
ZipFile apkZip (File::getSpecialLocation (File::invokedExecutableFile));
return std::unique_ptr<InputStream> (apkZip.createStreamForEntry (apkZip.getIndexOfFileName ("assets/" + String (resourcePath))));
const auto fileIndex = apkZip.getIndexOfFileName ("assets/" + String (resourcePath));
if (fileIndex == -1)
{
jassert (assertExists == AssertAssetExists::no);
return {};
}
return std::unique_ptr<InputStream> (apkZip.createStreamForEntry (fileIndex));
#else
#if JUCE_IOS
auto assetsDir = File::getSpecialLocation (File::currentExecutableFile)
@ -106,7 +121,12 @@ inline std::unique_ptr<InputStream> createAssetInputStream (const char* resource
#endif
auto resourceFile = assetsDir.getChildFile (resourcePath);
jassert (resourceFile.existsAsFile());
if (! resourceFile.existsAsFile())
{
jassert (assertExists == AssertAssetExists::no);
return {};
}
return resourceFile.createInputStream();
#endif

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>WebViewPluginDemo</title>
<style type="text/css">
body {
background-color: white;
}
</style>
</head>
<body>
<h1>WebViewPluginDemo</h1>
<p>
This document is a placeholder for the GUI component of the
WebViewPluginDemo.
</p>
<p>
To build the fully fledged user interface you need to install
<a href="https://nodejs.org">node.js</a>
</p>
<p>
Then navigate into the
<code>examples/GUI/WebViewPluginDemoGUI</code> directory inside your JUCE
directory, and issue the following commands.
</p>
<pre>
npm install
npm run build
npm run zip
</pre
>
<p>
This will build the full GUI package and place it in the
<code>Assets</code> directory.
</p>
<p>After this, rebuild and restart this demo.</p>
</body>
</html>