kopia lustrzana https://github.com/jameshball/osci-render
Add new 'add effect' button and toggle between the list of effects and the effects grid
rodzic
cfd2773f52
commit
3fc9fa208b
|
@ -33,8 +33,55 @@ EffectsComponent::EffectsComponent(OscirenderAudioProcessor& p, OscirenderAudioP
|
|||
audioProcessor.broadcaster.addChangeListener(this);
|
||||
}
|
||||
|
||||
// Wire list model to notify when user wants to add
|
||||
itemData.onAddNewEffectRequested = [this]() {
|
||||
showingGrid = true;
|
||||
if (grid)
|
||||
grid->setVisible(true);
|
||||
listBox.setVisible(false);
|
||||
resized();
|
||||
repaint();
|
||||
};
|
||||
|
||||
// Start with grid visible by default
|
||||
showingGrid = true;
|
||||
grid = std::make_unique<EffectTypeGridComponent>(audioProcessor);
|
||||
grid->onEffectSelected = [this](const juce::String& effectId) {
|
||||
DBG("Effect selected from grid: " + effectId);
|
||||
// Mark the chosen effect as selected and enabled (no instance creation for now)
|
||||
{
|
||||
juce::SpinLock::ScopedLockType lock(audioProcessor.effectsLock);
|
||||
for (auto& eff : audioProcessor.toggleableEffects) {
|
||||
if (eff->getId() == effectId) {
|
||||
eff->markSelectable(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Refresh list content
|
||||
itemData.resetData();
|
||||
listBox.updateContent();
|
||||
showingGrid = false;
|
||||
listBox.setVisible(true);
|
||||
if (grid)
|
||||
grid->setVisible(false);
|
||||
resized();
|
||||
repaint();
|
||||
};
|
||||
grid->onCanceled = [this]() {
|
||||
// If canceled while default grid, just show list
|
||||
showingGrid = false;
|
||||
listBox.setVisible(true);
|
||||
if (grid)
|
||||
grid->setVisible(false);
|
||||
resized();
|
||||
repaint();
|
||||
};
|
||||
|
||||
listBox.setModel(&listBoxModel);
|
||||
addAndMakeVisible(listBox);
|
||||
addAndMakeVisible(*grid);
|
||||
listBox.setVisible(false); // grid shown first
|
||||
}
|
||||
|
||||
EffectsComponent::~EffectsComponent() {
|
||||
|
@ -52,7 +99,12 @@ void EffectsComponent::resized() {
|
|||
frequency.setBounds(area.removeFromTop(30));
|
||||
|
||||
area.removeFromTop(6);
|
||||
listBox.setBounds(area);
|
||||
if (showingGrid) {
|
||||
if (grid)
|
||||
grid->setBounds(area);
|
||||
} else {
|
||||
listBox.setBounds(area);
|
||||
}
|
||||
}
|
||||
|
||||
void EffectsComponent::changeListenerCallback(juce::ChangeBroadcaster* source) {
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
#include "PluginProcessor.h"
|
||||
#include "components/DraggableListBox.h"
|
||||
#include "components/EffectsListComponent.h"
|
||||
#include "components/EffectTypeGridComponent.h"
|
||||
|
||||
class OscirenderAudioProcessorEditor;
|
||||
class EffectsComponent : public juce::GroupComponent, public juce::ChangeListener {
|
||||
|
@ -26,6 +27,8 @@ private:
|
|||
AudioEffectListBoxItemData itemData;
|
||||
EffectsListBoxModel listBoxModel;
|
||||
DraggableListBox listBox;
|
||||
std::unique_ptr<EffectTypeGridComponent> grid;
|
||||
bool showingGrid = true; // show grid by default
|
||||
|
||||
EffectComponent frequency = EffectComponent(*audioProcessor.frequencyEffect, false);
|
||||
|
||||
|
|
|
@ -162,6 +162,9 @@ OscirenderAudioProcessor::OscirenderAudioProcessor() : CommonAudioProcessor(Buse
|
|||
|
||||
for (int i = 0; i < toggleableEffects.size(); i++) {
|
||||
auto effect = toggleableEffects[i];
|
||||
effect->markSelectable(false);
|
||||
booleanParameters.push_back(effect->selected);
|
||||
effect->selected->setValueNotifyingHost(false);
|
||||
effect->markEnableable(false);
|
||||
booleanParameters.push_back(effect->enabled);
|
||||
effect->enabled->setValueNotifyingHost(false);
|
||||
|
@ -630,7 +633,9 @@ void OscirenderAudioProcessor::processBlock(juce::AudioBuffer<float>& buffer, ju
|
|||
juce::SpinLock::ScopedLockType lock2(effectsLock);
|
||||
if (volume > EPSILON) {
|
||||
for (auto& effect : toggleableEffects) {
|
||||
if (effect->enabled->getValue()) {
|
||||
bool isEnabled = effect->enabled != nullptr && effect->enabled->getValue();
|
||||
bool isSelected = effect->selected == nullptr ? true : effect->selected->getBoolValue();
|
||||
if (isEnabled && isSelected) {
|
||||
if (effect->getId() == custom->getId()) {
|
||||
effect->setExternalInput(osci::Point{ left, right });
|
||||
}
|
||||
|
|
|
@ -6,6 +6,10 @@ EffectTypeGridComponent::EffectTypeGridComponent(OscirenderAudioProcessor& proce
|
|||
{
|
||||
setupEffectItems();
|
||||
setSize(400, 200);
|
||||
addAndMakeVisible(cancelButton);
|
||||
cancelButton.onClick = [this]() {
|
||||
if (onCanceled) onCanceled();
|
||||
};
|
||||
}
|
||||
|
||||
EffectTypeGridComponent::~EffectTypeGridComponent() = default;
|
||||
|
@ -44,6 +48,8 @@ void EffectTypeGridComponent::paint(juce::Graphics& g)
|
|||
void EffectTypeGridComponent::resized()
|
||||
{
|
||||
auto bounds = getLocalBounds();
|
||||
auto topBar = bounds.removeFromTop(30);
|
||||
cancelButton.setBounds(topBar.removeFromRight(80).reduced(4));
|
||||
|
||||
// Create FlexBox for responsive grid layout
|
||||
flexBox = juce::FlexBox();
|
||||
|
|
|
@ -14,11 +14,13 @@ public:
|
|||
|
||||
int calculateRequiredHeight(int availableWidth) const;
|
||||
std::function<void(const juce::String& effectId)> onEffectSelected;
|
||||
std::function<void()> onCanceled; // optional cancel handler
|
||||
|
||||
private:
|
||||
OscirenderAudioProcessor& audioProcessor;
|
||||
juce::OwnedArray<EffectTypeItemComponent> effectItems;
|
||||
juce::FlexBox flexBox;
|
||||
juce::TextButton cancelButton { "Cancel" };
|
||||
|
||||
static constexpr int ITEM_HEIGHT = 80;
|
||||
static constexpr int MIN_ITEM_WIDTH = 180;
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
#include "EffectTypeItemComponent.h"
|
||||
|
||||
EffectTypeItemComponent::EffectTypeItemComponent(const juce::String& name, const juce::String& icon, const juce::String& id)
|
||||
: effectName(name), effectId(id),
|
||||
hoverAnimation(std::make_unique<HoverAnimationMixin>(this))
|
||||
: effectName(name), effectId(id)
|
||||
{
|
||||
juce::String iconSvg = icon;
|
||||
if (icon.isEmpty()) {
|
||||
|
@ -26,8 +25,8 @@ void EffectTypeItemComponent::paint(juce::Graphics& g)
|
|||
{
|
||||
auto bounds = getLocalBounds().toFloat().reduced(10);
|
||||
|
||||
// Get animation progress from the hover animation mixin
|
||||
auto animationProgress = hoverAnimation->getAnimationProgress();
|
||||
// Get animation progress from inherited HoverAnimationMixin
|
||||
auto animationProgress = getAnimationProgress();
|
||||
|
||||
// Apply upward shift based on animation progress
|
||||
auto yOffset = -animationProgress * HOVER_LIFT_AMOUNT;
|
||||
|
@ -40,10 +39,11 @@ void EffectTypeItemComponent::paint(juce::Graphics& g)
|
|||
shadow.radius = 15 * animationProgress;
|
||||
shadow.offset = juce::Point<int>(0, 4);
|
||||
|
||||
juce::Path shadowPath;
|
||||
shadowPath.addRoundedRectangle(bounds.toFloat(), CORNER_RADIUS);
|
||||
shadow.drawForPath(g, shadowPath);
|
||||
|
||||
if (shadow.radius > 0) {
|
||||
juce::Path shadowPath;
|
||||
shadowPath.addRoundedRectangle(bounds.toFloat(), CORNER_RADIUS);
|
||||
shadow.drawForPath(g, shadowPath);
|
||||
}
|
||||
}
|
||||
|
||||
// Draw background with rounded corners - interpolate between normal and hover colors
|
||||
|
@ -79,32 +79,19 @@ void EffectTypeItemComponent::resized()
|
|||
iconButton->setBounds(iconArea);
|
||||
|
||||
// Get animation progress and calculate Y offset
|
||||
auto animationProgress = hoverAnimation->getAnimationProgress();
|
||||
auto animationProgress = getAnimationProgress();
|
||||
auto yOffset = -animationProgress * HOVER_LIFT_AMOUNT;
|
||||
|
||||
iconButton->setTransform(juce::AffineTransform::translation(0, yOffset));
|
||||
}
|
||||
|
||||
void EffectTypeItemComponent::mouseEnter(const juce::MouseEvent& event)
|
||||
{
|
||||
hoverAnimation->handleMouseEnter();
|
||||
}
|
||||
|
||||
void EffectTypeItemComponent::mouseExit(const juce::MouseEvent& event)
|
||||
{
|
||||
hoverAnimation->handleMouseExit();
|
||||
}
|
||||
|
||||
void EffectTypeItemComponent::mouseDown(const juce::MouseEvent& event)
|
||||
{
|
||||
if (onEffectSelected)
|
||||
// Extend base behavior to keep hover press animation
|
||||
HoverAnimationMixin::mouseDown(event);
|
||||
if (onEffectSelected) {
|
||||
onEffectSelected(effectId);
|
||||
hoverAnimation->handleMouseDown();
|
||||
}
|
||||
|
||||
void EffectTypeItemComponent::mouseUp(const juce::MouseEvent& event)
|
||||
{
|
||||
hoverAnimation->handleMouseUp(event.getPosition(), getLocalBounds());
|
||||
}
|
||||
}
|
||||
|
||||
void EffectTypeItemComponent::mouseMove(const juce::MouseEvent& event) {
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
#include "HoverAnimationMixin.h"
|
||||
#include "SvgButton.h"
|
||||
|
||||
class EffectTypeItemComponent : public juce::Component
|
||||
class EffectTypeItemComponent : public HoverAnimationMixin
|
||||
{
|
||||
public:
|
||||
EffectTypeItemComponent(const juce::String& name, const juce::String& icon, const juce::String& id);
|
||||
|
@ -12,10 +12,7 @@ public:
|
|||
|
||||
void paint(juce::Graphics& g) override;
|
||||
void resized() override;
|
||||
void mouseEnter(const juce::MouseEvent& event) override;
|
||||
void mouseExit(const juce::MouseEvent& event) override;
|
||||
void mouseDown(const juce::MouseEvent& event) override;
|
||||
void mouseUp(const juce::MouseEvent& event) override;
|
||||
void mouseMove(const juce::MouseEvent& event) override;
|
||||
|
||||
const juce::String& getEffectId() const { return effectId; }
|
||||
|
@ -27,9 +24,6 @@ private:
|
|||
juce::String effectName;
|
||||
juce::String effectId;
|
||||
|
||||
// Hover animation functionality
|
||||
std::unique_ptr<HoverAnimationMixin> hoverAnimation;
|
||||
|
||||
// Icon for the effect
|
||||
std::unique_ptr<SvgButton> iconButton;
|
||||
|
||||
|
|
|
@ -114,6 +114,10 @@ std::shared_ptr<juce::Component> EffectsListComponent::createComponent(osci::Eff
|
|||
|
||||
int EffectsListBoxModel::getRowHeight(int row) {
|
||||
auto data = (AudioEffectListBoxItemData&)modelData;
|
||||
if (row == data.getNumItems() - 1) {
|
||||
// Last row is the "Add new effect" button
|
||||
return 44; // a tidy button height
|
||||
}
|
||||
return data.getEffect(row)->parameters.size() * EffectsListComponent::ROW_HEIGHT + EffectsListComponent::PADDING;
|
||||
}
|
||||
|
||||
|
@ -124,11 +128,24 @@ bool EffectsListBoxModel::hasVariableHeightRows() const {
|
|||
juce::Component* EffectsListBoxModel::refreshComponentForRow(int rowNumber, bool isRowSelected, juce::Component *existingComponentToUpdate) {
|
||||
auto data = (AudioEffectListBoxItemData&)modelData;
|
||||
|
||||
if (juce::isPositiveAndBelow(rowNumber, data.getNumItems())) {
|
||||
if (juce::isPositiveAndBelow(rowNumber, data.getNumItems() - 1)) {
|
||||
// Regular effect component
|
||||
std::unique_ptr<EffectsListComponent> item(dynamic_cast<EffectsListComponent*>(existingComponentToUpdate));
|
||||
item = std::make_unique<EffectsListComponent>(listBox, data, rowNumber, *data.getEffect(rowNumber));
|
||||
item = std::make_unique<EffectsListComponent>(listBox, (AudioEffectListBoxItemData&)modelData, rowNumber, *data.getEffect(rowNumber));
|
||||
return item.release();
|
||||
} else if (rowNumber == data.getNumItems() - 1) {
|
||||
// Last row becomes an "Add new effect" button
|
||||
auto* btn = dynamic_cast<juce::TextButton*>(existingComponentToUpdate);
|
||||
if (btn == nullptr)
|
||||
btn = new juce::TextButton("+ Add new effect");
|
||||
|
||||
btn->setButtonText("+ Add new effect");
|
||||
auto onAdd = data.onAddNewEffectRequested; // copy to avoid dangling reference
|
||||
btn->onClick = [onAdd]() mutable {
|
||||
if (onAdd)
|
||||
onAdd();
|
||||
};
|
||||
return btn;
|
||||
}
|
||||
|
||||
return nullptr;
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
#include "EffectComponent.h"
|
||||
#include "ComponentList.h"
|
||||
#include "SwitchButton.h"
|
||||
#include "EffectTypeGridComponent.h"
|
||||
#include <random>
|
||||
|
||||
// Application-specific data container
|
||||
|
@ -14,6 +15,7 @@ struct AudioEffectListBoxItemData : public DraggableListBoxItemData
|
|||
std::vector<std::shared_ptr<osci::Effect>> data;
|
||||
OscirenderAudioProcessor& audioProcessor;
|
||||
OscirenderAudioProcessorEditor& editor;
|
||||
std::function<void()> onAddNewEffectRequested; // callback hooked by parent to open the grid
|
||||
|
||||
AudioEffectListBoxItemData(OscirenderAudioProcessor& p, OscirenderAudioProcessorEditor& editor) : audioProcessor(p), editor(editor) {
|
||||
resetData();
|
||||
|
@ -21,11 +23,31 @@ struct AudioEffectListBoxItemData : public DraggableListBoxItemData
|
|||
|
||||
void randomise() {
|
||||
juce::SpinLock::ScopedLockType lock(audioProcessor.effectsLock);
|
||||
|
||||
for (int i = 0; i < data.size(); i++) {
|
||||
auto effect = data[i];
|
||||
// Decide how many effects to select (1..5 or up to available)
|
||||
int total = (int) audioProcessor.toggleableEffects.size();
|
||||
int maxPick = juce::jmin(5, total);
|
||||
int numPick = juce::jmax(1, juce::Random::getSystemRandom().nextInt({1, maxPick + 1}));
|
||||
|
||||
// Build indices [0..total)
|
||||
std::vector<int> indices(total);
|
||||
std::iota(indices.begin(), indices.end(), 0);
|
||||
std::random_device rd;
|
||||
std::mt19937 g(rd());
|
||||
std::shuffle(indices.begin(), indices.end(), g);
|
||||
|
||||
// First, deselect and disable all
|
||||
for (auto& effect : audioProcessor.toggleableEffects) {
|
||||
effect->markSelectable(false);
|
||||
effect->markEnableable(false);
|
||||
}
|
||||
|
||||
// Pick numPick to select & enable, and randomise params
|
||||
for (int k = 0; k < numPick && k < indices.size(); ++k) {
|
||||
auto& effect = audioProcessor.toggleableEffects[indices[k]];
|
||||
effect->markSelectable(true);
|
||||
effect->markEnableable(true);
|
||||
|
||||
auto id = effect->getId().toLowerCase();
|
||||
|
||||
if (id.contains("scale") || id.contains("translate") || id.contains("trace")) {
|
||||
continue;
|
||||
}
|
||||
|
@ -35,25 +57,22 @@ struct AudioEffectListBoxItemData : public DraggableListBoxItemData
|
|||
if (parameter->lfo != nullptr) {
|
||||
parameter->lfo->setUnnormalisedValueNotifyingHost((int) osci::LfoType::Static);
|
||||
parameter->lfoRate->setUnnormalisedValueNotifyingHost(1);
|
||||
|
||||
if (juce::Random::getSystemRandom().nextFloat() > 0.8) {
|
||||
parameter->lfo->setUnnormalisedValueNotifyingHost((int)(juce::Random::getSystemRandom().nextFloat() * (int) osci::LfoType::Noise));
|
||||
parameter->lfoRate->setValueNotifyingHost(juce::Random::getSystemRandom().nextFloat() * 0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
effect->enabled->setValueNotifyingHost(juce::Random::getSystemRandom().nextFloat() > 0.7);
|
||||
}
|
||||
|
||||
// shuffle precedence
|
||||
std::random_device rd;
|
||||
std::mt19937 g(rd());
|
||||
// Refresh local data with only selected effects
|
||||
resetData();
|
||||
|
||||
// shuffle precedence of the selected subset
|
||||
std::shuffle(data.begin(), data.end(), g);
|
||||
|
||||
for (int i = 0; i < data.size(); i++) {
|
||||
data[i]->setPrecedence(i);
|
||||
}
|
||||
|
||||
audioProcessor.updateEffectPrecedence();
|
||||
}
|
||||
|
||||
|
@ -63,12 +82,16 @@ struct AudioEffectListBoxItemData : public DraggableListBoxItemData
|
|||
for (int i = 0; i < audioProcessor.toggleableEffects.size(); i++) {
|
||||
auto effect = audioProcessor.toggleableEffects[i];
|
||||
effect->setValue(effect->getValue());
|
||||
data.push_back(effect);
|
||||
// Ensure 'selected' exists and defaults to true for older projects
|
||||
effect->markSelectable(effect->selected == nullptr ? true : effect->selected->getBoolValue());
|
||||
if (effect->selected == nullptr || effect->selected->getBoolValue()) {
|
||||
data.push_back(effect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int getNumItems() override {
|
||||
return data.size();
|
||||
return data.size() + 1;
|
||||
}
|
||||
|
||||
// CURRENTLY NOT USED
|
||||
|
|
|
@ -1,17 +1,14 @@
|
|||
#include "HoverAnimationMixin.h"
|
||||
|
||||
HoverAnimationMixin::HoverAnimationMixin(juce::Component* targetComponent)
|
||||
: component(targetComponent),
|
||||
animatorUpdater(targetComponent),
|
||||
HoverAnimationMixin::HoverAnimationMixin()
|
||||
: animatorUpdater(this),
|
||||
hoverAnimator(juce::ValueAnimatorBuilder{}
|
||||
.withEasing(getEasingFunction())
|
||||
.withDurationMs(getHoverAnimationDurationMs())
|
||||
.withValueChangedCallback([this](auto value) {
|
||||
animationProgress = static_cast<float>(value);
|
||||
if (component != nullptr) {
|
||||
component->repaint();
|
||||
component->resized();
|
||||
}
|
||||
repaint();
|
||||
resized();
|
||||
})
|
||||
.build()),
|
||||
unhoverAnimator(juce::ValueAnimatorBuilder{}
|
||||
|
@ -19,10 +16,8 @@ HoverAnimationMixin::HoverAnimationMixin(juce::Component* targetComponent)
|
|||
.withDurationMs(getHoverAnimationDurationMs())
|
||||
.withValueChangedCallback([this](auto value) {
|
||||
animationProgress = 1.0f - static_cast<float>(value);
|
||||
if (component != nullptr) {
|
||||
component->repaint();
|
||||
component->resized();
|
||||
}
|
||||
repaint();
|
||||
resized();
|
||||
})
|
||||
.build())
|
||||
{
|
||||
|
@ -49,27 +44,27 @@ void HoverAnimationMixin::animateHover(bool isHovering)
|
|||
}
|
||||
}
|
||||
|
||||
void HoverAnimationMixin::handleMouseEnter()
|
||||
void HoverAnimationMixin::mouseEnter(const juce::MouseEvent&)
|
||||
{
|
||||
isHovered = true;
|
||||
animateHover(true);
|
||||
}
|
||||
|
||||
void HoverAnimationMixin::handleMouseExit()
|
||||
void HoverAnimationMixin::mouseExit(const juce::MouseEvent&)
|
||||
{
|
||||
isHovered = false;
|
||||
// Fixed logic to prevent getting stuck in hovered state
|
||||
animateHover(false);
|
||||
}
|
||||
|
||||
void HoverAnimationMixin::handleMouseDown()
|
||||
void HoverAnimationMixin::mouseDown(const juce::MouseEvent&)
|
||||
{
|
||||
animateHover(false);
|
||||
}
|
||||
|
||||
void HoverAnimationMixin::handleMouseUp(const juce::Point<int>& mousePosition, const juce::Rectangle<int>& componentBounds)
|
||||
void HoverAnimationMixin::mouseUp(const juce::MouseEvent& event)
|
||||
{
|
||||
// Only animate hover if the mouse is still within the component bounds
|
||||
if (componentBounds.contains(mousePosition))
|
||||
if (getLocalBounds().contains(event.getEventRelativeTo(this).getPosition()))
|
||||
animateHover(true);
|
||||
}
|
||||
|
|
|
@ -1,32 +1,33 @@
|
|||
#pragma once
|
||||
#include <JuceHeader.h>
|
||||
|
||||
class HoverAnimationMixin
|
||||
// Base Component providing animated hover behavior via JUCE mouse overrides.
|
||||
class HoverAnimationMixin : public juce::Component
|
||||
{
|
||||
public:
|
||||
HoverAnimationMixin(juce::Component* targetComponent);
|
||||
virtual ~HoverAnimationMixin() = default;
|
||||
HoverAnimationMixin();
|
||||
~HoverAnimationMixin() override = default;
|
||||
|
||||
// Animation control
|
||||
// Animation control (available for programmatic triggers if needed)
|
||||
void animateHover(bool isHovering);
|
||||
|
||||
// Getters
|
||||
float getAnimationProgress() const { return animationProgress; }
|
||||
bool getIsHovered() const { return isHovered; }
|
||||
|
||||
// Mouse event handlers to be called from the component
|
||||
void handleMouseEnter();
|
||||
void handleMouseExit();
|
||||
void handleMouseDown();
|
||||
void handleMouseUp(const juce::Point<int>& mousePosition, const juce::Rectangle<int>& componentBounds);
|
||||
|
||||
protected:
|
||||
// Override this to customize animation parameters
|
||||
// Customization hooks
|
||||
virtual int getHoverAnimationDurationMs() const { return 200; }
|
||||
virtual std::function<float(float)> getEasingFunction() const { return juce::Easings::createEaseOut(); }
|
||||
|
||||
public:
|
||||
// juce::Component overrides for mouse events - keep public so children can call base explicitly
|
||||
void mouseEnter(const juce::MouseEvent& event) override;
|
||||
void mouseExit(const juce::MouseEvent& event) override;
|
||||
void mouseDown(const juce::MouseEvent& event) override;
|
||||
void mouseUp(const juce::MouseEvent& event) override;
|
||||
|
||||
private:
|
||||
juce::Component* component;
|
||||
float animationProgress = 0.0f;
|
||||
bool isHovered = false;
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
Subproject commit 475f3017cda5377062611d287605b4f26d4e3551
|
||||
Subproject commit e21dd509d93aba1ee8ef31eecb54f5f9fc6f0644
|
Ładowanie…
Reference in New Issue