Repository: aleho/gnome-shell-volume-mixer Branch: main Commit: 039463c4a62a Files: 66 Total size: 216.9 KB Directory structure: gitextract_5h_ew3wj/ ├── .editorconfig ├── .eslintrc.js ├── .gitattributes ├── .github/ │ └── workflows/ │ └── linting.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── Makefile ├── README.md ├── bin/ │ ├── tmux.conf │ └── tools.sh ├── crowdin.yml ├── package.json ├── pubkey.asc ├── shell-volume-mixer@derhofbauer.at/ │ ├── extension.js │ ├── lib/ │ │ ├── dbus/ │ │ │ └── dbus.js │ │ ├── main.js │ │ ├── menu/ │ │ │ ├── indicator.js │ │ │ └── menu.js │ │ ├── settings.js │ │ ├── utils/ │ │ │ ├── cards.js │ │ │ ├── eventBroker.js │ │ │ ├── eventHandlerDelegate.js │ │ │ ├── hotkeys.js │ │ │ ├── log.js │ │ │ ├── paHelper.js │ │ │ ├── process.js │ │ │ ├── string.js │ │ │ └── utils.js │ │ ├── volume/ │ │ │ ├── mixer.js │ │ │ └── profiles.js │ │ └── widget/ │ │ ├── floatingLabel.js │ │ ├── menuItem.js │ │ ├── panelButton.js │ │ ├── percentageLabel.js │ │ ├── slider.js │ │ └── volume.js │ ├── locale/ │ │ ├── cs/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── gnome-shell-extensions-shell-volume-mixer.mo │ │ │ └── gnome-shell-extensions-shell-volume-mixer.po │ │ ├── de/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── gnome-shell-extensions-shell-volume-mixer.mo │ │ │ └── gnome-shell-extensions-shell-volume-mixer.po │ │ ├── it/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── gnome-shell-extensions-shell-volume-mixer.mo │ │ │ └── gnome-shell-extensions-shell-volume-mixer.po │ │ ├── nl/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── gnome-shell-extensions-shell-volume-mixer.mo │ │ │ └── gnome-shell-extensions-shell-volume-mixer.po │ │ ├── pl/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── gnome-shell-extensions-shell-volume-mixer.mo │ │ │ └── gnome-shell-extensions-shell-volume-mixer.po │ │ ├── pt_BR/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── gnome-shell-extensions-shell-volume-mixer.mo │ │ │ └── gnome-shell-extensions-shell-volume-mixer.po │ │ ├── ru/ │ │ │ └── LC_MESSAGES/ │ │ │ ├── gnome-shell-extensions-shell-volume-mixer.mo │ │ │ └── gnome-shell-extensions-shell-volume-mixer.po │ │ └── translations.pot │ ├── metadata.json │ ├── pautils/ │ │ ├── lib/ │ │ │ ├── __init__.py │ │ │ ├── cards.py │ │ │ ├── libpulse.py │ │ │ ├── log.py │ │ │ ├── pulseaudio.py │ │ │ └── sinks.py │ │ └── query.py │ ├── prefs.js │ ├── prefs.ui │ ├── schemas/ │ │ ├── gschemas.compiled │ │ └── org.gnome.shell.extensions.shell-volume-mixer.gschema.xml │ └── stylesheet.css └── styles.scss ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [Makefile] indent_style = tab ================================================ FILE: .eslintrc.js ================================================ module.exports = { 'env': { 'es6': true, }, 'globals': { '_': false, 'ARGV': false, 'C_': false, 'Debugger': false, 'GjsFileImporter': false, 'global': false, 'imports': false, 'InternalError': false, 'Iterator': false, 'log': false, 'logError': false, 'N_': false, 'ngettext': false, 'print': false, 'printerr': false, 'StopIteration': false, 'uneval': false, 'window': false, }, 'extends': 'eslint:recommended', 'parserOptions': { 'ecmaVersion': 2018, }, 'rules': { 'indent': [ 'error', 4, { 'SwitchCase': 1 }, ], 'linebreak-style': [ 'error', 'unix', ], 'quotes': [ 'error', 'single', { 'allowTemplateLiterals': true }, ], 'semi': [ 'error', 'always', ], 'no-unused-vars': [ 2, { 'vars': 'local', 'args': 'after-used' }, ], }, }; ================================================ FILE: .gitattributes ================================================ shell-volume-mixer@derhofbauer.at/schemas/gschemas.compiled binary ================================================ FILE: .github/workflows/linting.yml ================================================ name: Linting on: push: branches: [ main ] pull_request: jobs: checks: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Setup Node.js 14 uses: actions/setup-node@v2 with: node-version: '14' - name: Cache node modules id: node-cache uses: actions/cache@v2 with: path: node_modules key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} restore-keys: | ${{ runner.os }}-node- - name: npm install if: steps.node-cache.outputs.cache-hit != 'true' run: npm install - name: Run ESLint run: npm run eslint ================================================ FILE: .gitignore ================================================ node_modules/ build/ *.swp *.pyc *.*~ shell-volume-mixer-*.zip ================================================ FILE: .gitmodules ================================================ [submodule "gnome-shell-sass"] path = gnome-shell-sass url = https://gitlab.gnome.org/GNOME/gnome-shell-sass.git ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) 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. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. ================================================ FILE: Makefile ================================================ VERSION = 42.0 EXTENSION = shell-volume-mixer@derhofbauer.at SRCDIR = $(EXTENSION) BUILDDIR = build/ PACKAGE = shell-volume-mixer-$(VERSION).zip FILES = LICENSE README.md LOCALE_DIR = $(SRCDIR)/locale LOCALES_SRC = $(foreach dir,$(LOCALE_DIR),$(wildcard $(dir)/*/*/*.po)) LOCALES = $(patsubst %.po,%.mo,$(LOCALES_SRC)) SOURCES = \ pautils/lib/*.py \ pautils/query.py \ lib/** \ *.js \ prefs.ui \ stylesheet.css \ $(LOCALES:$(SRCDIR)/%=%) \ $(GSCHEMA) $(SCHEMA_COMP) I18N = \ *.js \ lib/**/*.js \ prefs.ui SCHEMA_COMP = schemas/gschemas.compiled GSCHEMA = schemas/org.gnome.shell.extensions.shell-volume-mixer.gschema.xml SRCFILES = $(addprefix $(SRCDIR)/, $(SOURCES) $(GSCHEMA) $(GSCHEMA_COMP)) dist: clean build check package build: install-deps i18n stylesheet.css package: $(PACKAGE) prepare: mkdir -p $(BUILDDIR) install-deps: npm install git submodule update --init $(SRCDIR)/$(SCHEMA_COMP): $(SRCDIR)/$(GSCHEMA) glib-compile-schemas --targetdir=$(SRCDIR)/schemas $(SRCDIR)/schemas $(PACKAGE): metadata.json $(SRCFILES) $(FILES) cd $(SRCDIR) && zip -r ../$(PACKAGE) $(SOURCES) zip $(PACKAGE) $(FILES) cd $(BUILDDIR) && zip ../$(PACKAGE) * i18n: $(LOCALES_SRC) @xgettext \ --keyword --keyword=__ \ --omit-header \ --default-domain=$(EXTENSION) \ --from-code=UTF-8 \ --output=$(LOCALE_DIR)/translations.pot \ $(wildcard $(addprefix $(SRCDIR)/, $(I18N))) metadata.json: prepare cat $(addprefix $(SRCDIR)/, metadata.json) | grep -v '"version":' > $(BUILDDIR)/metadata.json stylesheet.css: npm run build # remove harmful content produced by gnome-shell-sass sed -i '/\/\*\sGlobal\sValues\s\*\//,/\/\*\sGeneral\sTypography\s\*/d' $(SRCDIR)/stylesheet.css check: npm run eslint clean: @test ! -d "$(BUILDDIR)" || rm -rf $(BUILDDIR) @test ! -f "$(SRCDIR)/$(SCHEMA_COMP)" || rm $(SRCDIR)/$(SCHEMA_COMP) @test ! -f "$(PACKAGE)" || rm $(PACKAGE) .PHONY: clean i18n ================================================ FILE: README.md ================================================ GNOME Shell Volume Mixer ======================== [![Linting](https://github.com/aleho/gnome-shell-volume-mixer/actions/workflows/linting.yml/badge.svg)](https://github.com/aleho/gnome-shell-volume-mixer/actions/workflows/linting.yml) Shell Volume Mixer is an extension for GNOME Shell allowing separate configuration of PulseAudio devices and output switches. It features a profile switcher to quickly switch between pinned profiles and devices. Middle mouse click on an indicator or a slider mutes the selected stream. Indicators and streams are also scrollable, Outputs menuInputs menu Requirements ------------ - PulseAudio (for retrieval of card details) - gettext (for building of language files) - nodejs / npm (styles and linting) - glib2 bin (schema compilation) Installation ------------ ``` $ make ``` That's it. Add the resulting archive via GNOME Tweak Tool (extensions tab) or copy it's content manually to ".~/.local/share/gnome-shell/extensions/shell-volume-mixer@derhofbauer.at". Volume Steps ------------ GNOME Settings Daemon (GSD) hardcodes the step for each key press of volume keys to 6% of maximum. While this might be OK for most people, some would prefer a configurable setting. There's a bug in GNOME's tracker which, according to the comments by developers, won't ever get fixed in a way that could allow configurable volume steps[[1]](https://bugzilla.gnome.org/show_bug.cgi?id=650371). Shell Volume Mixer tried to grab GSD's hotkeys to provide configurable steps for sliders and media keys in the past, but at some point this stopped working. GNOME's current solution to the problem is Shift + Key, i.e. hold down the shift button to switch to a 2% step. Acknowledgments --------------- This is a fork of AdvancedVolumeMixer by [Harry Karvonen](https://github.com/Hatell) (git://repo.or.cz/AdvancedVolumeMixer.git). Many thanks go out to him for his initial work. ================================================ FILE: bin/tmux.conf ================================================ set -g mouse on ================================================ FILE: bin/tools.sh ================================================ #!/usr/bin/env bash set -e WINDOW_MODE=1280x800 X11=0 NAME="" TMUX_SESSION="gsvm" function print_help() { cat <<- EOT Shell Volume Mixer dev toolkit *) test Runs the extension in a nested session --mode Sets the nested session window size (default: 1280x800) --x11 Runs a X11/xorg session (defaulting to wayland) *) lg Toggles Looking Glass via D-Bus *) command Executes a D-Bus call to debug the running extension (use "help" or see D-Bus interface for methods) *) add-sink Adds a virtual sink via PulseAudio --name Virtual sink name *) debug Enables or disables debugging [true, false] EOT } ### COMMAND=$1 if [[ -z $COMMAND ]]; then echo "Command required" echo "" print_help exit 1 fi shift OPTIONS=$(getopt -n "$0" -o h --long help,mode:,name:,x11 -- "$@") if [[ $? -ne 0 ]]; then print_help exit 1 fi eval set -- "$OPTIONS" while true; do case $1 in --mode) WINDOW_MODE=$2 shift ;; --name) NAME=$2 shift ;; --x11) X11=1 ;; -h|--help) print_help exit ;; --) shift break ;; *) print_help exit 1 ;; esac shift done ### function add_virtual_sink() { local props if [[ -n $NAME ]]; then props="sink_properties=device.description=$NAME" fi local pipewire_opts="object.linger=1 media.class=Audio/Sink" pactl load-module module-null-sink sink_name=svm-${NAME:-virtual-sink} $pipewire_opts "$props" \ || pactl load-module module-null-sink sink_name=svm-${NAME:-virtual-sink} "$props" } function toggle_looking_glass() { gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell --method org.gnome.Shell.Eval 'Main.createLookingGlass() && Main.lookingGlass.toggle();' } function enable_debugging() { local enable="$1" if [[ $enable == 1 || $enable == true ]]; then echo "Enabling debug" enable="true" else echo "Disabling debug" enable="false" fi dconf write /org/gnome/shell/extensions/shell-volume-mixer/debug $enable } function has_tmux_session() { set +e if tmux has-session -t $TMUX_SESSION 2>/dev/null; then echo 1 else echo 0 fi set -e } function run_nested_session() { local manager="" if [[ $X11 == 1 ]]; then manager="--x11" fi if [[ $(has_tmux_session) == 1 ]]; then tmux kill-session -t $TMUX_SESSION fi dbus-run-session -- tmux -f bin/tmux.conf new-session -s $TMUX_SESSION "bin/tools.sh run-test-session --mode=${WINDOW_MODE} $manager" } function run_test_session() { local manager if [[ $X11 == 1 ]]; then manager="--x11" else manager="--wayland" fi set -x export MUTTER_DEBUG_NUM_DUMMY_MONITORS=1 export MUTTER_DEBUG_DUMMY_MODE_SPECS="${WINDOW_MODE}" gnome-shell --nested $manager } function dbus_command() { local command="$1" local args="$2" if [[ -z $command ]]; then echo "Command needed" exit 1 fi local command=( gdbus call --session --dest "org.gnome.Shell" --object-path "/at/derhofbauer/shell/VolumeMixer" --method "at.derhofbauer.shell.VolumeMixer.$command" ) if [[ -n $args ]]; then command+=("$args") fi if [[ $(has_tmux_session) == 1 ]]; then local buffer="_cmd_output" if [[ -f $buffer ]]; then rm $buffer fi echo "Running command in test session" tmux new-window -n dbus-command -t $TMUX_SESSION: "${command[@]}" \; pipe-pane "cat > $buffer" sleep .3 cat $buffer rm $buffer else "${command[@]}" fi } ### case $COMMAND in add-sink) add_virtual_sink ;; lg) toggle_looking_glass ;; test) run_nested_session ;; run-test-session) run_test_session ;; command) dbus_command "${@}" ;; debug) enable_debugging "$1" ;; *) print_help exit 1 ;; esac ================================================ FILE: crowdin.yml ================================================ files: - source: /shell-volume-mixer@derhofbauer.at/locale/translations.pot translation: /shell-volume-mixer@derhofbauer.at/locale/%osx_locale%/LC_MESSAGES/gnome-shell-extensions-shell-volume-mixer.po ================================================ FILE: package.json ================================================ { "license": "GPL-2.0-only", "repository": "https://github.com/aleho/gnome-shell-volume-mixer", "devDependencies": { "eslint": "^8.13.0", "sass": "^1.50.0" }, "scripts": { "eslint": "eslint shell-volume-mixer@derhofbauer.at", "build": "sass --no-source-map styles.scss shell-volume-mixer@derhofbauer.at/stylesheet.css" } } ================================================ FILE: pubkey.asc ================================================ -----BEGIN PGP PUBLIC KEY BLOCK----- mQENBFfd0QwBCADiW+vORNE/bGD7p8eUVIJ00REornZ4AfPMBb2gNN/AFG4MnN/c uSgL1Tlbe7YuNaUMmtNWx/c41k7PYab8IciD/at0KBr/0hTRezk3a6aJlKfxibLF hiQsCXkgg4SzTcovDnZSsNf3nlybDlY3824w1vNhyhvMYSG05aQkufeJadlN+66t vgjfUeQHh2cVC3QalK19iq1h324DQsoYQfcj6xNMBHqIeWitqsM0yqxP6poxDeYT MBBmXdnOMkJQMwNfo+2eMl9k35c0IhDoIvTYYbxqDedrQIe1Qyl1NYxEC7eo1Iaz 8KilmMSt/iLgky4WA/z32Wl44GAq3UwEUX7pABEBAAG0I0FsZXggSG9mYmF1ZXIg PGFsZXhAZGVyaG9mYmF1ZXIuYXQ+iQE9BBMBCAAnBQJX3dEMAhsDBQkJZgGABQsJ CAcCBhUICQoLAgQWAgMBAh4BAheAAAoJEGrPIVMXiTpBOa4H/ihUAzQzgbpH1TAH p2VZ1l5hgFgtYnQ3BvzISatrIG3NsQ9QxHxdZCoNXsIveTOPoEqlex3TQMVOY2uD LOTYtk9Ul0cSYUU1VgFd9PVVQuThxLct7xTDzTpsp0oYdwlVc3Szg+ggAJE8Bu4p Ghn9p4pcNgIVZVwpLCcP5S1NODcGpU0MChyTl+UrGmrVpjoHZix88/eewijbTOrs opjxHIlaIxOMJJ4Fip4jLHkoYOL5rQCaLDgv+ZiAaWz3l8mjGsceG4aUt22ljY1l vKwyfT5QgR5kJOG/D81yjZZ1AJMYmlnhEHLORtvFhsYKop/fsAK+AG5ArtLqtz+G OR42dvu0KEFsZXhhbmRlciBIb2ZiYXVlciA8YWxleEBkZXJob2ZiYXVlci5hdD6J AT0EEwEIACcFAlfd02wCGwMFCQlmAYAFCwkIBwIGFQgJCgsCBBYCAwECHgECF4AA CgkQas8hUxeJOkFOfggAuAAWwXVfwMGkpxV7ujt51SOZf1WV5AJAxMumIeqsvj69 9LHphnpeO6fm0nRmYYoDTLM1gXMVXfRGu/ZFz2S0xTNJZOkiWaHT/+DpNvnFLLoc 5XMnZouNvrMJuu/DFH++BA7t3aXbJ6TE5J2gYF1xtI8UrAygnohQoZ3dtIlBy2Kf ohZZcRiKYZRdWtl7ERpPJc19Pwe12Kza875YQHCKc2dkl0ya5AbDEHXfCzMGPNxF RFgGcTTPt4nHsCCEE6kEPuRAfFsptMSCQKB1Z55sccdPdGyoKuy5yHwxU9L89+nj lvzKd1swBJn2in7nPWWJcUa4AkwxmxzYvleqCMRJg7kBDQRX3dEMAQgAuV7wsfIQ qjkxUvBIMVwocTmnUerOuwar4WfzJJx/oO49wd3jFto/WcJmJiJ67STM2Vi64iCa 8OzBudpYt7/9FZCFogJE2JUofjLFM/z9USgovOOVF7eeVggKchGRM7SeG3k/tK+s bobt1F62Lk2WhYFbSDUr/WrtdnFNXSD4tCkpz1dBvd2codvTbCmuhl/vppJaRO8u m3y9I11YXAL8yfiLYOGLyhBcOohWKxIZhBYEZhKdFUOjMnnYUOZmhRh6g7an/3FW wVYvper6WMta0LdK8obTNC1koCaBQQIZISNwSF2/CIDiMwxL32txazZI7GqGHIwG tm69OGqZ0FhraQARAQABiQElBBgBCAAPBQJX3dEMAhsMBQkJZgGAAAoJEGrPIVMX iTpB0AoIAIUzzhKY53lrQzgQiyOQCkVYYyBY1kRKqSBYgWZbfItNTuYkpYSbB9tU U7CtDLErS0K5xlrYMjQvDHEiskra4IGCvRDuNMu275VL7+hJHqjnXhoLWSzHXGrG K4mZVX2QqD010KQZLC1aFHXnPM+boGVId/Lauu4i3RmUpZiLyvaodRaB0yfJm8WV 2sLbVEBBWYOWGGGt6OSuO/O6pyDpU6Grzy61X7donHMzkgfBPaqE13feVs3p+GwE lfVc5Sr3v71RqctkHl1vLICjn8RAjb701B6ZQGVXH8XuqEZxEYuRdF32SP9G7XHF 6fZSkjd0dNecjp3b9tVRIxTnuEdkHIA= =nkLe -----END PGP PUBLIC KEY BLOCK----- ================================================ FILE: shell-volume-mixer@derhofbauer.at/extension.js ================================================ /** * Shell Volume Mixer * * Advanced mixer extension. * * @author Alexander Hofbauer */ /* exported init */ const ExtensionUtils = imports.misc.extensionUtils; const Lib = ExtensionUtils.getCurrentExtension().imports.lib; const { Extension } = Lib.main; function init() { ExtensionUtils.initTranslations(); return new Extension(); } ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/dbus/dbus.js ================================================ /** * Shell Volume Mixer * * D-Bus command module. * * @author Alexander Hofbauer */ /* exported Dbus */ const { DBus, DBusExportedObject } = imports.gi.Gio; const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const Log = Lib.utils.log; const DBUS_INTERFACE = ' \ \ \ \ \ \ \ \ \ \ \ \ '; let instance; /** @typedef {{ * reloadExtension: function, * debugCards: {function():String}, * debugStreams: {function():String}, * debugEvents: {function():String}, * }} CommandHandler */ /** * @property {CommandHandler} _handler */ var Dbus = class { /** * @param {CommandHandler} commandHandler * @returns {*} */ constructor(commandHandler = {}) { if (instance) { return instance; } instance = this; this._handler = new Proxy(commandHandler, { get: function (target, prop) { if (prop in target && target[prop]) { Log.info(`Got command "${prop}"`); return Reflect.get(...arguments); } Log.info(`Unimplemented command "${prop}"`); } }); } init() { this._dbus = DBusExportedObject.wrapJSObject(DBUS_INTERFACE, this); this._dbus.export(DBus.session, '/at/derhofbauer/shell/VolumeMixer'); Log.info('D-Bus command interface enabled'); } destroy() { if (this._dbus) { this._dbus.unexport(); this._dbus = null; Log.info('D-Bus command interface disabled'); } } //region dbus methods reload() { this._handler.reload(); } /** * @param {string} what to debug * @return {string} */ debug(what) { let result = ''; switch (what) { case 'cards': result = this._handler.debugCards(); break; case 'streams': result = this._handler.debugStreams(); break; case 'events': result = this._handler.debugEvents(); break; case '': Log.error(`D-Bus: command missing`); break; default: Log.error(`D-Bus: unknown command ${what}`); } return result; } help() { return 'Commands: debug (cards, streams, events), reload'; } //endregion dbus methods }; ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/main.js ================================================ /** * Shell Volume Mixer * * Main extension setup. * * @author Alexander Hofbauer */ /* exported Extension */ const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const Main = imports.ui.main; const PopupMenu = imports.ui.popupMenu; const { Dbus } = Lib.dbus.dbus; const { EventBroker } = Lib.utils.eventBroker; const { Mixer } = Lib.volume.mixer; const { Indicator } = Lib.menu.indicator; const { PanelButton } = Lib.widget.panelButton; const { Settings, SETTING, cleanup: settingsCleanup } = Lib.settings; const Log = Lib.utils.log; const DEFAULT_INDICATOR_POS = 4; let instance; var Extension = class { constructor() { if (instance) { return instance; } instance = this; // save the original volume reference in aggregate menu. this._orgVolume = this._menu._volume; } /** * Settings instance. * @private */ get _settings() { if (!this._settingsInstance) { this._settingsInstance = new Settings(); } return this._settingsInstance; } /** * The shell aggregate menu instance. * @private */ get _menu() { return Main.panel.statusArea.aggregateMenu; } enable() { this._events = new EventBroker(); this._events.connect('extension-disable', () => { this.disable(); }); this._events.connect('extension-enable', () => { this.enable(); }); this._events.connect('extension-reload', () => { this._reloadExtension(); }); this._settings.connectChanged(() => { this._reloadExtension(); }); this._mixer = new Mixer(); let position = this._settings.get_enum(SETTING.position); if (position === SETTING.position_at.menu) { this._replaceOriginal(); } else { this._addPanelButton(position); } if (this._settings.get_boolean(SETTING.debug) === true) { this._enableDebugging(); } } disable() { instance = null; this._menu._volume = this._orgVolume; this._showOriginal(); if (this._events) { this._events.disconnectAll(); this._events = null; } if (this._mixer) { this._mixer.destroy(); this._mixer = null; } if (this._indicator) { this._menu._indicators.remove_actor(this._indicator); this._indicator.destroy(); this._indicator = null; } if (this._panelButton) { this._panelButton.destroy(); this._panelButton = null; } if (this._dbus) { this._dbus.destroy(); this._dbus = null; } this._settingsInstance = null; settingsCleanup(); } /** * Hides the original menu item and icon. * @private */ _hideOriginal() { this._orgVolume._volumeMenu.actor.hide(); this._orgVolume._primaryIndicator.hide(); this._menu._indicators.remove_child(this._orgVolume); } /** * Restores the original menu item and icon. * @private */ _showOriginal() { this._menu._indicators.insert_child_at_index(this._orgVolume, this._indicatorPos || DEFAULT_INDICATOR_POS); this._orgVolume._volumeMenu.actor.show(); this._orgVolume._primaryIndicator.show(); } /** * Replaces the current indicator and menu. * @private */ _replaceOriginal() { this._indicator = new Indicator(this._mixer, { separator: false, showPercentageLabel: this._settings.get_boolean(SETTING.show_percentage_label), menuClass: 'svm-integrated-menu', }); // get current indicator position this._indicatorPos = this._getCurrentIndicatorPosition(); this._hideOriginal(); // add our own indicator and menu this._menu._volume = this._indicator; this._menu._indicators.insert_child_at_index(this._indicator, this._indicatorPos); this._menu.menu.addMenuItem(this._indicator.menu, 0); this._menu.menu.addMenuItem(new PopupMenu.PopupSeparatorMenuItem(), 1); // on disable/enable we won't get a stream-changed event, so trigger it here to be safe this._indicator.updateOutputIcon(); } /** * Find the current volume icon's position. * @private */ _getCurrentIndicatorPosition() { let indicators = this._menu._indicators.get_children(); let indicatorPos = DEFAULT_INDICATOR_POS; for (let i = 0; i < indicators.length; i++) { if (this._orgVolume === indicators[i]) { indicatorPos = i; break; } } return indicatorPos; } /** * Inits this extension as stand-alone button. * @private */ _addPanelButton(position) { if (this._settings.get_boolean(SETTING.remove_original)) { this._hideOriginal(); } this._panelButton = new PanelButton(this._mixer, { showPercentageLabel: this._settings.get_boolean(SETTING.show_percentage_label), }); if (position === SETTING.position_at.left) { Main.panel.addToStatusArea('ShellVolumeMenu', this._panelButton, 999, 'left'); } else if (position === SETTING.position_at.center) { Main.panel.addToStatusArea('ShellVolumeMenu', this._panelButton, 999, 'center'); } else { Main.panel.addToStatusArea('ShellVolumeMenu', this._panelButton); } } /** * Reloads the extension be disabling / enabling it. * @private */ _reloadExtension() { this.disable(); this.enable(); } /** * Enables the debugging and messages. * * @private */ _enableDebugging() { Log.verbose = true; this._dbus = new Dbus({ debugCards: () => { return this._emitDebugEvent('debug-cards'); }, debugStreams: () => { return this._emitDebugEvent('debug-streams'); }, debugEvents: () => { return this._emitDebugEvent('debug-events'); }, reload: () => { this._reloadExtension(); }, }); this._dbus.init(); } _emitDebugEvent(eventName) { this._events.emit(eventName, result => { Log.info(result); }); return 'OK'; } }; ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/menu/indicator.js ================================================ /** * Shell Volume Mixer * * Customized indicator using Volume Menu. * * @author Alexander Hofbauer */ /* exported Indicator */ const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const { GObject, Clutter } = imports.gi; const PanelMenu = imports.ui.panelMenu; const Volume = imports.ui.status.volume; const { EventHandlerDelegate } = Lib.utils.eventHandlerDelegate; const { Menu } = Lib.menu.menu; const { PercentageLabel } = Lib.widget.percentageLabel; const Utils = Lib.utils.utils; const VolumeType = { OUTPUT: 0, INPUT: 1, }; /** * Custom indicator with dropdown menu. * Copied from status/volume.js * * @copypaste from Volume.Indicator: Needs code to not initialize volume menu and all its events. */ var Indicator = GObject.registerClass(class Indicator extends PanelMenu.SystemIndicator { /** * @param {Mixer} mixer * @param {Object} options * @private */ _init(mixer, options = {}) { super._init(); this._primaryIndicator = this._addIndicator(); if (options.showPercentageLabel) { this._percentageLabel = new PercentageLabel(mixer); this.add_actor(this._percentageLabel); this._percentageLabel.reactive = true; this.connect(this._percentageLabel, 'scroll-event', (actor, event) => this._handleScrollEvent(VolumeType.OUTPUT, event)); this.connect(this._percentageLabel, 'button-press-event', (actor, event) => this._handleButtonPress(VolumeType.OUTPUT, event)); } this._inputIndicator = this._addIndicator(); this._primaryIndicator.reactive = true; this._inputIndicator.reactive = true; this.connect(this._primaryIndicator, 'scroll-event', (actor, event) => this._handleScrollEvent(VolumeType.OUTPUT, event)); this.connect(this._inputIndicator, 'scroll-event', (actor, event) => this._handleScrollEvent(VolumeType.INPUT, event)); this.connect(this._primaryIndicator, 'button-press-event', (actor, event) => this._handleButtonPress(VolumeType.OUTPUT, event)); this.connect(this._inputIndicator, 'button-press-event', (actor, event) => this._handleButtonPress(VolumeType.INPUT, event)); this._control = mixer.control; this._volumeMenu = new Menu(mixer, options); this._volumeMenu.actor.add_style_class_name(options.menuClass); this.connect(this._volumeMenu, 'output-icon-changed', this.updateOutputIcon.bind(this)); this._inputIndicator.visible = this._volumeMenu.getInputVisible(); this.connect(this._volumeMenu, 'input-visible-changed', () => { this._inputIndicator.visible = this._volumeMenu.getInputVisible(); }); this.connect(this._volumeMenu, 'input-icon-changed', this.updateInputIcon.bind(this)); // initial call to get an icon (especially for "show-always" setups) this.updateInputIcon(); this.menu.addMenuItem(this._volumeMenu); } updateOutputIcon() { let icon = this._volumeMenu.getIcon(VolumeType.OUTPUT); if (icon) { this._primaryIndicator.icon_name = icon; this._primaryIndicator.visible = true; } else { this._primaryIndicator.visible = false; } } updateInputIcon() { let icon = this._volumeMenu.getIcon(VolumeType.INPUT); if (icon !== null) { this._inputIndicator.icon_name = icon; } } _handleScrollEvent(type, event) { return Volume.Indicator.prototype._handleScrollEvent.apply(this, [type, event]); } _handleButtonPress(type, event) { if (event.get_button() === 2) { if (type === VolumeType.OUTPUT) { this._volumeMenu._output.toggleMute(); } else { this._volumeMenu._input.toggleMute(); } return Clutter.EVENT_STOP; } return Clutter.EVENT_PROPAGATE; } destroy() { this.disconnectAll(); if (this.menu) { this.menu.destroy(); this.menu = null; } if (this._percentageLabel) { this._percentageLabel.destroy(); this._percentageLabel = null; } } }); Utils.mixin(Indicator, EventHandlerDelegate); ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/menu/menu.js ================================================ /** * Shell Volume Mixer * * Volume menu item implementation. * * @author Alexander Hofbauer */ /* exported Menu */ const Gvc = imports.gi.Gvc; const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const PopupMenu = imports.ui.popupMenu; const Volume = imports.ui.status.volume; const { EventBroker } = Lib.utils.eventBroker; const { Settings, SETTING } = Lib.settings; const Log = Lib.utils.log; const Utils = Lib.utils.utils; const { AggregatedInput, EventsSlider, InputSlider, InputStreamSlider, MasterSlider, OutputSlider, } = Lib.widget.volume; const VolumeType = { OUTPUT: 0, INPUT: 1, }; /** * Extension of Volume.VolumeMenu without its constructor(). */ class VolumeMenuExtension extends PopupMenu.PopupMenuSection {} Utils.mixin(VolumeMenuExtension, Volume.VolumeMenu); /** @typedef {{ * mixer: Mixer, * detailed: Boolean, * symbolicIcons: Boolean, * stream: Gcv.MixerStream, * }} sliderOptions */ /** * Our own ui.status.VolumeMenu implementation. */ var Menu = class extends VolumeMenuExtension { /** * @param {Mixer} mixer * @param {Object} options */ constructor(mixer, options = {}) { super(); this._events = new EventBroker(); this._settings = new Settings(); this.options = { detailed: this._settings.get_boolean(SETTING.show_detailed_sliders), systemSounds: this._settings.get_boolean(SETTING.show_system_sounds), virtualStreams: this._settings.get_boolean(SETTING.show_virtual_streams), symbolicIcons: this._settings.get_boolean(SETTING.use_symbolic_icons), alwaysShowInputStreams: this._settings.get_boolean(SETTING.always_show_input_streams) }; this.actor.add_style_class_name('svm-menu'); // master-menu items this._outputs = {}; // input-menu items this._inputs = {}; // menu items (all other menu items) this._items = {}; this._mixer = mixer; this._control = mixer.control; this._init(mixer, options); this._events.connect('debug-streams', (event, callback) => { this._debugStreams(callback); }); } _init(mixer, options) { const signals = { 'state-changed': this._onControlStateChanged, 'default-sink-changed': this._readOutput, 'default-source-changed': this._readInput, 'stream-added': this._streamAdded, 'stream-removed': this._streamRemoved, 'stream-changed': this._streamChanged }; for (let name in signals) { try { mixer.connect(name, signals[name].bind(this)); } catch (exception) { Log.info(`Could not connect to signal ${name} -`, exception); } } this._output = new MasterSlider(this._control, { mixer: mixer, detailed: this.options.detailed, symbolicIcons: this.options.symbolicIcons }); this._output.connect('stream-updated', () => { this.emit('output-icon-changed'); }); this._inputMenu = new AggregatedInput(this._control, { mixer: mixer, detailed: this.options.detailed, symbolicIcons: this.options.symbolicIcons }); this._input = new InputStreamSlider(this._control, { mixer: mixer, showAlways: this.options.alwaysShowInputStreams, detailed: this.options.detailed, symbolicIcons: this.options.symbolicIcons }); this._input.item.connect('notify::visible', () => { this.emit('input-visible-changed'); }); this._input.connect('stream-updated', () => { this.emit('input-icon-changed'); }); this.addMenuItem(this._output.item, 0); this.addMenuItem(this._inputMenu.item, 2); this._inputMenu.setInputStream(this._input); if (options.separator) { this._addSeparator(); } this._onControlStateChanged(); } open(animate) { this._output.hideVolumeInfo(); super.open(animate); } close(animate) { for (let id in this._outputs) { this._outputs[id].hideVolumeInfo(); } for (let id in this._inputs) { this._inputs[id].hideVolumeInfo(); } for (let id in this._items) { this._items[id].hideVolumeInfo(); } this._output.hideVolumeInfo(); super.close(animate); } _addSeparator() { if (this._separator) { this._separator.destroy(); } this._separator = new PopupMenu.PopupSeparatorMenuItem(); this.addMenuItem(this._separator, 3); } _addStream(control, stream) { if (stream instanceof Gvc.MixerSource || stream instanceof Gvc.MixerSourceOutput ) { return; } const isSystemSound = stream instanceof Gvc.MixerEventRole; const isInputStream = stream instanceof Gvc.MixerSinkInput; const isOutputStream = stream instanceof Gvc.MixerSink; if (stream.is_event_stream) { Log.info(`Skipping event stream (${stream.id})`); return; } if (stream.is_virtual && !this.options.virtualStreams) { Log.info(`Skipping virtual stream (${stream.id})`); return; } if (isSystemSound && !this.options.systemSounds) { Log.info(`Skipping system sound stream (${stream.id})`); return; } const options = { mixer: this._mixer, detailed: this.options.detailed, symbolicIcons: this.options.symbolicIcons, stream: stream }; if (isSystemSound) { this._addSliderStream(stream, control, options); } else if (isInputStream) { this._addInputStream(stream, control, options); } else if (isOutputStream) { this._addOutputStream(stream, control, options); } else { Log.info(`Unhandled stream ${stream.id} (${stream.name} (${stream.constructor.name}))`); } } /** * Adds a stream to the multi-input menu. * * @param stream * @param control * @param {sliderOptions} options * @private */ _addInputStream(stream, control, options) { if (stream.id in this._inputs) { Log.info(`Not adding already known input stream ${stream.id}:${stream.name}`); return; } Log.info(`Adding input stream ${stream.id}:${stream.name}`); let slider = new InputSlider(control, options); this._inputs[stream.id] = slider; this._inputMenu.addSlider(slider); } /** * Adds a stream to the master slider menu. * * @param stream * @param control * @param {sliderOptions} options * @private */ _addOutputStream(stream, control, options) { if (stream.id in this._outputs) { Log.info(`Not adding already known output stream ${stream.id}:${stream.name}`); return; } Log.info(`Adding output stream ${stream.id}:${stream.name}`); let slider = new OutputSlider(control, options); this._outputs[stream.id] = slider; this._output.addOutputSlider(slider); } /** * Adds an additional slider below master and input slider. * * @param stream * @param control * @param {sliderOptions} options * @private */ _addSliderStream(stream, control, options) { if (stream.id in this._items) { Log.info(`Not adding already known stream ${stream.id}:${stream.name}`); return; } Log.info(`Adding stream ${stream.id}:${stream.name}`); let slider = new EventsSlider(control, options); this._items[stream.id] = slider; this.addMenuItem(slider.item, 1); } _streamAdded(control, id) { let stream = control.lookup_stream_id(id); this._addStream(control, stream); } _streamRemoved(control, id) { if (id in this._items) { this._items[id].item.destroy(); delete this._items[id]; } else if (id in this._outputs) { this._outputs[id].item.destroy(); delete this._outputs[id]; } else if (id in this._inputs) { this._inputs[id].item.destroy(); delete this._inputs[id]; this._inputMenu.refresh(); } } _streamChanged(control, id) { if (id in this._items) { this._items[id].refresh(); } else if (id in this._outputs) { this._outputs[id].refresh(); } else if (id in this._inputs) { this._inputs[id].refresh(); } } _onControlStateChanged() { super._onControlStateChanged(); if (this._control.get_state() !== Gvc.MixerControlState.READY) { return; } let streams = this._control.get_streams(); for (let stream of streams) { this._addStream(this._control, stream); } } _readInput() { if (!this._input) { return; } super._readInput(); } _debugStreams(callback) { let dump = []; if (Object.keys(this._outputs).length) { dump.push('Output Streams:'); } for (let id in this._outputs) { const stream = this._outputs[id].stream; dump.push(` ${stream.id} (${stream.name}) (port=${stream.port}) ${stream.description}`); const ports = stream.get_ports(); for (let p in ports) { const port = ports[p]; dump.push(` ${port.port}`); } } if (Object.keys(this._inputs).length) { dump.push('Input Streams:'); } for (let id in this._inputs) { const stream = this._inputs[id].stream; dump.push(` ${stream.id} (name=${stream.name}) (port=${stream.port}) ${stream.description}`); } callback(dump.join('\n')); } getInputVisible() { return this._input.isVisible(); } }; ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/settings.js ================================================ /** * Shell Volume Mixer * * Convenience class to wrap Gio.Settings. * * @author Alexander Hofbauer */ /* exported Settings, cleanup */ /* exported SOUND_SETTINGS_SCHEMA, ALLOW_AMPLIFIED_VOLUME_KEY, SETTING */ const ExtensionUtils = imports.misc.extensionUtils; const Extension = ExtensionUtils.getCurrentExtension(); const { Gio, GLib } = imports.gi; const Lib = Extension.imports.lib; const Log = Lib.utils.log; const Utils = Lib.utils.utils; const SETTINGS_SCHEMA = 'org.gnome.shell.extensions.shell-volume-mixer'; var SOUND_SETTINGS_SCHEMA = 'org.gnome.desktop.sound'; var ALLOW_AMPLIFIED_VOLUME_KEY = 'allow-volume-above-100-percent'; var SETTING = Object.freeze({ position: 'position', remove_original: 'remove-original', show_percentage_label: 'show-percentage-label', show_detailed_sliders: 'show-detailed-sliders', show_system_sounds: 'show-system-sounds', show_virtual_streams: 'show-virtual-streams', always_show_input_streams: 'always-show-input-streams', use_symbolic_icons: 'use-symbolic-icons', profile_switcher_hotkey: 'profile-switcher-hotkey', pinned_profiles: 'pinned-profiles', debug: 'debug', position_at: { menu: 0, left: 1, center: 2, right: 3, }, }); const SIGNALS = {}; const GSETTINGS = {}; var Settings = class { get settings() { if (!GSETTINGS[this.schema]) { GSETTINGS[this.schema] = ExtensionUtils.getSettings(this.schema); } return GSETTINGS[this.schema]; } constructor(schema) { this.schema = schema || SETTINGS_SCHEMA; if (!SIGNALS[this.schema]) { SIGNALS[this.schema] = {}; } this._signals = SIGNALS[this.schema]; } /** * Registers a listener for a signal. * * @param {string} signal * @param {function()} callback * @param {boolean} allowMultiple Whether to error out if the setting has already been connected */ connect(signal, callback, allowMultiple = false) { if (!allowMultiple && this._signals[signal]) { Log.error('Settings', 'connect', `Signal "${signal}" already bound for "${this.schema}"`); return false; } Log.info(`Connecting to settings change signal "${signal}"`); let id = this.settings.connect(signal, callback); this._signals[signal] = id; return id; } /** * Registers a listener to changed events. * * @param {function()} callback */ connectChanged(callback) { this.connect('changed', callback); } /** * Disconnects all connected signals. */ disconnectAll() { for (let signal in this._signals) { this.disconnect(this._signals[signal]); } } /** * Disconnects a signal by name. */ disconnect(signal) { if (this._signals[signal]) { this.settings.disconnect(this._signals[signal]); delete this._signals[signal]; return true; } return false; } /** * Disconnects a signal by id. */ disconnectById(signalId) { for (let name in this._signals) { let id = this._signals[name]; if (signalId === id) { this.settings.disconnect(id); delete this._signals[name]; return true; } } return false; } /** * Retrieves the value of an 's' type key. */ get_string(key) { return this.settings.get_string(key); } /** * Sets the value of an 's' type key. */ set_string(key, value) { return this.settings.set_string(key, value); } /** * Retrieves the value of an 'i' type key. */ get_int(key) { return this.settings.get_int(key); } /** * Sets the value of an 'i' type key. */ set_int(key, value) { return this.settings.set_int(key, value); } /** * Retrieves the value of a 'b' type key. */ get_boolean(key) { return this.settings.get_boolean(key); } /** * Sets the value of a 'b' type key. */ set_boolean(key, value) { return this.settings.set_boolean(key, value); } /** * Retrieves the value of an enum key. */ get_enum(key) { return this.settings.get_enum(key); } /** * Sets the value of an enum key. */ set_enum(key, value) { return this.settings.set_enum(key, value); } /** * Retrieves the value of an array key. */ get_array(key) { return this.settings.get_strv(key); } /** * Sets the value of an array key. */ set_array(key, value) { return this.settings.set_strv(key, value); } }; /** * Disconnects all signals of all schemas. * Used to make sure all there are no connected signals left. */ function cleanup() { for (let schema in SIGNALS) { if (!GSETTINGS[schema]) { continue; } for (let signal in SIGNALS[schema]) { GSETTINGS[schema].disconnect(SIGNALS[schema][signal]); delete SIGNALS[schema][signal]; } } } ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js ================================================ /** * Shell Volume Mixer * * PulseAudio card retrieval utilities. * * @author Alexander Hofbauer */ /* exported Cards, STREAM_MATCHING */ const ExtensionUtils = imports.misc.extensionUtils; const Lib = ExtensionUtils.getCurrentExtension().imports.lib; const Main = imports.ui.main; const { Gvc, GLib } = imports.gi; const __ = ExtensionUtils.gettext; const { EventBroker } = Lib.utils.eventBroker; const { EventHandlerDelegate } = Lib.utils.eventHandlerDelegate; const Log = Lib.utils.log; const Utils = Lib.utils.utils; const PaHelper = Lib.utils.paHelper; var STREAM_MATCHING = Object.freeze({ stream: 1, card: 2, }); const NULL_CARD = 4294967295; /** @typedef {{ * name: String, * description: String, * available: Boolean, * }} paProfile */ /** @typedef {{ * name: String, * description: String, * available: Boolean, * direction: String, * }} paPort */ /** @typedef {{ * index: Number, * alsaCard: Number, * name: String, * description: String, * active_profile: String, * profiles: Object., * ports: Object., * fake: Boolean, * card: Object, * }} paCard */ /** * @property {Object.} _paCards * @mixes EventHandlerDelegate */ var Cards = class { /** * @param {Gvc.MixerControl} control */ constructor(control) { this._events = new EventBroker(); this._control = control; this.eventHandlerDelegate = control; this._initDone = new Promise(resolve => { this._initialized = resolve; }); this.connect('state-changed', this._onStateChanged.bind(this), () => { return [this._control, this._control.get_state()]; }); this._events.connect('debug-cards', (event, callback) => { callback(Log.dump(this._paCards)); }); } /** * @returns {boolean} * @private */ _controlIsReady() { return (this._control && this._control.get_state() === Gvc.MixerControlState.READY); } /** * Callback for state changes. */ _onStateChanged(/* control, state */) { if (!this._controlIsReady()) { return; } // noinspection JSIgnoredPromiseFromCall this._init(); } /** * @returns {Promise} * @private */ async _init() { try { await this._initCards(); this._initialized(); } catch (e) { Log.error('Cards', '_init', e); Main.notifyError('Volume Mixer', __('Querying PulseAudio sound cards failed, disabling extension')); this._events.emit('extension-disable'); return; } this.connect('card-added', this._onCardAdded.bind(this)); this.connect('card-removed', this._onCardRemoved.bind(this)); } /** * Retrieves a list of all cards available, using our Python helper. * Tries to be error-resistant in case the helper cannot deliver. * * @returns {Promise} */ async _initCards() { this._paCards = {}; this._cardNames = {}; let cards; let retries = 3; do { cards = await this._getCardDetails(); } while (!cards && (--retries) > 0 && await new Promise(resolve => GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, 2, () => resolve(true))) ); if (!cards) { throw Error('Could not retrieve PA card details with Python helper script'); } this._paCards = cards; } /** * @returns {?Object.} * @private */ async _getCardDetails() { const paCards = await PaHelper.getCards(); if (!paCards || !Object.keys(paCards).length) { return null; } if (this._controlIsReady()) { for (let card of this._control.get_cards()) { if (card.index in paCards) { this._addGvcCard(paCards[card.index], card); } } } return paCards; } /** * @param {paCard} paCard * @param {Gvc.MixerCard} card * @private */ _addGvcCard(paCard, card) { if (!paCard) { Log.error('Cards', '_addGvcCard', 'No paCard passed'); return; } if (!paCard.name) { Log.error('Cards', '_addGvcCard', 'Invalid paCard data, name missing'); return; } Log.info('Card added', card.index, paCard.name); paCard.card = card; this._cardNames[paCard.name] = card.index; } /** * Signal for added cards. */ async _onCardAdded(control, index) { // we're actually looking up card.index let card = control.lookup_card_id(index); let paCard = await this.get(index); if (!paCard || paCard.fake) { try { paCard = await PaHelper.getCardByIndex(index); } catch (e) { Log.error('Cards', '_onCardAdded', 'Calling Python helper failed'); } if (!paCard) { Log.error('Cards', '_onCardAdded', 'GVC card not found through Python helper'); // external script couldn't get card info, fake it paCard = { // card name (human name) won't be useful, we'll set it anyway name: card.name, index: index, profiles: [], fake: true }; } this._paCards[index] = paCard; } this._addGvcCard(paCard, card); } /** * Signal for removed cards. */ _onCardRemoved(control, index) { if (index in this._paCards) { const name = this._paCards[index].name; delete this._cardNames[name]; delete this._paCards[index]; Log.info('Card removed', index, name); } else { Log.info('Untracked card not removed', index); } } /** * Finds a card by card index. * * @param {number} index * @param {Boolean} forceRefresh * @returns {Promise} */ async get(index, forceRefresh = false) { if (index === NULL_CARD) { return null; } await this._initDone; if (!forceRefresh) { if (index in this._paCards) { return this._paCards[index]; } Log.info(`Card ${index} not found, querying...`); } const paCard = await PaHelper.getCardByIndex(index); if (!paCard) { return null; } if (this._controlIsReady()) { let card = this._control.lookup_card_id(paCard.index); this._addGvcCard(paCard, card); } return paCard; } /** * Finds a card by name. * * @param {string} name * @returns {Promise} */ async getByName(name) { await this._initDone; const index = this._cardNames[name]; if (!isNaN(index) && index >= 0) { return this.get(index); } return null; } /** * Tries to find out whether a certain stream matches profile for a card. * * @param {Gvc.MixerStream} stream * @param {paCard} paCard * @param {string} profileName * @returns {STREAM_MATCHING} */ streamMatchesPaCard(stream, paCard, profileName) { const streamName = stream.name; const cardName = paCard.name; let [, streamAddr, streamIndex, streamProfile] = streamName.split('.'); const [, cardAddr, cardIndex] = cardName.split('.'); // try to fix stream names without index (cardName will not have an index either) if (streamIndex && !streamProfile && isNaN(streamIndex)) { streamProfile = streamIndex; streamIndex = undefined; } const profileParts = profileName.split(':'); // remove direction profileParts.shift(); const profile = profileParts.join(':'); if (streamAddr !== cardAddr || streamIndex !== cardIndex ) { // cards don't match, certainly no hit return false; } if (streamProfile !== profile) { return STREAM_MATCHING.card; } return STREAM_MATCHING.stream; } /** * Cleanup. */ destroy() { this.disconnectAll(); } }; Utils.mixin(Cards, EventHandlerDelegate); ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/eventBroker.js ================================================ /** * Shell Volume Mixer * * Global event broker singleton. * * @author Alexander Hofbauer */ /* exported EventBroker */ const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const Signals = imports.signals; const Log = Lib.utils.log; let instance; var EventBroker = class { constructor() { if (instance) { return instance; } instance = this; this.connect('debug-events', (event, callback) => { callback(Log.dump(this._signalConnections)); }); } }; Signals.addSignalMethods(EventBroker.prototype); ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/eventHandlerDelegate.js ================================================ /** * Shell Volume Mixer * * Event handler mixin. * * @author Alexander Hofbauer */ /* exported EventHandlerDelegate */ const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const Log = Lib.utils.log; /** * @mixin */ var EventHandlerDelegate = class { /** * @returns {string[]} * @private */ get _signals() { if (!this.__signals) { this.__signals = []; } return this.__signals; } /** * @param {Object} delegate */ set eventHandlerDelegate(delegate) { this.__delegate = delegate; } get eventHandlerDelegate() { return ('__delegate' in this) ? this.__delegate : null; } /** * Connects to a signal. * * @param {Object} target * @param {string} signal * @param {function()} [callback] * @param {?function()} [initial] Function returning the initial value */ connect(target, signal, callback, initial) { if (typeof target === 'string') { initial = callback; callback = signal; signal = target; target = this.eventHandlerDelegate; } if (!target) { Log.error('EventHandlerDelegate', 'connect', `Connect called before setting "eventHandlerDelegate" and without passing a target (${signal})`); return; } let id = target.connect(signal, callback); this._signals.push([target, id, signal]); if (typeof initial === 'function') { callback.apply(callback, initial()); } } /** * Connects to a signal. * * @param {Object} target * @param {string} signal */ disconnect(target, signal) { if (typeof target === 'string') { signal = target; } for (let index in this._signals) { const [target, id, name] = this._signals[index]; if (target === target && signal === name) { target.disconnect(id); this._signals.splice(index, 1); return; } } Log.error('EventHandlerDelegate', 'disconnect', `Signal ${signal} not found on target`); } /** * Disconnects all locally used signals. */ disconnectAll() { Log.info(`Disconnecting ${this._signals.length} signal(s)`); while (this._signals.length > 0) { const [target, id] = this._signals.pop(); Log.info(`Disconnecting signal ${id} from ${target.constructor.name}`); target.disconnect(id); } } }; ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/hotkeys.js ================================================ /** * Shell Volume Mixer * * Hotkeys. * * @author Alexander Hofbauer */ /* exported Hotkeys */ const { Meta, Shell } = imports.gi; const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const Main = imports.ui.main; const Log = Lib.utils.log; const BINDINGS = {}; var Hotkeys = class { constructor(settings) { this._settings = settings; this._bindings = BINDINGS; } /** * Binds a hotkey using the local settings instance. * * @param {string} setting Settings key * @param {function()} callback */ bind(setting, callback) { if (this._bindings[setting]) { Log.info(`Not binding hotkey for ${setting}, already bound`); return false; } const mode = Shell.ActionMode.NORMAL | Shell.ActionMode.OVERVIEW; const flags = Meta.KeyBindingFlags.IGNORE_AUTOREPEAT; const action = Main.wm.addKeybinding(setting, this._settings.settings, flags, mode, callback); if (action === Meta.KeyBindingAction.NONE) { Log.info(`Could not bind hotkey for ${setting}`); } else { Log.info(`Bound hotkey for ${setting}`); this._bindings[setting] = action; } } /** * Unbinds a hotkey. * * @param {string} setting Settings key */ unbind(setting) { if (this._bindings[setting]) { Main.wm.removeKeybinding(setting); delete this._bindings[setting]; Log.info(`Unbound hotkey for ${setting}`); } } /** * Unbinds all hotkeys. */ unbindAll() { Log.info('Unbinding all hotkeys'); for (let setting in this._bindings) { this.unbind(setting); } } }; ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/log.js ================================================ /** * Shell Volume Mixer * * Logging. * * @author Alexander Hofbauer */ /* exported l, d, info, error, dump, verbose */ const Extension = imports.misc.extensionUtils.getCurrentExtension(); const Lib = Extension.imports.lib; const LOG_PREAMBLE = Extension.metadata.uuid || 'Shell Volume Mixer'; var verbose = false; /** * Helper to debug any variables(s) as string, using separators. * * @param {...string} */ function l() { log(Array.prototype.slice.call(arguments).join(' ')); } /** * Logs verbose messages, if enabled. * * @param {...string} */ function info() { if (verbose) { log(`${LOG_PREAMBLE} | ${Array.prototype.slice.call(arguments).join(' ')}`); } } /** * Helper to debug any variable with pretty output. * * @param {*} object * @param {number} maxDepth */ function d(object, maxDepth = 1) { try { l(_dumpObject(object, maxDepth)); } catch (e) { l(object); } } /** * Logs an error message. * * @param {string} module Module the error occurred in. * @param {string} context Optional. * @param {string|Error} error Error or error message (to construct Error from). */ function error(module, context, error) { if (module && !context && !error) { error = module; module = undefined; context = undefined; } if (context && !error) { error = context; context = undefined; } let output = LOG_PREAMBLE; if (module) { output += ` | ${module}.js`; } if (context) { output += ` | ${context}()`; } if (!(error instanceof Error)) { error = Error(error); } logError(error, output); } /** * Helper to dump any variable to a string. * * @param {*} object * @param {number} maxDepth */ function dump(object, maxDepth) { try { return _dumpObject(object, maxDepth); } catch (e) { return `Error dumping object: ${e}`; } } /** * Dumps any variable into a string that can be output through log(). * * @param {*} object * @param {number} maxDepth * @param {number} currDepth * @returns {string} */ function _dumpObject(object, maxDepth = 8, currDepth = 0) { if (currDepth > maxDepth) { return ''; } if (object === null) { return 'null'; } if (object === undefined) { return 'undefined'; } let dump = ''; let indent = ''; let stringMode = false; if (currDepth > 0) { indent = '\u00A0'.repeat(currDepth * 4); } else { let objectString = object.toString(); dump += `${objectString}\n${'-'.repeat(objectString.length)}\n\n`; if (typeof object == 'string') { stringMode = true; } } let isFirst = true; for (let key in object) { const item = object[key]; const type = typeof item; let typeInfo = type; if (stringMode) { let pos = parseInt(key); if (isNaN(pos)) { // don't debug string methods break; } typeInfo = object.charCodeAt(pos); } if (!isFirst) { dump += ',\n'; } isFirst = false; dump += `${indent + key} => (${typeInfo})`; if (item === null) { dump += ' null'; } else if (type ==='object' || type === 'function') { const isArray = Array.isArray(item); if (isArray) { dump += ' ['; } else if (type === 'function') { dump += ' ('; } else { // we're assuming toString() yields sane values let itemString = item.toString(); if (itemString !== '[object Object]') { dump += ` ${itemString}`; } dump += ' {'; } let objDump = ''; try { objDump = _dumpObject(item, maxDepth, currDepth + 1); } catch (e) { // object cannot be dumped, probably a non-null pointer } if (objDump.trim() !== '') { dump += `\n${objDump}\n${indent}`; } if (isArray) { dump += ']'; } else if (type === 'function') { dump += ')'; } else { dump += '}'; } } else { dump += ` ${item}`; } } return dump; } ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/paHelper.js ================================================ /** * Shell Volume Mixer * * PulseAudio helper. * * @author Alexander Hofbauer */ /* exported getCards, getCardByIndex */ const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const Log = Lib.utils.log; const Process = Lib.utils.process; const Utils = Lib.utils.utils; const PYTHON_HELPER_PATH = 'pautils/query.py'; const TYPE_CARDS = 'cards'; let PYTHON; async function findPython() { if (PYTHON === undefined) { for (let python of ['python3', 'python']) { let ret; let stderr; try { [ret, , stderr] = await Process.execAsync(['/usr/bin/env', python]); if (ret === 0) { PYTHON = python; break; } Log.error('paHelper', 'findPython', `${python} not found: ${stderr} (${ret})`); } catch (e) { PYTHON = false; Log.error('paHelper', 'findPython', e); } } } return PYTHON; } /** * @param {string} type Type of data to query * @param {?number} [index=undefined] * @returns {Promise>} JSON object of the output */ async function execHelper(type, index = undefined) { const paUtilPath = Utils.getExtensionPath(PYTHON_HELPER_PATH); if (!paUtilPath) { Log.error('paHelper', 'execHelper', `Could not find PulseAudio utility in extension path ${PYTHON_HELPER_PATH}`); return null; } const python = await findPython(); if (!python) { return null; } const args = ['/usr/bin/env', python, paUtilPath, type]; if (!isNaN(index)) { args.push(index); } let ret; let stdout; let stderr; let pythonError; try { [ret, stdout, stderr] = await Process.execAsync(args); } catch (e) { pythonError = e; } if (pythonError) { Log.error('paHelper', 'execHelper', pythonError); if (stderr) { Log.error('paHelper', 'execHelper', `(${ret}) ${stderr}`); } } if (!stdout) { return null; } let data = null; try { data = JSON.parse(stdout); } catch (e) { Log.error('paHelper', 'execHelper', e); return null; } if (!data || typeof data !== 'object') { Log.error('paHelper', 'execHelper', 'Invalid response'); return null; } if ('success' in data && data.success === false) { Log.error('paHelper', 'execHelper', `Error: ${data.error}`); return null; } return data; } /** * Calls the Python helper script to get details about all available cards and their profiles. * * @returns {Promise>} JSON object of the output */ async function getCards() { return await execHelper(TYPE_CARDS); } /** * Calls the Python helper script to get more details about a card and its profiles. * * @param {number} index * @returns {Promise} JSON object of the output */ async function getCardByIndex(index) { const data = await execHelper(TYPE_CARDS, index); if (data && data[index]) { return data[index]; } return null; } ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/process.js ================================================ /** * Shell Volume Mixer * * Process utility. * * @author Alexander Hofbauer */ /* exported execAsync */ const { Gio } = imports.gi; /** * Executes an async command. * * @param {Array} command * @returns {Promise<[int, string, string]>|Promise} */ async function execAsync(command) { const process = new Gio.Subprocess({ argv: command.map(arg => arg.toString()), flags: Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE }); process.init(null); return new Promise((resolve, reject) => { process.communicate_utf8_async(null, null, (process, result) => { try { const [success, stdout, stderr] = process.communicate_utf8_finish(result); const ret = process.get_exit_status(); if (!success) { reject(Error('Error spawning subprocess')); } else { resolve([ret, stdout, stderr]); } } catch (e) { reject(e); } }); }); } ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/string.js ================================================ /** * Shell Volume Mixer * * String utilities. * * @author Alexander Hofbauer */ /* exported repeat, versionGreaterOrEqual */ /** * Parses a version string and returns an array. * * @param {string} string * @returns {number[]} */ function parseVersionString(string) { let version = string.split('.', 3); for (let i = 0; i < 3; i++) { if (version[i]) { version[i] = parseInt(version[i]); } else { version[i] = 0; } } return version; } /** * Returns true if the current shell version is greater than the version string passed. * * @param {string} string Version string like 3.38. */ function versionGreaterOrEqual(string) { let current = parseVersionString(imports.misc.config.PACKAGE_VERSION); let version = parseVersionString(string); for (let i = 0; i < 3; i++) { if (current[i] < version[i]) { return false; } } return true; } ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/utils/utils.js ================================================ /** * Shell Volume Mixer * * Utilities. * * @author Alexander Hofbauer */ /* exported getCards, getExtensionPath, mixin */ const Extension = imports.misc.extensionUtils.getCurrentExtension(); /** * Returns this extension's path, optionally querying a subdirectory or file. * * @param {string} [subpath] Subdirectory or -path to get. */ function getExtensionPath(subpath) { let dir = Extension.dir; if (subpath) { dir = dir.get_child(subpath); } if (!dir.query_exists(null)) { return null; } return dir.get_path(); } /** * Allows a target object to receive all properties from a source. */ function mixin(target, source, keepExisting = false) { const sourceProps = Object.getOwnPropertyDescriptors(source.prototype); for (let name of Object.keys(sourceProps)) { if (keepExisting === true && Object.prototype.hasOwnProperty.call(target, name)) { continue; } Object.defineProperty(target.prototype, name, sourceProps[name]); } } ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/volume/mixer.js ================================================ /** * Shell Volume Mixer * * Mixer class wrapping the mixer control. * * @author Alexander Hofbauer */ /* exported Mixer */ const {Gio, Gvc} = imports.gi; const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const Main = imports.ui.main; const Volume = imports.ui.status.volume; const { Cards, STREAM_MATCHING } = Lib.utils.cards; const { EventBroker } = Lib.utils.eventBroker; const { EventHandlerDelegate } = Lib.utils.eventHandlerDelegate; const { Hotkeys } = Lib.utils.hotkeys; const { Profiles } = Lib.volume.profiles; const { Settings, SETTING } = Lib.settings; const Log = Lib.utils.log; const Utils = Lib.utils.utils; /** * @mixes EventHandlerDelegate */ var Mixer = class { constructor() { this._events = new EventBroker(); this._control = Volume.getMixerControl(); this.eventHandlerDelegate = this._control; this._settings = new Settings(); this._hotkeys = new Hotkeys(this._settings); this._profiles = new Profiles(this._settings); this._cards = new Cards(this._control); this._state = null; this._defaultSink = null; this._pauseDefaultSinkEvent = false; this.connect(this._control, 'state-changed', this._onStateChanged.bind(this), () => { return [this._control, this._control.get_state()]; }); this.connect(this._control, 'default-sink-changed', this._onDefaultSinkChanged.bind(this)); this._bindProfileHotkey(); } /** * The current Gvc.MixerControl. * * @returns {Gvc.MixerControl} */ get control() { return this._control; } /** * @returns {Cards} */ get cards() { return this._cards; } /** * @returns {?Gvc.MixerSink} */ get defaultSink() { return this._defaultSink; } /** * Returns the current volume in percent. * * @returns {?number} */ getVolume() { if (!this._defaultSink) { return null; } if (this._defaultSink.is_muted) { return 0; } return Math.round(this._defaultSink.volume / this._control.get_vol_max_norm() * 100); } /** * Cleanup. */ destroy() { this.disconnectAll(); this._hotkeys.unbindAll(); this._cards.destroy(); } /** * Binds the hotkey for profile rotation. * @private */ _bindProfileHotkey() { if (!this._profiles.count()) { Log.info('No profiles found, not enabling profile switching hotkey'); return; } Log.info('Profiles found, enabling profile switching hotkey'); this._hotkeys.bind(SETTING.profile_switcher_hotkey, this._switchProfile.bind(this)); } /** * Disconnects volume update signals from the default sink. * @private */ _disconnectSink() { this.disconnect(this._defaultSink, 'notify::is-muted'); this.disconnect(this._defaultSink, 'notify::volume'); } /** * Connects volume update signals from the default sink for notifications. * @private */ _connectSink() { this.connect(this._defaultSink, 'notify::is-muted', this._onVolumeUpdate.bind(this)); this.connect(this._defaultSink, 'notify::volume', this._onVolumeUpdate.bind(this)); } /** * Emits a stream update event if volume changes. * @private */ _onVolumeUpdate() { const percent = this.getVolume(); if (percent !== null) { this._events.emit('volume-changed', percent); } } /** * Updates the default sink, trying to mark the currently active card. * @private */ async _updateDefaultSink(stream) { if (this._defaultSink !== stream) { if (this._defaultSink) { this._disconnectSink(); } this._defaultSink = stream; if (stream) { Log.info(`Updating default sink ${stream.id}:${stream.name}`); this._connectSink(); this._onVolumeUpdate(); } else { Log.info('Default sink updated to null, cannot update'); } } // we might get a sink id without being able to lookup // ...or local code causing the event triggers another update if (stream && !this._pauseDefaultSinkEvent) { try { const paCard = await this._cards.get(stream.card_index); if (!paCard || !paCard.card) { Log.info(`Default sink updated but PA/GVC card not found (${stream.card_index}/${stream.name})`); } else { this._profiles.setCurrent(paCard); } } catch (e) { Log.error('Mixer', '_updateDefaultSink', e); } } this._pauseDefaultSinkEvent = false; this._events.emit('default-sink-updated', stream); } /** * Callback for state changes. * @private */ _onStateChanged(control, state) { this._state = state; if (state !== Gvc.MixerControlState.READY) { return; } // noinspection JSIgnoredPromiseFromCall this._updateDefaultSink(this._control.get_default_sink()); } /** * Callback for default sink changes. * @private */ _onDefaultSinkChanged(control, id) { // noinspection JSIgnoredPromiseFromCall this._updateDefaultSink(control.lookup_stream_id(id)); } /** * Hotkey handler to switch between profiles. * @private */ async _switchProfile() { if (this._state !== Gvc.MixerControlState.READY) { return; } const next = this._profiles.next(); if (!next) { return; } let paCard; try { // lookup card indirectly via name (indexes aren't UUIDs) paCard = await this._cards.getByName(next.card); } catch (e) { Log.error('Mixer', '_switchProfile', e); } if (!paCard || !paCard.card) { // we don't know this card, we won't be able to set its profile return; } // profile's changed, now get that new sink const sinks = this._control.get_sinks(); let newSink = null; for (let sink of sinks) { let result = this._cards.streamMatchesPaCard(sink, paCard, next.profile); if (result === STREAM_MATCHING.stream) { newSink = sink; break; } if (result === STREAM_MATCHING.card || !newSink) { // maybe we can use this stream later, but we'll keep searching newSink = sink; } } paCard.card.set_profile(next.profile); if (newSink) { this._pauseDefaultSinkEvent = true; this._control.set_default_sink(newSink); } const paProfile = paCard.profiles[next.profile]; this._showNotification(`${paCard.description || paCard.name}\n${paProfile.description || paProfile.name}`); } /** * Shows a notification window through Shell's OSD Window Manager. * * @param {string} text Text to display * @private */ _showNotification(text) { let monitor = -1; let icon = Gio.Icon.new_for_string('audio-speakers-symbolic'); Main.osdWindowManager.show(monitor, icon, text); } }; Utils.mixin(Mixer, EventHandlerDelegate); ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/volume/profiles.js ================================================ /** * Shell Volume Mixer * * Card / profile settings cycling. * * @author Alexander Hofbauer */ /* exported Profiles */ const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const { SETTING } = Lib.settings; const Log = Lib.utils.log; /** @typedef {{ * card: String, * profile: String, * switcher: Boolean, * show: Boolean, * next: paCycledProfile, * }} paCycledProfile */ var Profiles = class { constructor(settings) { this._settings = settings; this._current = null; const data = this._settings.get_array(SETTING.pinned_profiles) || []; this._profiles = this._parseProfilesSetting(data); } /** * @returns {number} */ count() { return this._profiles.length; } /** * @param {paCard} paCard */ setCurrent(paCard) { if (!this.count()) { return; } let profile = paCard.card.profile; for (let entry of this._profiles) { if (entry.card === paCard.name && entry.profile === profile) { this._current = entry; break; } } } /** * @returns {?paCycledProfile} */ next() { if (!this.count()) { return; } if (!this._current) { this._current = this._profiles[0]; } this._current = this._current.next; return this._current; } /** * Reads all pinned profiles from settings. * * @param {string[]} data JSON formatted data * @private */ _parseProfilesSetting(data) { const profiles = []; let count = 0; for (let entry of data) { let item = null; try { item = JSON.parse(entry); } catch (e) { Log.error('Profiles', '_parseProfilesSetting', e); } if (!item) { continue; } profiles.push(item); if (count > 0) { profiles[count - 1].next = item; } count++; } if (count > 0) { profiles[count - 1].next = profiles[0]; } return profiles; } }; ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/widget/floatingLabel.js ================================================ /** * Shell Volume Mixer * * Floating label. * * @author Alexander Hofbauer */ /* exported FloatingLabel */ const Main = imports.ui.main; const {Clutter, St} = imports.gi; /** * A tooltip-like label to display the current value of a slider. * * Shamelessly stolen from gnome-shell/js/ui/dash.js. */ var FloatingLabel = class { constructor() { this._label = new St.Label({ style_class: 'dash-label floating-label' }); this.text = '100%'; this._label.hide(); Main.layoutManager.addChrome(this._label); } get text() { return this._label.get_text(); } set text(text) { this._label.set_text(text); } get size() { return this._label.get_size(); } show(x, y, animate) { this._label.opacity = 0; this._label.show(); Main.uiGroup.set_child_above_sibling(this._label, null); const labelHeight = this._label.get_height(); const labelWidth = this._label.get_width(); const node = this._label.get_theme_node(); const xOffset = node.get_length('-x-offset'); let xPos; if (Clutter.get_default_text_direction() === Clutter.TextDirection.RTL) { xPos = x - labelWidth - xOffset; } else { xPos = x + labelWidth + xOffset; } const yPos = y - labelHeight; this._label.set_position(xPos, yPos); this._label.ease({ opacity: 255, duration: animate !== false ? 150 : 0, mode: Clutter.AnimationMode.EASE_OUT_QUAD, }); } hide(animate) { this._label.ease({ opacity: 0, duration: animate !== false ? 100 : 0, mode: Clutter.AnimationMode.EASE_OUT_QUAD, onComplete: () => this._label.hide(), }); } }; ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/widget/menuItem.js ================================================ /** * Shell Volume Mixer * * Menu items. * * @author Alexander Hofbauer */ /* exported MasterMenuItem, SubMenuItem */ const { Clutter, GObject, St } = imports.gi; const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const PopupMenu = imports.ui.popupMenu; const Slider = Lib.widget.slider; const Utils = Lib.utils.utils; /** * @mixin */ class BaseMenuItem { _makeOrnamentLabel() { return new St.Label({ style_class: 'popup-menu-ornament' }); } /** * @returns {St.BoxLayout} */ _makeItemLine() { return new St.BoxLayout({ style_class: 'popup-menu-item svm-container-line', reactive: true, }); } _prepareMenuItem() { this.get_children().map(child => { this.remove_actor(child); }); this.container = new St.BoxLayout({ vertical: true, x_expand: true, style_class: 'svm-menu-item-container', }); this.add(this.container); if (!this.firstLine) { this.firstLine = this._makeItemLine(); if (this._ornamentLabel) { this.firstLine.add_child(this._ornamentLabel); } this.container.add(this.firstLine); } if (!this.secondLine) { this.secondLine = this._makeItemLine(); if (this._ornamentLabel) { this.secondLine.add_child(this._makeOrnamentLabel()); } this.container.add(this.secondLine); } this.firstLine.add_style_class_name('line-1'); this.secondLine.add_style_class_name('line-2'); } } /** * Submenu item for the sink selection menu. * * @mixes BaseMenuItem */ var MasterMenuItem = GObject.registerClass(class MasterMenuItem extends PopupMenu.PopupSubMenuMenuItem { _init() { super._init('', true); this._prepareMenuItem(); this._slider = new Slider.VolumeSlider(0); this.firstLine.add_child(this.icon); this.firstLine.add_child(this.label); this.firstLine.add_child(new St.Bin({ style_class: 'popup-menu-item-expander', x_expand: true, })); this.firstLine.add_child(this._triangleBin); this.secondLine.add_child(this._slider); // shell uses add_child here but that breaks layout? this.secondLine.add_style_class_name('svm-master-slider-line'); this.label.add_style_class_name('svm-master-label'); this.add_style_class_name('svm-master-slider svm-menu-item'); } vfunc_button_release_event(event) { if (event.button === 2) { return Clutter.EVENT_STOP; } return super.vfunc_button_release_event(event); } /** * Change volume on left / right. */ vfunc_key_press_event(event) { const symbol = event.keyval; if (symbol === Clutter.KEY_Right || symbol === Clutter.KEY_Left) { return this._slider.emit('key-press-event', event); } return super.vfunc_key_press_event(event); } addMenuItem(item) { const pos = (this.menu._getMenuItems().length || 0) - 1; this.menu.addMenuItem(item, pos < 0 ? 0 : pos); } }); Utils.mixin(MasterMenuItem, BaseMenuItem, true); /** * Sub menu item implementation for dropdown menus (via master slider menu or input menu). * * @mixes BaseMenuItem */ var SubMenuItem = GObject.registerClass(class SubMenuItem extends PopupMenu.PopupBaseMenuItem { _init(params = {}) { super._init({ ...params, activate: false, }); this._prepareMenuItem(); } addDetails(label) { const line = this._makeItemLine(); line.add_child(label); this.container.insert_child_at_index(line, 1); } }); Utils.mixin(SubMenuItem, BaseMenuItem, true); ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/widget/panelButton.js ================================================ /** * Shell Volume Mixer * * Stand-alone menu panel button. * * @author Alexander Hofbauer */ /* exported PanelButton */ const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const { GObject, St } = imports.gi; const PanelMenu = imports.ui.panelMenu; const Volume = imports.ui.status.volume; const { Indicator } = Lib.menu.indicator; /** * Stand-alone panel menu button. */ var PanelButton = GObject.registerClass(class PanelButton extends PanelMenu.Button { /** * @param {Mixer} mixer * @param {Object} options * @private */ _init(mixer, options = {}) { super._init(0.0, 'ShellVolumeMixer', false); this._indicators = new St.BoxLayout({ style_class: 'panel-status-indicators-box' }); this.add_child(this._indicators); this._volume = new Indicator(mixer, { ...options, separator: true, menuClass: 'svm-standalone-menu', }); this._indicators.add_child(this._volume); this.menu.addMenuItem(this._volume.menu); } }); ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/widget/percentageLabel.js ================================================ /** * Shell Volume Mixer * * Percentage label. * * @author Alexander Hofbauer */ /* exported PercentageLabel */ const { Clutter, GObject, St } = imports.gi; const Lib = imports.misc.extensionUtils.getCurrentExtension().imports.lib; const { EventBroker } = Lib.utils.eventBroker; var PercentageLabel = GObject.registerClass(class PercentageLabel extends St.Label { _init(mixer) { this._events = new EventBroker(); super._init({ y_expand: true, y_align: Clutter.ActorAlign.CENTER, }); this.add_style_class_name('percentage-label'); this._events.connect('volume-changed', (event, volume) => { this._setText(volume); }); // set initial value, if available this._setText(mixer.getVolume()); } /** * @param {?number} percent * @private */ _setText(percent) { if (percent === null) { this.text = ''; } else { this.text = _('%d\u2009%%').format(percent); } } }); ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/widget/slider.js ================================================ /** * Shell Volume Mixer * * Sliders. * * @author Alexander Hofbauer */ /* exported VolumeSlider */ const { Clutter, GObject } = imports.gi; const Slider = imports.ui.slider; /** * Custom Slider to allow for mute via middle button. */ var VolumeSlider = GObject.registerClass(class VolumeSlider extends Slider.Slider { /** * Allow middle button event to bubble up for mute / unmute. */ startDragging(event) { if (event.get_button() === 2) { return Clutter.EVENT_PROPAGATE; } return super.startDragging(event); } }); ================================================ FILE: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js ================================================ /** * Shell Volume Mixer * * Volume widgets. * * @author Alexander Hofbauer */ /* exported MasterSlider, AggregatedInput, OutputSlider, EventsSlider, InputSlider, InputStreamSlider, VolumeMenu */ const { Clutter, GLib, St } = imports.gi; const ExtensionUtils = imports.misc.extensionUtils; const Lib = ExtensionUtils.getCurrentExtension().imports.lib; const Main = imports.ui.main; const PopupMenu = imports.ui.popupMenu; const Volume = imports.ui.status.volume; const __ = ExtensionUtils.gettext; const { EventBroker } = Lib.utils.eventBroker; const { FloatingLabel } = Lib.widget.floatingLabel; const Log = Lib.utils.log; const MenuItem = Lib.widget.menuItem; const Settings = Lib.settings; const Slider = Lib.widget.slider; const Utils = Lib.utils.utils; /** * Extension of Volume.StreamSlider without its constructor(). */ class StreamSliderExtension {} Utils.mixin(StreamSliderExtension, Volume.StreamSlider); /** * Extension of Volume.OutputStreamSlider without its constructor(). */ class OutputStreamSliderExtension extends StreamSliderExtension {} Utils.mixin(OutputStreamSliderExtension, Volume.OutputStreamSlider); /** * Basic StreamSlider implementation for Input- and OutputStreams. * * We can extend (and monkey patch) Volume.OutputStreamSlider * (Volume.InputStreamSlider is meant for microphones only and Volume.StreamSlider is only basic). */ const StreamSlider = class extends OutputStreamSliderExtension { /** * @param {Gvc.MixerControl} control * @param {sliderOptions} options * @private */ constructor(control, options = {}) { super(); this._isDestroyed = false; this._hasHeadphones = false; this.options = options; this._control = control; this._mixer = options.mixer; this._events = new EventBroker(); this._init(options); return this; } /** * Init basically copied from Volume.StreamSlider (all init) and Volume.OutputStreamSlider (icons). * * @param {sliderOptions} options */ _init(options) { if (!this.item) { this.item = new MenuItem.SubMenuItem(); } if (this.icon) { // different widgets seem to use different naming this._icon = this.icon; } if (!this._icon) { this._icon = new St.Icon({ style_class: 'popup-menu-icon' }); this.item.firstLine.add_child(this._icon); } if (!this._label) { this._label = new St.Label({ text: '' }); this.item.firstLine.add_child(this._label); } this.item.label_actor = this._label; if (!this._slider) { this._slider = new Slider.VolumeSlider(0); this.item.secondLine.add_child(this._slider); } this._volumeInfo = new FloatingLabel(); this.item.connect('destroy', this._onDestroy.bind(this)); if (this._onButtonPress) { this.item.connect('button-press-event', this._onButtonPress.bind(this)); } if (this._onKeyPress) { this.item.connect('key-press-event', this._onKeyPress.bind(this)); } if (this._slider.scroll) { this.item.connect('scroll-event', (slider, event) => { return this._slider.emit('scroll-event', event); }); } this.item.connect('scroll-event', () => { this._showVolumeInfo(); }); this._inDrag = false; this._notifyVolumeChangeId = 0; this._soundSettings = new Settings.Settings(Settings.SOUND_SETTINGS_SCHEMA); this._soundSettings.connect(`changed::${Settings.ALLOW_AMPLIFIED_VOLUME_KEY}`, this._amplifySettingsChanged.bind(this), true); this._amplifySettingsChanged(); this._sliderChangedId = this._slider.connect('notify::value', this._sliderChanged.bind(this)); this._slider.connect('drag-begin', () => (this._inDrag = true)); this._slider.connect('drag-end', () => { this._inDrag = false; this._notifyVolumeChange(); }); this.stream = options.stream || null; this._volumeCancellable = null; this._icons = [ 'audio-volume-muted-symbolic', 'audio-volume-low-symbolic', 'audio-volume-medium-symbolic', 'audio-volume-high-symbolic', 'audio-volume-overamplified-symbolic', ]; } _onKeyPress(actor, event) { return this._slider.emit('key-press-event', event); } _onButtonPress(actor, event) { if (event.get_button() === 2) { this.toggleMute(); return Clutter.EVENT_STOP; } return this._slider.startDragging(event); } refresh() { this._updateLabel(); this._updateSliderIcon(); } _updateSliderIcon() { if (this._stream && !this.options.symbolicIcons) { this._icon.gicon = this._stream.get_gicon(); } else { super._updateSliderIcon(); } this.emit('stream-updated'); } _connectStream(stream) { super._connectStream(stream); this.refresh(); } _updateLabel() { this._label.text = this._stream.name || this._stream.description || ''; } _showVolumeInfo(position) { if (!this._stream || !this._volumeInfo) { return; } this._volumeInfo.text = Math.round(this._slider.value * 100) + '%'; if (this._labelTimeoutId) { GLib.source_remove(this._labelTimeoutId); this._labelTimeoutId = undefined; } if (!this._infoShowing) { this._infoShowing = true; let x, y; if (position) { [x, y] = position; x += 15; y += 100; } else { [x, y] = this._slider.get_transformed_position(); x = x + Math.floor(this._slider.get_width() / 2); } this._volumeInfo.show(x, y); } this._labelTimeoutId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 1000, () => { this._infoShowing = false; this._labelTimeoutId = undefined; this._volumeInfo.hide(); return GLib.SOURCE_REMOVE; }); } hideVolumeInfo() { if (this._labelTimeoutId) { GLib.source_remove(this._labelTimeoutId); this._labelTimeoutId = undefined; } this._infoShowing = false; this._volumeInfo.hide(false); } toggleMute() { if (this._stream) { this._stream.change_is_muted(!this._stream.is_muted); } } _onDestroy() { this._isDestroyed = true; // make sure we clean up all bindings if (this._stream) { this._disconnectStream(this._stream); } } }; /** * Slider replacing the master volume slider. */ var MasterSlider = class extends StreamSlider { _init(options) { this.item = new MenuItem.MasterMenuItem(); this.item.menu.actor.add_style_class_name('svm-master-slider-menu'); this._slider = this.item._slider; this._icon = this.item.icon; this._label = this.item.label; this._slider.accessible_name = _('Volume'); super._init(options); this._addSettingsItem(); } /** * Add settings shortcuts to the menu. * * @private */ _addSettingsItem() { this.item.menu.addAction(_('Settings'), () => ExtensionUtils.openPrefs()); } /** * @param {OutputSlider} slider */ addOutputSlider(slider) { this.item.addMenuItem(slider.item); } /** * Override button click to allow for mute / unmute and menu to be opened. */ _onButtonPress(actor, event) { if (event.get_button() === 2) { this.toggleMute(); } return Clutter.EVENT_STOP; } _updateLabel() { this._label.text = this._stream.description; } scroll(event) { const eventResult = super.scroll(event); if (Main.panel.statusArea.aggregateMenu.menu.isOpen) { this._showVolumeInfo(); } return eventResult; } }; /** * Menu item for aggregated input streams. */ var AggregatedInput = class { constructor() { this.item = new PopupMenu.PopupSubMenuMenuItem(__('Inputs'), true); this.item.icon.icon_name = 'applications-multimedia-symbolic'; this.item.accessible_name = __('Inputs'); this._inputStream = null; } setInputStream(inputSlider) { this._inputStream = inputSlider; this.addSlider(inputSlider, 0); } addSlider(slider, pos) { this.item.menu.addMenuItem(slider.item, pos || undefined); slider.connect('stream-updated', () => { this.refresh(); }); } refresh() { this.item.visible = (this.item.menu.numMenuItems > 1 || (this.item.menu.numMenuItems > 0 && this._inputStream.isVisible()) ); } }; /** * Slider for output sinks (e.g. alsa devices, different profiles). */ var OutputSlider = class extends StreamSlider { _init(options) { // make details widget available before parent triggers accessing it if (options.detailed) { this._details = new St.Label({ text: '', style_class: 'svm-slider-details' }); } super._init(options); if (options.detailed) { this.item.addDetails(this._details); } this._cards = options.mixer.cards; this._updateVisibility(false); this._events.connect('default-sink-updated', this._onDefaultSinkUpdated.bind(this)); } _onButtonPress(actor, event) { if (event.get_button() === 1) { this._setAsDefault(); return Clutter.EVENT_PROPAGATE; } return super._onButtonPress(actor, event); } _onKeyPress(actor, event) { let symbol = event.get_key_symbol(); if (symbol === Clutter.KEY_space || symbol === Clutter.KEY_Return) { this._setAsDefault(); return Clutter.EVENT_STOP; } return super._onKeyPress(actor, event); } _updateLabel() { let text = this._stream.description; let description = this._stream.name; this._label.text = text; if (this.options.detailed && text !== description && description) { let parts = description.split('.'); if (parts.length > 1) { if (parts[0] === 'alsa_output') { // remove the common first (and uninteresting) part parts.shift(); } // the last segment of the path is the most interesting one description = parts.pop(); description += ` | ${parts.join('.')}`; } this._details.text = description; } } _setAsDefault() { this._control.set_default_sink(this._stream); } _onDefaultSinkUpdated(/* stream */) { this._updateVisibility(false); } _updateVisibility(forceRefresh = true) { if (!this._shouldBeVisible()) { // set invisible immediately this.item.visible = false; } else { // check if port is available before setting visible (async () => { try { const byPort = await this._shouldBeVisibleByPort(forceRefresh); if (this._isDestroyed) { // async race condition, we're already gone return; } // This could be a race condition with the async code finishing after current conditions have changed. // Therefore we have to check the sync path again. this.item.visible = byPort && this._shouldBeVisible(); } catch (e) { Log.error('OutputSlider', '_updateVisibility', e); } })(); } } _shouldBeVisible() { if (this.options.mixer.defaultSink === this._stream) { Log.info(`Hiding ${this._stream.id}:${this._stream.name}, it's the default sink`); return false; } return super._shouldBeVisible(); } /** * @returns {Promise} * @private */ async _shouldBeVisibleByPort(forceRefresh = true) { if (!this._stream || ! this._cards) { return true; } if (!this._stream.card_index) { Log.error('OutputSlider', '_shouldBeVisibleByPort', 'Stream cannot be identified, no card index available'); return true; } const card = await this._cards.get(this._stream.card_index, forceRefresh); if (!card) { Log.info(`Card ${this._stream.card_index} not found for stream ${this._stream.id}:${this._stream.name}`); return true; } const port = this._stream.port in card.ports ? card.ports[this._stream.port] : null; if (!port) { Log.error('OutputSlider', '_shouldBeVisibleByPort', `Port ${this._stream.port} not found for stream ${this._stream.id}:${this._stream.name}`); return true; } // null == cannot be disabled, true == available, false == not available if (port.available !== false) { return true; } Log.info(`Hiding ${this._stream.id}:${this._stream.name}, port "${this._stream.port}" not available`); return false; } }; /** * Slider for system sounds. */ var EventsSlider = class extends StreamSlider { /** * @param {sliderOptions} options * @private */ _init(options) { super._init(options); this.item.add_style_class_name('events-stream-slider'); this.item.secondLine.add_style_class_name('svm-events-slider-line'); } _updateLabel() { this._label.text = this._stream.name; } }; /** * Slider for input sinks (e.g. media players). */ var InputSlider = class extends StreamSlider { _updateLabel() { let text = this._stream.name; let description = this._stream.description; if (description && text !== description) { if (text) { text = `${description} | ${text}`; } else { text = description; } } this._label.text = text || '[%s]'.format(__('unknown')); } }; /** * Input stream slider (microphones, etc ?). */ var InputStreamSlider = class extends StreamSlider { /** * @param {sliderOptions} options */ _init(options) { super._init(options); this._showInput = false; this._slider.accessible_name = _('Microphone'); this._streamAddedId = this._control.connect('stream-added', this._maybeShowInput.bind(this)); this._streamRemovedId = this._control.connect('stream-removed', this._maybeShowInput.bind(this)); this._icon.icon_name = 'audio-input-microphone-symbolic'; this._icons = [ 'microphone-sensitivity-muted-symbolic', 'microphone-sensitivity-low-symbolic', 'microphone-sensitivity-medium-symbolic', 'microphone-sensitivity-high-symbolic', ]; } _connectStream(stream) { Volume.InputStreamSlider.prototype._connectStream.apply(this, [stream]); this.refresh(); } _maybeShowInput() { if (this.options.showAlways === true) { this._showInput = true; this._updateVisibility(); } else { // we extend from output stream slider impl, but this is an input stream slider Volume.InputStreamSlider.prototype._maybeShowInput.call(this); } } _shouldBeVisible() { return Volume.InputStreamSlider.prototype._shouldBeVisible.call(this); } isVisible() { return this._shouldBeVisible(); } _updateLabel() { this._label.text = _('Microphone'); } _updateSliderIcon() { if (this._stream && !this.options.symbolicIcons) { this._icon.gicon = this._stream.get_gicon(); } else { this._icon.icon_name = 'audio-input-microphone-symbolic'; } this.emit('stream-updated'); } _onDestroy() { if (this._streamAddedId) { this._control.disconnect(this._streamAddedId); } if (this._streamRemovedId) { this._control.disconnect(this._streamRemovedId); } super._onDestroy(); } }; ================================================ FILE: shell-volume-mixer@derhofbauer.at/locale/cs/LC_MESSAGES/gnome-shell-extensions-shell-volume-mixer.po ================================================ # Czech translation for GNOME Shell Volume Mixer. # Copyright (C) 2017 # This file is distributed under the same license as the GNOME Shell Volume Mixer. # Daniel Rusek # msgid "" msgstr "" "Project-Id-Version: GNOME Shell Volume Mixer\n" "PO-Creation-Date: 2017-11-07 23:12+0100\n" "PO-Revision-Date: 2021-12-08 11:37+0100\n" "Last-Translator: Daniel Rusek \n" "Language-Team: \n" "Language: cs\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-Basepath: ..\n" "X-Poedit-KeywordsList: ;__\n" "X-Generator: Poedit 3.0\n" "X-Poedit-SourceCharset: UTF-8\n" "X-Poedit-SearchPath-0: .\n" "X-Poedit-SearchPathExcluded-0: locale\n" "X-Poedit-SearchPathExcluded-1: pautils\n" "X-Poedit-SearchPathExcluded-2: schemas\n" #: shell-volume-mixer@derhofbauer.at/prefs.js:219 msgid "Error retrieving card details" msgstr "Chyba při získávání detailů o kartě" #: shell-volume-mixer@derhofbauer.at/prefs.js:220 msgid "Helper script did not return valid card data." msgstr "Pomocný skript nevrátil platná data o kartě." #: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js:116 msgid "Querying PulseAudio sound cards failed, disabling extension" msgstr "" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:323 #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:325 msgid "Inputs" msgstr "Vstupy" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:541 msgid "unknown" msgstr "neznámé" #: shell-volume-mixer@derhofbauer.at/prefs.ui:39 msgid "Position of volume mixer" msgstr "Umístění směšovače hlasitosti" #: shell-volume-mixer@derhofbauer.at/prefs.ui:53 msgid "Status Menu" msgstr "Stavové menu" #: shell-volume-mixer@derhofbauer.at/prefs.ui:54 msgid "Left" msgstr "Vlevo" #: shell-volume-mixer@derhofbauer.at/prefs.ui:55 msgid "Center" msgstr "Uprostřed" #: shell-volume-mixer@derhofbauer.at/prefs.ui:56 msgid "Right" msgstr "Vpravo" #: shell-volume-mixer@derhofbauer.at/prefs.ui:69 msgid "Remove original slider" msgstr "Odstranit původní posuvníky" #: shell-volume-mixer@derhofbauer.at/prefs.ui:81 msgid "Show detailed sliders" msgstr "Zobrazit detailní posuvníky" #: shell-volume-mixer@derhofbauer.at/prefs.ui:116 msgid "Hotkey for profile switcher" msgstr "Klávesová zkratka pro přepínač profilů" #: shell-volume-mixer@derhofbauer.at/prefs.ui:137 msgid "(e.g. \"p\")" msgstr "(např. „p”)" #: shell-volume-mixer@derhofbauer.at/prefs.ui:149 msgid "Use symbolic icons" msgstr "Použít symbolické ikony" #: shell-volume-mixer@derhofbauer.at/prefs.ui:172 msgid "Show system sounds" msgstr "Zobrazit systémové zvuky" #: shell-volume-mixer@derhofbauer.at/prefs.ui:195 msgid "Show virtual streams" msgstr "Zobrazit virtuální proudy" #: shell-volume-mixer@derhofbauer.at/prefs.ui:218 msgid "Always show input streams" msgstr "Vždy zobrazit vstupní proudy" #: shell-volume-mixer@derhofbauer.at/prefs.ui:240 msgid "Shows the slider even if no application is recording" msgstr "Zobrazí posuvník i pokud žádná aplikace nenahrává zvuk" #: shell-volume-mixer@derhofbauer.at/prefs.ui:288 msgid "Show percentage" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:310 msgid "Whether to show percentage of current output right of the icon" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:340 msgid "Settings" msgstr "Nastavení" #: shell-volume-mixer@derhofbauer.at/prefs.ui:372 #: shell-volume-mixer@derhofbauer.at/prefs.ui:502 msgid "Devices" msgstr "Zařízení" #: shell-volume-mixer@derhofbauer.at/prefs.ui:413 msgid "Card" msgstr "Karta" #: shell-volume-mixer@derhofbauer.at/prefs.ui:427 msgid "Profile" msgstr "Profil" #: shell-volume-mixer@derhofbauer.at/prefs.ui:440 msgid "Switch" msgstr "Přepínač" #: shell-volume-mixer@derhofbauer.at/prefs.ui:455 msgid "Display" msgstr "Zobrazit" ================================================ FILE: shell-volume-mixer@derhofbauer.at/locale/de/LC_MESSAGES/gnome-shell-extensions-shell-volume-mixer.po ================================================ # German translation for GNOME Shell Volume Mixer. # Copyright (C) 2014 # This file is distributed under the same license as the GNOME Shell Volume Mixer. # Jonatan Zedler , 2014. # Alexander Hofbauer , 2014. # msgid "" msgstr "" "Project-Id-Version: GNOME Shell Volume Mixer\n" "Report-Msgid-Bugs-To: \n" "PO-Creation-Date: 2014-11-04 21:00+0100\n" "PO-Revision-Date: 2021-12-08 11:35+0100\n" "Last-Translator: Alexander Hofbauer \n" "Language-Team: German\n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.0\n" "X-Poedit-SourceCharset: UTF-8\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: shell-volume-mixer@derhofbauer.at/prefs.js:219 msgid "Error retrieving card details" msgstr "Fehler beim Abfragen der Karten" #: shell-volume-mixer@derhofbauer.at/prefs.js:220 msgid "Helper script did not return valid card data." msgstr "Hilfsprogramm gab keine gültigen Daten zurück." #: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js:116 msgid "Querying PulseAudio sound cards failed, disabling extension" msgstr "Abfrage der PulseAudio-Soundkarten fehlgeschlagen, Erweiterung deaktiviert" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:323 #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:325 msgid "Inputs" msgstr "Eingänge" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:541 msgid "unknown" msgstr "unbekannt" #: shell-volume-mixer@derhofbauer.at/prefs.ui:39 msgid "Position of volume mixer" msgstr "Position des Lautstärkereglers" #: shell-volume-mixer@derhofbauer.at/prefs.ui:53 msgid "Status Menu" msgstr "Statusmenü" #: shell-volume-mixer@derhofbauer.at/prefs.ui:54 msgid "Left" msgstr "Links" #: shell-volume-mixer@derhofbauer.at/prefs.ui:55 msgid "Center" msgstr "Zentriert" #: shell-volume-mixer@derhofbauer.at/prefs.ui:56 msgid "Right" msgstr "Rechts" #: shell-volume-mixer@derhofbauer.at/prefs.ui:69 msgid "Remove original slider" msgstr "Originalen Lautstärkeregler entfernen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:81 msgid "Show detailed sliders" msgstr "Details an den Schiebereglern anzeigen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:116 msgid "Hotkey for profile switcher" msgstr "Hotkey für Profilumschalter" #: shell-volume-mixer@derhofbauer.at/prefs.ui:137 msgid "(e.g. \"p\")" msgstr "(z.B. \"p\")" #: shell-volume-mixer@derhofbauer.at/prefs.ui:149 msgid "Use symbolic icons" msgstr "Symbolische Icons verwenden" #: shell-volume-mixer@derhofbauer.at/prefs.ui:172 msgid "Show system sounds" msgstr "Systemklänge anzeigen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:195 msgid "Show virtual streams" msgstr "Virtuelle Streams anzeigen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:218 msgid "Always show input streams" msgstr "Eingänge immer anzeigen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:240 msgid "Shows the slider even if no application is recording" msgstr "" "Zeigt Aufnahmeströme (Mikrofone) immer an, auch wenn keine Applikation " "gerade aufnimmt" #: shell-volume-mixer@derhofbauer.at/prefs.ui:288 msgid "Show percentage" msgstr "Prozent anzeigen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:310 msgid "Whether to show percentage of current output right of the icon" msgstr "Lautstärke des aktuellen Ausgangs in Prozent neben dem Icon anzeigen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:340 msgid "Settings" msgstr "Einstellungen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:372 #: shell-volume-mixer@derhofbauer.at/prefs.ui:502 msgid "Devices" msgstr "Geräte" #: shell-volume-mixer@derhofbauer.at/prefs.ui:413 msgid "Card" msgstr "Karte" #: shell-volume-mixer@derhofbauer.at/prefs.ui:427 msgid "Profile" msgstr "Profil" #: shell-volume-mixer@derhofbauer.at/prefs.ui:440 msgid "Switch" msgstr "Umschalter" #: shell-volume-mixer@derhofbauer.at/prefs.ui:455 msgid "Display" msgstr "Anzeigen" ================================================ FILE: shell-volume-mixer@derhofbauer.at/locale/it/LC_MESSAGES/gnome-shell-extensions-shell-volume-mixer.po ================================================ # This file is distributed under the same license as the GNOME Shell Volume Mixer. # Gianvito Cavasoli , 2017. # msgid "" msgstr "" "Project-Id-Version: GNOME Shell Volume Mixer\n" "PO-Creation-Date: 2017-11-07 23:12+0100\n" "PO-Revision-Date: 2021-12-08 11:37+0100\n" "Last-Translator: Gianvito Cavasoli \n" "Language-Team: \n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Generator: Poedit 3.0\n" #: shell-volume-mixer@derhofbauer.at/prefs.js:219 msgid "Error retrieving card details" msgstr "Errore nel recuperare le informazioni sulla scheda" #: shell-volume-mixer@derhofbauer.at/prefs.js:220 msgid "Helper script did not return valid card data." msgstr "Lo script di aiuto non ha restituito dati della scheda validi" #: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js:116 msgid "Querying PulseAudio sound cards failed, disabling extension" msgstr "" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:323 #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:325 msgid "Inputs" msgstr "Sorgenti" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:541 msgid "unknown" msgstr "sconosciuto" #: shell-volume-mixer@derhofbauer.at/prefs.ui:39 msgid "Position of volume mixer" msgstr "Posizione mixer del volume" #: shell-volume-mixer@derhofbauer.at/prefs.ui:53 msgid "Status Menu" msgstr "Menù di stato" #: shell-volume-mixer@derhofbauer.at/prefs.ui:54 msgid "Left" msgstr "Sinistra" #: shell-volume-mixer@derhofbauer.at/prefs.ui:55 msgid "Center" msgstr "Centro" #: shell-volume-mixer@derhofbauer.at/prefs.ui:56 msgid "Right" msgstr "Destra" #: shell-volume-mixer@derhofbauer.at/prefs.ui:69 msgid "Remove original slider" msgstr "Rimuovere cursore di scorrimento originale" #: shell-volume-mixer@derhofbauer.at/prefs.ui:81 msgid "Show detailed sliders" msgstr "Mostrare cursore di scorrimento dettagliato" #: shell-volume-mixer@derhofbauer.at/prefs.ui:116 msgid "Hotkey for profile switcher" msgstr "Scorciatoia selettore del profilo" #: shell-volume-mixer@derhofbauer.at/prefs.ui:137 msgid "(e.g. \"p\")" msgstr "(esempio «p»)" #: shell-volume-mixer@derhofbauer.at/prefs.ui:149 msgid "Use symbolic icons" msgstr "Usare le icone simboliche" #: shell-volume-mixer@derhofbauer.at/prefs.ui:172 msgid "Show system sounds" msgstr "Mostra i suoni di sistema" #: shell-volume-mixer@derhofbauer.at/prefs.ui:195 msgid "Show virtual streams" msgstr "Mostrare i flussi virtuali" #: shell-volume-mixer@derhofbauer.at/prefs.ui:218 msgid "Always show input streams" msgstr "Mostrare sempre i flussi di input" #: shell-volume-mixer@derhofbauer.at/prefs.ui:240 msgid "Shows the slider even if no application is recording" msgstr "Mostra il cursore di scorrimento anche se nessuna applicazione sta registrando" #: shell-volume-mixer@derhofbauer.at/prefs.ui:288 msgid "Show percentage" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:310 msgid "Whether to show percentage of current output right of the icon" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:340 msgid "Settings" msgstr "Impostazioni" #: shell-volume-mixer@derhofbauer.at/prefs.ui:372 #: shell-volume-mixer@derhofbauer.at/prefs.ui:502 msgid "Devices" msgstr "Dispositivi" #: shell-volume-mixer@derhofbauer.at/prefs.ui:413 msgid "Card" msgstr "Scheda" #: shell-volume-mixer@derhofbauer.at/prefs.ui:427 msgid "Profile" msgstr "Profilo" #: shell-volume-mixer@derhofbauer.at/prefs.ui:440 msgid "Switch" msgstr "Attiva" #: shell-volume-mixer@derhofbauer.at/prefs.ui:455 msgid "Display" msgstr "Visualizza" ================================================ FILE: shell-volume-mixer@derhofbauer.at/locale/nl/LC_MESSAGES/gnome-shell-extensions-shell-volume-mixer.po ================================================ # This file is distributed under the same license as the GNOME Shell Volume Mixer. # Heimen Stoffels , 2020. msgid "" msgstr "" "Project-Id-Version: \n" "PO-Creation-Date: 2020-10-26 13:47+0100\n" "PO-Revision-Date: 2021-12-08 11:37+0100\n" "Last-Translator: Heimen Stoffels \n" "Language-Team: \n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.0\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" #: shell-volume-mixer@derhofbauer.at/prefs.js:219 msgid "Error retrieving card details" msgstr "Fout tijdens ophalen van kaartinformatie" #: shell-volume-mixer@derhofbauer.at/prefs.js:220 msgid "Helper script did not return valid card data." msgstr "Het hulpscript koppelde geen geldige kaartinformatie terug." #: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js:116 msgid "Querying PulseAudio sound cards failed, disabling extension" msgstr "" "Het opvragen van PulseAudio-geluidskaarten is mislukt - de uitbreiding wordt uitgeschakeld" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:323 #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:325 msgid "Inputs" msgstr "Invoerbronnen" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:541 msgid "unknown" msgstr "onbekend" #: shell-volume-mixer@derhofbauer.at/prefs.ui:39 msgid "Position of volume mixer" msgstr "Positie van volumemixer" #: shell-volume-mixer@derhofbauer.at/prefs.ui:53 msgid "Status Menu" msgstr "Statusmenu" #: shell-volume-mixer@derhofbauer.at/prefs.ui:54 msgid "Left" msgstr "Links" #: shell-volume-mixer@derhofbauer.at/prefs.ui:55 msgid "Center" msgstr "Midden" #: shell-volume-mixer@derhofbauer.at/prefs.ui:56 msgid "Right" msgstr "Rechts" #: shell-volume-mixer@derhofbauer.at/prefs.ui:69 msgid "Remove original slider" msgstr "GNOME's schuifbalk verbergen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:81 msgid "Show detailed sliders" msgstr "Uitgebreide schuifbalken tonen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:116 msgid "Hotkey for profile switcher" msgstr "Sneltoets om te wisselen van profiel" #: shell-volume-mixer@derhofbauer.at/prefs.ui:137 msgid "(e.g. \"p\")" msgstr "(bijv. 'p')" #: shell-volume-mixer@derhofbauer.at/prefs.ui:149 msgid "Use symbolic icons" msgstr "Symbolische pictogrammen gebruiken" #: shell-volume-mixer@derhofbauer.at/prefs.ui:172 msgid "Show system sounds" msgstr "Systeemgeluiden tonen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:195 msgid "Show virtual streams" msgstr "Virtuele streams tonen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:218 msgid "Always show input streams" msgstr "Altijd invoerstreams tonen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:240 msgid "Shows the slider even if no application is recording" msgstr "Toon de schuifbalk ook als er geen opnames plaatsvinden" #: shell-volume-mixer@derhofbauer.at/prefs.ui:288 msgid "Show percentage" msgstr "Percentage tonen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:310 msgid "Whether to show percentage of current output right of the icon" msgstr "Toon het volumepercentage van de huidige uitvoerbron op het pictogram" #: shell-volume-mixer@derhofbauer.at/prefs.ui:340 msgid "Settings" msgstr "Instellingen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:372 #: shell-volume-mixer@derhofbauer.at/prefs.ui:502 msgid "Devices" msgstr "Apparaten" #: shell-volume-mixer@derhofbauer.at/prefs.ui:413 msgid "Card" msgstr "Kaart" #: shell-volume-mixer@derhofbauer.at/prefs.ui:427 msgid "Profile" msgstr "Profiel" #: shell-volume-mixer@derhofbauer.at/prefs.ui:440 msgid "Switch" msgstr "Wisselen" #: shell-volume-mixer@derhofbauer.at/prefs.ui:455 msgid "Display" msgstr "Weergave" ================================================ FILE: shell-volume-mixer@derhofbauer.at/locale/pl/LC_MESSAGES/gnome-shell-extensions-shell-volume-mixer.po ================================================ # Polish translation for GNOME Shell Volume Mixer. # Copyright (C) 2017 # This file is distributed under the same license as the GNOME Shell Volume Mixer. # Piotr Komur, pkomur@gmail.com, 2017. # msgid "" msgstr "" "Project-Id-Version: GNOME Shell Volume Mixer\n" "Report-Msgid-Bugs-To: \n" "PO-Creation-Date: 2017-11-07 23:12+0100\n" "PO-Revision-Date: 2021-12-08 11:36+0100\n" "Last-Translator: Piotr Komur \n" "Language-Team: \n" "Language: pl_PL\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.0\n" "X-Poedit-SourceCharset: UTF-8\n" "Plural-Forms: nplurals=3; plural=(n==1 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" "X-Poedit-Basepath: .\n" "X-Poedit-KeywordsList: _;title;label\n" #: shell-volume-mixer@derhofbauer.at/prefs.js:219 msgid "Error retrieving card details" msgstr "Błąd podczas pobierania danych karty" #: shell-volume-mixer@derhofbauer.at/prefs.js:220 msgid "Helper script did not return valid card data." msgstr "Narzędzie nie zwróciło prawidłowych danych." #: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js:116 msgid "Querying PulseAudio sound cards failed, disabling extension" msgstr "" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:323 #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:325 msgid "Inputs" msgstr "Wejścia" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:541 msgid "unknown" msgstr "nieznany" #: shell-volume-mixer@derhofbauer.at/prefs.ui:39 msgid "Position of volume mixer" msgstr "Położenie miksera" #: shell-volume-mixer@derhofbauer.at/prefs.ui:53 msgid "Status Menu" msgstr "Menu systemowe" #: shell-volume-mixer@derhofbauer.at/prefs.ui:54 msgid "Left" msgstr "Z lewej" #: shell-volume-mixer@derhofbauer.at/prefs.ui:55 msgid "Center" msgstr "Po środku" #: shell-volume-mixer@derhofbauer.at/prefs.ui:56 msgid "Right" msgstr "Z prawej" #: shell-volume-mixer@derhofbauer.at/prefs.ui:69 msgid "Remove original slider" msgstr "Ukryj systemowy regulator" #: shell-volume-mixer@derhofbauer.at/prefs.ui:81 msgid "Show detailed sliders" msgstr "Pokaż szczegóły urządzenia" #: shell-volume-mixer@derhofbauer.at/prefs.ui:116 msgid "Hotkey for profile switcher" msgstr "Skrót klawiszowy przełączania profilów" #: shell-volume-mixer@derhofbauer.at/prefs.ui:137 msgid "(e.g. \"p\")" msgstr "(np. \"p\")" #: shell-volume-mixer@derhofbauer.at/prefs.ui:149 msgid "Use symbolic icons" msgstr "Pokaż ikony symboliczne" #: shell-volume-mixer@derhofbauer.at/prefs.ui:172 msgid "Show system sounds" msgstr "Pokaż dźwięki systemowe" #: shell-volume-mixer@derhofbauer.at/prefs.ui:195 msgid "Show virtual streams" msgstr "Pokaż strumienie wirtualne" #: shell-volume-mixer@derhofbauer.at/prefs.ui:218 msgid "Always show input streams" msgstr "Pokaż strumienie wejściowe" #: shell-volume-mixer@derhofbauer.at/prefs.ui:240 msgid "Shows the slider even if no application is recording" msgstr "(Pokazuje regulatory, nawet przy braku aktywnych programów nagrywających)" #: shell-volume-mixer@derhofbauer.at/prefs.ui:288 msgid "Show percentage" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:310 msgid "Whether to show percentage of current output right of the icon" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:340 msgid "Settings" msgstr "Ustawienia" #: shell-volume-mixer@derhofbauer.at/prefs.ui:372 #: shell-volume-mixer@derhofbauer.at/prefs.ui:502 msgid "Devices" msgstr "Urządzenia" #: shell-volume-mixer@derhofbauer.at/prefs.ui:413 msgid "Card" msgstr "Karta" #: shell-volume-mixer@derhofbauer.at/prefs.ui:427 msgid "Profile" msgstr "Profil" #: shell-volume-mixer@derhofbauer.at/prefs.ui:440 msgid "Switch" msgstr "Przełącznik" #: shell-volume-mixer@derhofbauer.at/prefs.ui:455 msgid "Display" msgstr "Wyświetlacz" ================================================ FILE: shell-volume-mixer@derhofbauer.at/locale/pt_BR/LC_MESSAGES/gnome-shell-extensions-shell-volume-mixer.po ================================================ # Brazilian Portuguese translation for GNOME Shell Volume Mixer. # This file is distributed under the same license as the GNOME Shell Volume Mixer. # Ricardo Silva Veloso , 2018. # msgid "" msgstr "" "Project-Id-Version: GNOME Shell Volume Mixer\n" "PO-Creation-Date: 2017-11-07 23:12+0100\n" "PO-Revision-Date: 2021-12-08 11:37+0100\n" "Last-Translator: Ricardo Silva Veloso \n" "Language-Team: \n" "Language: pt_BR\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n > 1);\n" "X-Poedit-Basepath: ..\n" "X-Poedit-KeywordsList: ;__\n" "X-Generator: Poedit 3.0\n" "X-Poedit-SourceCharset: UTF-8\n" "X-Poedit-SearchPath-0: .\n" "X-Poedit-SearchPathExcluded-0: locale\n" "X-Poedit-SearchPathExcluded-1: pautils\n" "X-Poedit-SearchPathExcluded-2: schemas\n" #: shell-volume-mixer@derhofbauer.at/prefs.js:219 msgid "Error retrieving card details" msgstr "Erro ao recuperar detalhes da placa" #: shell-volume-mixer@derhofbauer.at/prefs.js:220 msgid "Helper script did not return valid card data." msgstr "O script ajudante não retornou dados de placa válidos." #: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js:116 msgid "Querying PulseAudio sound cards failed, disabling extension" msgstr "" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:323 #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:325 msgid "Inputs" msgstr "Entradas" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:541 msgid "unknown" msgstr "desconhecido" #: shell-volume-mixer@derhofbauer.at/prefs.ui:39 msgid "Position of volume mixer" msgstr "Posição do mixador de volume" #: shell-volume-mixer@derhofbauer.at/prefs.ui:53 msgid "Status Menu" msgstr "Menu de Status" #: shell-volume-mixer@derhofbauer.at/prefs.ui:54 msgid "Left" msgstr "Esquerda" #: shell-volume-mixer@derhofbauer.at/prefs.ui:55 msgid "Center" msgstr "Centro" #: shell-volume-mixer@derhofbauer.at/prefs.ui:56 msgid "Right" msgstr "Direita" #: shell-volume-mixer@derhofbauer.at/prefs.ui:69 msgid "Remove original slider" msgstr "Remover controle deslizante" #: shell-volume-mixer@derhofbauer.at/prefs.ui:81 msgid "Show detailed sliders" msgstr "Exibir controles deslizantes detalhados" #: shell-volume-mixer@derhofbauer.at/prefs.ui:116 msgid "Hotkey for profile switcher" msgstr "Tecla de atalho para seletor de perfil" #: shell-volume-mixer@derhofbauer.at/prefs.ui:137 msgid "(e.g. \"p\")" msgstr "(ex. \"p\")" #: shell-volume-mixer@derhofbauer.at/prefs.ui:149 msgid "Use symbolic icons" msgstr "Usar ícones simbólicos" #: shell-volume-mixer@derhofbauer.at/prefs.ui:172 msgid "Show system sounds" msgstr "Exibir sons do sistema" #: shell-volume-mixer@derhofbauer.at/prefs.ui:195 msgid "Show virtual streams" msgstr "Exibir fluxos virtuais" #: shell-volume-mixer@derhofbauer.at/prefs.ui:218 msgid "Always show input streams" msgstr "Sempre exibir fluxos de entrada" #: shell-volume-mixer@derhofbauer.at/prefs.ui:240 msgid "Shows the slider even if no application is recording" msgstr "Exibe o controle deslizante mesmo se nenhum aplicativo estiver gravando" #: shell-volume-mixer@derhofbauer.at/prefs.ui:288 msgid "Show percentage" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:310 msgid "Whether to show percentage of current output right of the icon" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:340 msgid "Settings" msgstr "Configurações" #: shell-volume-mixer@derhofbauer.at/prefs.ui:372 #: shell-volume-mixer@derhofbauer.at/prefs.ui:502 msgid "Devices" msgstr "Dispositivos" #: shell-volume-mixer@derhofbauer.at/prefs.ui:413 msgid "Card" msgstr "Placa" #: shell-volume-mixer@derhofbauer.at/prefs.ui:427 msgid "Profile" msgstr "Perfil" #: shell-volume-mixer@derhofbauer.at/prefs.ui:440 msgid "Switch" msgstr "Ativar" #: shell-volume-mixer@derhofbauer.at/prefs.ui:455 msgid "Display" msgstr "Mostrar" ================================================ FILE: shell-volume-mixer@derhofbauer.at/locale/ru/LC_MESSAGES/gnome-shell-extensions-shell-volume-mixer.po ================================================ # This file is distributed under the same license as the GNOME Shell Volume Mixer. # Alexandr K , 2021. msgid "" msgstr "" "Project-Id-Version: gnome-shell-volume-mixer\n" "PO-Creation-Date: 2021-11-03 18:39+0100\n" "PO-Revision-Date: 2021-12-08 11:37+0100\n" "Last-Translator: Alexandr K \n" "Language-Team: Alexandr K \n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 3.0\n" "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : 2);\n" #: shell-volume-mixer@derhofbauer.at/prefs.js:219 msgid "Error retrieving card details" msgstr "Ошибка при получении сведений о карте" #: shell-volume-mixer@derhofbauer.at/prefs.js:220 msgid "Helper script did not return valid card data." msgstr "Вспомогательный скрипт не вернул действительные данные карты." #: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js:116 msgid "Querying PulseAudio sound cards failed, disabling extension" msgstr "Сбой запроса звуковых карт PulseAudio, отключение расширения" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:323 #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:325 msgid "Inputs" msgstr "Входы" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:541 msgid "unknown" msgstr "неизвестно" #: shell-volume-mixer@derhofbauer.at/prefs.ui:39 msgid "Position of volume mixer" msgstr "Позиция volume mixer" #: shell-volume-mixer@derhofbauer.at/prefs.ui:53 msgid "Status Menu" msgstr "В меню состояния" #: shell-volume-mixer@derhofbauer.at/prefs.ui:54 msgid "Left" msgstr "Слева" #: shell-volume-mixer@derhofbauer.at/prefs.ui:55 msgid "Center" msgstr "По центру" #: shell-volume-mixer@derhofbauer.at/prefs.ui:56 msgid "Right" msgstr "Справо" #: shell-volume-mixer@derhofbauer.at/prefs.ui:69 msgid "Remove original slider" msgstr "Скрыть оригинальный ползунок" #: shell-volume-mixer@derhofbauer.at/prefs.ui:81 msgid "Show detailed sliders" msgstr "Показывать детали под ползунком" #: shell-volume-mixer@derhofbauer.at/prefs.ui:116 msgid "Hotkey for profile switcher" msgstr "Горячая клавиша для переключателя профилей" #: shell-volume-mixer@derhofbauer.at/prefs.ui:137 msgid "(e.g. \"p\")" msgstr "(например, \"p\")" #: shell-volume-mixer@derhofbauer.at/prefs.ui:149 msgid "Use symbolic icons" msgstr "Использовать символьные иконки" #: shell-volume-mixer@derhofbauer.at/prefs.ui:172 msgid "Show system sounds" msgstr "Показывать системные звуки" #: shell-volume-mixer@derhofbauer.at/prefs.ui:195 msgid "Show virtual streams" msgstr "Показать действующие устройства" #: shell-volume-mixer@derhofbauer.at/prefs.ui:218 msgid "Always show input streams" msgstr "Всегда показывать устройства ввода" #: shell-volume-mixer@derhofbauer.at/prefs.ui:240 msgid "Shows the slider even if no application is recording" msgstr "Показывает ползунок, даже если приложение не записывает" #: shell-volume-mixer@derhofbauer.at/prefs.ui:288 msgid "Show percentage" msgstr "Показать процент громкости" #: shell-volume-mixer@derhofbauer.at/prefs.ui:310 msgid "Whether to show percentage of current output right of the icon" msgstr "Показывать ли процент от текущего выходного сигнала справа от значка" #: shell-volume-mixer@derhofbauer.at/prefs.ui:340 msgid "Settings" msgstr "Общие настройки" #: shell-volume-mixer@derhofbauer.at/prefs.ui:372 #: shell-volume-mixer@derhofbauer.at/prefs.ui:502 msgid "Devices" msgstr "Устройства вывода" #: shell-volume-mixer@derhofbauer.at/prefs.ui:413 msgid "Card" msgstr "Карты" #: shell-volume-mixer@derhofbauer.at/prefs.ui:427 msgid "Profile" msgstr "Профиль" #: shell-volume-mixer@derhofbauer.at/prefs.ui:440 msgid "Switch" msgstr "Переключить" #: shell-volume-mixer@derhofbauer.at/prefs.ui:455 msgid "Display" msgstr "Показать" ================================================ FILE: shell-volume-mixer@derhofbauer.at/locale/translations.pot ================================================ #: shell-volume-mixer@derhofbauer.at/prefs.js:219 msgid "Error retrieving card details" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.js:220 msgid "Helper script did not return valid card data." msgstr "" #: shell-volume-mixer@derhofbauer.at/lib/utils/cards.js:116 msgid "Querying PulseAudio sound cards failed, disabling extension" msgstr "" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:323 #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:325 msgid "Inputs" msgstr "" #: shell-volume-mixer@derhofbauer.at/lib/widget/volume.js:541 msgid "unknown" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:39 msgid "Position of volume mixer" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:53 msgid "Status Menu" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:54 msgid "Left" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:55 msgid "Center" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:56 msgid "Right" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:69 msgid "Remove original slider" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:81 msgid "Show detailed sliders" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:116 msgid "Hotkey for profile switcher" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:137 msgid "(e.g. \"p\")" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:149 msgid "Use symbolic icons" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:172 msgid "Show system sounds" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:195 msgid "Show virtual streams" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:218 msgid "Always show input streams" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:240 msgid "Shows the slider even if no application is recording" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:288 msgid "Show percentage" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:310 msgid "Whether to show percentage of current output right of the icon" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:340 msgid "Settings" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:372 #: shell-volume-mixer@derhofbauer.at/prefs.ui:502 msgid "Devices" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:413 msgid "Card" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:427 msgid "Profile" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:440 msgid "Switch" msgstr "" #: shell-volume-mixer@derhofbauer.at/prefs.ui:455 msgid "Display" msgstr "" ================================================ FILE: shell-volume-mixer@derhofbauer.at/metadata.json ================================================ { "version": 9999, "uuid": "shell-volume-mixer@derhofbauer.at", "name": "Volume Mixer", "description": "Applet allowing separate configuration of PulseAudio mixers.\n\nShell Volume Mixer is an extension for GNOME Shell allowing separate configuration of PulseAudio devices and output switches. It features a profile switcher to quickly switch between pinned profiles and devices.\n\nMiddle mouse click on a slider mutes the selected stream.\n\nPlease file bugs and feature requests on the GitHub page.", "shell-version": [ "42" ], "settings-schema": "org.gnome.shell.extensions.shell-volume-mixer", "gettext-domain": "gnome-shell-extensions-shell-volume-mixer", "url": "https://github.com/aleho/gnome-shell-volume-mixer" } ================================================ FILE: shell-volume-mixer@derhofbauer.at/pautils/lib/__init__.py ================================================ ================================================ FILE: shell-volume-mixer@derhofbauer.at/pautils/lib/cards.py ================================================ # This file is part of GNOME Shell Volume Mixer # Copyright (C) 2021 Alexander Hofbauer # # 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 3 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, see . from .pulseaudio import Pulseaudio from . import log from . import libpulse class Cards(Pulseaudio): def build_callback(self): return libpulse.card_info_cb_t(self.pa_cb) def get_by_index(self, index, callback): return libpulse.context_get_card_info_by_index(self._context, index, callback, None) def get_by_name(self, name, callback): return libpulse.context_get_card_info_by_name(self._context, name, callback, None) def get_all(self, callback): return libpulse.context_get_card_info_list(self._context, callback, None) def cb_data(self, pa_card): card_name = pa_card.name.decode('utf8') try: alsa_card = libpulse.proplist_gets(pa_card.proplist, b'alsa.card') alsa_card = int(alsa_card.decode('utf8')) if alsa_card else None except Exception: log.debug('No property "alsa.card"') alsa_card = None try: description = libpulse.proplist_gets(pa_card.proplist, b'device.description') description = description.decode('utf8') if description else None except Exception: log.debug('No property "device.description"') description = None if not description: try: description = libpulse.proplist_gets(pa_card.proplist, b'alsa.card_name') description = description.decode('utf8') if description else None except Exception: log.debug('No property "alsa.card_name"') description = card_name card = { 'index': pa_card.index, 'alsaCard': alsa_card, 'name': card_name, 'description': description, 'active_profile': None, 'profiles': { }, 'ports': { }, } if pa_card.active_profile and pa_card.active_profile[0]: ap = pa_card.active_profile[0] card['active_profile'] = ap.name.decode('utf8') for i in range(0, pa_card.n_profiles): if not pa_card.profiles2[i] or not pa_card.profiles2[i][0]: continue profile = pa_card.profiles2[i][0] name = profile.name.decode('utf8') card['profiles'][name] = { 'name': name, 'description': profile.description.decode('utf8'), 'available': bool(profile.available), } for index in range(0, pa_card.n_ports): if not pa_card.ports[index] or not pa_card.ports[index][0]: continue port = pa_card.ports[index][0] name = port.name.decode('utf8') card['ports'][name] = { 'name': name, 'description': port.description.decode('utf8'), 'direction': 'out' if port.direction == 1 else 'in', 'available': True if port.available == 2 else (False if port.available == 1 else None), } return card ================================================ FILE: shell-volume-mixer@derhofbauer.at/pautils/lib/libpulse.py ================================================ # This library was generated through introspection of # /usr/include/pulse/introspect.h and # /usr/include/pulse/mainloop.h. # (E.g.: # python /usr/share/pyshared/ctypeslib/h2xml.py /usr/include/pulse/introspect.h -o pa.xml # python /usr/share/pyshared/ctypeslib/xml2py.py pa.xml -k f -l /usr/lib/libpulse.so -o pa.py # ) # # License according to the header files listed above: # GNU Lesser General Public License # # This file is part of GNOME Shell Volume Mixer # Copyright (C) 2014 Alexander Hofbauer from ctypes import * try: lib = CDLL('libpulse.so.0') except: lib = CDLL('libpulse.so') STRING = c_char_p WSTRING = c_wchar_p class mainloop(Structure): pass class mainloop_api(Structure): pass class spawn_api(Structure): pass class context(Structure): pass class operation(Structure): pass class proplist(Structure): pass class card_profile_info(Structure): _fields_ = [ ('name', STRING), ('description', STRING), ('n_sinks', c_uint32), ('n_sources', c_uint32), ('priority', c_uint32), ] class card_profile_info2(Structure): _fields_ = [ ('name', STRING), ('description', STRING), ('n_sinks', c_uint32), ('n_sources', c_uint32), ('priority', c_uint32), ('available', c_int), ] class card_port_info(Structure): _fields_ = [ ('name', STRING), ('description', STRING), ('priority', c_uint32), ('available', c_int), ('direction', c_int), ('n_profiles', c_uint32), ('profiles', POINTER(POINTER(card_profile_info))), ('proplist', POINTER(proplist)), ('latency_offset', c_int64), ('profiles2', POINTER(POINTER(card_profile_info2))), ] class card_info(Structure): _fields_ = [ ('index', c_uint32), ('name', STRING), ('owner_module', c_uint32), ('driver', STRING), ('n_profiles', c_uint32), ('profiles', POINTER(card_profile_info)), ('active_profile', POINTER(card_profile_info)), ('proplist', POINTER(proplist)), ('n_ports', c_uint32), ('ports', POINTER(POINTER(card_port_info))), ('profiles2', POINTER(POINTER(card_profile_info2))), ('active_profile2', POINTER(card_profile_info2)), ] class sink_port_info(Structure): _fields_ = [ ('name', STRING), ('description', STRING), ('priority', c_uint32), ('available', c_int), ('availability_group', STRING), ('type', c_uint32), ] class format_info(Structure): _fields_ = [ ('encoding', c_int), ('plist', POINTER(proplist)), ] class sample_spec(Structure): _fields_ = [ ('format', c_int), ('rate', c_uint32), ('channels', c_uint8), ] class pa_channel_map(Structure): _fields_ = [ ('channels', c_uint8), ('map', c_int * int(32)), ] class pa_cvolume(Structure): _fields_ = [ ('channels', c_uint8), ('values', c_uint32 * int(32)), ] class sink_info(Structure): _fields_ = [ ('name', STRING), ('index', c_uint32), ('description', STRING), ('sample_spec', sample_spec), ('channel_map', pa_channel_map), ('owner_module', c_uint32), ('volume', pa_cvolume), ('mute', c_int), ('monitor_source', c_uint32), ('monitor_source_name', STRING), ('latency', c_uint64), ('driver', STRING), ('flags', c_int), ('proplist', POINTER(proplist)), ('configured_latency', c_uint64), ('base_volume', c_uint32), ('state', c_int), ('n_volume_steps', c_uint32), ('card', c_uint32), ('n_ports', c_uint32), ('ports', POINTER(POINTER(sink_port_info))), ('active_port', POINTER(sink_port_info)), ('n_formats', c_uint8), ('formats', POINTER(POINTER(format_info))), ] mainloop_new = lib.pa_mainloop_new mainloop_new.restype = POINTER(mainloop) mainloop_new.argtypes = [] mainloop_get_api = lib.pa_mainloop_get_api mainloop_get_api.restype = POINTER(mainloop_api) mainloop_get_api.argtypes = [POINTER(mainloop)] mainloop_iterate = lib.pa_mainloop_iterate mainloop_iterate.restype = c_int mainloop_iterate.argtypes = [POINTER(mainloop), c_int, POINTER(c_int)] mainloop_free = lib.pa_mainloop_free mainloop_free.restype = None mainloop_free.argtypes = [POINTER(mainloop)] context_flags = c_int # enum context_flags_t = context_flags context_state = c_int # enum context_state_t = context_state # values for enumeration 'context_state' CONTEXT_UNCONNECTED = 0 CONTEXT_CONNECTING = 1 CONTEXT_AUTHORIZING = 2 CONTEXT_SETTING_NAME = 3 CONTEXT_READY = 4 CONTEXT_FAILED = 5 CONTEXT_TERMINATED = 6 context_new = lib.pa_context_new context_new.restype = POINTER(context) context_new.argtypes = [POINTER(mainloop_api), STRING] context_notify_cb_t = CFUNCTYPE(None, POINTER(context), c_void_p) context_set_state_callback = lib.pa_context_set_state_callback context_set_state_callback.restype = None context_set_state_callback.argtypes = [POINTER(context), context_notify_cb_t, c_void_p] context_connect = lib.pa_context_connect context_connect.restype = c_int context_connect.argtypes = [POINTER(context), STRING, context_flags_t, POINTER(spawn_api)] context_disconnect = lib.pa_context_disconnect context_disconnect.restype = None context_disconnect.argtypes = [POINTER(context)] context_unref = lib.pa_context_unref context_unref.restype = None context_unref.argtypes = [POINTER(context)] context_get_state = lib.pa_context_get_state context_get_state.restype = context_state_t context_get_state.argtypes = [POINTER(context)] operation_unref = lib.pa_operation_unref operation_unref.restype = None operation_unref.argtypes = [POINTER(operation)] card_info_cb_t = CFUNCTYPE(None, POINTER(context), POINTER(card_info), c_int, c_void_p) context_get_card_info_by_index = lib.pa_context_get_card_info_by_index context_get_card_info_by_index.restype = POINTER(operation) context_get_card_info_by_index.argtypes = [POINTER(context), c_uint32, card_info_cb_t, c_void_p] context_get_card_info_by_name = lib.pa_context_get_card_info_by_name context_get_card_info_by_name.restype = POINTER(operation) context_get_card_info_by_name.argtypes = [POINTER(context), STRING, card_info_cb_t, c_void_p] context_get_card_info_list = lib.pa_context_get_card_info_list context_get_card_info_list.restype = POINTER(operation) context_get_card_info_list.argtypes = [POINTER(context), card_info_cb_t, c_void_p] sink_info_cb_t = CFUNCTYPE(None, POINTER(context), POINTER(sink_info), c_int, c_void_p) context_get_sink_info_by_index = lib.pa_context_get_sink_info_by_index context_get_sink_info_by_index.restype = POINTER(operation) context_get_sink_info_by_index.argtypes = [POINTER(context), c_uint32, sink_info_cb_t, c_void_p] context_get_sink_info_by_name = lib.pa_context_get_sink_info_by_name context_get_sink_info_by_name.restype = POINTER(operation) context_get_sink_info_by_name.argtypes = [POINTER(context), STRING, sink_info_cb_t, c_void_p] context_get_sink_info_list = lib.pa_context_get_sink_info_list context_get_sink_info_list.restype = POINTER(operation) context_get_sink_info_list.argtypes = [POINTER(context), sink_info_cb_t, c_void_p] proplist_gets = lib.pa_proplist_gets proplist_gets.restype = STRING proplist_gets.argtypes = [POINTER(proplist), STRING] proplist_to_string = lib.pa_proplist_to_string proplist_to_string.restype = STRING proplist_to_string.argtypes = [POINTER(proplist)] # this is a "magic" number (probably -1 at int32, but unsigned?) and tells us there's something fishy NULL_ID = 4294967295 ================================================ FILE: shell-volume-mixer@derhofbauer.at/pautils/lib/log.py ================================================ # This file is part of GNOME Shell Volume Mixer # Copyright (C) 2021 Alexander Hofbauer # # 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 3 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, see . import os import sys DEBUG = True if 'DEBUG' in os.environ else False def debug(*msg): if DEBUG: print(*msg, file=sys.stderr) ================================================ FILE: shell-volume-mixer@derhofbauer.at/pautils/lib/pulseaudio.py ================================================ # This file is part of GNOME Shell Volume Mixer # Copyright (C) 2021 Alexander Hofbauer # # 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 3 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, see . import abc from time import sleep from . import log from . import libpulse class Pulseaudio: _pa_state = libpulse.CONTEXT_UNCONNECTED _op_done = False _iterations_max = 10000 _iterations_intrv = 0.0001 _data = {} _error = { 'success': False, 'error': None, } def __init__(self): self._pa_mainloop = libpulse.mainloop_new() self._pa_mainloop_api = libpulse.mainloop_get_api(self._pa_mainloop) self._context = libpulse.context_new(self._pa_mainloop_api, b'ShellVolumeMixer') self._context_notify_cb = libpulse.context_notify_cb_t(self.context_notify_cb) libpulse.context_set_state_callback(self._context, self._context_notify_cb, None) def __enter__(self): libpulse.context_connect(self._context, None, 0, None) return self def __exit__(self, exc_type, exc_value, traceback): libpulse.context_disconnect(self._context) libpulse.context_unref(self._context) libpulse.mainloop_free(self._pa_mainloop) def context_notify_cb(self, context, userdata): try: self._pa_state = libpulse.context_get_state(context) except Exception: self._pa_state = libpulse.CONTEXT_FAILED log.debug('Context failed') def get_info(self, index=None, name=None): log.debug('Querying details...') operation = None count = 0 while self._pa_state != libpulse.CONTEXT_TERMINATED: if self._op_done: break if self._pa_state == libpulse.CONTEXT_FAILED: self._data = self._error self._data['error'] = 'context failed' break if count >= self._iterations_max: log.debug(f'Stopping iterations after {self._iterations_intrv * self._iterations_max}s (bug?)') break if count > 0 and count % (self._iterations_max / 2) == 0: log.debug(f'Delaying after {self._iterations_intrv * count}s (bug?)') sleep(2) if self._pa_state == libpulse.CONTEXT_READY and operation is None: callback = self.build_callback() if name: log.debug('Requesting details for', name) operation = self.get_by_name(name.encode('utf8'), callback) elif index and index >= 0: log.debug('Requesting details for', index) operation = self.get_by_index(index, callback) else: log.debug('Requesting all available data') operation = self.get_all(callback) libpulse.mainloop_iterate(self._pa_mainloop, 0, None) count += 1 sleep(self._iterations_intrv) if operation: libpulse.operation_unref(operation) log.debug('Query done') return self._data def pa_cb(self, context, struct, eol, user_data): log.debug('In callback') if eol: self._op_done = True log.debug('All done') return if not struct or not struct[0]: log.debug('No data received for callback') return item = self.cb_data(struct[0]) if item and 'index' in item: self._data[item['index']] = item log.debug('Callback done') @abc.abstractmethod def build_callback(self): return @abc.abstractmethod def get_by_index(self, index, callback): return @abc.abstractmethod def get_by_name(self, name, callback): return @abc.abstractmethod def get_all(self, callback): return @abc.abstractmethod def cb_data(self, data): return None ================================================ FILE: shell-volume-mixer@derhofbauer.at/pautils/lib/sinks.py ================================================ # This file is part of GNOME Shell Volume Mixer # Copyright (C) 2021 Alexander Hofbauer # # 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 3 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, see . from .pulseaudio import Pulseaudio from . import log from . import libpulse class Sinks(Pulseaudio): def build_callback(self): return libpulse.sink_info_cb_t(self.pa_cb) def get_by_index(self, index, callback): return libpulse.context_get_sink_info_by_index(self._context, index, callback, None) def get_by_name(self, name, callback): return libpulse.context_get_sink_info_by_name(self._context, name, callback, None) def get_all(self, callback): return libpulse.context_get_sink_info_list(self._context, callback, None) def cb_data(self, pa_sink): sink_name = pa_sink.name.decode('utf8') description = pa_sink.description.decode('utf8') if not description: try: description = libpulse.proplist_gets(pa_sink.proplist, b'device.description') description = description.decode('utf8') if description else None except Exception: log.debug('No property "device.description"') description = sink_name try: alsa_card = libpulse.proplist_gets(pa_sink.proplist, b'alsa.card') alsa_card = int(alsa_card.decode('utf8')) if alsa_card else None except Exception: log.debug('No property "alsa.card"') alsa_card = None sink = { 'index': pa_sink.index, 'alsaCard': alsa_card, 'name': sink_name, 'description': description, 'card': pa_sink.card if pa_sink.card != libpulse.NULL_ID else None, 'active_port': None, 'ports': { }, } if pa_sink.active_port and pa_sink.active_port[0]: ap = pa_sink.active_port[0] sink['active_port'] = ap.name.decode('utf8') for index in range(0, pa_sink.n_ports): if not pa_sink.ports[index] or not pa_sink.ports[index][0]: continue port = pa_sink.ports[index][0] name = port.name.decode('utf8') sink['ports'][name] = { 'name': name, 'description': port.description.decode('utf8'), 'type': port.type, 'available': True if port.available == 2 else (False if port.available == 1 else None), } return sink ================================================ FILE: shell-volume-mixer@derhofbauer.at/pautils/query.py ================================================ #!/usr/bin/env python3 # # Usage: query.py [cards|sinks] [index or name, omit for all data] # # Output is either a JSON object or an array of all data available, depending on # whether an index / name was passed or no parameters at all. # # This file is part of GNOME Shell Volume Mixer # Copyright (C) 2021 Alexander Hofbauer # # 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 3 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, see . import json import sys from lib import log from lib.cards import Cards from lib.sinks import Sinks from lib.libpulse import NULL_ID if len(sys.argv) < 2: print('Need a type to query') sys.exit(1) op_type = sys.argv[1] index = name = None if len(sys.argv) > 2: filter_arg = sys.argv[2] if filter_arg.isdigit(): index = int(filter_arg) else: name = filter_arg if index == NULL_ID: result = {} elif op_type == 'cards': with Cards() as cards: result = cards.get_info(index=index, name=name) elif op_type == 'sinks': with Sinks() as sinks: result = sinks.get_info(index=index, name=name) else: print('Invalid type', op_type, 'requested') sys.exit(1) print(json.dumps(result, indent=4 if log.DEBUG else None)) ================================================ FILE: shell-volume-mixer@derhofbauer.at/prefs.js ================================================ /** * Shell Volume Mixer * * Preferences widget. * * @author Alexander Hofbauer */ /* exported init, buildPrefsWidget */ const { Gtk, GObject } = imports.gi; const ExtensionUtils = imports.misc.extensionUtils; const Lib = ExtensionUtils.getCurrentExtension().imports.lib; const __ = ExtensionUtils.gettext; const { Settings, SETTING } = Lib.settings; const Log = Lib.utils.log; const PaHelper = Lib.utils.paHelper; const Utils = Lib.utils.utils; let preferences; const WIDGETS = { cmbPosition: SETTING.position, swRemoveOriginal: SETTING.remove_original, swShowPercentageLabel: SETTING.show_percentage_label, swShowDetailedSliders: SETTING.show_detailed_sliders, swShowSystemSounds: SETTING.show_system_sounds, swShowVirtualStreams: SETTING.show_virtual_streams, swAlwaysShowInputStreams: SETTING.always_show_input_streams, swUseSymbolicIcons: SETTING.use_symbolic_icons, txtProfileSwitch: null, treeDevices: null, treePinned: null, btnAddDevice: null, btnRemoveDevice: null, }; const Preferences = GObject.registerClass({ Implements: [ Gtk.BuilderScope ], }, class Preferences extends GObject.Object { vfunc_create_closure(builder, handlerName, flags, connectObject) { if (flags & Gtk.BuilderClosureFlags.SWAPPED) throw new Error('Unsupported template signal flag "swapped"'); if (typeof this[handlerName] === 'undefined') throw new Error(`${handlerName} is undefined`); return this[handlerName].bind(connectObject || this); } _getId(object) { for (const [id, widget] of Object.entries(this._widgets)) { if (widget === object) { return id; } } return null; } _getSetting(object) { const id = this._getId(object); if (id) { return this._widgetSettings[id] || null; } return null; } toggleBoolean(object) { const setting = this._getSetting(object); if (!setting) { Log.error('Preferences', 'toggleBoolean', new Error(`BUG! No widget found for toggle handler (${this._getId(object)})`)); return; } const active = object.get_active(); this._settings.set_boolean(setting, active); } /** * Callback for menu position combobox. * * @param cmbPosition */ onPositionChanged(cmbPosition) { let value = cmbPosition.get_active(); this._settings.set_enum(SETTING.position, value); let checkbox = this._widgets.swRemoveOriginal; if (!checkbox) { return; } if (value === SETTING.position_at.menu) { checkbox.set_sensitive(false); } else { checkbox.set_sensitive(true); } } /** * Callback for hotkey button entry. * * @param widget */ onProfileSwitchChanged(widget) { let entry = widget.get_text().trim(); if (!entry) { this._settings.set_array(SETTING.profile_switcher_hotkey, []); return; } try { let [ok, key, mods] = Gtk.accelerator_parse(entry); if (!ok || key === 0 || mods === 0) { return; } let hotkey = Gtk.accelerator_name(key, mods); if (hotkey) { widget.set_text(hotkey); // Main.wm.addKeybinding expects an array this._settings.set_array(SETTING.profile_switcher_hotkey, [hotkey]); } } catch (e) { Log.error('Preferences', 'onProfileSwitchChanged', e); } } /** * Callback for add button. */ onAddDevice(widget) { widget.set_sensitive(false); let [isSelected, store, iter] = this._deviceSelection.get_selected(); if (!isSelected) { return; } let cardid = store.get_value(iter, 1); let profileid = store.get_value(iter, 2); let card = this._cards[cardid]; let profile = this._cards[cardid].profiles[profileid]; if (profile.pinned) { // safety check, we should never reach this anyway return; } profile.pinned = true; this._pinned.set(this._pinned.append(), [0, 1, 2, 3, 4, 5], [card.description, profile.description, true, true, cardid, profileid]); this._storePinned(); } /** * Callback for remove button. */ onRemoveDevice() { let [isSelected, store, iter] = this._pinnedSelection.get_selected(); if (!isSelected) { return; } let cardid = store.get_value(iter, 4); let profileid = store.get_value(iter, 5); this._cards[cardid].profiles[profileid].pinned = false; store.remove(iter); this._storePinned(); // now check for the currently selected entry in devices list [isSelected, store, iter] = this._deviceSelection.get_selected(); if (!isSelected) { return; } let cardidSel = store.get_value(iter, 1); let profileidSel = store.get_value(iter, 2); if (cardid === cardidSel && profileid === profileidSel) { this._widgets.btnAddDevice.set_sensitive(true); } } /** * Callback for notebook tab selection. */ onSwitchPage(page, pageNum) { if (this._hasCards || this._cardsWarningShown) { return; } if (pageNum === 1) { return; } this._showMessage(__('Error retrieving card details'), __('Helper script did not return valid card data.')); this._cardsWarningShown = true; } /** * Toggle event for quickswitch switches. */ onQuickswitchToggled(widget, path) { let active = !widget.active; let [success, iter] = this._pinned.get_iter_from_string(path); if (!success) { return; } this._pinned.set_value(iter, 2, active); this._storePinned(); } /** * Toggle event for display switches. */ onDisplayToggled(widget, path) { let active = !widget.active; let [success, iter] = this._pinned.get_iter_from_string(path); if (!success) { return; } this._pinned.set_value(iter, 3, active); this._storePinned(); } /** * Determines whether selection allows to enable the remove button. */ onPinnedSelectionChanged(selection) { if (selection.count_selected_rows() > 0) { this._widgets.btnRemoveDevice.set_sensitive(true); } else { this._widgets.btnRemoveDevice.set_sensitive(false); } } /** * Selection event for devices, before selection is set. */ onDeviceSelection(selection, model, path) { return path && path.get_depth() >= 2; } /** * Determines whether selection allows to enable the add button. */ onDeviceSelectionChanged(selection) { this._widgets.btnAddDevice.set_sensitive(false); if (selection.count_selected_rows() <= 0) { return; } let [success, store, iter] = selection.get_selected(); if (!success) { return; } let cardid = store.get_value(iter, 1); let profileid = store.get_value(iter, 2); if (this._cards[cardid].profiles[profileid].pinned) { // don't allow pinning of already pinned profiles return; } this._widgets.btnAddDevice.set_sensitive(true); } _init() { super._init(); ExtensionUtils.initTranslations(); this._widgets = {}; this._widgetSettings = []; this._settings = new Settings(); this._builder = new Gtk.Builder(); this._builder.set_scope(this); } buildWidget() { this._builder.add_from_file(Utils.getExtensionPath('prefs.ui')); this._tabs = this._builder.get_object('tabs'); for (const [id, setting] of Object.entries(WIDGETS)) { const widget = this._builder.get_object(id); this._widgets[id] = widget; this._widgetSettings[id] = setting; if (!setting) { continue; } if (widget instanceof Gtk.ComboBox || widget instanceof Gtk.ComboBoxText ) { widget.set_active(this._settings.get_enum(setting)); } else if (widget instanceof Gtk.Switch) { widget.set_active(this._settings.get_boolean(setting)); } } this._widgets.txtProfileSwitch.set_text(this._settings.get_array(SETTING.profile_switcher_hotkey)[0] || ''); this._deviceSelection = this._widgets.treeDevices.get_selection(); this._deviceSelection.set_select_function(this.onDeviceSelection.bind(this)); this._deviceSelection.connect('changed', this.onDeviceSelectionChanged.bind(this)); this._pinnedSelection = this._widgets.treePinned.get_selection(); this._pinnedSelection.connect('changed', this.onPinnedSelectionChanged.bind(this)); this._devices = this._builder.get_object('storeDevices'); this._pinned = this._builder.get_object('storePinned'); (async () => { await this._initCards(); this._populatePinned(); })(); this.onPositionChanged(this._widgets.cmbPosition); return this._tabs; } /** * Initializes the content of the cards / profiles selection tree. */ async _initCards() { this._cards = {}; let cards = await PaHelper.getCards(); let details = this._widgets.swShowDetailedSliders.active; for (let k in cards) { let card = cards[k]; let row = this._devices.append(null); this._devices.set(row, [0, 1, 2], [card.description, '', '']); let profiles = {}; for (let p in card.profiles) { let profile = card.profiles[p]; if (profile.name === 'off' || profile.available === false) { continue; } let invalid = false; let test = profile.name.split('+'); for (let parts of test) { let [part] = parts.split(':', 1); // profiles containing 'input' won't be accepted by Gvc if (part === 'input') { invalid = true; break; } } if (invalid) { continue; } let profiletext = profile.description; if (details) { profiletext += `\n${profile.name}`; } this._devices.set(this._devices.append(row), [0, 1, 2], [profiletext, card.name, profile.name]); profiles[profile.name] = { description: profile.description, pinned: false }; } this._hasCards = true; this._cards[card.name] = { description: card.description, profiles: profiles }; } this._widgets.treeDevices.expand_all(); } /** * Updates the content of the selection list with all values from the * settings key. */ _populatePinned() { let pinned = this._settings.get_array(SETTING.pinned_profiles); this._pinned.clear(); for (let item of pinned) { if (!item) { continue; } let entry = null; try { entry = JSON.parse(item); } catch (e) { Log.error('Preferences', '_populatePinned', e); } if (!entry || !entry.card || !entry.profile) { continue; } let card = this._cards[entry.card]; if (!card) { continue; } let profile = card.profiles[entry.profile]; if (!profile) { continue; } profile.pinned = true; this._pinned.set(this._pinned.append(), [0, 1, 2, 3, 4, 5], [ card.description, profile.description, entry.switcher, entry.show, entry.card, entry.profile ]); } } /** * Returns the entries in the selection list as array of strings. */ _storePinned() { let values = []; let [success, iter] = this._pinned.get_iter_first(); while (iter && success) { values.push(JSON.stringify({ card: this._pinned.get_value(iter, 4), profile: this._pinned.get_value(iter, 5), switcher: this._pinned.get_value(iter, 2), show: this._pinned.get_value(iter, 3) })); success = this._pinned.iter_next(iter); } this._settings.set_array(SETTING.pinned_profiles, values); } /** * Shows a message dialog bound to the parent window. */ _showMessage(title, text, type = 'WARNING') { let dialog = new Gtk.MessageDialog({ text: title, secondary_text: text, message_type: Gtk.MessageType[type], buttons: Gtk.ButtonsType.OK, transient_for: this._tabs.get_root(), modal: true, }); dialog.connect('response', () => { dialog.destroy(); }); dialog.show(); } }); function init() { preferences = new Preferences(); } function buildPrefsWidget() { return preferences.buildWidget(); } ================================================ FILE: shell-volume-mixer@derhofbauer.at/prefs.ui ================================================ 1 20 20 20 20 10 20 0 end baseline Position of volume mixer 0 0 0 center 0 Status Menu Left Center Right 1 0 0 end baseline Remove original slider 0 1 0 end baseline Show detailed sliders 0 4 0 end center 1 1 end center 1 4 0 end baseline Hotkey for profile switcher 0 11 center alpha 1 11 0 (e.g. "<Super>p") 2 11 0 end baseline Use symbolic icons 0 5 end center 1 5 0 end baseline Show system sounds 0 7 end center 1 7 0 end baseline Show virtual streams 0 8 end center 1 8 0 end baseline Always show input streams 0 9 end center 1 9 0 baseline Shows the slider even if no application is recording 2 9 0 10 10 0 2 3 0 10 10 0 6 3 0 10 10 0 10 3 0 end baseline Show percentage 0 3 end center 1 3 0 baseline Whether to show percentage of current output right of the icon 2 3 0 Settings 1 20 20 10 20 10 300 200 400 1 1 storeDevices 0 0 Devices 1 0 0 0 vertical 1 600 400 1 1 storePinned 0 0 0 0 1 autosize Card 1 0 1 autosize Profile 1 1 fixed Switch 2 0 fixed Display 3 0 0 0 list-add-symbolic 0 0 list-remove-symbolic 0 Devices ================================================ FILE: shell-volume-mixer@derhofbauer.at/schemas/org.gnome.shell.extensions.shell-volume-mixer.gschema.xml ================================================ 'aggregateMenu' Position of volume mixer Whether to put the applet in a separate menu or replace the master volume mixer already present false Remove original slider Whether to remove the master volume slider from status menu false Show percentage of volume Whether to show percentage of current output right of the icon false Show detailed sliders Whether to show detailed sliders with different icons and the stream's description false Show system sounds Whether to show a sliders for system sounds false Show virtual streams Whether to show virtual streams in the applications menu false Always show input streams Show input streams even if no application is recording true Use symbolic icons Whether to use the default symbolic icons or those provided by PulseAudio [] Profile switcher hotkey Global hotkey to switch / circle pinned profiles [] Pinned profiles Profiles to always show in the list of devices, even if they're not retrieved through Gvc.MixerControl false Enable debug mode Enables debugging output and the D-Bus interface ================================================ FILE: shell-volume-mixer@derhofbauer.at/stylesheet.css ================================================ /* This stylesheet is generated, DO NOT EDIT */ /* Copyright 2009, 2015 Red Hat, Inc. * * Portions adapted from Mx's data/style/default.css * Copyright 2009 Intel Corporation * * This program is free software; you can redistribute it and/or modify it * under the terms and conditions of the GNU Lesser General Public License, * version 2.1, as published by the Free Software Foundation. * * This program is distributed in the hope it will be useful, but WITHOUT ANY * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for * more details. * * You should have received a copy of the GNU Lesser General Public License * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin St - Fifth Floor, Boston, MA 02110-1301 USA. */ .svm-menu { /** * Common */ /** * SVM integrated in status menu dropdown */ /** * Stand-alone menu via extra-button */ } .svm-menu .svm-slider-details { font-size: 0.9em; font-weight: 400; } .svm-menu .popup-menu-item:ltr { margin-left: 0; padding-left: 0; } .svm-menu .popup-menu-item:rtl { margin-right: 0; padding-right: 0; } .svm-menu .popup-sub-menu .popup-menu-item:ltr { padding-right: 14px; } .svm-menu .popup-sub-menu .popup-menu-item:rtl { padding-left: 14px; } .svm-menu .popup-sub-menu .popup-menu-item > .svm-menu-item-container { padding-left: 0; padding-right: 0; margin-left: 0; margin-right: 0; } .svm-menu .popup-sub-menu .popup-menu-item > .svm-menu-item-container > .svm-container-line { /* somewhere along all these paddings there's too much added * here, offsetting sliders in submenus by a few pixels */ } .svm-menu .popup-sub-menu .popup-menu-item > .svm-menu-item-container > .svm-container-line:ltr { padding-right: 12px; } .svm-menu .popup-sub-menu .popup-menu-item > .svm-menu-item-container > .svm-container-line:rtl { padding-left: 12px; } .svm-menu > .popup-menu-item > .svm-menu-item-container { padding-left: 0; padding-right: 0; margin-left: 0; margin-right: 0; } .svm-menu > .popup-menu-item > .svm-menu-item-container > .svm-container-line { padding-left: 0; padding-right: 0; margin-left: 0; margin-right: 0; } .svm-menu.svm-integrated-menu .popup-sub-menu .popup-menu-item > .svm-menu-item-container > .svm-container-line.line-1 .popup-menu-ornament:ltr { margin-left: 0; padding-left: 0; } .svm-menu.svm-integrated-menu .popup-sub-menu .popup-menu-item > .svm-menu-item-container > .svm-container-line.line-1 .popup-menu-ornament:rtl { margin-right: 0; padding-right: 0; } .svm-menu.svm-integrated-menu > .popup-menu-item > .svm-menu-item-container .svm-master-slider-line > .popup-menu-ornament, .svm-menu.svm-integrated-menu > .popup-menu-item > .svm-menu-item-container .svm-events-slider-line > .popup-menu-ornament { /** @copypaste from gnome-shell .aggregate-menu .popup-sub-menu .popup-menu-item > :first-child **/ } .svm-menu.svm-integrated-menu > .popup-menu-item > .svm-menu-item-container .svm-master-slider-line > .popup-menu-ornament:ltr, .svm-menu.svm-integrated-menu > .popup-menu-item > .svm-menu-item-container .svm-events-slider-line > .popup-menu-ornament:ltr { padding-left: 14px; margin-left: 1.09em; } .svm-menu.svm-integrated-menu > .popup-menu-item > .svm-menu-item-container .svm-master-slider-line > .popup-menu-ornament:rtl, .svm-menu.svm-integrated-menu > .popup-menu-item > .svm-menu-item-container .svm-events-slider-line > .popup-menu-ornament:rtl { padding-right: 14px; margin-right: 1.09em; } .svm-menu.svm-standalone-menu { width: 30em; } ================================================ FILE: styles.scss ================================================ $variant: 'light'; @import "gnome-shell-sass/colors"; @import "gnome-shell-sass/drawing"; @import "gnome-shell-sass/common"; .svm-menu { $shell_margin: $base_padding + $base_margin * 2; @mixin no-spacing { padding-left: 0; padding-right: 0; margin-left: 0; margin-right: 0; } @mixin default-padding { /** @copypaste from gnome-shell .aggregate-menu .popup-sub-menu .popup-menu-item > :first-child **/ &:ltr { padding-left: $shell_margin; margin-left: $base_icon_size; } &:rtl { padding-right: $shell_margin; margin-right: $base_icon_size; } } @mixin submenu-padding { &:ltr { padding-right: $shell_margin; } &:rtl { padding-left: $shell_margin; } } @mixin no-ornament-margin { // fix padding of ornament (selection) in submenu &:ltr { margin-left: 0; padding-left: 0; } &:rtl { margin-right: 0; padding-right: 0; } } /** * Common */ .svm-slider-details { font-size: 0.9em; font-weight: 400; } .popup-menu-item { @include no-ornament-margin; } // sub menu items (of top level menu items) .popup-sub-menu { .popup-menu-item { @include submenu-padding; > .svm-menu-item-container { @include no-spacing; > .svm-container-line { /* somewhere along all these paddings there's too much added * here, offsetting sliders in submenus by a few pixels */ &:ltr { padding-right: $shell_margin - 2px; } &:rtl { padding-left: $shell_margin - 2px; } } } } } // top-level menu items > .popup-menu-item { > .svm-menu-item-container { @include no-spacing; > .svm-container-line { @include no-spacing; } } } /** * SVM integrated in status menu dropdown */ &.svm-integrated-menu { // only sub menu items (of top level items) .popup-sub-menu { .popup-menu-item { > .svm-menu-item-container { > .svm-container-line { &.line-1 { // align ornament to parent items .popup-menu-ornament { @include no-ornament-margin; } } } } } } // top-level menu items > .popup-menu-item { > .svm-menu-item-container { // offset top level items to align with label instead of ornament icon .svm-master-slider-line, .svm-events-slider-line { > .popup-menu-ornament { @include default-padding; } } } } } /** * Stand-alone menu via extra-button */ &.svm-standalone-menu { width: 30em; } }