mirror of
https://github.com/brunoherbelin/vimix.git
synced 2025-12-14 11:49:59 +01:00
Introducing multiple levels of Source Failure
This allows Mixer manager to deal with failed sources with the appropriate action: try to repair, leave for user to recreate, or delete.
This commit is contained in:
@@ -61,6 +61,12 @@ CloneSource::~CloneSource()
|
||||
delete filter_;
|
||||
}
|
||||
|
||||
void CloneSource::detach()
|
||||
{
|
||||
Log::Info("Source '%s' detached from '%s'.", name().c_str(), origin_->name().c_str() );
|
||||
origin_ = nullptr;
|
||||
}
|
||||
|
||||
void CloneSource::init()
|
||||
{
|
||||
if (origin_ && origin_->ready_ && origin_->mode_ > Source::UNINITIALIZED && origin_->renderbuffer_) {
|
||||
@@ -225,6 +231,11 @@ uint CloneSource::texture() const
|
||||
return Resource::getTextureBlack();
|
||||
}
|
||||
|
||||
Source::Failure CloneSource::failed() const
|
||||
{
|
||||
return (origin_ == nullptr || origin_->failed()) ? FAIL_FATAL : FAIL_NONE;
|
||||
}
|
||||
|
||||
void CloneSource::accept(Visitor& v)
|
||||
{
|
||||
Source::accept(v);
|
||||
|
||||
@@ -22,14 +22,14 @@ public:
|
||||
void replay () override;
|
||||
guint64 playtime () const override;
|
||||
uint texture() const override;
|
||||
bool failed() const override { return origin_ == nullptr || origin_->failed(); }
|
||||
Failure failed() const override;
|
||||
void accept (Visitor& v) override;
|
||||
void render() override;
|
||||
glm::ivec2 icon() const override;
|
||||
std::string info() const override;
|
||||
|
||||
// implementation of cloning mechanism
|
||||
inline void detach() { origin_ = nullptr; }
|
||||
void detach();
|
||||
inline Source *origin() const { return origin_; }
|
||||
|
||||
// Filtering
|
||||
|
||||
@@ -26,15 +26,10 @@
|
||||
#include <gst/gst.h>
|
||||
|
||||
|
||||
#include "defines.h"
|
||||
#include "Log.h"
|
||||
#include "ImageShader.h"
|
||||
#include "ImageProcessingShader.h"
|
||||
#include "Resource.h"
|
||||
#include "Decorations.h"
|
||||
#include "Stream.h"
|
||||
#include "Visitor.h"
|
||||
#include "CloneSource.h"
|
||||
|
||||
#include "DeviceSource.h"
|
||||
|
||||
@@ -525,9 +520,9 @@ void DeviceSource::accept(Visitor& v)
|
||||
v.visit(*this);
|
||||
}
|
||||
|
||||
bool DeviceSource::failed() const
|
||||
Source::Failure DeviceSource::failed() const
|
||||
{
|
||||
return unplugged_ || StreamSource::failed();
|
||||
return (unplugged_ || StreamSource::failed()) ? FAIL_CRITICAL : FAIL_NONE;
|
||||
}
|
||||
|
||||
DeviceConfigSet Device::getDeviceConfigs(const std::string &src_description)
|
||||
|
||||
@@ -15,7 +15,7 @@ public:
|
||||
~DeviceSource();
|
||||
|
||||
// Source interface
|
||||
bool failed() const override;
|
||||
Failure failed() const override;
|
||||
void accept (Visitor& v) override;
|
||||
void setActive (bool on) override;
|
||||
|
||||
|
||||
@@ -443,9 +443,9 @@ void ImGuiVisitor::visit (Source& s)
|
||||
ImGui::SetCursorPos( ImVec2(preview_width + 20, pos.y ) );
|
||||
if (s.active()) {
|
||||
if (s.blendingShader()->color.a > 0.f)
|
||||
ImGuiToolkit::Indication("Visible", ICON_FA_EYE);
|
||||
ImGuiToolkit::Indication("Visible", ICON_FA_SUN);
|
||||
else
|
||||
ImGuiToolkit::Indication("Not visible", ICON_FA_EYE_SLASH);
|
||||
ImGuiToolkit::Indication("Not visible", ICON_FA_CLOUD_SUN);
|
||||
}
|
||||
else
|
||||
ImGuiToolkit::Indication("Inactive", ICON_FA_SNOWFLAKE);
|
||||
@@ -1142,8 +1142,9 @@ void ImGuiVisitor::visit (CloneSource& s)
|
||||
ImGui::Text("%s", info.str().c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
|
||||
// link to origin source
|
||||
if ( !s.failed() ) {
|
||||
|
||||
// link to origin source
|
||||
std::string label = std::string(s.origin()->initials()) + " - " + s.origin()->name();
|
||||
if (ImGui::Button(label.c_str(), ImVec2(IMGUI_RIGHT_ALIGN, 0) )) {
|
||||
Mixer::manager().setCurrentSource(s.origin());
|
||||
@@ -1153,49 +1154,46 @@ void ImGuiVisitor::visit (CloneSource& s)
|
||||
}
|
||||
ImGui::SameLine(0, IMGUI_SAME_LINE);
|
||||
ImGui::Text("Origin");
|
||||
}
|
||||
else {
|
||||
ImGuiToolkit::ButtonDisabled("No source", ImVec2(IMGUI_RIGHT_ALIGN, 0) );
|
||||
|
||||
// filter selection
|
||||
std::ostringstream oss;
|
||||
oss << s.name();
|
||||
int type = (int) s.filter()->type();
|
||||
ImGui::SetNextItemWidth(IMGUI_RIGHT_ALIGN);
|
||||
if (ImGuiToolkit::ComboIcon("##SelectFilter", &type, FrameBufferFilter::Types)) {
|
||||
s.setFilter( FrameBufferFilter::Type(type) );
|
||||
oss << ": Filter " << std::get<2>(FrameBufferFilter::Types[type]);
|
||||
Action::manager().store(oss.str());
|
||||
info.reset();
|
||||
}
|
||||
ImGui::SameLine(0, IMGUI_SAME_LINE);
|
||||
ImGui::Text("Origin");
|
||||
}
|
||||
if (ImGuiToolkit::TextButton("Filter")) {
|
||||
s.setFilter( FrameBufferFilter::FILTER_PASSTHROUGH );
|
||||
oss << ": Filter None";
|
||||
Action::manager().store(oss.str());
|
||||
info.reset();
|
||||
}
|
||||
|
||||
// filter selection
|
||||
std::ostringstream oss;
|
||||
oss << s.name();
|
||||
int type = (int) s.filter()->type();
|
||||
ImGui::SetNextItemWidth(IMGUI_RIGHT_ALIGN);
|
||||
if (ImGuiToolkit::ComboIcon("##SelectFilter", &type, FrameBufferFilter::Types)) {
|
||||
s.setFilter( FrameBufferFilter::Type(type) );
|
||||
oss << ": Filter " << std::get<2>(FrameBufferFilter::Types[type]);
|
||||
Action::manager().store(oss.str());
|
||||
info.reset();
|
||||
}
|
||||
ImGui::SameLine(0, IMGUI_SAME_LINE);
|
||||
if (ImGuiToolkit::TextButton("Filter")) {
|
||||
s.setFilter( FrameBufferFilter::FILTER_PASSTHROUGH );
|
||||
oss << ": Filter None";
|
||||
Action::manager().store(oss.str());
|
||||
info.reset();
|
||||
}
|
||||
// filter options
|
||||
s.filter()->accept(*this);
|
||||
|
||||
// filter options
|
||||
s.filter()->accept(*this);
|
||||
ImVec2 botom = ImGui::GetCursorPos();
|
||||
|
||||
ImVec2 botom = ImGui::GetCursorPos();
|
||||
|
||||
if ( !s.failed() ) {
|
||||
// icon (>) to open player
|
||||
if ( s.playable() ) {
|
||||
ImGui::SetCursorPos(top);
|
||||
if (ImGuiToolkit::IconButton(ICON_FA_PLAY_CIRCLE, "Open in Player"))
|
||||
UserInterface::manager().showSourceEditor(&s);
|
||||
}
|
||||
}
|
||||
else
|
||||
info.reset();
|
||||
|
||||
ImGui::SetCursorPos(botom);
|
||||
ImGui::SetCursorPos(botom);
|
||||
}
|
||||
else {
|
||||
ImGuiToolkit::ButtonDisabled("No source", ImVec2(IMGUI_RIGHT_ALIGN, 0) );
|
||||
ImGui::SameLine(0, IMGUI_SAME_LINE);
|
||||
ImGui::Text("Origin");
|
||||
info.reset();
|
||||
}
|
||||
}
|
||||
|
||||
void ImGuiVisitor::visit (PatternSource& s)
|
||||
@@ -1254,25 +1252,26 @@ void ImGuiVisitor::visit (DeviceSource& s)
|
||||
ImGui::Text("%s", info.str().c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
|
||||
ImGui::SetNextItemWidth(IMGUI_RIGHT_ALIGN);
|
||||
if (ImGui::BeginCombo("Device", s.device().c_str()))
|
||||
{
|
||||
for (int d = 0; d < Device::manager().numDevices(); ++d){
|
||||
std::string namedev = Device::manager().name(d);
|
||||
if (ImGui::Selectable( namedev.c_str() )) {
|
||||
s.setDevice(namedev);
|
||||
info.reset();
|
||||
std::ostringstream oss;
|
||||
oss << s.name() << " Device " << namedev;
|
||||
Action::manager().store(oss.str());
|
||||
}
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
|
||||
ImVec2 botom = ImGui::GetCursorPos();
|
||||
|
||||
if ( !s.failed() ) {
|
||||
|
||||
ImGui::SetNextItemWidth(IMGUI_RIGHT_ALIGN);
|
||||
if (ImGui::BeginCombo("Device", s.device().c_str()))
|
||||
{
|
||||
for (int d = 0; d < Device::manager().numDevices(); ++d){
|
||||
std::string namedev = Device::manager().name(d);
|
||||
if (ImGui::Selectable( namedev.c_str() )) {
|
||||
s.setDevice(namedev);
|
||||
info.reset();
|
||||
std::ostringstream oss;
|
||||
oss << s.name() << " Device " << namedev;
|
||||
Action::manager().store(oss.str());
|
||||
}
|
||||
}
|
||||
ImGui::EndCombo();
|
||||
}
|
||||
|
||||
// icon (>) to open player
|
||||
if ( s.playable() ) {
|
||||
ImGui::SetCursorPos(top);
|
||||
@@ -1427,21 +1426,22 @@ void ImGuiVisitor::visit (GenericStreamSource& s)
|
||||
ImGui::Text("%s", info.str().c_str());
|
||||
ImGui::PopTextWrapPos();
|
||||
|
||||
// Prepare display pipeline text
|
||||
static int numlines = 0;
|
||||
const ImGuiContext& g = *GImGui;
|
||||
ImVec2 fieldsize(w, MAX(3, numlines) * g.FontSize + g.Style.ItemSpacing.y + g.Style.FramePadding.y);
|
||||
|
||||
// Editor
|
||||
std::string _description = s.description();
|
||||
if ( ImGuiToolkit::InputCodeMultiline("Pipeline", &_description, fieldsize, &numlines) ) {
|
||||
s.setDescription(_description);
|
||||
Action::manager().store( s.name() + ": Change pipeline");
|
||||
}
|
||||
|
||||
ImVec2 botom = ImGui::GetCursorPos();
|
||||
|
||||
if ( !s.failed() ) {
|
||||
|
||||
// Prepare display pipeline text
|
||||
static int numlines = 0;
|
||||
const ImGuiContext& g = *GImGui;
|
||||
ImVec2 fieldsize(w, MAX(3, numlines) * g.FontSize + g.Style.ItemSpacing.y + g.Style.FramePadding.y);
|
||||
|
||||
// Editor
|
||||
std::string _description = s.description();
|
||||
if ( ImGuiToolkit::InputCodeMultiline("Pipeline", &_description, fieldsize, &numlines) ) {
|
||||
s.setDescription(_description);
|
||||
Action::manager().store( s.name() + ": Change pipeline");
|
||||
}
|
||||
|
||||
// icon (>) to open player
|
||||
if ( s.playable() ) {
|
||||
ImGui::SetCursorPos(top);
|
||||
|
||||
@@ -78,9 +78,9 @@ std::string MediaSource::info() const
|
||||
return "Video File";
|
||||
}
|
||||
|
||||
bool MediaSource::failed() const
|
||||
Source::Failure MediaSource::failed() const
|
||||
{
|
||||
return mediaplayer_->failed();
|
||||
return mediaplayer_->failed() ? FAIL_CRITICAL : FAIL_NONE;
|
||||
}
|
||||
|
||||
uint MediaSource::texture() const
|
||||
|
||||
@@ -20,7 +20,7 @@ public:
|
||||
void replay () override;
|
||||
guint64 playtime () const override;
|
||||
void render() override;
|
||||
bool failed() const override;
|
||||
Failure failed() const override;
|
||||
uint texture() const override;
|
||||
void accept (Visitor& v) override;
|
||||
|
||||
|
||||
@@ -205,25 +205,37 @@ void Mixer::update()
|
||||
FrameGrabbing::manager().grabFrame(session_->frame());
|
||||
|
||||
// manage sources which failed update
|
||||
SourceListUnique failures = session()->failedSources();
|
||||
for(auto it = failures.begin(); it != failures.end(); ++it) {
|
||||
// if the failed source is still attached to the mixer
|
||||
if ( attached( *it ) ) {
|
||||
// special case of failed Render loopback :
|
||||
// it fails when resolution change, and we can fix it by
|
||||
// recreating it in the current session
|
||||
RenderSource *failedRender = dynamic_cast<RenderSource *>(*it);
|
||||
if (failedRender != nullptr) {
|
||||
// try to recreate the failed render source
|
||||
if ( !recreateSource(failedRender) )
|
||||
// delete the source if could not
|
||||
deleteSource(failedRender);
|
||||
if (session_->ready()) {
|
||||
// go through all failed sources
|
||||
SourceListUnique _failedsources = session_->failedSources();
|
||||
for(auto it = _failedsources.begin(); it != _failedsources.end(); ++it) {
|
||||
// only deal with sources that are still attached to mixer
|
||||
if ( attached( *it ) ) {
|
||||
// intervention depends on the severity of the failure
|
||||
Source::Failure fail = (*it)->failed();
|
||||
// Attempt to repair BAD failed sources
|
||||
// (can be automatically repaired without user intervention)
|
||||
if (fail == Source::FAIL_BAD) {
|
||||
if ( !recreateSource( *it ) ) {
|
||||
Log::Warning("Source '%s' failed and was deleted.", (*it)->name().c_str());
|
||||
// delete failed source if could not recreate it
|
||||
deleteSource( *it );
|
||||
}
|
||||
}
|
||||
// Detatch CRITICAL failed sources from the mixer
|
||||
// (not deleted in the session; user can replace it)
|
||||
else if (fail == Source::FAIL_CRITICAL) {
|
||||
detach( *it );
|
||||
}
|
||||
// Delete FATAL failed sources from the mixer
|
||||
// (nothing can be done by the user)
|
||||
else {
|
||||
Log::Warning("Source '%s' failed and was deleted.", (*it)->name().c_str());
|
||||
deleteSource( *it );
|
||||
}
|
||||
// needs refresh after intervention
|
||||
++View::need_deep_update_;
|
||||
}
|
||||
// general case:
|
||||
// detatch failed sources from the mixer
|
||||
// (not deleted in the session; user can replace it)
|
||||
else
|
||||
detach( *it );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -47,12 +47,12 @@ RenderSource::~RenderSource()
|
||||
delete rendered_output_;
|
||||
}
|
||||
|
||||
bool RenderSource::failed() const
|
||||
Source::Failure RenderSource::failed() const
|
||||
{
|
||||
if ( rendered_output_ != nullptr && session_ != nullptr )
|
||||
return rendered_output_->resolution() != session_->frame()->resolution();
|
||||
return rendered_output_->resolution() != session_->frame()->resolution() ? FAIL_BAD : FAIL_NONE;
|
||||
|
||||
return false;
|
||||
return FAIL_NONE;
|
||||
}
|
||||
|
||||
uint RenderSource::texture() const
|
||||
|
||||
@@ -18,7 +18,7 @@ public:
|
||||
void replay () override {}
|
||||
bool playable () const override { return true; }
|
||||
guint64 playtime () const override { return runtime_; }
|
||||
bool failed () const override;
|
||||
Failure failed () const override;
|
||||
uint texture() const override;
|
||||
void accept (Visitor& v) override;
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ SessionNote::SessionNote(const std::string &t, bool l, int s): label(std::to_str
|
||||
}
|
||||
|
||||
Session::Session(uint64_t id) : id_(id), active_(true), activation_threshold_(MIXING_MIN_THRESHOLD),
|
||||
filename_(""), thumbnail_(nullptr)
|
||||
filename_(""), thumbnail_(nullptr), ready_(false)
|
||||
{
|
||||
// create unique id
|
||||
if (id_ == 0)
|
||||
@@ -138,6 +138,14 @@ void Session::setActive (bool on)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
void Session::deleteFailedSources ()
|
||||
{
|
||||
while (!failed_.empty()) {
|
||||
deleteSource( *(failed_.begin()) );
|
||||
}
|
||||
}
|
||||
|
||||
// update all sources
|
||||
void Session::update(float dt)
|
||||
{
|
||||
@@ -234,7 +242,7 @@ void Session::update(float dt)
|
||||
}
|
||||
|
||||
// pre-render all sources
|
||||
bool ready = true;
|
||||
ready_ = true;
|
||||
for( SourceList::iterator it = sources_.begin(); it != sources_.end(); ++it){
|
||||
|
||||
// ensure the RenderSource is rendering *this* session
|
||||
@@ -244,17 +252,20 @@ void Session::update(float dt)
|
||||
|
||||
// discard failed source
|
||||
if ( (*it)->failed() ) {
|
||||
// insert source in list of failed
|
||||
// (NB: insert in set fails if source is already listed)
|
||||
failed_.insert( *it );
|
||||
// do not render
|
||||
render_.scene.ws()->detach( (*it)->group(View::RENDERING) );
|
||||
// if source is already attached
|
||||
if ((*it)->group(View::RENDERING)->refcount_ > 0) {
|
||||
// insert source in list of failed
|
||||
// (NB: insert in set fails if source is already listed)
|
||||
failed_.insert( *it );
|
||||
// detatch from rendering (do not render)
|
||||
render_.scene.ws()->detach( (*it)->group(View::RENDERING) );
|
||||
}
|
||||
}
|
||||
// render normally
|
||||
else {
|
||||
// session is not ready if one source is not ready
|
||||
if ( !(*it)->ready() )
|
||||
ready = false;
|
||||
ready_ = false;
|
||||
// update the source
|
||||
(*it)->setActive(activation_threshold_);
|
||||
(*it)->update(dt);
|
||||
@@ -307,7 +318,7 @@ void Session::update(float dt)
|
||||
render_.draw();
|
||||
|
||||
// draw the thumbnail only after all sources are ready
|
||||
if (ready)
|
||||
if (ready_)
|
||||
render_.drawThumbnail();
|
||||
}
|
||||
|
||||
@@ -589,9 +600,9 @@ bool Session::canlink (SourceList sources)
|
||||
validate(sources);
|
||||
|
||||
for (auto it = sources.begin(); it != sources.end(); ++it) {
|
||||
// this source is linked
|
||||
// if this source is linked
|
||||
if ( (*it)->mixingGroup() != nullptr ) {
|
||||
// askt its group to detach it
|
||||
// ask its group to detach it
|
||||
canlink = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +88,7 @@ public:
|
||||
uint numSources() const;
|
||||
|
||||
// update all sources and mark sources which failed
|
||||
inline bool ready () const { return ready_; }
|
||||
void update (float dt);
|
||||
uint64_t runtime() const;
|
||||
|
||||
@@ -97,6 +98,7 @@ public:
|
||||
|
||||
// return the list of sources which failed
|
||||
SourceListUnique failedSources () const { return failed_; }
|
||||
void deleteFailedSources ();
|
||||
|
||||
// get frame result of render
|
||||
inline FrameBuffer *frame () const { return render_.frame(); }
|
||||
@@ -195,6 +197,7 @@ protected:
|
||||
std::mutex access_;
|
||||
FrameBufferImage *thumbnail_;
|
||||
uint64_t start_time_;
|
||||
bool ready_;
|
||||
|
||||
struct Fading
|
||||
{
|
||||
|
||||
@@ -113,9 +113,9 @@ Session *SessionSource::detach()
|
||||
return giveaway;
|
||||
}
|
||||
|
||||
bool SessionSource::failed() const
|
||||
Source::Failure SessionSource::failed() const
|
||||
{
|
||||
return failed_;
|
||||
return failed_ ? FAIL_CRITICAL : FAIL_NONE;
|
||||
}
|
||||
|
||||
uint SessionSource::texture() const
|
||||
@@ -149,15 +149,9 @@ void SessionSource::update(float dt)
|
||||
timer_ += guint64(dt * 1000.f) * GST_USECOND;
|
||||
}
|
||||
|
||||
// delete source which failed
|
||||
if ( !session_->failedSources().empty() ) {
|
||||
Source *failure = *(session_->failedSources().cbegin());
|
||||
Log::Info("Source '%s' deleted from Child Session %s.", failure->name().c_str(), std::to_string(session_->id()).c_str());
|
||||
session_->deleteSource( failure );
|
||||
// fail session if all sources failed
|
||||
if ( session_->size() < 1)
|
||||
failed_ = true;
|
||||
}
|
||||
// fail session if all its sources failed
|
||||
if ( session_->failedSources().size() == session_->size() )
|
||||
failed_ = true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ public:
|
||||
bool playable () const override;
|
||||
guint64 playtime () const override { return timer_; }
|
||||
void replay () override;
|
||||
bool failed () const override;
|
||||
Failure failed () const override;
|
||||
uint texture () const override;
|
||||
|
||||
Session *detach();
|
||||
|
||||
10
src/Source.h
10
src/Source.h
@@ -177,7 +177,13 @@ public:
|
||||
virtual guint64 playtime () const { return 0; }
|
||||
|
||||
// a Source shall informs if the source failed (i.e. shall be deleted)
|
||||
virtual bool failed () const = 0;
|
||||
typedef enum {
|
||||
FAIL_NONE = 0,
|
||||
FAIL_BAD= 1,
|
||||
FAIL_CRITICAL = 2,
|
||||
FAIL_FATAL = 3
|
||||
} Failure;
|
||||
virtual Failure failed () const = 0;
|
||||
|
||||
// a Source shall define a way to get a texture
|
||||
virtual uint texture () const = 0;
|
||||
@@ -251,7 +257,7 @@ public:
|
||||
}
|
||||
|
||||
static bool isInitialized (const Source* elem) {
|
||||
return (elem && elem->mode_ > Source::UNINITIALIZED);
|
||||
return (elem && ( elem->mode_ > Source::UNINITIALIZED || elem->failed() ) );
|
||||
}
|
||||
|
||||
// class-dependent icon
|
||||
|
||||
@@ -46,7 +46,7 @@ SourceList playable_only (const SourceList &list)
|
||||
}
|
||||
|
||||
|
||||
bool isfailed (const Source *s) { return s->failed(); }
|
||||
bool isfailed (const Source *s) { return s->failed() != Source::FAIL_NONE; }
|
||||
|
||||
SourceList valid_only (const SourceList &list)
|
||||
{
|
||||
|
||||
@@ -93,9 +93,9 @@ StreamSource::~StreamSource()
|
||||
delete stream_;
|
||||
}
|
||||
|
||||
bool StreamSource::failed() const
|
||||
Source::Failure StreamSource::failed() const
|
||||
{
|
||||
return (stream_ != nullptr && stream_->failed() );
|
||||
return (stream_ != nullptr && stream_->failed()) ? FAIL_CRITICAL : FAIL_NONE;
|
||||
}
|
||||
|
||||
uint StreamSource::texture() const
|
||||
|
||||
@@ -35,7 +35,7 @@ public:
|
||||
bool playable () const override;
|
||||
void replay () override;
|
||||
guint64 playtime () const override;
|
||||
bool failed() const override;
|
||||
Failure failed() const override;
|
||||
uint texture() const override;
|
||||
|
||||
// pure virtual interface
|
||||
|
||||
Reference in New Issue
Block a user