2nd Pass Render
This commit is contained in:
+2
-2
@@ -21,9 +21,9 @@ cmake_minimum_required(VERSION 3.19)
|
|||||||
# invariant (reconstruct-from-components inside app_version.cpp) via a permanent synthetic
|
# invariant (reconstruct-from-components inside app_version.cpp) via a permanent synthetic
|
||||||
# "0.9.01" fixture there that must NEVER be bumped on release. It cannot see this line
|
# "0.9.01" fixture there that must NEVER be bumped on release. It cannot see this line
|
||||||
# becoming a CMake derivation — that is this comment's job.
|
# becoming a CMake derivation — that is this comment's job.
|
||||||
set(REASAMPLER_VERSION "1.6.0")
|
set(REASAMPLER_VERSION "1.7.3")
|
||||||
|
|
||||||
project(reaper_reasampler VERSION 1.6.0 LANGUAGES CXX)
|
project(reaper_reasampler VERSION 1.7.3 LANGUAGES CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 17)
|
set(CMAKE_CXX_STANDARD 17)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ add_library(reaper_reasampler MODULE
|
|||||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_audition.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_bank_ops.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_drag.cpp
|
||||||
|
${REASAMPLER_SRC_DIR}/shell/panel/footer_input.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_input.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_layout.cpp
|
||||||
${REASAMPLER_SRC_DIR}/shell/panel/panel_render.cpp
|
${REASAMPLER_SRC_DIR}/shell/panel/panel_render.cpp
|
||||||
|
|||||||
@@ -100,6 +100,10 @@ RenderSettingsChoice renderSettingsFor(SourceMode mode, double /*wetDry*/) {
|
|||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void applySecondPassRenderSettings(const bool secondPass, RenderSettingsChoice& choice) {
|
||||||
|
if (secondPass) { choice.settings |= kSecondPassRender; }
|
||||||
|
}
|
||||||
|
|
||||||
const char* renderSourceLabel(SourceMode mode) {
|
const char* renderSourceLabel(SourceMode mode) {
|
||||||
switch (mode) {
|
switch (mode) {
|
||||||
// MasterMix and TimeSelection share this label because they ARE the same
|
// MasterMix and TimeSelection share this label because they ARE the same
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ inline constexpr int kRenderMasterMix = 0; // (&(1|2))==0, no sour
|
|||||||
inline constexpr int kRenderSelItems = 32; // &32 selected media items
|
inline constexpr int kRenderSelItems = 32; // &32 selected media items
|
||||||
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
|
inline constexpr int kRenderSelItemsViaMaster = 64; // &64 selected media items via master
|
||||||
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
|
inline constexpr int kRenderSelTracksViaMaster = 128; // &128 selected tracks via master
|
||||||
|
inline constexpr int kSecondPassRender = 2048; // &2048 2nd Pass Render
|
||||||
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
inline constexpr int kRenderRazorEdits = 4096; // &4096 render razor edits
|
||||||
// kRenderPreFaderStems (&8192) is deliberately NOT used — REAPER offline render
|
// kRenderPreFaderStems (&8192) is deliberately NOT used — REAPER offline render
|
||||||
// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
|
// has no true pre-FX "dry" bit. FX scoping is done by the FX-bypass-around-render
|
||||||
@@ -113,6 +114,9 @@ struct RenderSettingsChoice {
|
|||||||
// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
|
// SelectedItems -> &32|single-file; RazorArea -> &4096|single-file.
|
||||||
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
|
RenderSettingsChoice renderSettingsFor(SourceMode mode, double wetDry);
|
||||||
|
|
||||||
|
// Apply the 2nd Pass Render setting to the RENDER_SETTINGS block
|
||||||
|
void applySecondPassRenderSettings(const bool secondPass, RenderSettingsChoice& choice);
|
||||||
|
|
||||||
// The render source a mode drives, in words. Exists for the offline backend's
|
// The render source a mode drives, in words. Exists for the offline backend's
|
||||||
// bounds refusal: the two ways a render can miss its window — a source that
|
// bounds refusal: the two ways a render can miss its window — a source that
|
||||||
// derives its own bounds (selected items, razor edits) versus a time-bounded
|
// derives its own bounds (selected items, razor edits) versus a time-bounded
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ TailMode cycleTailMode(TailMode current) {
|
|||||||
return TailMode::None; // unreachable for a valid enum; fail to the safe default
|
return TailMode::None; // unreachable for a valid enum; fail to the safe default
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool toggleSecondPass(bool current) {
|
||||||
|
return !current; // wow
|
||||||
|
}
|
||||||
|
|
||||||
double clampManualMs(double manualMs) {
|
double clampManualMs(double manualMs) {
|
||||||
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
return std::clamp(manualMs, 0.0, kMaxTailMs);
|
||||||
}
|
}
|
||||||
@@ -77,6 +81,7 @@ std::string serializeTailSetting(const TailSetting& setting) {
|
|||||||
json::Writer w(out);
|
json::Writer w(out);
|
||||||
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
|
w.keyRaw("mode", json::numToStr(modeToInt(setting.mode)));
|
||||||
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
|
w.keyRaw("manualMs", json::numToStr(setting.manualMs));
|
||||||
|
w.keyRaw("secondPass", json::boolToStr(setting.secondPass));
|
||||||
} // Writer closes the object here (see bank_model's NRVO note)
|
} // Writer closes the object here (see bank_model's NRVO note)
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -87,7 +92,8 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
|
|||||||
|
|
||||||
int modeInt = 0;
|
int modeInt = 0;
|
||||||
double ms = 0.0;
|
double ms = 0.0;
|
||||||
bool haveMode = false, haveMs = false;
|
bool secondPass = false;
|
||||||
|
bool haveMode = false, haveMs = false, haveSecondPass = false;
|
||||||
r.skipWs();
|
r.skipWs();
|
||||||
if (!r.consume('}')) {
|
if (!r.consume('}')) {
|
||||||
do {
|
do {
|
||||||
@@ -99,13 +105,16 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
|
|||||||
} else if (key == "manualMs") {
|
} else if (key == "manualMs") {
|
||||||
if (!r.parseDouble(ms)) return std::nullopt;
|
if (!r.parseDouble(ms)) return std::nullopt;
|
||||||
haveMs = true;
|
haveMs = true;
|
||||||
|
} else if (key == "secondPass") {
|
||||||
|
if (!r.parseBool(secondPass)) return std::nullopt;
|
||||||
|
haveSecondPass = true;
|
||||||
} else {
|
} else {
|
||||||
if (!r.skipValue()) return std::nullopt; // forward-compat
|
if (!r.skipValue()) return std::nullopt; // forward-compat
|
||||||
}
|
}
|
||||||
} while (r.consume(','));
|
} while (r.consume(','));
|
||||||
if (!r.consume('}')) return std::nullopt;
|
if (!r.consume('}')) return std::nullopt;
|
||||||
}
|
}
|
||||||
if (!haveMode || !haveMs) return std::nullopt; // absent key -> malformed -> default
|
if (!haveMode || !haveMs || !haveSecondPass) return std::nullopt; // absent key -> malformed -> default
|
||||||
|
|
||||||
const std::optional<TailMode> mode = modeFromInt(modeInt);
|
const std::optional<TailMode> mode = modeFromInt(modeInt);
|
||||||
if (!mode) return std::nullopt;
|
if (!mode) return std::nullopt;
|
||||||
@@ -113,6 +122,7 @@ std::optional<TailSetting> deserializeTailSetting(const std::string& blob) {
|
|||||||
TailSetting out;
|
TailSetting out;
|
||||||
out.mode = *mode;
|
out.mode = *mode;
|
||||||
out.manualMs = ms;
|
out.manualMs = ms;
|
||||||
|
out.secondPass = secondPass;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,19 @@ inline constexpr double kManualStepMs = 250.0;
|
|||||||
// The panel's current tail setting: mode + the length used only when Manual.
|
// The panel's current tail setting: mode + the length used only when Manual.
|
||||||
// Default None so a capture with no explicit choice stays exact-bounds.
|
// Default None so a capture with no explicit choice stays exact-bounds.
|
||||||
// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
|
// `manualMs` is clamped to kMaxTailMs before it ever reaches a CaptureRequest.
|
||||||
|
// 2nd-Pass Render optional, off by default
|
||||||
struct TailSetting {
|
struct TailSetting {
|
||||||
TailMode mode = TailMode::None;
|
TailMode mode = TailMode::None;
|
||||||
double manualMs = kDefaultManualTailMs;
|
double manualMs = kDefaultManualTailMs;
|
||||||
|
bool secondPass = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cycles the tail mode: None -> Auto -> Manual -> None.
|
// Cycles the tail mode: None -> Auto -> Manual -> None.
|
||||||
TailMode cycleTailMode(TailMode current);
|
TailMode cycleTailMode(TailMode current);
|
||||||
|
|
||||||
|
// Toggles the 2nd-Pass Render
|
||||||
|
bool toggleSecondPass(bool current);
|
||||||
|
|
||||||
// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
|
// The effective manual length a Manual capture uses: clamped to [0, kMaxTailMs].
|
||||||
// Exposed so the panel can show the clamped value. Meaningful only for Manual.
|
// Exposed so the panel can show the clamped value. Meaningful only for Manual.
|
||||||
double clampManualMs(double manualMs);
|
double clampManualMs(double manualMs);
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ std::string numToStr(int v) {
|
|||||||
return buf;
|
return buf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string boolToStr(bool v) {
|
||||||
|
char buf[6];
|
||||||
|
std::snprintf(buf, sizeof(buf), "%s", v ? "true" : "false");
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
void writeStringArray(std::string& out, const std::vector<std::string>& v) {
|
void writeStringArray(std::string& out, const std::vector<std::string>& v) {
|
||||||
out += '[';
|
out += '[';
|
||||||
for (std::size_t i = 0; i < v.size(); ++i) {
|
for (std::size_t i = 0; i < v.size(); ++i) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ void writeEscaped(std::string& out, const std::string& s);
|
|||||||
std::string numToStr(double v);
|
std::string numToStr(double v);
|
||||||
std::string numToStr(std::int64_t v);
|
std::string numToStr(std::int64_t v);
|
||||||
std::string numToStr(int v);
|
std::string numToStr(int v);
|
||||||
|
std::string boolToStr(bool v);
|
||||||
|
|
||||||
// Flat homogeneous arrays: ["a","b"] / [1,2]. Empty vector -> "[]".
|
// Flat homogeneous arrays: ["a","b"] / [1,2]. Empty vector -> "[]".
|
||||||
void writeStringArray(std::string& out, const std::vector<std::string>& v);
|
void writeStringArray(std::string& out, const std::vector<std::string>& v);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
#include "core/ui/footer_bar.h"
|
#include "core/ui/footer_bar.h"
|
||||||
|
|
||||||
|
#include <assert.h>
|
||||||
|
|
||||||
namespace reasampler::ui {
|
namespace reasampler::ui {
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
@@ -50,8 +52,18 @@ FooterBarLayout computeFooterBar(const FooterRect& footer, const FooterBarSpec&
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Tail button.
|
// Tail button.
|
||||||
if (fitsLeftOf(cursorX, spec.tailWidth, rightBound))
|
if (fitsLeftOf(cursorX, spec.tailWidth, rightBound)) {
|
||||||
out.tail = FooterBarRect{cursorX, top, spec.tailWidth, boxH};
|
out.tail = FooterBarRect{cursorX, top, spec.tailWidth, boxH};
|
||||||
|
cursorX += spec.tailWidth + spec.gap;
|
||||||
|
}
|
||||||
|
// 2nd Pass Render button.
|
||||||
|
if (fitsLeftOf(cursorX, spec.secondPassWidth, rightBound)) {
|
||||||
|
out.secondPass = FooterBarRect{cursorX,top, spec.secondPassWidth, boxH};
|
||||||
|
// no need to increment the cursor for final element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Package the whole footer bounds into the layout object
|
||||||
|
out.footer = FooterRect{footer};
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -61,7 +73,10 @@ FooterHit hitTestFooterBar(int px, int py, const FooterBarLayout& layout) {
|
|||||||
// count label is a passive readout — never a hit target.
|
// count label is a passive readout — never a hit target.
|
||||||
if (pointIn(px, py, layout.toggle)) return FooterHit::Toggle;
|
if (pointIn(px, py, layout.toggle)) return FooterHit::Toggle;
|
||||||
if (pointIn(px, py, layout.tail)) return FooterHit::Tail;
|
if (pointIn(px, py, layout.tail)) return FooterHit::Tail;
|
||||||
return FooterHit::None;
|
if (pointIn(px, py, layout.secondPass)) return FooterHit::SecondPass;
|
||||||
|
// Finally test if it's within the footer at all
|
||||||
|
if (pointIn(px, py, layout.footer)) return FooterHit::Invalid;
|
||||||
|
return FooterHit::Outside;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable) {
|
bool modeSegmentEnabled(bool isActiveSegment, bool transportRunning, bool routable) {
|
||||||
|
|||||||
@@ -25,18 +25,24 @@ using FooterBarRect = Rect;
|
|||||||
// left of the reserved right margin; placement is greedy left-to-right (toggle survives longest,
|
// left of the reserved right margin; placement is greedy left-to-right (toggle survives longest,
|
||||||
// Tail drops first on a very narrow footer).
|
// Tail drops first on a very narrow footer).
|
||||||
struct FooterBarLayout {
|
struct FooterBarLayout {
|
||||||
|
FooterRect footer;
|
||||||
FooterBarRect toggle;
|
FooterBarRect toggle;
|
||||||
FooterBarRect count;
|
FooterBarRect count;
|
||||||
FooterBarRect tail;
|
FooterBarRect tail;
|
||||||
|
FooterBarRect secondPass;
|
||||||
|
|
||||||
bool operator==(const FooterBarLayout& o) const {
|
bool operator==(const FooterBarLayout& o) const {
|
||||||
return toggle == o.toggle && count == o.count && tail == o.tail;
|
return footer == o.footer &&
|
||||||
|
toggle == o.toggle &&
|
||||||
|
count == o.count &&
|
||||||
|
tail == o.tail &&
|
||||||
|
secondPass == o.secondPass;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Which footer LEFT-group affordance a point landed on. Prune is hit-tested separately via
|
// Which footer LEFT-group affordance a point landed on. Prune is hit-tested separately via
|
||||||
// hitTestPruneButton.
|
// hitTestPruneButton.
|
||||||
enum class FooterHit { None, Toggle, Tail };
|
enum class FooterHit { Invalid, Toggle, Tail, SecondPass, Outside };
|
||||||
|
|
||||||
// Layout inputs, in pixels; defaults are the bank_panel footer metrics.
|
// Layout inputs, in pixels; defaults are the bank_panel footer metrics.
|
||||||
// * rightReserve — pixels reserved at the footer's right for the prune button + version
|
// * rightReserve — pixels reserved at the footer's right for the prune button + version
|
||||||
@@ -45,6 +51,7 @@ struct FooterBarSpec {
|
|||||||
int toggleWidth = 132;
|
int toggleWidth = 132;
|
||||||
int countWidth = 64;
|
int countWidth = 64;
|
||||||
int tailWidth = 132;
|
int tailWidth = 132;
|
||||||
|
int secondPassWidth = 96;
|
||||||
int gap = 6;
|
int gap = 6;
|
||||||
int leftPad = 8;
|
int leftPad = 8;
|
||||||
int verticalInset = 4;
|
int verticalInset = 4;
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
|
|
||||||
// SourceMode::Realtime is refused here — that's the realtime backend's job —
|
// SourceMode::Realtime is refused here — that's the realtime backend's job —
|
||||||
// so the offline path never silently renders the wrong thing.
|
// so the offline path never silently renders the wrong thing.
|
||||||
const RenderSettingsChoice choice =
|
RenderSettingsChoice choice =
|
||||||
renderSettingsFor(request.sourceMode, request.wetDry);
|
renderSettingsFor(request.sourceMode, request.wetDry);
|
||||||
if (!choice.supported) {
|
if (!choice.supported) {
|
||||||
result.status = CaptureStatus::UnsupportedMode;
|
result.status = CaptureStatus::UnsupportedMode;
|
||||||
@@ -487,8 +487,12 @@ CaptureResult OfflineRenderBackend::capture(const CaptureRequest& request) {
|
|||||||
static_cast<double>(tail.tailFlag), true);
|
static_cast<double>(tail.tailFlag), true);
|
||||||
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
|
GetSetProjectInfo(proj, "RENDER_TAILMS", tail.tailMs, true);
|
||||||
|
|
||||||
|
// 2nd Pass Render
|
||||||
|
applySecondPassRenderSettings(request.secondPass, choice);
|
||||||
|
|
||||||
// Source-selection bits for this mode (SDK header ~3041), all wet-only:
|
// Source-selection bits for this mode (SDK header ~3041), all wet-only:
|
||||||
// master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file.
|
// master mix = 0; tracks = &128; items = &32|single-file; razor = &4096|single-file.
|
||||||
|
// Also is the door to control the 2nd Pass Render setting.
|
||||||
GetSetProjectInfo(proj, "RENDER_SETTINGS",
|
GetSetProjectInfo(proj, "RENDER_SETTINGS",
|
||||||
static_cast<double>(choice.settings), true);
|
static_cast<double>(choice.settings), true);
|
||||||
|
|
||||||
|
|||||||
@@ -59,11 +59,13 @@ struct CaptureRequest {
|
|||||||
// bounds, no added silence — the only mode valid for null-test/verify captures.
|
// bounds, no added silence — the only mode valid for null-test/verify captures.
|
||||||
// tailMs applies only to Manual (clamped to 8s by the pure mapping); Auto uses
|
// tailMs applies only to Manual (clamped to 8s by the pure mapping); Auto uses
|
||||||
// the 8s cap + -72 dB trim internally, None ignores it.
|
// the 8s cap + -72 dB trim internally, None ignores it.
|
||||||
TailMode tailMode = TailMode::None;
|
TailMode tailMode = TailMode::None;
|
||||||
double tailMs = 0.0;
|
double tailMs = 0.0;
|
||||||
|
bool secondPass = false;
|
||||||
|
|
||||||
// 0 sampleRate => follow project rate.
|
// 0 sampleRate => follow project rate.
|
||||||
int sampleRate = 0;
|
int sampleRate = 0;
|
||||||
|
|
||||||
// What the RENDER is asked for (RENDER_CHANNELS / the realtime record mode), not
|
// What the RENDER is asked for (RENDER_CHANNELS / the realtime record mode), not
|
||||||
// what the capture lands as: a dual-mono render is collapsed to 1 channel after
|
// what the capture lands as: a dual-mono render is collapsed to 1 channel after
|
||||||
// the fact, and the Sample's count comes from the produced file.
|
// the fact, and the Sample's count comes from the produced file.
|
||||||
|
|||||||
@@ -257,6 +257,7 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
|
|||||||
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
req.wetDry = 1.0; // wet post the FX left enabled by the scope
|
||||||
req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
|
req.tailMode = tail.mode; // None / Auto / Manual, from the panel toggle
|
||||||
req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto
|
req.tailMs = tail.manualMs; // Manual-only (clamped); ignored for None/Auto
|
||||||
|
req.secondPass = tail.secondPass; // Whether 2nd-Pass Render should be performed
|
||||||
req.sampleRate = 0; // follow project rate
|
req.sampleRate = 0; // follow project rate
|
||||||
req.channelCount = 2;
|
req.channelCount = 2;
|
||||||
req.bitDepth = WavBitDepth::Float32; // deterministic, no dither
|
req.bitDepth = WavBitDepth::Float32; // deterministic, no dither
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ void RunRenderTrackInPlace(ReaSamplerSession& session) {
|
|||||||
req.wetDry = 1.0;
|
req.wetDry = 1.0;
|
||||||
req.tailMode = tail.mode;
|
req.tailMode = tail.mode;
|
||||||
req.tailMs = tail.manualMs;
|
req.tailMs = tail.manualMs;
|
||||||
|
req.secondPass = tail.secondPass;
|
||||||
req.sampleRate = 0; // follow project rate
|
req.sampleRate = 0; // follow project rate
|
||||||
req.channelCount = 2;
|
req.channelCount = 2;
|
||||||
req.bitDepth = WavBitDepth::Float32;
|
req.bitDepth = WavBitDepth::Float32;
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ void ReaSamplerEditor::paintChrome(LICE_IBitmap* bmp, const FaceLayout& fl, bool
|
|||||||
|
|
||||||
// Toolbar: product name + live readout. The beta channel gets no distinct accent; the
|
// Toolbar: product name + live readout. The beta channel gets no distinct accent; the
|
||||||
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
// channel-derived vstPluginName is the only beta-vs-stable signal.
|
||||||
std::string title = version::vstPluginName();
|
std::string title = version::vstPluginName() + " v" + version::appVersion();
|
||||||
if (processor_ && processor_->bridge().isConnected()) {
|
if (processor_ && processor_->bridge().isConnected()) {
|
||||||
// The instance's own loaded state outranks bank availability (the bank is a browser
|
// The instance's own loaded state outranks bank availability (the bank is a browser
|
||||||
// source, not the instrument's identity) — a self-contained instance names its sound
|
// source, not the instrument's identity) — a self-contained instance names its sound
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
#include "shell/panel/footer_input.h"
|
||||||
|
#include "shell/panel/panel_state.h"
|
||||||
|
#include "core/view/view_mode_model.h"
|
||||||
|
#include "shell/actions/bank_actions.h"
|
||||||
|
#include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation
|
||||||
|
|
||||||
|
#define REAPERAPI_MINIMAL
|
||||||
|
#define REAPERAPI_WANT_EnumProjects
|
||||||
|
#define REAPERAPI_WANT_MarkProjectDirty
|
||||||
|
#define REAPERAPI_WANT_Main_OnCommand
|
||||||
|
#include "reaper_plugin_functions.h"
|
||||||
|
|
||||||
|
namespace reasampler::panel {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
// Commits the current tail setting to ext state and marks the active project dirty so the
|
||||||
|
// change travels inside the .rpp on Ctrl+S — closes the gap where toggle/scroll would dirty
|
||||||
|
// the project but never write the new value. No-ops cleanly on an unsaved project.
|
||||||
|
void markTailDirty()
|
||||||
|
{
|
||||||
|
if (g_panel.session) g_panel.session->saveToActiveProject();
|
||||||
|
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
||||||
|
if (proj) MarkProjectDirty(proj);
|
||||||
|
}
|
||||||
|
|
||||||
|
// click was confirmed on the toggle button;
|
||||||
|
// detect which mode segment was hit (if any)
|
||||||
|
bool clickToggle(int x, int y, ui::FooterBarRect toggleRect)
|
||||||
|
{
|
||||||
|
const int seg = hitTestSegment(x, y, toggleRect, modeCount());
|
||||||
|
if (seg >= 0) {
|
||||||
|
const ViewModeModel& view = g_panel.session->view();
|
||||||
|
const std::vector<Mode>& modes = view.modes().all();
|
||||||
|
|
||||||
|
if (seg < static_cast<int>(modes.size())) {
|
||||||
|
const std::string& id = modes[static_cast<std::size_t>(seg)].id;
|
||||||
|
const bool isActive = id == view.activeModeId();
|
||||||
|
const int cmd = modeActivateCommandId(id);
|
||||||
|
if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked, cmd != 0)) {
|
||||||
|
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// click was confirmed on the Tail button
|
||||||
|
void clickTail()
|
||||||
|
{
|
||||||
|
TailSetting& tail = g_panel.session->tail();
|
||||||
|
tail.mode = cycleTailMode(tail.mode);
|
||||||
|
markTailDirty();
|
||||||
|
invalidatePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clickSecondPass()
|
||||||
|
{
|
||||||
|
TailSetting& tail = g_panel.session->tail();
|
||||||
|
tail.secondPass = capture::toggleSecondPass(tail.secondPass);
|
||||||
|
markTailDirty();
|
||||||
|
invalidatePanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
// click was confirmed on the prune button
|
||||||
|
void clickPrune()
|
||||||
|
{
|
||||||
|
// Fires through its registered command id (not the session directly) so the panel
|
||||||
|
// affordance and the bindable action share the one guarded dry-run/confirm/delete
|
||||||
|
// path in doBankPruneFolder.
|
||||||
|
const int cmd = bankPruneCommandId();
|
||||||
|
if (cmd != 0) Main_OnCommand(cmd, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Returns: true if the click was inside the footer bar,
|
||||||
|
// false if the click was outside the footer bar.
|
||||||
|
bool handleFooterClick(int x, int y, int w, int h)
|
||||||
|
{
|
||||||
|
|
||||||
|
const ButtonRect pb = pruneButtonRectFor(w, h);
|
||||||
|
if (hitTestPruneButton(x, y, pb)) {
|
||||||
|
clickPrune();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FooterBarLayout fb = footerBarLayoutFor(w, h);
|
||||||
|
if (g_panel.session) {
|
||||||
|
FooterHit clicked = hitTestFooterBar(x, y, fb);
|
||||||
|
|
||||||
|
// The short-circuit cases
|
||||||
|
if (clicked == FooterHit::Outside) { return false; }
|
||||||
|
if (clicked == FooterHit::Invalid) { return true; }
|
||||||
|
|
||||||
|
// Check actual clickable elements:
|
||||||
|
|
||||||
|
// Cycles None -> Auto -> Manual -> None
|
||||||
|
if (clicked == FooterHit::Tail) { clickTail(); }
|
||||||
|
|
||||||
|
// Detect which segment was selected and activate that mode
|
||||||
|
if (clicked == FooterHit::Toggle) { clickToggle(x, y, fb.toggle); }
|
||||||
|
|
||||||
|
// Toggle 2nd Pass Render
|
||||||
|
if (clicked == FooterHit::SecondPass) { clickSecondPass(); }
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we reach this point, no valid click target inside the footer was hit.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool handleFooterWheel(int x, int y, int delta) {
|
||||||
|
if (!g_panel.session) return false;
|
||||||
|
|
||||||
|
TailSetting& tail = g_panel.session->tail();
|
||||||
|
if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only
|
||||||
|
|
||||||
|
// One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad
|
||||||
|
// that sends fractional deltas still steps predictably. Sign carries direction.
|
||||||
|
const int notches = delta / 120;
|
||||||
|
if (notches == 0) return false; // sub-notch movement — nothing to apply yet
|
||||||
|
|
||||||
|
const double before = tail.manualMs;
|
||||||
|
tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs);
|
||||||
|
if (tail.manualMs == before) return true; // already at a bound — consumed, no change
|
||||||
|
|
||||||
|
markTailDirty();
|
||||||
|
invalidatePanel(); // label shows the new length live
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace reasampler::panel {
|
||||||
|
|
||||||
|
bool handleFooterClick(int x, int y, int w, int h);
|
||||||
|
|
||||||
|
bool handleFooterWheel(int x, int y, int delta);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -274,7 +274,9 @@ Hover resolveHover(int x, int y) {
|
|||||||
const int seg = footerToggleSegmentHit(x, y, w, h);
|
const int seg = footerToggleSegmentHit(x, y, w, h);
|
||||||
if (seg >= 0) return Hover{HoverKind::ModeSegment, seg};
|
if (seg >= 0) return Hover{HoverKind::ModeSegment, seg};
|
||||||
const FooterBarLayout fb = footerBarLayoutFor(w, h);
|
const FooterBarLayout fb = footerBarLayoutFor(w, h);
|
||||||
if (hitTestFooterBar(x, y, fb) == FooterHit::Tail) return Hover{HoverKind::TailButton, -1};
|
const FooterHit footerHit = hitTestFooterBar(x, y, fb);
|
||||||
|
if (footerHit == FooterHit::Tail) return Hover{HoverKind::TailButton, -1};
|
||||||
|
if (footerHit == FooterHit::SecondPass) return Hover{HoverKind::SecondPassButton, -1};
|
||||||
const ButtonRect pb = pruneButtonRectFor(w, h);
|
const ButtonRect pb = pruneButtonRectFor(w, h);
|
||||||
if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1};
|
if (hitTestPruneButton(x, y, pb)) return Hover{HoverKind::PruneButton, -1};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
#include "shell/panel/panel_state.h"
|
#include "shell/panel/panel_state.h"
|
||||||
#include "shell/panel/panel_input.h"
|
#include "shell/panel/panel_input.h"
|
||||||
|
#include "shell/panel/footer_input.h"
|
||||||
|
|
||||||
#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch
|
#include "shell/actions/bank_actions.h" // bankPruneCommandId — the footer Prune dispatch
|
||||||
#include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation
|
#include "shell/persist/session.h" // ReaSamplerSession — view/tail reads + mutation
|
||||||
@@ -61,15 +62,6 @@ int modeActivateCommandId(const std::string& modeId) {
|
|||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
// Commits the current tail setting to ext state and marks the active project dirty so the
|
|
||||||
// change travels inside the .rpp on Ctrl+S — closes the gap where toggle/scroll would dirty
|
|
||||||
// the project but never write the new value. No-ops cleanly on an unsaved project.
|
|
||||||
void markTailDirty() {
|
|
||||||
if (g_panel.session) g_panel.session->saveToActiveProject();
|
|
||||||
ReaProject* proj = EnumProjects(-1, nullptr, 0);
|
|
||||||
if (proj) MarkProjectDirty(proj);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Routes a click in a toolbar to the hit button's action via Main_OnCommand. Returns true
|
// Routes a click in a toolbar to the hit button's action via Main_OnCommand. Returns true
|
||||||
// iff the click was inside the bar band, so the caller stops before grid handling.
|
// iff the click was inside the bar band, so the caller stops before grid handling.
|
||||||
bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
|
bool handleToolbarClick(int x, int y, const ActionBarRect& bar,
|
||||||
@@ -332,46 +324,9 @@ void handleClick(int x, int y) {
|
|||||||
// the More button) so a click there is inert chrome, never a fall-through to the grid.
|
// the More button) so a click there is inert chrome, never a fall-through to the grid.
|
||||||
if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return;
|
if (y >= 0 && y < kTopToolbarHeight && x >= 0 && x < w) return;
|
||||||
|
|
||||||
// Footer: mode toggle (left) -> Tail button -> Prune (right). Checked before the bottom
|
// Footer: mode toggle (left) -> Tail button -> 2nd Pass Render -> Prune (right). Checked before the bottom
|
||||||
// toolbar / grid so a footer click never selects a cell.
|
// toolbar / grid so a footer click never selects a cell.
|
||||||
{
|
if (handleFooterClick(x, y, w, h)) return;
|
||||||
const int seg = footerToggleSegmentHit(x, y, w, h);
|
|
||||||
if (seg >= 0) {
|
|
||||||
const ViewModeModel& view = g_panel.session->view();
|
|
||||||
const std::vector<Mode>& modes = view.modes().all();
|
|
||||||
if (seg < static_cast<int>(modes.size())) {
|
|
||||||
const std::string& id = modes[static_cast<std::size_t>(seg)].id;
|
|
||||||
const bool isActive = id == view.activeModeId();
|
|
||||||
const int cmd = modeActivateCommandId(id);
|
|
||||||
// Disabled/dead rationale: core/ui/footer_bar.h. Claimed but inert, the
|
|
||||||
// same shape a disabled toolbar row takes — never falls through to the grid.
|
|
||||||
if (modeSegmentEnabled(isActive, g_panel.modeSwitchBlocked, cmd != 0)) {
|
|
||||||
if (cmd != 0 && Main_OnCommand) Main_OnCommand(cmd, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const FooterBarLayout fb = footerBarLayoutFor(w, h);
|
|
||||||
if (g_panel.session && hitTestFooterBar(x, y, fb) == FooterHit::Tail) {
|
|
||||||
// Cycles None -> Auto -> Manual -> None; touches NOTHING in the bank/arrange.
|
|
||||||
TailSetting& tail = g_panel.session->tail();
|
|
||||||
tail.mode = cycleTailMode(tail.mode);
|
|
||||||
markTailDirty();
|
|
||||||
invalidatePanel();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fires through its registered command id (not the session directly) so the panel
|
|
||||||
// affordance and the bindable action share the one guarded dry-run/confirm/delete
|
|
||||||
// path in doBankPruneFolder.
|
|
||||||
const ButtonRect pb = pruneButtonRectFor(w, h);
|
|
||||||
if (hitTestPruneButton(x, y, pb)) {
|
|
||||||
const int cmd = bankPruneCommandId();
|
|
||||||
if (cmd != 0) Main_OnCommand(cmd, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return;
|
if (handleToolbarClick(x, y, bottomToolbarRect(w, h), bottomBarRows())) return;
|
||||||
|
|
||||||
@@ -456,24 +411,10 @@ void handleClick(int x, int y) {
|
|||||||
// [0, kMaxTailMs]. Otherwise does nothing (returns false so the caller can let REAPER/the
|
// [0, kMaxTailMs]. Otherwise does nothing (returns false so the caller can let REAPER/the
|
||||||
// docker handle the wheel normally). Returns true iff the wheel was consumed.
|
// docker handle the wheel normally). Returns true iff the wheel was consumed.
|
||||||
bool handleWheel(int x, int y, int delta) {
|
bool handleWheel(int x, int y, int delta) {
|
||||||
if (!g_panel.session) return false;
|
if (pointInFooter(x, y)) return handleFooterWheel(x, y, delta);
|
||||||
if (!pointInFooter(x, y)) return false;
|
|
||||||
|
|
||||||
TailSetting& tail = g_panel.session->tail();
|
// Unhandled
|
||||||
if (tail.mode != TailMode::Manual) return false; // fine-adjust is Manual-only
|
return false;
|
||||||
|
|
||||||
// One notch is WHEEL_DELTA (120); accumulate whole notches so a high-res trackpad
|
|
||||||
// that sends fractional deltas still steps predictably. Sign carries direction.
|
|
||||||
const int notches = delta / 120;
|
|
||||||
if (notches == 0) return false; // sub-notch movement — nothing to apply yet
|
|
||||||
|
|
||||||
const double before = tail.manualMs;
|
|
||||||
tail.manualMs = adjustManualMs(tail.manualMs, notches, kManualStepMs);
|
|
||||||
if (tail.manualMs == before) return true; // already at a bound — consumed, no change
|
|
||||||
|
|
||||||
markTailDirty();
|
|
||||||
invalidatePanel(); // label shows the new length live
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
|
|||||||
@@ -165,6 +165,19 @@ void drawFooter(LICE_IBitmap* bmp, int w, int h) {
|
|||||||
drawButton(bmp, box, label.c_str(), state, /*warn=*/false);
|
drawButton(bmp, box, label.c_str(), state, /*warn=*/false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2nd Pass Render BUTTON
|
||||||
|
if (!fb.secondPass.empty()) {
|
||||||
|
const TailSetting tail = currentTail();
|
||||||
|
const InteractionState state = tail.secondPass
|
||||||
|
? InteractionState::Active
|
||||||
|
: hoverState(g_panel.hovered, HoverKind::SecondPassButton, -1);
|
||||||
|
const std::string label = "2nd Pass";
|
||||||
|
const KitButtonBox box{
|
||||||
|
KitBox{fb.secondPass.x, fb.secondPass.y, fb.secondPass.width, fb.secondPass.height}
|
||||||
|
};
|
||||||
|
drawButton(bmp, box, label.c_str(), state, /*warn=*/false);
|
||||||
|
}
|
||||||
|
|
||||||
// Version/channel readout. appVersion() renders the configured version string on
|
// Version/channel readout. appVersion() renders the configured version string on
|
||||||
// stable and that string plus "-beta" on beta, so a beta panel self-identifies.
|
// stable and that string plus "-beta" on beta, so a beta panel self-identifies.
|
||||||
kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top},
|
kitText(bmp, KitBox{f.left, f.top, (f.right - f.left) - 8, f.bottom - f.top},
|
||||||
|
|||||||
@@ -272,6 +272,7 @@ enum class HoverKind {
|
|||||||
CreateBank,
|
CreateBank,
|
||||||
Tab, // index = tab ordinal
|
Tab, // index = tab ordinal
|
||||||
TailButton,
|
TailButton,
|
||||||
|
SecondPassButton,
|
||||||
ModeSegment, // index = segment ordinal
|
ModeSegment, // index = segment ordinal
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -174,9 +174,9 @@ static void testHitToggleAndTail() {
|
|||||||
L.tail.y + L.tail.height / 2, L) == FooterHit::Tail);
|
L.tail.y + L.tail.height / 2, L) == FooterHit::Tail);
|
||||||
// The count label is a passive readout — never a hit target.
|
// The count label is a passive readout — never a hit target.
|
||||||
CHECK(hitTestFooterBar(L.count.x + L.count.width / 2,
|
CHECK(hitTestFooterBar(L.count.x + L.count.width / 2,
|
||||||
L.count.y + L.count.height / 2, L) == FooterHit::None);
|
L.count.y + L.count.height / 2, L) == FooterHit::Invalid);
|
||||||
// Between the toggle and the count (the gap) is a clean miss.
|
// Between the toggle and the count (the gap) is a clean miss.
|
||||||
CHECK(hitTestFooterBar(L.toggle.x + L.toggle.width, L.toggle.y, L) == FooterHit::None);
|
CHECK(hitTestFooterBar(L.toggle.x + L.toggle.width, L.toggle.y, L) == FooterHit::Invalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Half-open bounds: far edges excluded; points outside every box miss.
|
// Half-open bounds: far edges excluded; points outside every box miss.
|
||||||
@@ -184,12 +184,12 @@ static void testHitEdgesAndOutside() {
|
|||||||
const FooterBarSpec s = roundSpec();
|
const FooterBarSpec s = roundSpec();
|
||||||
FooterRect f{0, 100, 700, 26};
|
FooterRect f{0, 100, 700, 26};
|
||||||
const FooterBarLayout L = computeFooterBar(f, s);
|
const FooterBarLayout L = computeFooterBar(f, s);
|
||||||
CHECK(hitTestFooterBar(L.toggle.x - 1, L.toggle.y, L) == FooterHit::None);
|
CHECK(hitTestFooterBar(L.toggle.x - 1, L.toggle.y, L) == FooterHit::Invalid);
|
||||||
CHECK(hitTestFooterBar(L.tail.x + L.tail.width, L.tail.y, L) == FooterHit::None); // right edge excl
|
CHECK(hitTestFooterBar(L.tail.x + L.tail.width, L.tail.y, L) == FooterHit::Invalid); // right edge excl
|
||||||
CHECK(hitTestFooterBar(L.toggle.x, L.toggle.y - 1, L) == FooterHit::None); // above
|
CHECK(hitTestFooterBar(L.toggle.x, L.toggle.y - 1, L) == FooterHit::Invalid); // above
|
||||||
CHECK(hitTestFooterBar(L.toggle.x, L.toggle.y + L.toggle.height, L) == FooterHit::None); // below excl
|
CHECK(hitTestFooterBar(L.toggle.x, L.toggle.y + L.toggle.height, L) == FooterHit::Invalid); // below excl
|
||||||
// Far right (the prune/version region) is not this module's — a clean None here.
|
// Far right (the prune/version region) is not this module's — a clean None here.
|
||||||
CHECK(hitTestFooterBar(f.x + f.width - 10, L.toggle.y, L) == FooterHit::None);
|
CHECK(hitTestFooterBar(f.x + f.width - 10, L.toggle.y, L) == FooterHit::Invalid);
|
||||||
}
|
}
|
||||||
|
|
||||||
// A suppressed box claims no point — a Tail hit-test where the Tail was dropped is None.
|
// A suppressed box claims no point — a Tail hit-test where the Tail was dropped is None.
|
||||||
|
|||||||
@@ -106,12 +106,13 @@ static void testDefaultSettingIsOff() {
|
|||||||
CHECK(s.mode == TailMode::None);
|
CHECK(s.mode == TailMode::None);
|
||||||
CHECK(s.manualMs == kDefaultManualTailMs);
|
CHECK(s.manualMs == kDefaultManualTailMs);
|
||||||
CHECK(tailToggleLabel(s) == "Tail: Off");
|
CHECK(tailToggleLabel(s) == "Tail: Off");
|
||||||
|
CHECK(s.secondPass == false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- serialize/deserialize: per-project persistence round-trip ----------------
|
// --- serialize/deserialize: per-project persistence round-trip ----------------
|
||||||
|
|
||||||
static bool settingsEqual(const TailSetting& a, const TailSetting& b) {
|
static bool settingsEqual(const TailSetting& a, const TailSetting& b) {
|
||||||
return a.mode == b.mode && a.manualMs == b.manualMs;
|
return a.mode == b.mode && a.manualMs == b.manualMs && a.secondPass == b.secondPass;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void testRoundTripNoneDefault() {
|
static void testRoundTripNoneDefault() {
|
||||||
@@ -142,7 +143,7 @@ static void testSerializeByteIdentity() {
|
|||||||
// and re-serializing a round-tripped setting must be byte-identical — the blob
|
// and re-serializing a round-tripped setting must be byte-identical — the blob
|
||||||
// lives in the .rpp, so a byte shift would dirty every saved project.
|
// lives in the .rpp, so a byte shift would dirty every saved project.
|
||||||
TailSetting s; // None + 2000.0 default
|
TailSetting s; // None + 2000.0 default
|
||||||
CHECK(serializeTailSetting(s) == "{\"mode\":0,\"manualMs\":2000}");
|
CHECK(serializeTailSetting(s) == "{\"mode\":0,\"manualMs\":2000,\"secondPass\":false}");
|
||||||
TailSetting man; man.mode = TailMode::Manual; man.manualMs = 3141.592653589793;
|
TailSetting man; man.mode = TailMode::Manual; man.manualMs = 3141.592653589793;
|
||||||
const std::string json = serializeTailSetting(man);
|
const std::string json = serializeTailSetting(man);
|
||||||
auto back = deserializeTailSetting(json);
|
auto back = deserializeTailSetting(json);
|
||||||
|
|||||||
Reference in New Issue
Block a user