Notify web UI of assignments, and listeners of changes from UI
This commit is contained in:
parent
105e00a1ef
commit
97efa774aa
@ -152,6 +152,16 @@ struct Plugin : public clap_plugin {
|
||||
this->get_extension = plugin_get_extension;
|
||||
this->on_main_thread = plugin_on_main_thread;
|
||||
|
||||
effect.paramListenerRange = [&](stfx::web::ParamContext context, double v){
|
||||
std::cout << "param #" << context.index << " change: " << v << "\n";
|
||||
};
|
||||
effect.paramListenerGesture = [&](stfx::web::ParamContext context, bool gesture){
|
||||
std::cout << "param #" << context.index << " gesture: " << gesture << "\n";
|
||||
};
|
||||
effect.paramListenerStepped = [&](stfx::web::ParamContext context, int v){
|
||||
std::cout << "param #" << context.index << " change: " << v << "\n";
|
||||
};
|
||||
|
||||
scanParams();
|
||||
}
|
||||
|
||||
@ -308,7 +318,9 @@ struct Plugin : public clap_plugin {
|
||||
}
|
||||
inputBuffers.resize(0);
|
||||
outputBuffers.resize(0);
|
||||
|
||||
|
||||
plugin.host->request_callback(plugin.host);
|
||||
|
||||
if (plugin.effect.hasPendingWebMessage()) {
|
||||
plugin.host->request_callback(plugin.host);
|
||||
}
|
||||
@ -368,6 +380,7 @@ struct Plugin : public clap_plugin {
|
||||
}
|
||||
template<class PR>
|
||||
RangeParamInfo & range(const char *key, PR ¶m) {
|
||||
param.context.index = params.size();
|
||||
params.emplace_back();
|
||||
auto &entry = params.back();
|
||||
entry.id = crc.copy().addString(key, true).done();
|
||||
@ -376,6 +389,7 @@ struct Plugin : public clap_plugin {
|
||||
}
|
||||
template<class PS>
|
||||
SteppedParamInfo & stepped(const char *key, PS ¶m) {
|
||||
param.context.index = params.size();
|
||||
params.emplace_back();
|
||||
auto &entry = params.back();
|
||||
entry.id = crc.copy().addString(key, true).done();
|
||||
@ -616,8 +630,7 @@ struct Plugin : public clap_plugin {
|
||||
if (!fillFromStream(stream, buffer)) return false;
|
||||
StateReader storage{buffer};
|
||||
plugin.effect.state(storage);
|
||||
|
||||
plugin.effect.markStateDirty();
|
||||
|
||||
plugin.sendWebMessages(); // should already be on the main thread
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -39,6 +39,11 @@
|
||||
grid-area: header;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 0.5rem;
|
||||
|
||||
font-weight: normal;
|
||||
letter-spacing: 0.1ex;
|
||||
transform: scale(0.95, 1);
|
||||
}
|
||||
#params {
|
||||
grid-area: params;
|
||||
@ -77,6 +82,7 @@
|
||||
top: 30%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
letter-spacing: -0.1ex;
|
||||
}
|
||||
.param-range-units {
|
||||
position: absolute;
|
||||
@ -99,14 +105,24 @@
|
||||
<label class="param-range" @if="${d => d.$type == 'ParamRange'}">
|
||||
<div class="param-range-name">{name}</div>
|
||||
<script>
|
||||
function move(data, dx, dy) {
|
||||
data.rangeUnit = Math.min(1, Math.max(0, data.rangeUnit - dy/250));
|
||||
function move(data, dx, dy, element) {
|
||||
let prev = data.gesture ? data._gestureUnit : data.rangeUnit;
|
||||
data._gestureUnit = data.rangeUnit = Math.min(1, Math.max(0, prev - dy/250));
|
||||
}
|
||||
function press(data, count) {
|
||||
if (count == 2) data.value = data.defaultValue;
|
||||
data.gesture = true;
|
||||
data._gestureUnit = data.rangeUnit;
|
||||
if (count == 2) {
|
||||
console.log("data.gesture", data.gesture);
|
||||
data.value = data.defaultValue;
|
||||
data.gesture = false;
|
||||
}
|
||||
}
|
||||
function unpress(data) {
|
||||
data.gesture = false;
|
||||
}
|
||||
</script>
|
||||
<div class="param-range-value" $move="${move}" $press="${press}">
|
||||
<div class="param-range-value" $move="${move}" $press="${press}" $unpress="${unpress}">
|
||||
<canvas class="param-range-dial" $update="${drawDial}"></canvas>
|
||||
<output class="param-range-text">{text}</output><br>
|
||||
<div class="param-range-units">{textUnits}</div>
|
||||
@ -129,15 +145,23 @@
|
||||
context.translate(1, 1);
|
||||
|
||||
context.beginPath();
|
||||
context.ellipse(0, 0, 1, 1, 0, 0, Math.PI*2);
|
||||
context.ellipse(0, 0, 0.8, 0.8, 0, 0, Math.PI*2);
|
||||
context.fillStyle = '#FFF';
|
||||
context.fill();
|
||||
|
||||
context.beginPath();
|
||||
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*(-1.249 + data.rangeUnit*1.499));
|
||||
context.strokeStyle = '#000';
|
||||
|
||||
context.lineWidth = 0.2;
|
||||
context.lineCap = 'round';
|
||||
context.beginPath();
|
||||
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*0.25);
|
||||
context.strokeStyle = '#0001';
|
||||
context.stroke();
|
||||
|
||||
let rangeUnit = data.rangeUnit;
|
||||
if (data.gesture) rangeUnit = data._gestureUnit;
|
||||
|
||||
context.beginPath();
|
||||
context.ellipse(0, 0, 0.9, 0.9, 0, Math.PI*-1.25, Math.PI*(-1.249 + rangeUnit*1.499));
|
||||
context.strokeStyle = '#000';
|
||||
context.stroke();
|
||||
}
|
||||
</script>
|
||||
|
||||
400
stfx/ui/web-ui.h
400
stfx/ui/web-ui.h
@ -12,6 +12,101 @@
|
||||
|
||||
namespace stfx { namespace web {
|
||||
|
||||
struct ParamContext {
|
||||
union {
|
||||
void *pointer;
|
||||
size_t index;
|
||||
};
|
||||
|
||||
ParamContext() : index(0) {}
|
||||
};
|
||||
|
||||
struct WebStateWriter : public storage::StorageCborWriter<false, WebStateWriter> {
|
||||
using Super = storage::StorageCborWriter<false, WebStateWriter>;
|
||||
|
||||
using Super::Super;
|
||||
|
||||
void info(const char *name, const char *desc) {
|
||||
extra("name", name);
|
||||
extra("desc", desc);
|
||||
}
|
||||
|
||||
int version(int v) {
|
||||
return v; // we don't use old states, so not worth tracking
|
||||
}
|
||||
|
||||
bool extra() {
|
||||
return true;
|
||||
}
|
||||
|
||||
template<class V>
|
||||
void extra(const char *key, V v) {
|
||||
(*this)(key, v);
|
||||
}
|
||||
|
||||
template<class PR>
|
||||
RangeParamIgnore range(const char *key, PR ¶m) {
|
||||
(*this)(key, param);
|
||||
return {};
|
||||
}
|
||||
template<class PS>
|
||||
SteppedParamIgnore stepped(const char *key, PS ¶m) {
|
||||
(*this)(key, param);
|
||||
return {};
|
||||
}
|
||||
template<class V>
|
||||
bool changed(const char *key, V &v) {
|
||||
(*this)(key, v);
|
||||
return false;
|
||||
}
|
||||
void invalidate(const char *) {}
|
||||
};
|
||||
|
||||
struct WebStateReader : public storage::StorageCborReader<false, WebStateReader> {
|
||||
using Super = storage::StorageCborReader<false, WebStateReader>;
|
||||
|
||||
using Super::Super;
|
||||
|
||||
void info(const char *, const char *) {}
|
||||
|
||||
int version(int v) {return v;}
|
||||
|
||||
bool extra() {
|
||||
// We're interested in an extended set of values being sent back, even if we ignore actual "extra" things
|
||||
return true;
|
||||
}
|
||||
|
||||
template<class V>
|
||||
void extra(const char *key, V v) {}
|
||||
|
||||
template<class PR>
|
||||
RangeParamIgnore range(const char *key, PR ¶m) {
|
||||
(*this)(key, param);
|
||||
return {};
|
||||
}
|
||||
template<class PS>
|
||||
SteppedParamIgnore stepped(const char *key, PS ¶m) {
|
||||
(*this)(key, param);
|
||||
return {};
|
||||
}
|
||||
template<class V>
|
||||
bool changed(const char *key, V &v) {
|
||||
V prev = v;
|
||||
(*this)(key, v);
|
||||
return v != prev;
|
||||
}
|
||||
void invalidate(const char *invalidatedKey) {
|
||||
LOG_EXPR(invalidatedKey);
|
||||
}
|
||||
};
|
||||
|
||||
template<class S>
|
||||
constexpr bool storageIsWeb() {return false;}
|
||||
template<>
|
||||
constexpr bool storageIsWeb<WebStateWriter>() {return true;}
|
||||
template<>
|
||||
constexpr bool storageIsWeb<WebStateReader>() {return true;}
|
||||
|
||||
/* Given an STFX template which (when insantiated) has inheritance like:
|
||||
|
||||
EffectSTFX : Effect
|
||||
@ -25,74 +120,86 @@ The WebBase template replaces the ParamRange/ParamStepped classes, so that the U
|
||||
|
||||
template<template<class, class...> class EffectSTFX>
|
||||
struct WebUIHelper {
|
||||
|
||||
struct MaybeChanged {
|
||||
virtual void maybeChanged() = 0;
|
||||
};
|
||||
|
||||
struct WebParamScanner {
|
||||
MaybeChanged *effect;
|
||||
|
||||
// Do nothing for most types
|
||||
template<class V>
|
||||
void operator()(const char *key, V &v) {
|
||||
if constexpr (std::is_void_v<decltype(v.state(*this))>) {
|
||||
v.state(*this);
|
||||
}
|
||||
}
|
||||
|
||||
// Extra methods we add for STFX storage
|
||||
void info(const char *, const char *) {}
|
||||
int version(int v) {
|
||||
return v;
|
||||
}
|
||||
template<class PR>
|
||||
RangeParamInfo & range(const char *key, PR ¶m) {
|
||||
param.effect = effect;
|
||||
param.info = std::unique_ptr<RangeParamInfo>{
|
||||
new RangeParamInfo(param)
|
||||
};
|
||||
return *param.info;
|
||||
}
|
||||
template<class PS>
|
||||
SteppedParamInfo & stepped(const char *key, PS ¶m) {
|
||||
param.effect = effect;
|
||||
param.info = std::unique_ptr<SteppedParamInfo>{
|
||||
new SteppedParamInfo(param)
|
||||
};
|
||||
return *param.info;
|
||||
}
|
||||
template<class V>
|
||||
bool changed(const char *key, V &v) {
|
||||
(*this)(key, v);
|
||||
return false;
|
||||
}
|
||||
void invalidate(const char *) {}
|
||||
};
|
||||
|
||||
template<class Effect>
|
||||
template<class Effect, class SubClass>
|
||||
class WebBase : public Effect {
|
||||
using SuperRange = typename Effect::ParamRange;
|
||||
using SuperStepped = typename Effect::ParamStepped;
|
||||
public:
|
||||
|
||||
using Effect::Effect;
|
||||
|
||||
protected:
|
||||
struct WebParamScanner {
|
||||
SubClass *effect;
|
||||
std::vector<std::string> scope;
|
||||
|
||||
WebParamScanner(SubClass *effect) : effect(effect) {}
|
||||
|
||||
// Do nothing for most types
|
||||
template<class V>
|
||||
void operator()(const char *key, V &v) {
|
||||
scope.emplace_back(key);
|
||||
// TODO: detect objects with .state() somehow
|
||||
v.state(*this);
|
||||
scope.pop_back();
|
||||
}
|
||||
|
||||
// Extra methods we add for STFX storage
|
||||
void info(const char *, const char *) {}
|
||||
int version(int v) {
|
||||
return v;
|
||||
}
|
||||
template<class PR>
|
||||
RangeParamInfo & range(const char *key, PR ¶m) {
|
||||
param.effect = effect;
|
||||
param.scope = scope;
|
||||
param.scope.emplace_back(key);
|
||||
param.info = std::unique_ptr<RangeParamInfo>{
|
||||
new RangeParamInfo(param)
|
||||
};
|
||||
return *param.info;
|
||||
}
|
||||
template<class PS>
|
||||
SteppedParamInfo & stepped(const char *key, PS ¶m) {
|
||||
param.effect = effect;
|
||||
param.scope = scope;
|
||||
param.scope.emplace_back(key);
|
||||
param.info = std::unique_ptr<SteppedParamInfo>{
|
||||
new SteppedParamInfo(param)
|
||||
};
|
||||
return *param.info;
|
||||
}
|
||||
template<class V>
|
||||
bool changed(const char *key, V &v) {
|
||||
(*this)(key, v);
|
||||
return false;
|
||||
}
|
||||
void invalidate(const char *) {}
|
||||
};
|
||||
|
||||
public:
|
||||
using Effect::Effect;
|
||||
|
||||
std::string webPage = "html/generic.html";
|
||||
int webWidth = 640, webHeight = 480;
|
||||
|
||||
struct ParamRange : public SuperRange {
|
||||
using SuperRange::SuperRange;
|
||||
|
||||
ParamContext context;
|
||||
|
||||
ParamRange & operator=(double v) {
|
||||
SuperRange::operator=(v);
|
||||
effect->maybeChanged();
|
||||
sendUpdateMessage();
|
||||
return *this;
|
||||
}
|
||||
|
||||
template<class Storage>
|
||||
void state(Storage &storage) {
|
||||
double prevV = *this;
|
||||
bool prevGesture = gesture;
|
||||
SuperRange::state(storage);
|
||||
|
||||
if (storage.extra()) {
|
||||
constexpr bool isWeb = storageIsWeb<Storage>();
|
||||
if (isWeb) { // a bunch of extra state
|
||||
storage.extra("$type", "ParamRange");
|
||||
storage.extra("name", info->name);
|
||||
storage.extra("defaultValue", info->defaultValue);
|
||||
@ -100,34 +207,64 @@ struct WebUIHelper {
|
||||
info->toString((double)*this, text, units);
|
||||
storage.extra("text", text);
|
||||
storage.extra("textUnits", units);
|
||||
storage("gesture", gesture);
|
||||
|
||||
double unit = info->toUnit(*this);
|
||||
if (storage.changed("rangeUnit", unit)) {
|
||||
*this = info->fromUnit(unit);
|
||||
SuperRange::operator=(info->fromUnit(unit));
|
||||
storage.invalidate("value");
|
||||
}
|
||||
}
|
||||
|
||||
if (prevV != *this) {
|
||||
effect->maybeChanged();
|
||||
storage.invalidate("rangeUnit");
|
||||
storage.invalidate("text");
|
||||
storage.invalidate("textUnits");
|
||||
if (isWeb) {
|
||||
if (effect->paramListenerRange) {
|
||||
effect->paramListenerRange(context, *this);
|
||||
}
|
||||
effect->requestEntireState(); // TODO: shouldn't be necessary once we have `.invalidate()` working
|
||||
} else {
|
||||
sendUpdateMessage();
|
||||
}
|
||||
}
|
||||
if (gesture != prevGesture) {
|
||||
if (isWeb && effect->paramListenerGesture) {
|
||||
effect->paramListenerGesture(context, gesture);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
friend struct WebParamScanner;
|
||||
MaybeChanged *effect = nullptr;
|
||||
SubClass *effect = nullptr;
|
||||
std::vector<std::string> scope;
|
||||
std::unique_ptr<RangeParamInfo> info;
|
||||
bool gesture = false;
|
||||
|
||||
void sendUpdateMessage() {
|
||||
if (auto *m = effect->getEmptyMessage()) {
|
||||
signalsmith::cbor::CborWriter cbor{m->bytes};
|
||||
for (auto &key : scope) {
|
||||
cbor.openMap(1);
|
||||
cbor.addUtf8(key);
|
||||
}
|
||||
WebStateWriter storage{cbor};
|
||||
state(storage);
|
||||
m->markReady();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
struct ParamStepped : public SuperStepped {
|
||||
using SuperStepped::SuperStepped;
|
||||
|
||||
ParamStepped & operator=(double v) {
|
||||
ParamContext context;
|
||||
|
||||
ParamStepped & operator=(int v) {
|
||||
SuperStepped::operator=(v);
|
||||
effect->maybeChanged();
|
||||
sendUpdateMessage();
|
||||
return *this;
|
||||
}
|
||||
|
||||
@ -136,119 +273,68 @@ struct WebUIHelper {
|
||||
int prevV = *this;
|
||||
SuperStepped::state(storage);
|
||||
|
||||
if (storage.extra()) {
|
||||
storage.extra("name", info->name);
|
||||
constexpr bool isWeb = storageIsWeb<Storage>();
|
||||
if (isWeb) {
|
||||
storage.extra("$type", "ParamStepped");
|
||||
storage.extra("low", info->low);
|
||||
storage.extra("high", info->high);
|
||||
storage.extra("name", info->name);
|
||||
std::string text = info->toString((int)*this);
|
||||
storage.extra("text", text);
|
||||
}
|
||||
|
||||
if (prevV != *this) {
|
||||
effect->maybeChanged();
|
||||
storage.invalidate("text");
|
||||
if (isWeb) {
|
||||
if (effect->paramListenerStepped) {
|
||||
effect->paramListenerStepped(context, *this);
|
||||
}
|
||||
effect->requestEntireState(); // TODO: shouldn't be necessary once we have `.invalidate()` working
|
||||
} else {
|
||||
sendUpdateMessage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
friend struct WebParamScanner;
|
||||
MaybeChanged *effect = nullptr;
|
||||
SubClass *effect = nullptr;
|
||||
std::vector<std::string> scope;
|
||||
std::unique_ptr<SteppedParamInfo> info;
|
||||
|
||||
void sendUpdateMessage() {
|
||||
if (auto *m = effect->getEmptyMessage()) {
|
||||
signalsmith::cbor::CborWriter cbor{m->bytes};
|
||||
for (auto &key : scope) {
|
||||
cbor.openMap(1);
|
||||
cbor.addUtf8(key);
|
||||
}
|
||||
WebStateWriter storage{cbor};
|
||||
state(storage);
|
||||
m->markReady();
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
struct WebStateWriter : public storage::StorageCborWriter<false, WebStateWriter> {
|
||||
using Super = storage::StorageCborWriter<false, WebStateWriter>;
|
||||
|
||||
using Super::Super;
|
||||
|
||||
void info(const char *name, const char *desc) {
|
||||
extra("name", name);
|
||||
extra("desc", desc);
|
||||
}
|
||||
|
||||
int version(int v) {
|
||||
return v; // we don't use old states, so not worth tracking
|
||||
}
|
||||
|
||||
bool extra() {
|
||||
return true;
|
||||
}
|
||||
|
||||
template<class V>
|
||||
void extra(const char *key, V v) {
|
||||
(*this)(key, v);
|
||||
}
|
||||
|
||||
template<class PR>
|
||||
RangeParamIgnore range(const char *key, PR ¶m) {
|
||||
(*this)(key, param);
|
||||
return {};
|
||||
}
|
||||
template<class PS>
|
||||
SteppedParamIgnore stepped(const char *key, PS ¶m) {
|
||||
(*this)(key, param);
|
||||
return {};
|
||||
}
|
||||
template<class V>
|
||||
bool changed(const char *key, V &v) {
|
||||
(*this)(key, v);
|
||||
return false;
|
||||
}
|
||||
void invalidate(const char *) {}
|
||||
};
|
||||
|
||||
struct WebStateReader : public storage::StorageCborReader<false, WebStateReader> {
|
||||
using Super = storage::StorageCborReader<false, WebStateReader>;
|
||||
|
||||
using Super::Super;
|
||||
|
||||
void info(const char *, const char *) {}
|
||||
|
||||
int version(int v) {return v;}
|
||||
|
||||
bool extra() {
|
||||
// We're interested in an extended set of values being sent back, even if we ignore actual "extra" things
|
||||
return true;
|
||||
}
|
||||
|
||||
template<class V>
|
||||
void extra(const char *key, V v) {}
|
||||
|
||||
template<class PR>
|
||||
RangeParamIgnore range(const char *key, PR ¶m) {
|
||||
(*this)(key, param);
|
||||
return {};
|
||||
}
|
||||
template<class PS>
|
||||
SteppedParamIgnore stepped(const char *key, PS ¶m) {
|
||||
(*this)(key, param);
|
||||
return {};
|
||||
}
|
||||
template<class V>
|
||||
bool changed(const char *key, V &v) {
|
||||
V prev = v;
|
||||
(*this)(key, v);
|
||||
return v != prev;
|
||||
}
|
||||
void invalidate(const char *invalidatedKey) {
|
||||
LOG_EXPR(invalidatedKey);
|
||||
}
|
||||
};
|
||||
|
||||
template<class Effect, class... ExtraArgs>
|
||||
struct WebSTFX : public EffectSTFX<WebBase<Effect>, ExtraArgs...>, MaybeChanged {
|
||||
// Inherit constructor(s)
|
||||
using Super = EffectSTFX<WebBase<Effect>, ExtraArgs...>;
|
||||
|
||||
struct WebSTFX : public EffectSTFX<WebBase<Effect, WebSTFX<Effect, ExtraArgs...>>, ExtraArgs...> {
|
||||
/* TODO: without the ExtraArgs, it would be a bit neater:
|
||||
EffectSTFX<WebBase<Effect, WebSTFX<Effect>>>
|
||||
*/
|
||||
using Super = EffectSTFX<WebBase<Effect, WebSTFX<Effect, ExtraArgs...>>, ExtraArgs...>;
|
||||
|
||||
template<class... Args>
|
||||
WebSTFX(Args... args) : Super(args...) {
|
||||
WebParamScanner scanner{this};
|
||||
typename Super::WebParamScanner scanner{this};
|
||||
this->state(scanner);
|
||||
};
|
||||
|
||||
std::string webPage = "html/generic.html";
|
||||
int webWidth = 640, webHeight = 480;
|
||||
|
||||
// These are called when the parameter is changed from the web UI
|
||||
std::function<void(ParamContext, double)> paramListenerRange;
|
||||
std::function<void(ParamContext, bool)> paramListenerGesture;
|
||||
std::function<void(ParamContext, int)> paramListenerStepped;
|
||||
|
||||
struct WebMessage {
|
||||
std::vector<unsigned char> bytes;
|
||||
|
||||
@ -259,15 +345,18 @@ struct WebUIHelper {
|
||||
void sent() {
|
||||
readyToSend.clear();
|
||||
}
|
||||
|
||||
private:
|
||||
friend struct WebSTFX;
|
||||
friend class WebSTFX;
|
||||
friend struct Super::ParamRange;
|
||||
friend struct Super::ParamStepped;
|
||||
std::atomic_flag readyToSend = ATOMIC_FLAG_INIT;
|
||||
|
||||
|
||||
void markReady() {
|
||||
readyToSend.test_and_set();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
bool hasPendingWebMessage() const {
|
||||
auto &message = queue[readIndex];
|
||||
return message.readyToSend.test();
|
||||
@ -313,18 +402,11 @@ struct WebUIHelper {
|
||||
this->state(reader);
|
||||
}
|
||||
}
|
||||
|
||||
void maybeChanged() override {
|
||||
std::cout << "web effect maybe changed\n";
|
||||
// TODO: something better
|
||||
requestEntireState();
|
||||
}
|
||||
|
||||
void markStateDirty() {
|
||||
requestEntireState();
|
||||
}
|
||||
|
||||
private:
|
||||
friend struct Super::ParamRange;
|
||||
friend struct Super::ParamStepped;
|
||||
|
||||
std::vector<WebMessage> queue = std::vector<WebMessage>(64); // power of 2 so that overflow doesn't mess with the modulo
|
||||
size_t readIndex = 0;
|
||||
std::atomic_flag resetQueue = ATOMIC_FLAG_INIT;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user