Signed-off-by: falkTX <falktx@falktx.com>pull/457/head
@@ -48,6 +48,26 @@ endif | |||||
endif | endif | ||||
# --------------------------------------------------------------------------------------------------------------------- | |||||
# Check for proper UI_TYPE parameter | |||||
ifeq ($(UI_TYPE),) | |||||
else ifeq ($(UI_TYPE),generic) | |||||
else ifeq ($(UI_TYPE),external) | |||||
else ifeq ($(UI_TYPE),cairo) | |||||
else ifeq ($(UI_TYPE),opengl) | |||||
else ifeq ($(UI_TYPE),opengl3) | |||||
USE_OPENGL3 = true | |||||
else ifeq ($(UI_TYPE),vulkan) | |||||
else ifeq ($(UI_TYPE),webview) | |||||
USE_WEB_VIEW = true | |||||
else | |||||
$(error unknown UI_TYPE $(UI_TYPE)) | |||||
endif | |||||
# --------------------------------------------------------------------------------------------------------------------- | |||||
# Include DPF base setup | |||||
include $(DPF_PATH)/Makefile.base.mk | include $(DPF_PATH)/Makefile.base.mk | ||||
# --------------------------------------------------------------------------------------------------------------------- | # --------------------------------------------------------------------------------------------------------------------- | ||||
@@ -251,11 +271,16 @@ HAVE_DGL = false | |||||
endif | endif | ||||
endif | endif | ||||
ifeq ($(UI_TYPE),web) | |||||
DGL_FLAGS += -DDGL_WEB -DHAVE_DGL | |||||
ifeq ($(UI_TYPE),webview) | |||||
DGL_FLAGS += -DDGL_EXTERNAL -DHAVE_DGL | |||||
ifeq ($(HAVE_STUB),true) | |||||
DGL_FLAGS += $(STUB_FLAGS) | |||||
DGL_LIBS += $(STUB_LIBS) | |||||
DGL_LIB = $(DGL_BUILD_DIR)/libdgl-stub.a | DGL_LIB = $(DGL_BUILD_DIR)/libdgl-stub.a | ||||
HAVE_DGL = true | HAVE_DGL = true | ||||
USE_WEB_VIEW = true | |||||
else | |||||
HAVE_DGL = false | |||||
endif | |||||
endif | endif | ||||
ifeq ($(HAVE_DGL)$(LINUX)$(USE_WEB_VIEW),truetruetrue) | ifeq ($(HAVE_DGL)$(LINUX)$(USE_WEB_VIEW),truetruetrue) | ||||
@@ -1,6 +1,6 @@ | |||||
/* | /* | ||||
* DISTRHO Plugin Framework (DPF) | * DISTRHO Plugin Framework (DPF) | ||||
* Copyright (C) 2012-2021 Filipe Coelho <falktx@falktx.com> | |||||
* Copyright (C) 2012-2024 Filipe Coelho <falktx@falktx.com> | |||||
* | * | ||||
* Permission to use, copy, modify, and/or distribute this software for any purpose with | * 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 | * or without fee is hereby granted, provided that the above copyright notice and this | ||||
@@ -192,8 +192,7 @@ public: | |||||
This function does not block the event loop. | This function does not block the event loop. | ||||
@note This is exactly the same API as provided by the Window class, | |||||
but redeclared here so that non-embed/DGL based UIs can still use file browser related functions. | |||||
@note This is exactly the same API as provided by the Window class, but redeclared here for convenience. | |||||
*/ | */ | ||||
bool openFileBrowser(const DISTRHO_NAMESPACE::FileBrowserOptions& options = FileBrowserOptions()); | bool openFileBrowser(const DISTRHO_NAMESPACE::FileBrowserOptions& options = FileBrowserOptions()); | ||||
#endif | #endif | ||||
@@ -415,10 +415,10 @@ WebViewHandle webViewCreate(const char* const url, | |||||
SetParent(hwnd, reinterpret_cast<HWND>(windowId)); | SetParent(hwnd, reinterpret_cast<HWND>(windowId)); | ||||
SetWindowPos(hwnd, nullptr, | SetWindowPos(hwnd, nullptr, | ||||
options.offset.x * scaleFactor, | |||||
options.offset.y * scaleFactor, | |||||
(initialWidth - options.offset.x) * scaleFactor, | |||||
(initialHeight - options.offset.y) * scaleFactor, | |||||
options.offset.x, | |||||
options.offset.y, | |||||
initialWidth - options.offset.x, | |||||
initialHeight - options.offset.y, | |||||
SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED); | ||||
ShowWindow(hwnd, SW_SHOW); | ShowWindow(hwnd, SW_SHOW); | ||||
#endif | #endif | ||||
@@ -448,8 +448,8 @@ WebViewHandle webViewCreate(const char* const url, | |||||
const CGRect rect = CGRectMake(options.offset.x / scaleFactor, | const CGRect rect = CGRectMake(options.offset.x / scaleFactor, | ||||
options.offset.y / scaleFactor, | options.offset.y / scaleFactor, | ||||
initialWidth, | |||||
initialHeight); | |||||
initialWidth / scaleFactor, | |||||
initialHeight / scaleFactor); | |||||
WKWebView* const webview = [[WKWebView alloc] initWithFrame:rect | WKWebView* const webview = [[WKWebView alloc] initWithFrame:rect | ||||
configuration:config]; | configuration:config]; | ||||
@@ -84,7 +84,7 @@ struct WebViewOptions { | |||||
This means it will draw on top of whatever is below it, | This means it will draw on top of whatever is below it, | ||||
something to take into consideration if mixing regular widgets with web views. | something to take into consideration if mixing regular widgets with web views. | ||||
Provided metrics must not have scale factor pre-applied. | |||||
Provided metrics must have scale factor pre-applied. | |||||
@p windowId: The native window id to attach this view to (X11 Window, HWND or NSView*) | @p windowId: The native window id to attach this view to (X11 Window, HWND or NSView*) | ||||
@p scaleFactor: Scale factor in use | @p scaleFactor: Scale factor in use | ||||
@@ -163,7 +163,7 @@ | |||||
# error invalid build config: file browser requested but `USE_FILE_BROWSER` build option is not set | # error invalid build config: file browser requested but `USE_FILE_BROWSER` build option is not set | ||||
#endif | #endif | ||||
#if DISTRHO_UI_USE_WEB_VIEW && !defined(DGL_UI_USE_WEB_VIEW) | |||||
#if DISTRHO_UI_USE_WEB_VIEW && !defined(DGL_USE_WEB_VIEW) | |||||
# error invalid build config: web view requested but `USE_WEB_VIEW` build option is not set | # error invalid build config: web view requested but `USE_WEB_VIEW` build option is not set | ||||
#endif | #endif | ||||
@@ -82,11 +82,6 @@ END_NAMESPACE_DISTRHO | |||||
START_NAMESPACE_DISTRHO | START_NAMESPACE_DISTRHO | ||||
/* ------------------------------------------------------------------------------------------------------------ | |||||
* Static data, see DistrhoUIInternal.hpp */ | |||||
const char* g_nextBundlePath = nullptr; | |||||
/* ------------------------------------------------------------------------------------------------------------ | /* ------------------------------------------------------------------------------------------------------------ | ||||
* get global scale factor */ | * get global scale factor */ | ||||
@@ -178,8 +173,8 @@ UI::PrivateData* UI::PrivateData::s_nextPrivateData = nullptr; | |||||
PluginWindow& UI::PrivateData::createNextWindow(UI* const ui, uint width, uint height, const bool adjustForScaleFactor) | PluginWindow& UI::PrivateData::createNextWindow(UI* const ui, uint width, uint height, const bool adjustForScaleFactor) | ||||
{ | { | ||||
UI::PrivateData* const pData = s_nextPrivateData; | |||||
const double scaleFactor = d_isNotZero(pData->scaleFactor) ? pData->scaleFactor : getDesktopScaleFactor(pData->winId); | |||||
UI::PrivateData* const uiData = s_nextPrivateData; | |||||
const double scaleFactor = d_isNotZero(uiData->scaleFactor) ? uiData->scaleFactor : getDesktopScaleFactor(uiData->winId); | |||||
if (adjustForScaleFactor && d_isNotZero(scaleFactor) && d_isNotEqual(scaleFactor, 1.0)) | if (adjustForScaleFactor && d_isNotZero(scaleFactor) && d_isNotEqual(scaleFactor, 1.0)) | ||||
{ | { | ||||
@@ -188,15 +183,132 @@ PluginWindow& UI::PrivateData::createNextWindow(UI* const ui, uint width, uint h | |||||
} | } | ||||
d_stdout("createNextWindow %u %u %f %d", width, height, scaleFactor, adjustForScaleFactor); | d_stdout("createNextWindow %u %u %f %d", width, height, scaleFactor, adjustForScaleFactor); | ||||
pData->window = new PluginWindow(ui, pData->app, pData->winId, width, height, scaleFactor); | |||||
uiData->window = new PluginWindow(ui, uiData->app, uiData->winId, width, height, scaleFactor); | |||||
if (uiData->callbacksPtr != nullptr) | |||||
{ | |||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
String path; | |||||
if (uiData->bundlePath != nullptr) | |||||
{ | |||||
path = getResourcePath(uiData->bundlePath); | |||||
} | |||||
else | |||||
{ | |||||
path = getBinaryFilename(); | |||||
path.truncate(path.rfind(DISTRHO_OS_SEP)); | |||||
path += "/resources"; | |||||
} | |||||
// TODO convert win32 paths to web | |||||
// TODO encode paths (e.g. %20 for space) | |||||
WebViewOptions opts; | |||||
opts.initialJS = "" | |||||
"editParameter = function(index, started){ postMessage('editparam '+index+' '+(started ? 1 : 0)) };" | |||||
"setParameterValue = function(index, value){ postMessage('setparam '+index+' '+value) };" | |||||
#if DISTRHO_PLUGIN_WANT_STATE | |||||
"setState = function(key, value){ postMessage('setstate '+key+' '+value) };" | |||||
"requestStateFile = function(key){ postMessage('reqstatefile '+key) };" | |||||
#endif | |||||
#if DISTRHO_PLUGIN_WANT_MIDI_INPUT | |||||
"sendNote = function(channel, note, velocity){ postMessage('sendnote '+channel+' '+note+' '+velocity) };" | |||||
#endif | |||||
; | |||||
opts.callback = webViewMessageCallback; | |||||
opts.callbackPtr = uiData; | |||||
uiData->webview = webViewCreate("file://" + path + "/index.html", uiData->winId, width, height, scaleFactor, opts); | |||||
#endif | |||||
} | |||||
// If there are no callbacks, this is most likely a temporary window, so ignore idle callbacks | // If there are no callbacks, this is most likely a temporary window, so ignore idle callbacks | ||||
if (pData->callbacksPtr == nullptr) | |||||
pData->window->setIgnoreIdleCallbacks(); | |||||
else | |||||
{ | |||||
uiData->window->setIgnoreIdleCallbacks(); | |||||
} | |||||
return pData->window.getObject(); | |||||
return uiData->window.getObject(); | |||||
} | } | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
void UI::PrivateData::webViewMessageCallback(void* const arg, char* const msg) | |||||
{ | |||||
UI::PrivateData* const uiData = static_cast<UI::PrivateData*>(arg); | |||||
if (std::strncmp(msg, "setparam ", 9) == 0) | |||||
{ | |||||
const char* const strindex = msg + 9; | |||||
char* strvalue = nullptr; | |||||
const ulong index = std::strtoul(strindex, &strvalue, 10); | |||||
DISTRHO_SAFE_ASSERT_RETURN(strvalue != nullptr && strindex != strvalue,); | |||||
float value; | |||||
{ | |||||
const ScopedSafeLocale ssl; | |||||
value = std::atof(strvalue); | |||||
} | |||||
uiData->setParamCallback(index + uiData->parameterOffset, value); | |||||
return; | |||||
} | |||||
if (std::strncmp(msg, "editparam ", 10) == 0) | |||||
{ | |||||
const char* const strindex = msg + 10; | |||||
char* strvalue = nullptr; | |||||
const ulong index = std::strtoul(strindex, &strvalue, 10); | |||||
DISTRHO_SAFE_ASSERT_RETURN(strvalue != nullptr && strindex != strvalue,); | |||||
const bool started = strvalue[0] != '0'; | |||||
uiData->editParamCallback(index + uiData->parameterOffset, started); | |||||
return; | |||||
} | |||||
#if DISTRHO_PLUGIN_WANT_STATE | |||||
if (std::strncmp(msg, "setstate ", 9) == 0) | |||||
{ | |||||
char* const key = msg + 9; | |||||
char* const sep = std::strchr(key, ' '); | |||||
DISTRHO_SAFE_ASSERT_RETURN(sep != nullptr,); | |||||
*sep = 0; | |||||
char* const value = sep + 1; | |||||
uiData->setStateCallback(key, value); | |||||
return; | |||||
} | |||||
if (std::strncmp(msg, "reqstatefile ", 13) == 0) | |||||
{ | |||||
const char* const key = msg + 13; | |||||
uiData->fileRequestCallback(key); | |||||
return; | |||||
} | |||||
#endif | |||||
#if DISTRHO_PLUGIN_WANT_MIDI_INPUT | |||||
if (std::strncmp(msg, "sendnote ", 9) == 0) | |||||
{ | |||||
const char* const strchannel = msg + 9; | |||||
char* strnote = nullptr; | |||||
char* strvelocity = nullptr; | |||||
char* end = nullptr; | |||||
const ulong channel = std::strtoul(strchannel, &strnote, 10); | |||||
DISTRHO_SAFE_ASSERT_RETURN(strnote != nullptr && strchannel != strnote,); | |||||
const ulong note = std::strtoul(strnote, &strvelocity, 10); | |||||
DISTRHO_SAFE_ASSERT_RETURN(strvelocity != nullptr && strchannel != strvelocity,); | |||||
const ulong velocity = std::strtoul(strvelocity, &end, 10); | |||||
DISTRHO_SAFE_ASSERT_RETURN(end != nullptr && strvelocity != end,); | |||||
uiData->sendNoteCallback(channel, note, velocity); | |||||
return; | |||||
} | |||||
#endif | |||||
d_stderr("UI received unknown message '%s'", msg); | |||||
} | |||||
#endif | |||||
/* ------------------------------------------------------------------------------------------------------------ | /* ------------------------------------------------------------------------------------------------------------ | ||||
* UI */ | * UI */ | ||||
@@ -238,6 +350,10 @@ UI::UI(const uint width, const uint height, const bool automaticallyScaleAndSetA | |||||
UI::~UI() | UI::~UI() | ||||
{ | { | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
if (uiData->webview != nullptr) | |||||
webViewDestroy(uiData->webview); | |||||
#endif | |||||
} | } | ||||
/* ------------------------------------------------------------------------------------------------------------ | /* ------------------------------------------------------------------------------------------------------------ | ||||
@@ -323,27 +439,91 @@ void* UI::getPluginInstancePointer() const noexcept | |||||
/* ------------------------------------------------------------------------------------------------------------ | /* ------------------------------------------------------------------------------------------------------------ | ||||
* DSP/Plugin Callbacks */ | * DSP/Plugin Callbacks */ | ||||
void UI::parameterChanged(uint32_t, float) | |||||
void UI::parameterChanged(const uint32_t index, const float value) | |||||
{ | { | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
if (uiData->webview != nullptr) | |||||
{ | |||||
char msg[128]; | |||||
{ | |||||
const ScopedSafeLocale ssl; | |||||
std::snprintf(msg, sizeof(msg) - 1, | |||||
"typeof(parameterChanged) === 'function' && parameterChanged(%u,%f)", index, value); | |||||
} | |||||
webViewEvaluateJS(uiData->webview, msg); | |||||
} | |||||
#else | |||||
// unused | |||||
(void)index; | |||||
(void)value; | |||||
#endif | |||||
} | } | ||||
#if DISTRHO_PLUGIN_WANT_PROGRAMS | #if DISTRHO_PLUGIN_WANT_PROGRAMS | ||||
void UI::programLoaded(uint32_t) | |||||
void UI::programLoaded(const uint32_t index) | |||||
{ | { | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
if (uiData->webview != nullptr) | |||||
{ | |||||
char msg[128]; | |||||
std::snprintf(msg, sizeof(msg) - 1, | |||||
"typeof(programLoaded) === 'function' && programLoaded(%u)", index); | |||||
webViewEvaluateJS(uiData->webview, msg); | |||||
} | |||||
#else | |||||
// unused | |||||
(void)index; | |||||
#endif | |||||
} | } | ||||
#endif | #endif | ||||
#if DISTRHO_PLUGIN_WANT_STATE | #if DISTRHO_PLUGIN_WANT_STATE | ||||
void UI::stateChanged(const char*, const char*) | |||||
void UI::stateChanged(const char* const key, const char* const value) | |||||
{ | { | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
if (uiData->webview != nullptr) | |||||
{ | |||||
const size_t keylen = std::strlen(key); | |||||
const size_t valuelen = std::strlen(value); | |||||
const size_t msglen = keylen + valuelen + 60; | |||||
if (char* const msg = static_cast<char*>(std::malloc(msglen))) | |||||
{ | |||||
// TODO escape \\' | |||||
std::snprintf(msg, sizeof(msglen) - 1, | |||||
"typeof(stateChanged) === 'function' && stateChanged('%s','%s')", key, value); | |||||
msg[msglen - 1] = '\0'; | |||||
webViewEvaluateJS(uiData->webview, msg); | |||||
std::free(msg); | |||||
} | |||||
} | |||||
#else | |||||
// unused | |||||
(void)key; | |||||
(void)value; | |||||
#endif | |||||
} | } | ||||
#endif | #endif | ||||
/* ------------------------------------------------------------------------------------------------------------ | /* ------------------------------------------------------------------------------------------------------------ | ||||
* DSP/Plugin Callbacks (optional) */ | * DSP/Plugin Callbacks (optional) */ | ||||
void UI::sampleRateChanged(double) | |||||
void UI::sampleRateChanged(const double sampleRate) | |||||
{ | { | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
if (uiData->webview != nullptr) | |||||
{ | |||||
char msg[128]; | |||||
{ | |||||
const ScopedSafeLocale ssl; | |||||
std::snprintf(msg, sizeof(msg) - 1, | |||||
"typeof(sampleRateChanged) === 'function' && sampleRateChanged(%f)", sampleRate); | |||||
} | |||||
webViewEvaluateJS(uiData->webview, msg); | |||||
} | |||||
#else | |||||
// unused | |||||
(void)sampleRate; | |||||
#endif | |||||
} | } | ||||
/* ------------------------------------------------------------------------------------------------------------ | /* ------------------------------------------------------------------------------------------------------------ | ||||
@@ -21,11 +21,6 @@ | |||||
START_NAMESPACE_DISTRHO | START_NAMESPACE_DISTRHO | ||||
// ----------------------------------------------------------------------- | |||||
// Static data, see DistrhoUI.cpp | |||||
extern const char* g_nextBundlePath; | |||||
// ----------------------------------------------------------------------- | // ----------------------------------------------------------------------- | ||||
// UI exporter class | // UI exporter class | ||||
@@ -75,12 +70,10 @@ public: | |||||
uiData->setSizeCallbackFunc = setSizeCall; | uiData->setSizeCallbackFunc = setSizeCall; | ||||
uiData->fileRequestCallbackFunc = fileRequestCall; | uiData->fileRequestCallbackFunc = fileRequestCall; | ||||
g_nextBundlePath = bundlePath; | |||||
UI::PrivateData::s_nextPrivateData = uiData; | UI::PrivateData::s_nextPrivateData = uiData; | ||||
UI* const uiPtr = createUI(); | UI* const uiPtr = createUI(); | ||||
g_nextBundlePath = nullptr; | |||||
// enter context called in the PluginWindow constructor, see DistrhoUIPrivateData.hpp | // enter context called in the PluginWindow constructor, see DistrhoUIPrivateData.hpp | ||||
uiData->window->leaveContext(); | uiData->window->leaveContext(); | ||||
UI::PrivateData::s_nextPrivateData = nullptr; | UI::PrivateData::s_nextPrivateData = nullptr; | ||||
@@ -201,12 +194,18 @@ public: | |||||
uiData->window->focus(); | uiData->window->focus(); | ||||
uiData->app.addIdleCallback(cb); | uiData->app.addIdleCallback(cb); | ||||
uiData->app.exec(); | uiData->app.exec(); | ||||
uiData->app.removeIdleCallback(cb); | |||||
} | } | ||||
void exec_idle() | void exec_idle() | ||||
{ | { | ||||
DISTRHO_SAFE_ASSERT_RETURN(ui != nullptr, ); | DISTRHO_SAFE_ASSERT_RETURN(ui != nullptr, ); | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
if (uiData->webview != nullptr) | |||||
webViewIdle(uiData->webview); | |||||
#endif | |||||
ui->uiIdle(); | ui->uiIdle(); | ||||
uiData->app.repaintIfNeeeded(); | uiData->app.repaintIfNeeeded(); | ||||
} | } | ||||
@@ -223,6 +222,12 @@ public: | |||||
DISTRHO_SAFE_ASSERT_RETURN(ui != nullptr, false); | DISTRHO_SAFE_ASSERT_RETURN(ui != nullptr, false); | ||||
uiData->app.idle(); | uiData->app.idle(); | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
if (uiData->webview != nullptr) | |||||
webViewIdle(uiData->webview); | |||||
#endif | |||||
ui->uiIdle(); | ui->uiIdle(); | ||||
uiData->app.repaintIfNeeeded(); | uiData->app.repaintIfNeeeded(); | ||||
return ! uiData->app.isQuitting(); | return ! uiData->app.isQuitting(); | ||||
@@ -252,6 +257,12 @@ public: | |||||
DISTRHO_SAFE_ASSERT_RETURN(ui != nullptr,); | DISTRHO_SAFE_ASSERT_RETURN(ui != nullptr,); | ||||
uiData->app.triggerIdleCallbacks(); | uiData->app.triggerIdleCallbacks(); | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
if (uiData->webview != nullptr) | |||||
webViewIdle(uiData->webview); | |||||
#endif | |||||
ui->uiIdle(); | ui->uiIdle(); | ||||
uiData->app.repaintIfNeeeded(); | uiData->app.repaintIfNeeeded(); | ||||
} | } | ||||
@@ -32,6 +32,10 @@ | |||||
# include <string> | # include <string> | ||||
#endif | #endif | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
# include "extra/WebView.hpp" | |||||
#endif | |||||
#if defined(DISTRHO_PLUGIN_TARGET_JACK) || defined(DISTRHO_PLUGIN_TARGET_DSSI) | #if defined(DISTRHO_PLUGIN_TARGET_JACK) || defined(DISTRHO_PLUGIN_TARGET_DSSI) | ||||
# define DISTRHO_UI_IS_STANDALONE 1 | # define DISTRHO_UI_IS_STANDALONE 1 | ||||
#else | #else | ||||
@@ -247,6 +251,9 @@ struct UI::PrivateData { | |||||
// DGL | // DGL | ||||
PluginApplication app; | PluginApplication app; | ||||
ScopedPointer<PluginWindow> window; | ScopedPointer<PluginWindow> window; | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
WebViewHandle webview; | |||||
#endif | |||||
// DSP | // DSP | ||||
double sampleRate; | double sampleRate; | ||||
@@ -279,6 +286,9 @@ struct UI::PrivateData { | |||||
PrivateData(const char* const appClassName) noexcept | PrivateData(const char* const appClassName) noexcept | ||||
: app(appClassName), | : app(appClassName), | ||||
window(nullptr), | window(nullptr), | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
webview(nullptr), | |||||
#endif | |||||
sampleRate(0), | sampleRate(0), | ||||
parameterOffset(0), | parameterOffset(0), | ||||
dspPtr(nullptr), | dspPtr(nullptr), | ||||
@@ -359,10 +369,13 @@ struct UI::PrivateData { | |||||
} | } | ||||
// implemented below, after PluginWindow | // implemented below, after PluginWindow | ||||
bool fileRequestCallback(const char* const key); | |||||
bool fileRequestCallback(const char* key); | |||||
static UI::PrivateData* s_nextPrivateData; | static UI::PrivateData* s_nextPrivateData; | ||||
static PluginWindow& createNextWindow(UI* ui, uint width, uint height, bool adjustForScaleFactor); | static PluginWindow& createNextWindow(UI* ui, uint width, uint height, bool adjustForScaleFactor); | ||||
#if DISTRHO_UI_USE_WEB_VIEW | |||||
static void webViewMessageCallback(void* arg, char* msg); | |||||
#endif | |||||
}; | }; | ||||
// ----------------------------------------------------------------------- | // ----------------------------------------------------------------------- | ||||
@@ -0,0 +1,12 @@ | |||||
# CMake file for DISTRHO Plugins # | |||||
# ------------------------------ # | |||||
dpf_add_plugin(d_meters | |||||
TARGETS jack dssi lv2 vst2 vst3 clap | |||||
FILES_DSP | |||||
ExamplePluginMeters.cpp | |||||
FILES_UI | |||||
ExampleUIMeters.cpp) | |||||
target_include_directories( | |||||
d_meters PUBLIC ".") |
@@ -0,0 +1,43 @@ | |||||
/* | |||||
* DISTRHO Plugin Framework (DPF) | |||||
* Copyright (C) 2012-2024 Filipe Coelho <falktx@falktx.com> | |||||
* | |||||
* 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. | |||||
*/ | |||||
#ifndef DISTRHO_PLUGIN_INFO_H_INCLUDED | |||||
#define DISTRHO_PLUGIN_INFO_H_INCLUDED | |||||
#define DISTRHO_PLUGIN_BRAND "DISTRHO" | |||||
#define DISTRHO_PLUGIN_NAME "Web Meters" | |||||
#define DISTRHO_PLUGIN_URI "http://distrho.sf.net/examples/WebMeters" | |||||
#define DISTRHO_PLUGIN_CLAP_ID "studio.kx.distrho.examples.webmeters" | |||||
#define DISTRHO_PLUGIN_BRAND_ID Dstr | |||||
#define DISTRHO_PLUGIN_UNIQUE_ID wMtr | |||||
#define DISTRHO_PLUGIN_HAS_UI 1 | |||||
#define DISTRHO_PLUGIN_IS_RT_SAFE 1 | |||||
#define DISTRHO_PLUGIN_NUM_INPUTS 2 | |||||
#define DISTRHO_PLUGIN_NUM_OUTPUTS 2 | |||||
#define DISTRHO_PLUGIN_WANT_STATE 1 | |||||
#define DISTRHO_UI_FILE_BROWSER 0 | |||||
#define DISTRHO_UI_USER_RESIZABLE 1 | |||||
#define DISTRHO_UI_USE_WEB_VIEW 1 | |||||
#define METER_COLOR_GREEN 0 | |||||
#define METER_COLOR_BLUE 1 | |||||
#define DISTRHO_UI_DEFAULT_WIDTH 100 | |||||
#define DISTRHO_UI_DEFAULT_HEIGHT 500 | |||||
#endif // DISTRHO_PLUGIN_INFO_H_INCLUDED |
@@ -0,0 +1,288 @@ | |||||
/* | |||||
* DISTRHO Plugin Framework (DPF) | |||||
* Copyright (C) 2012-2024 Filipe Coelho <falktx@falktx.com> | |||||
* | |||||
* 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. | |||||
*/ | |||||
#include "DistrhoPlugin.hpp" | |||||
START_NAMESPACE_DISTRHO | |||||
// ----------------------------------------------------------------------------------------------------------- | |||||
/** | |||||
Plugin to demonstrate parameter outputs using meters. | |||||
*/ | |||||
class ExamplePluginMeters : public Plugin | |||||
{ | |||||
public: | |||||
ExamplePluginMeters() | |||||
: Plugin(3, 0, 0), // 3 parameters, 0 programs, 0 states | |||||
fColor(0.0f), | |||||
fOutLeft(0.0f), | |||||
fOutRight(0.0f), | |||||
fNeedsReset(true) | |||||
{ | |||||
} | |||||
protected: | |||||
/* -------------------------------------------------------------------------------------------------------- | |||||
* Information */ | |||||
/** | |||||
Get the plugin label. | |||||
A plugin label follows the same rules as Parameter::symbol, with the exception that it can start with numbers. | |||||
*/ | |||||
const char* getLabel() const override | |||||
{ | |||||
return "meters"; | |||||
} | |||||
/** | |||||
Get an extensive comment/description about the plugin. | |||||
*/ | |||||
const char* getDescription() const override | |||||
{ | |||||
return "Plugin to demonstrate parameter outputs using meters."; | |||||
} | |||||
/** | |||||
Get the plugin author/maker. | |||||
*/ | |||||
const char* getMaker() const override | |||||
{ | |||||
return "DISTRHO"; | |||||
} | |||||
/** | |||||
Get the plugin homepage. | |||||
*/ | |||||
const char* getHomePage() const override | |||||
{ | |||||
return "https://github.com/DISTRHO/DPF"; | |||||
} | |||||
/** | |||||
Get the plugin license name (a single line of text). | |||||
For commercial plugins this should return some short copyright information. | |||||
*/ | |||||
const char* getLicense() const override | |||||
{ | |||||
return "ISC"; | |||||
} | |||||
/** | |||||
Get the plugin version, in hexadecimal. | |||||
*/ | |||||
uint32_t getVersion() const override | |||||
{ | |||||
return d_version(1, 0, 0); | |||||
} | |||||
/* -------------------------------------------------------------------------------------------------------- | |||||
* Init */ | |||||
/** | |||||
Initialize the audio port @a index.@n | |||||
This function will be called once, shortly after the plugin is created. | |||||
*/ | |||||
void initAudioPort(bool input, uint32_t index, AudioPort& port) override | |||||
{ | |||||
// treat meter audio ports as stereo | |||||
port.groupId = kPortGroupStereo; | |||||
// everything else is as default | |||||
Plugin::initAudioPort(input, index, port); | |||||
} | |||||
/** | |||||
Initialize the parameter @a index.@n | |||||
This function will be called once, shortly after the plugin is created. | |||||
*/ | |||||
void initParameter(uint32_t index, Parameter& parameter) override | |||||
{ | |||||
/** | |||||
All parameters in this plugin have the same ranges. | |||||
*/ | |||||
parameter.ranges.min = 0.0f; | |||||
parameter.ranges.max = 1.0f; | |||||
parameter.ranges.def = 0.0f; | |||||
/** | |||||
Set parameter data. | |||||
*/ | |||||
switch (index) | |||||
{ | |||||
case 0: | |||||
parameter.hints = kParameterIsAutomatable|kParameterIsInteger; | |||||
parameter.name = "color"; | |||||
parameter.symbol = "color"; | |||||
parameter.enumValues.count = 2; | |||||
parameter.enumValues.restrictedMode = true; | |||||
{ | |||||
ParameterEnumerationValue* const values = new ParameterEnumerationValue[2]; | |||||
parameter.enumValues.values = values; | |||||
values[0].label = "Green"; | |||||
values[0].value = METER_COLOR_GREEN; | |||||
values[1].label = "Blue"; | |||||
values[1].value = METER_COLOR_BLUE; | |||||
} | |||||
break; | |||||
case 1: | |||||
parameter.hints = kParameterIsAutomatable|kParameterIsOutput; | |||||
parameter.name = "out-left"; | |||||
parameter.symbol = "out_left"; | |||||
break; | |||||
case 2: | |||||
parameter.hints = kParameterIsAutomatable|kParameterIsOutput; | |||||
parameter.name = "out-right"; | |||||
parameter.symbol = "out_right"; | |||||
break; | |||||
} | |||||
} | |||||
/** | |||||
Set a state key and default value. | |||||
This function will be called once, shortly after the plugin is created. | |||||
*/ | |||||
void initState(uint32_t, String&, String&) override | |||||
{ | |||||
// we are using states but don't want them saved in the host | |||||
} | |||||
/* -------------------------------------------------------------------------------------------------------- | |||||
* Internal data */ | |||||
/** | |||||
Get the current value of a parameter. | |||||
*/ | |||||
float getParameterValue(uint32_t index) const override | |||||
{ | |||||
switch (index) | |||||
{ | |||||
case 0: return fColor; | |||||
case 1: return fOutLeft; | |||||
case 2: return fOutRight; | |||||
} | |||||
return 0.0f; | |||||
} | |||||
/** | |||||
Change a parameter value. | |||||
*/ | |||||
void setParameterValue(uint32_t index, float value) override | |||||
{ | |||||
// this is only called for input paramters, and we only have one of those. | |||||
if (index != 0) return; | |||||
fColor = value; | |||||
} | |||||
/** | |||||
Change an internal state. | |||||
*/ | |||||
void setState(const char* key, const char*) override | |||||
{ | |||||
if (std::strcmp(key, "reset") != 0) | |||||
return; | |||||
fNeedsReset = true; | |||||
} | |||||
/* -------------------------------------------------------------------------------------------------------- | |||||
* Process */ | |||||
/** | |||||
Run/process function for plugins without MIDI input. | |||||
*/ | |||||
void run(const float** inputs, float** outputs, uint32_t frames) override | |||||
{ | |||||
float tmp; | |||||
float tmpLeft = 0.0f; | |||||
float tmpRight = 0.0f; | |||||
for (uint32_t i=0; i<frames; ++i) | |||||
{ | |||||
// left | |||||
tmp = std::abs(inputs[0][i]); | |||||
if (tmp > tmpLeft) | |||||
tmpLeft = tmp; | |||||
// right | |||||
tmp = std::abs(inputs[1][i]); | |||||
if (tmp > tmpRight) | |||||
tmpRight = tmp; | |||||
} | |||||
if (tmpLeft > 1.0f) | |||||
tmpLeft = 1.0f; | |||||
if (tmpRight > 1.0f) | |||||
tmpRight = 1.0f; | |||||
if (fNeedsReset) | |||||
{ | |||||
fOutLeft = tmpLeft; | |||||
fOutRight = tmpRight; | |||||
fNeedsReset = false; | |||||
} | |||||
else | |||||
{ | |||||
if (tmpLeft > fOutLeft) | |||||
fOutLeft = tmpLeft; | |||||
if (tmpRight > fOutRight) | |||||
fOutRight = tmpRight; | |||||
} | |||||
// copy inputs over outputs if needed | |||||
if (outputs[0] != inputs[0]) | |||||
std::memcpy(outputs[0], inputs[0], sizeof(float)*frames); | |||||
if (outputs[1] != inputs[1]) | |||||
std::memcpy(outputs[1], inputs[1], sizeof(float)*frames); | |||||
} | |||||
// ------------------------------------------------------------------------------------------------------- | |||||
private: | |||||
/** | |||||
Parameters. | |||||
*/ | |||||
float fColor, fOutLeft, fOutRight; | |||||
/** | |||||
Boolean used to reset meter values. | |||||
The UI will send a "reset" message which sets this as true. | |||||
*/ | |||||
volatile bool fNeedsReset; | |||||
/** | |||||
Set our plugin class as non-copyable and add a leak detector just in case. | |||||
*/ | |||||
DISTRHO_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ExamplePluginMeters) | |||||
}; | |||||
/* ------------------------------------------------------------------------------------------------------------ | |||||
* Plugin entry point, called by DPF to create a new plugin instance. */ | |||||
Plugin* createPlugin() | |||||
{ | |||||
return new ExamplePluginMeters(); | |||||
} | |||||
// ----------------------------------------------------------------------------------------------------------- | |||||
END_NAMESPACE_DISTRHO |
@@ -0,0 +1,61 @@ | |||||
/* | |||||
* DISTRHO Plugin Framework (DPF) | |||||
* Copyright (C) 2012-2024 Filipe Coelho <falktx@falktx.com> | |||||
* | |||||
* 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. | |||||
*/ | |||||
#include "DistrhoUI.hpp" | |||||
START_NAMESPACE_DISTRHO | |||||
// ----------------------------------------------------------------------------------------------------------- | |||||
class ExampleUIMeters : public UI | |||||
{ | |||||
public: | |||||
ExampleUIMeters() | |||||
: UI() | |||||
{ | |||||
/* | |||||
const double scaleFactor = getScaleFactor(); | |||||
if (d_isNotEqual(scaleFactor, 1.0)) | |||||
{ | |||||
setGeometryConstraints(DISTRHO_UI_DEFAULT_WIDTH * scaleFactor, DISTRHO_UI_DEFAULT_HEIGHT * scaleFactor); | |||||
setSize(DISTRHO_UI_DEFAULT_WIDTH * scaleFactor, DISTRHO_UI_DEFAULT_HEIGHT * scaleFactor); | |||||
} | |||||
else | |||||
{ | |||||
setGeometryConstraints(DISTRHO_UI_DEFAULT_WIDTH, DISTRHO_UI_DEFAULT_HEIGHT); | |||||
} | |||||
*/ | |||||
} | |||||
private: | |||||
/** | |||||
Set our UI class as non-copyable and add a leak detector just in case. | |||||
*/ | |||||
DISTRHO_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR(ExampleUIMeters) | |||||
}; | |||||
/* ------------------------------------------------------------------------------------------------------------ | |||||
* UI entry point, called by DPF to create a new UI instance. */ | |||||
UI* createUI() | |||||
{ | |||||
return new ExampleUIMeters(); | |||||
} | |||||
// ----------------------------------------------------------------------------------------------------------- | |||||
END_NAMESPACE_DISTRHO |
@@ -0,0 +1,68 @@ | |||||
#!/usr/bin/make -f | |||||
# Makefile for DISTRHO Plugins # | |||||
# ---------------------------- # | |||||
# Created by falkTX | |||||
# | |||||
# -------------------------------------------------------------- | |||||
# Project name, used for binaries | |||||
NAME = d_web | |||||
# -------------------------------------------------------------- | |||||
# Files to build | |||||
FILES_DSP = \ | |||||
ExamplePluginWebMeters.cpp | |||||
FILES_UI = \ | |||||
ExampleUIWebMeters.cpp | |||||
# -------------------------------------------------------------- | |||||
# Do some magic | |||||
# MACOS_NO_DEAD_STRIP = true | |||||
UI_TYPE = webview | |||||
include ../../Makefile.plugins.mk | |||||
# BUILD_CXX_FLAGS += $(shell pkg-config --cflags gtk+-3.0 webkit2gtk-4.0) | |||||
BUILD_CXX_FLAGS += -Wno-unused-parameter -Wno-unused-result | |||||
BUILD_CXX_FLAGS += -Wno-deprecated-declarations | |||||
# LINK_FLAGS += $(shell pkg-config --libs gtk+-3.0 webkit2gtk-4.0) | |||||
# -------------------------------------------------------------- | |||||
# Enable all possible plugin types | |||||
ifeq ($(HAVE_OPENGL),true) | |||||
TARGETS += jack | |||||
ifneq ($(MACOS_OR_WINDOWS),true) | |||||
ifeq ($(HAVE_LIBLO),true) | |||||
TARGETS += dssi | |||||
endif # HAVE_LIBLO | |||||
endif # MACOS_OR_WINDOWS | |||||
TARGETS += lv2_sep | |||||
TARGETS += vst2 | |||||
TARGETS += vst3 | |||||
TARGETS += clap | |||||
TARGETS += au | |||||
endif # HAVE_OPENGL | |||||
ifeq ($(MACOS_APP_BUNDLE),true) | |||||
jackfiles += $(TARGET_DIR)/$(NAME).app/Contents/Resources/index.html | |||||
else | |||||
jackfiles += $(TARGET_DIR)/resources/index.html | |||||
endif | |||||
vst3files += $(TARGET_DIR)/$(NAME).vst3/Contents/Resources/index.html | |||||
all: $(TARGETS) $(jackfiles) $(vst3files) | |||||
%/index.html: index.html | |||||
-$(SILENT)$(shell mkdir -p "$(shell dirname $(abspath $@))") | |||||
install -m 644 $< $(abspath $@) | |||||
# -------------------------------------------------------------- |
@@ -0,0 +1,8 @@ | |||||
# Meters example | |||||
This example will show how parameter outputs can be used for UI meters in DPF.<br/> | |||||
The plugin will inspect the host audio buffer but it won't change it in any way.<br/> | |||||
In this example the UI will display a simple meter based on the plugin's parameter outputs.<br/> | |||||
In order to make drawing easier the UI uses NanoVG instead of raw OpenGL.<br/> | |||||
Please see the Parameters and States examples before studying this one.<br/> |
@@ -0,0 +1,150 @@ | |||||
<!DOCTYPE html> | |||||
<html lang=""> | |||||
<head> | |||||
<meta charset="utf-8"> | |||||
<title></title> | |||||
<script> | |||||
const METER_COLOR_GREEN = 0; | |||||
const METER_COLOR_BLUE = 1; | |||||
const kSmoothMultiplier = 3.0; | |||||
let fColorValue = null; | |||||
let fColor = 'rgb(93, 231, 61)'; | |||||
let fOutLeft = 0.0; | |||||
let fOutRight = 0.0; | |||||
setTimeout(function() { | |||||
document.getElementById('user-agent').textContent = window.navigator.userAgent; | |||||
document.getElementById('left-meter').onclick = function() { | |||||
console.log("left meter clicked"); | |||||
fColorValue = fColorValue == 1 ? 0 : 1; | |||||
updateColor(fColorValue, true); | |||||
setParameterValue(0, fColorValue); | |||||
repaint(); | |||||
} | |||||
document.getElementById('right-meter').onclick = function() { | |||||
console.log("right meter clicked"); | |||||
fColorValue = fColorValue == 1 ? 0 : 1; | |||||
updateColor(fColorValue, true); | |||||
setParameterValue(0, fColorValue); | |||||
repaint(); | |||||
} | |||||
}, 1) | |||||
function repaint() { | |||||
const lmeter = document.getElementById('left-meter-x'); | |||||
const rmeter = document.getElementById('right-meter-x'); | |||||
lmeter.setAttribute('style', 'background:' + fColor + ';top:' + (100 * (1.0 - fOutLeft)) + '%;height:' + (100 * fOutLeft) + '%'); | |||||
rmeter.setAttribute('style', 'background:' + fColor + ';top:' + (100 * (1.0 - fOutRight)) + '%;height:' + (100 * fOutRight) + '%'); | |||||
setTimeout(function() { | |||||
setState('reset', ''); | |||||
}, 1) | |||||
} | |||||
function updateColor(color, forced) { | |||||
if (fColorValue === color && !forced) | |||||
return; | |||||
fColorValue = color; | |||||
switch (color) { | |||||
case METER_COLOR_GREEN: | |||||
fColor = "rgb(93, 231, 61)"; | |||||
break; | |||||
case METER_COLOR_BLUE: | |||||
fColor = "rgb(82, 238, 248)"; | |||||
break; | |||||
} | |||||
repaint(); | |||||
} | |||||
function parameterChanged(index, value) { | |||||
// console.log("paramChanged", index, value) | |||||
switch (index) { | |||||
case 0: // color | |||||
updateColor(parseInt(Math.round(value))); | |||||
break; | |||||
case 1: // out-left | |||||
value = (fOutLeft * kSmoothMultiplier + value) / (kSmoothMultiplier + 1.0); | |||||
/**/ if (value < 0.001) value = 0.0; | |||||
else if (value > 0.999) value = 1.0; | |||||
if (fOutLeft != value) | |||||
{ | |||||
fOutLeft = value; | |||||
repaint(); | |||||
} | |||||
break; | |||||
case 2: // out-right | |||||
value = (fOutRight * kSmoothMultiplier + value) / (kSmoothMultiplier + 1.0); | |||||
/**/ if (value < 0.001) value = 0.0; | |||||
else if (value > 0.999) value = 1.0; | |||||
if (fOutRight != value) | |||||
{ | |||||
fOutRight = value; | |||||
repaint(); | |||||
} | |||||
break; | |||||
} | |||||
} | |||||
</script> | |||||
<style> | |||||
html, body { | |||||
background: grey; | |||||
color: white; | |||||
margin: 0; | |||||
padding: 0; | |||||
} | |||||
body { | |||||
display: flex; | |||||
flex-direction: column; | |||||
} | |||||
p { | |||||
margin: 6px; | |||||
font-size: 15px; | |||||
overflow: hidden; | |||||
text-overflow: ellipsis; | |||||
width: calc(100% - 12px); | |||||
height: 15px; | |||||
white-space: nowrap; | |||||
} | |||||
#meters { | |||||
display: flex; | |||||
flex-direction: row; | |||||
} | |||||
.meter { | |||||
background: black; | |||||
margin: 6px; | |||||
margin-top: 0px; | |||||
width: calc(50vw - 9px); | |||||
height: calc(100vh - 12px - 6px - 15px); | |||||
} | |||||
.meter:first-child { | |||||
margin-right: 3px; | |||||
} | |||||
.meter:last-child { | |||||
margin-left: 3px; | |||||
} | |||||
.meter-x { | |||||
background: rgb(93, 231, 61); | |||||
position: relative; | |||||
top: 0%; | |||||
left: 0; | |||||
width: 100%; | |||||
height: 0%; | |||||
} | |||||
</style> | |||||
</head> | |||||
<body> | |||||
<p id="user-agent"> </p> | |||||
<div id="meters"> | |||||
<div class="meter" id="left-meter"> | |||||
<div class="meter-x" id="left-meter-x"></div> | |||||
</div> | |||||
<div class="meter" id="right-meter"> | |||||
<div class="meter-x" id="right-meter-x"></div> | |||||
</div> | |||||
</div> | |||||
</body> | |||||
</html> |