-
Notifications
You must be signed in to change notification settings - Fork 23
Add ALSA-based volume control and test logic #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: ev3dev-jessie
Are you sure you want to change the base?
Changes from 3 commits
bf709a3
13f2b9a
240ae1e
55d0a87
a426fe4
a745576
982690e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| /* | ||
| * brickman -- Brick Manager for LEGO MINDSTORMS EV3/ev3dev | ||
| * | ||
| * Copyright (C) 2016 Kaelin Laundry <wasabifan@outlook.com> | ||
| * | ||
| * This program is free software; you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation; either version 2 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with this program; if not, write to the Free Software | ||
| * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||
| * MA 02110-1301, USA. | ||
| */ | ||
|
|
||
| /* AlsaBackedMixerElement.vala - Implementation of IMixerElementViewModel using ALSA API */ | ||
|
|
||
| using Alsa; | ||
|
|
||
| namespace BrickManager { | ||
| public class AlsaBackedMixerElement: IMixerElementViewModel, Object { | ||
| private const SimpleChannelId primary_channel_id = SimpleChannelId.MONO; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are you planning on changing this value? I don't see a need to. So, we don't really need a constant for it.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's the equivalent of a "magic number" and is used in two places, so having it as a constant makes the code more readable and maintainable. While I don't intend to change it, I already had to change it a few times in development, so there's no reason to think I wouldn't need to change it again. |
||
|
|
||
| private unowned MixerElement alsa_element; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no need for
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There's also something to be said for being consistent. We don't use
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK, I can change that to keep it consistent. |
||
| private SimpleElementId alsa_id; | ||
|
|
||
| public AlsaBackedMixerElement(MixerElement element) { | ||
| this.alsa_element = element; | ||
| SimpleElementId.alloc(out alsa_id); | ||
| element.get_id(alsa_id); | ||
| } | ||
|
|
||
| public string name { | ||
| get { | ||
| return alsa_id.get_name(); | ||
| } | ||
| } | ||
|
|
||
| public uint index { | ||
| get { | ||
| return alsa_id.get_index(); | ||
| } | ||
| } | ||
|
|
||
| public int volume { | ||
| get { | ||
| long volume = 0; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. don't need to initialize value when used as out parameter. will do.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because it's an out parameter, if the code that I call doesn't set it, it will be left at whatever value it had before right? So I have it this way to ensure it's a known value if an error occurs and their code doesn't set a value.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. well, in this case, you should check for an error and set the value if reading
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would still like to see error checking here. |
||
| alsa_element.get_playback_volume(primary_channel_id, out volume); | ||
|
|
||
| long min_volume, max_volume; | ||
| alsa_element.get_playback_volume_range(out min_volume, out max_volume); | ||
|
|
||
| // Prevent division by zero | ||
| if(max_volume == min_volume) | ||
| return 0; | ||
|
|
||
| // Do calculations as floats so avoid odd-looking increments from truncation | ||
| return (int)Math.round((volume - min_volume) * 100 / (float)(max_volume - min_volume)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can simplify this by using
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If it works, then calling
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I have tested this now and it works very nicely.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This originally seemed like a great approach, however it isn't so good when you consider the fact that other apps can also set the range. So, if we did it this way, the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see. I'm actually working on another app that pokes the alsa mixer, so this is good to know.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. By the way, I don't think using float has any benefit here. You are still truncating with
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Another thing I just discovered is that if you call
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Unfortunately, doing it all as integer math (and multiplying before dividing) doesn't give the same result. I added the floats because both the write and the read were rounding down slightly through truncation so that the even increments on the 100 scale turned into inconsistent increments after the value was set and read. For example, going volume up then volume down repeatedly would continuously decrease the volume because of the rounding.
Interesting. I don't think there is much we can do about that, but it's good to know in case that turns into a problem later. |
||
| } | ||
| set { | ||
| long min_volume, max_volume; | ||
| alsa_element.get_playback_volume_range(out min_volume, out max_volume); | ||
|
|
||
| int constrained_volume = int.min(100, int.max(0, value)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. prefer
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👌 |
||
| float scaled_volume = constrained_volume * (max_volume - min_volume) / 100f + min_volume; | ||
| long rounded_volume = (long)Math.round(scaled_volume); | ||
|
|
||
| alsa_element.set_playback_volume_all(rounded_volume); | ||
|
|
||
| bool should_mute = rounded_volume <= min_volume; | ||
| if(is_muted != should_mute) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: space after
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will-do |
||
| set_is_muted(should_mute); | ||
| } | ||
| } | ||
|
|
||
| public bool can_mute { | ||
| get { | ||
| return alsa_element.has_playback_switch(); | ||
| } | ||
| } | ||
|
|
||
| public bool is_muted { | ||
| get { | ||
| if(!can_mute) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style (see previous comment) |
||
| return false; | ||
|
|
||
| int mute_switch = 0; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. again, no need to initialize.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See above; although in this case, it should actually be inverted. I'll switch that to a 1.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. theoretically, if there is an error, the value could be changed - the state is undefined, so better to check for error and set value if error. |
||
| alsa_element.get_playback_switch(primary_channel_id, out mute_switch); | ||
|
|
||
| return mute_switch == 0; | ||
| } | ||
| } | ||
|
|
||
| protected void set_is_muted(bool is_muted) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this not a setter of the
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can't add a setter to the property because the interface defines it as get-only. So, separate setters. This behavior is different from that of C# and friends.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the setters are public, why not just add
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Got it. I was confusing this with the |
||
| if(can_mute) | ||
| alsa_element.set_playback_switch_all(is_muted ? 0 : 1); | ||
| } | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. newline at end of file
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will-do |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,110 @@ | ||
| /* | ||
| * brickman -- Brick Manager for LEGO MINDSTORMS EV3/ev3dev | ||
| * | ||
| * Copyright (C) 2016 Kaelin Laundry <wasabifan@outlook.com> | ||
| * | ||
| * This program is free software; you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation; either version 2 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with this program; if not, write to the Free Software | ||
| * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||
| * MA 02110-1301, USA. | ||
| */ | ||
|
|
||
| /* AlsaInterface.vala - Definitions for interfacing with ALSA */ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same with this file. It should go in the |
||
|
|
||
| using Alsa; | ||
|
|
||
| namespace BrickManager { | ||
| public interface IMixerElementViewModel : Object { | ||
| public const int MIN_VOLUME = 0; | ||
| public const int MAX_VOLUME = 100; | ||
| public const int HALF_VOLUME = (MAX_VOLUME + MIN_VOLUME) / 2; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Shouldn't this be
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm taking the mean, so it sums the values then devides by the number of values. |
||
|
|
||
| public abstract string name { get; } | ||
| public abstract uint index { get; } | ||
| public abstract int volume { get; set; } | ||
| public abstract bool can_mute { get; } | ||
| public abstract bool is_muted { get; } | ||
| } | ||
|
|
||
| public class FakeMixerElement: IMixerElementViewModel, Object { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this class should go in a separate file under the |
||
| private string _name; | ||
| private uint _index; | ||
| private int _volume; | ||
| private bool _can_mute; | ||
| private bool _is_muted; | ||
|
|
||
| public string name { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why not Or if you want to be really glib-ish,
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nix the There is no need for separate setter functions (unless they throw an error that you want to catch). Also, there is no need to call
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See above note; can't have a
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Whoops, forgot to reply to this one. Adding |
||
| get { | ||
| return _name; | ||
| } | ||
| } | ||
|
|
||
| public uint index { | ||
| get { | ||
| return _index; | ||
| } | ||
| } | ||
|
|
||
| public FakeMixerElement(string name, uint index, int volume, bool can_mute) { | ||
| set_name(name); | ||
| set_index(index); | ||
| this.volume = volume; | ||
|
|
||
| set_can_mute(can_mute); | ||
| } | ||
|
|
||
| public int volume { | ||
| get { | ||
| return _volume; | ||
| } | ||
| set { | ||
| _volume = int.min(100, int.max(0, value)); | ||
|
|
||
| bool should_mute = _volume <= 0; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. volume won't be < 0 here
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is one of those "intent" things; I do that to make it clear that the intent is to check if the volume is not at an audible level. It just makes the code easier to read at a glance, and more resilient if changes are made later. |
||
| if(_is_muted != should_mute) | ||
| set_is_muted(should_mute); | ||
| } | ||
| } | ||
|
|
||
| public bool can_mute { | ||
| get { | ||
| return _can_mute; | ||
| } | ||
| } | ||
| public bool is_muted { | ||
| get { | ||
| return _is_muted; | ||
| } | ||
| } | ||
|
|
||
| public void set_name(string new_name) { | ||
| this._name = new_name; | ||
| notify_property("name"); | ||
| } | ||
|
|
||
| public void set_index(uint new_index) { | ||
| this._index = new_index; | ||
| notify_property("index"); | ||
| } | ||
|
|
||
| public void set_can_mute(bool can_mute) { | ||
| this._can_mute = can_mute; | ||
| notify_property("can_mute"); | ||
| } | ||
|
|
||
| public void set_is_muted(bool is_muted) { | ||
| this._is_muted = is_muted; | ||
| notify_property("is_muted"); | ||
| } | ||
| } | ||
| } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. newline at end of file
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👌 |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| /* | ||
| * brickman -- Brick Manager for LEGO MINDSTORMS EV3/ev3dev | ||
| * | ||
| * Copyright 2014-2015 David Lechner <david@lechnology.com> | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't write this. 😄
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Whoops, thought I got them all; looks like I completely forgot to change this header. |
||
| * | ||
| * This program is free software; you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation; either version 2 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * This program is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with this program; if not, write to the Free Software | ||
| * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, | ||
| * MA 02110-1301, USA. | ||
| */ | ||
|
|
||
| /* BatteryController.vala - Controller for monitoring battery */ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is not BatteryController |
||
|
|
||
| using Ev3devKit.Devices; | ||
| using Ev3devKit.Ui; | ||
| using Alsa; | ||
|
|
||
| namespace BrickManager { | ||
| public class AudioController : Object, IBrickManagerModule { | ||
| private const int VOLUME_STEP = 10; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. no need for
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See above |
||
|
|
||
| Mixer mixer; | ||
| MixerElementSelectorWindow mixer_select_window; | ||
| MixerElementVolumeWindow volume_window; | ||
|
|
||
| public string display_name { get { return "Sound"; } } | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It would be nice if the file name matched the display name. i.e.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will-do |
||
|
|
||
| protected void initialize_mixer() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why is this
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Because it's something that any future subclasses should be able to use. At a minimum, it illustrates intent, but in the future it may actually be used.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't see this ever being subclassed. I would consider all of the controller classes
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't feel strongly about it either way; I'll just default it to private and it can be dealt with later if it's ever subclassed. |
||
| mixer = null; | ||
| Mixer.open(out mixer); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should be checking error values here in case something goes wrong. At a minimum:
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Will-do. |
||
| mixer.attach(); | ||
| mixer.register(); | ||
| mixer.load(); | ||
| } | ||
|
|
||
| void create_main_window () { | ||
| mixer_select_window = new MixerElementSelectorWindow (); | ||
|
|
||
| mixer_select_window.mixer_elem_selected.connect ((selected_element) => { | ||
| if(volume_window == null) | ||
| create_volume_window(); | ||
|
|
||
| volume_window.current_element = selected_element; | ||
| volume_window.show_element_details = true; | ||
| volume_window.show(); | ||
| }); | ||
| } | ||
|
|
||
| void create_volume_window() { | ||
| volume_window = new MixerElementVolumeWindow(); | ||
|
|
||
| // Wire up handlers for volume window signals | ||
| volume_window.volume_up.connect(() => | ||
| volume_window.current_element.volume += VOLUME_STEP); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reference cycle. Need to use a weak reference to
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmmm... I must not understand reference cycles well enough. What makes this a reference cycle? Also, why does this not cause the program to crash/freeze?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A reference cycle leads to a memory leak. https://wiki.gnome.org/Projects/Vala/ReferenceHandling So what happens here is that in the generated C code, it takes a reference to So, you have to break this reference cycle. You could do this by disconnecting the signal, for example when the window is closed. You can also do it by using a weak reference to
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, I get it -- so it's not actively increasing the reference count in a cycle, it just holds a reference that will never let it be freed. I'll fix that. |
||
|
|
||
| volume_window.volume_down.connect(() => | ||
| volume_window.current_element.volume -= VOLUME_STEP); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reference cycle |
||
|
|
||
| volume_window.volume_min.connect(() => | ||
| volume_window.current_element.volume = IMixerElementViewModel.MIN_VOLUME); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. reference cycle |
||
| } | ||
|
|
||
| public void show_main_window () { | ||
| if (mixer_select_window == null) { | ||
| create_main_window (); | ||
| } | ||
|
|
||
| // Whenever the audio item is launched from the main menu, | ||
| // repopulate the mixer list | ||
| mixer_select_window.clear_elements(); | ||
| // Re-initializing will return updated data, including volume | ||
| initialize_mixer(); | ||
| for(MixerElement element = mixer.first_elem(); element != null; element = element.next()) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. space between |
||
| mixer_select_window.add_element(new AlsaBackedMixerElement(element)); | ||
| } | ||
|
|
||
| if(mixer_select_window.has_single_element) { | ||
| if(volume_window == null) | ||
| create_volume_window(); | ||
|
|
||
| volume_window.current_element = mixer_select_window.first_element; | ||
| volume_window.show_element_details = false; | ||
| volume_window.show(); | ||
| } | ||
| else | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. style: cuddled
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh gosh, you're a monster! 😁 I'll make that change. |
||
| mixer_select_window.show (); | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we are going to have view models now, let's start a new subdirectory for them., namely `view_model, and put this file in it.