diff --git a/.gitignore b/.gitignore index 5bd2c2a947f4180c9325610c4a82708668426f80..94769b5ebbb1536ec994de9a70c590a2663ac07c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,10 @@ -__pycache__/ - *.build/ -build/ -dist/ -repo/ -.flatpak-builder/ -.*_cache/ *.egg-info/ *.flatpak *~ +.*_cache/ +.flatpak-builder/ +.repo/ +__pycache__/ +build/ +dist/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 9cb63584cb58c1ce51c00c02b96431396d669508..82c0a9b85d9c7cd39d87666cfef5e1cc0ce1d4de 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,21 +1,40 @@ variables: APP_ID: io.gitlab.zehkira.Monophony + RELEASE_BRANCH: release # Branch on which to build and deploy automatically + FLATPAK_BRANCH: master # Branch of exported flatpak repo, same as in flatpakref file + IMAGE: alpine:3.22.1 stages: + - test - build-x86_64 - build-aarch64 - deploy +test: + stage: test + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" + image: ${IMAGE} + script: + - apk add appstream bash desktop-file-utils flatpak flatpak-builder make ruff + + - ruff check source + - appstreamcli validate --explain --strict source/data/metainfo.xml + - desktop-file-validate source/data/*.desktop + - find source/locales/ | tr "\n" " " | grep -vq \.mo + - make --directory=source/ flatpak + - flatpak run --filesystem=$CI_PROJECT_DIR/source/tests/:ro --command=source/tests/tests.py --env=LOG_LEVELS=ERRO ${APP_ID} + + .build: rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - when: manual - - if: $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH - when: never - image: bilelmoussaoui/flatpak-github-actions:gnome-46 + - if: $CI_COMMIT_BRANCH == $RELEASE_BRANCH + image: ${IMAGE} script: + - apk add flatpak flatpak-builder + - flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo - gpg --list-keys --with-keygrip @@ -24,8 +43,8 @@ stages: - cat $GPG_PASSPHRASE | /usr/libexec/gpg-preset-passphrase --preset $GPG_KEY_GREP - gpg --import --batch ${GPG_PRIVATE_KEY} - - flatpak-builder build --arch=${ARCH} --user --install-deps-from=flathub --gpg-sign=${GPG_KEY_ID} --disable-rofiles-fuse --disable-updates --force-clean --repo=repo ${BRANCH:+--default-branch=$BRANCH} $CI_PROJECT_DIR/source/data/manifest.json - - flatpak build-bundle --arch=${ARCH} --gpg-sign=${GPG_KEY_ID} repo ${APP_ID}.flatpak --runtime-repo=flathub ${APP_ID} ${BRANCH} + - flatpak-builder build --arch=${ARCH} --user --install-deps-from=flathub --gpg-sign=${GPG_KEY_ID} --disable-rofiles-fuse --disable-updates --force-clean --repo=repo --default-branch=${FLATPAK_BRANCH} $CI_PROJECT_DIR/source/data/manifest.json + - flatpak build-bundle --arch=${ARCH} --gpg-sign=${GPG_KEY_ID} repo ${APP_ID}.flatpak --runtime-repo=flathub ${APP_ID} ${FLATPAK_BRANCH} - flatpak build-update-repo --gpg-sign=${GPG_KEY_ID} --generate-static-deltas --prune repo/ artifacts: paths: @@ -33,36 +52,29 @@ stages: build-x86_64: - variables: - ARCH: x86_64 extends: .build stage: build-x86_64 + variables: + ARCH: x86_64 build-aarch64: - variables: - ARCH: aarch64 extends: .build stage: build-aarch64 - dependencies: - - "build-x86_64" + variables: + ARCH: aarch64 pages: - rules: - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - when: manual - - if: $CI_COMMIT_BRANCH != $CI_DEFAULT_BRANCH - when: never - variables: - BUILD_OUTPUT_PATH: ${CI_PROJECT_DIR}/repo stage: deploy - image: alpine:latest + rules: + - if: $CI_COMMIT_BRANCH == $RELEASE_BRANCH + image: ${IMAGE} script: - apk add rsync - find $BUILD_OUTPUT_PATH \( -type d -name .git -prune \) -o -type f -print0 | xargs -0 sed -i -e "s#href=\"\/#href=\"$CI_PAGES_URL/#g" -e "s#src=\"\/#src=\"$CI_PAGES_URL/#g" - - mkdir public || true - - rsync -av --exclude='public' --exclude='.git' $BUILD_OUTPUT_PATH/ public + - mkdir --parents public + - rsync -av --exclude='public' --exclude='.git' ${CI_PROJECT_DIR}/repo/ public artifacts: paths: - public diff --git a/.gitlab/issue_templates/Bug.md b/.gitlab/issue_templates/Bug.md index 945f90a297a251d52e3938922785cca50dc2c092..2160ff17c9c3183c2cc34bb40ca2e689dc257f2c 100644 --- a/.gitlab/issue_templates/Bug.md +++ b/.gitlab/issue_templates/Bug.md @@ -1,23 +1,8 @@ -# System information +**Bug description:** -**Operating system name and version:** - -**Desktop environment name and version:** - -**Installation source/method:** - - -**App version:** - - - -# Bug description - -**Description:** - -**Terminal output:** +**App logs (About → Troubleshooting → Debugging Information):** ```sh diff --git a/.gitlab/merge_request_templates/Default.md b/.gitlab/merge_request_templates/Default.md new file mode 100644 index 0000000000000000000000000000000000000000..1460f12fa9633622aff23b248a858891cb5cfbd9 --- /dev/null +++ b/.gitlab/merge_request_templates/Default.md @@ -0,0 +1,9 @@ + + + + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1f6bf4cdf1b241aeac556791b80b52275857ba9..1cc4900ee91805658bbaa7c3c38109c017a1326f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,18 @@ Before making any changes, make sure that your editor supports [EditorConfig](https://editorconfig.org/). +## Debugging + +The following environment variables are available: +- `DEBUG`: Print memory information to help with finding leaks (unset by default) +- `LOG_LEVELS`: Only print log messages of specified levels (`INFO,WARN,ERRO` by default) + +When running the app, environment variables can be set with `--env`: + +```sh +flatpak run --env=LOG_LEVELS=WARN,ERRO io.gitlab.zehkira.Monophony +``` + ## Translation -Standard translation files are located in `source/locales`. +Standard translation files are located in `source/locales`. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..32683a51f1944bb037050e540e22c8962ff900f3 --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright © Zehkira and contributors + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md index 77cc83652c18434ab820ee90d1b950f5cb2231e7..024aa1afb40d3e2ea79ea34541bb5611fed77088 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,6 @@ Monophony allows you to stream and download music from YouTube Music without ads, as well as create and import playlists without signing in. -**Copyright** © Zehkira and contributors, [GPL-2.0-or-later](https://gitlab.com/zehkira/monophony/-/blob/master/source/LICENSE). [Metadata](https://gitlab.com/zehkira/monophony/-/blob/master/source/data/metainfo.xml) provided under the [0BSD](https://opensource.org/license/0bsd/) license. Information about dependency licenses is available in the app. +**Copyright** © Zehkira and contributors, [0BSD](https://gitlab.com/zehkira/monophony/-/blob/master/LICENSE) license. Information about dependency licenses is available in the app. -screenshot +Screenshot diff --git a/assets/screenshot1.png b/assets/screenshot1.png index 03ce40cb19d174d6108347a902b4ef37d624bbbe..fb4620fdfc6dd4bca198163ff313cd81f02f2a98 100644 Binary files a/assets/screenshot1.png and b/assets/screenshot1.png differ diff --git a/assets/screenshot2.png b/assets/screenshot2.png index db65432f5e25d2c7306fae9c4f7e7876968380b1..d386a2c88b29bc5348283e6fedc2a5f374413326 100644 Binary files a/assets/screenshot2.png and b/assets/screenshot2.png differ diff --git a/source/LICENSE b/source/LICENSE deleted file mode 100644 index d159169d1050894d3ea3b98e1c965c4058208fe1..0000000000000000000000000000000000000000 --- a/source/LICENSE +++ /dev/null @@ -1,339 +0,0 @@ - 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. diff --git a/source/Makefile b/source/Makefile index fa352de222c13b94542a71ed723fc9c1171f6378..07dc4e4e39d062c654bac32ed910eccf3da4cbf1 100644 --- a/source/Makefile +++ b/source/Makefile @@ -4,55 +4,71 @@ SHELL = bash MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --no-builtin-rules -INSTALL = install -D +INSTALL_PROGRAM = install -D +INSTALL_DATA = install -D -m 644 ID = io.gitlab.zehkira.Monophony -ICONS_DIR = $(prefix)/share/icons/hicolor -LOCALES_DIR = $(prefix)/share/locale -APPS_DIR = $(prefix)/share/applications -META_DIR = $(prefix)/share/metainfo -LICENSE_DIR = $(prefix)/share/licenses +NAME = monophony BIN_DIR = $(prefix)/bin +DATA_DIR = $(prefix)/share +ICONS_DIR = $(DATA_DIR)/icons/hicolor +LOCALES_DIR = $(DATA_DIR)/locale +APPS_DIR = $(DATA_DIR)/applications +META_DIR = $(DATA_DIR)/metainfo +LICENSE_DIR = $(DATA_DIR)/licenses # Only use for packaging. For manual builds, see "flatpak" instead install: + @# Python module + pip3 install --prefix=$(prefix) --no-build-isolation --no-deps . + @# Executable - $(INSTALL) bin/monophony.py $(BIN_DIR)/monophony + $(INSTALL_PROGRAM) bin/$(NAME).py $(BIN_DIR)/$(NAME) + + @# GResources + mkdir --parents $(DATA_DIR)/$(NAME) + glib-compile-resources \ + --sourcedir=data \ + --target=$(DATA_DIR)/$(NAME)/resources.gresource \ + data/resources.gresource.xml @# Desktop file - $(INSTALL) data/monophony.desktop $(APPS_DIR)/$(ID).desktop + $(INSTALL_DATA) data/$(NAME).desktop $(APPS_DIR)/$(ID).desktop @# Metainfo - $(INSTALL) data/metainfo.xml $(META_DIR)/$(ID).metainfo.xml + $(INSTALL_DATA) data/metainfo.xml $(META_DIR)/$(ID).metainfo.xml @# Icons - $(INSTALL) data/icons/scalable.svg $(ICONS_DIR)/scalable/apps/$(ID).svg - $(INSTALL) \ + $(INSTALL_DATA) data/icons/scalable.svg $(ICONS_DIR)/scalable/apps/$(ID).svg + $(INSTALL_DATA) \ data/icons/symbolic.svg $(ICONS_DIR)/symbolic/apps/$(ID)-symbolic.svg - $(INSTALL) data/icons/128.png $(ICONS_DIR)/128x128/apps/$(ID).png - $(INSTALL) data/icons/16.png $(ICONS_DIR)/16x16/apps/$(ID).png - $(INSTALL) data/icons/192.png $(ICONS_DIR)/192x192/apps/$(ID).png - $(INSTALL) data/icons/22.png $(ICONS_DIR)/22x22/apps/$(ID).png - $(INSTALL) data/icons/24.png $(ICONS_DIR)/24x24/apps/$(ID).png - $(INSTALL) data/icons/256.png $(ICONS_DIR)/256x256/apps/$(ID).png - $(INSTALL) data/icons/32.png $(ICONS_DIR)/32x32/apps/$(ID).png - $(INSTALL) data/icons/36.png $(ICONS_DIR)/36x36/apps/$(ID).png - $(INSTALL) data/icons/384.png $(ICONS_DIR)/384x384/apps/$(ID).png - $(INSTALL) data/icons/48.png $(ICONS_DIR)/48x48/apps/$(ID).png - $(INSTALL) data/icons/512.png $(ICONS_DIR)/512x512/apps/$(ID).png - $(INSTALL) data/icons/64.png $(ICONS_DIR)/64x64/apps/$(ID).png - $(INSTALL) data/icons/72.png $(ICONS_DIR)/72x72/apps/$(ID).png - $(INSTALL) data/icons/96.png $(ICONS_DIR)/96x96/apps/$(ID).png + $(INSTALL_DATA) data/icons/128.png $(ICONS_DIR)/128x128/apps/$(ID).png + $(INSTALL_DATA) data/icons/16.png $(ICONS_DIR)/16x16/apps/$(ID).png + $(INSTALL_DATA) data/icons/192.png $(ICONS_DIR)/192x192/apps/$(ID).png + $(INSTALL_DATA) data/icons/22.png $(ICONS_DIR)/22x22/apps/$(ID).png + $(INSTALL_DATA) data/icons/24.png $(ICONS_DIR)/24x24/apps/$(ID).png + $(INSTALL_DATA) data/icons/256.png $(ICONS_DIR)/256x256/apps/$(ID).png + $(INSTALL_DATA) data/icons/32.png $(ICONS_DIR)/32x32/apps/$(ID).png + $(INSTALL_DATA) data/icons/36.png $(ICONS_DIR)/36x36/apps/$(ID).png + $(INSTALL_DATA) data/icons/384.png $(ICONS_DIR)/384x384/apps/$(ID).png + $(INSTALL_DATA) data/icons/48.png $(ICONS_DIR)/48x48/apps/$(ID).png + $(INSTALL_DATA) data/icons/512.png $(ICONS_DIR)/512x512/apps/$(ID).png + $(INSTALL_DATA) data/icons/64.png $(ICONS_DIR)/64x64/apps/$(ID).png + $(INSTALL_DATA) data/icons/72.png $(ICONS_DIR)/72x72/apps/$(ID).png + $(INSTALL_DATA) data/icons/96.png $(ICONS_DIR)/96x96/apps/$(ID).png @# Translations cd locales; \ for d in */; do \ - $(INSTALL) \ - $${d}LC_MESSAGES/all.mo $(LOCALES_DIR)/$${d}LC_MESSAGES/monophony.mo; \ + mkdir --parents $(LOCALES_DIR)/$${d}LC_MESSAGES/; \ + msgfmt \ + $${d}LC_MESSAGES/all.po \ + --output-file $(LOCALES_DIR)/$${d}LC_MESSAGES/$(NAME).mo; \ done @# License - $(INSTALL) LICENSE $(LICENSE_DIR)/monophony/LICENSE + cd ..; \ + $(INSTALL_DATA) LICENSE $(LICENSE_DIR)/$(NAME)/LICENSE .PHONY: install @@ -71,8 +87,8 @@ flatpak: --install \ --install-deps-from=flathub \ --force-clean \ - --repo=repo/ \ - build/ \ + --repo=.repo/ \ + .build/ \ data/manifest.json .PHONY: flatpak @@ -81,5 +97,5 @@ flatpak: translation: printf 'msgid \"\"\nmsgstr \"Content-Type: text/plain; charset=UTF-8\\n\"' \ > locales/all.pot - find monophony -iname "*.py" | xargs xgettext --omit-header -j -o locales/all.pot + find $(NAME) -iname "*.py" | xargs xgettext --omit-header -j -o locales/all.pot .PHONY: translation diff --git a/source/bin/monophony.py b/source/bin/monophony.py index bf594a15fb0b7b0a9e63fbdae3229f3162338ddd..d5c72d43cf0f24cead042a76964f2648fb36de43 100755 --- a/source/bin/monophony.py +++ b/source/bin/monophony.py @@ -1,25 +1,73 @@ #!/usr/bin/env python3 +# ruff: noqa: E402 - Allow gi.require_versions() -import gettext, os +import gettext +import os +import sys +import threading +import traceback -import monophony.backend.cache -from monophony.frontend.app import MonophonyApplication +import gi -def main(): - path = None - snap_path = os.getenv('SNAP') +gi.require_versions({ + 'Adw': '1', 'Gdk': '4.0', 'Gst': '1.0', 'GstAudio': '1.0', 'Gtk': '4.0' +}) - # yes, "container" env really is lowercase for some reason - if os.getenv('container', '') == 'flatpak': # noqa: SIM112 - path = '/app/share/locale' - elif snap_path: - path = os.path.join(snap_path, 'share/locale') - gettext.translation('monophony', path, fallback=True).install() - monophony.backend.cache.clean_up() - MonophonyApplication().run() +from monophony import NAME, logging +from monophony.app import Application +from gi.repository import Gio -if __name__ == '__main__': - main() + +sys.excepthook = lambda exception, value, trace: logging.error( + __name__, + 'Unhandled exception', + ''.join(traceback.format_exception(exception, value, trace)) +) +threading.excepthook = lambda args: logging.error( + f'{__name__} (thread "{args.thread.name}")', + 'Unhandled exception in thread', + ''.join( + traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback) + ) +) + +container = os.getenv('container', 'unknown') # noqa: SIM112 - Container is lowercase +if container != 'flatpak': + logging.warning( + __name__, + f'App was installed from unofficial source. Container type: {container}' + ) + +logging.info(__name__, 'Loading GResources...') +resources_file = 'resources.gresource' +for path in os.getenv('XDG_DATA_DIRS', '/usr/share/').split(':'): + data_path = f'{path}{NAME}' if path.endswith('/') else f'{path}/{NAME}' + logging.info(__name__, f'Trying to load GResources from "{data_path}"...') + resource = Gio.Resource.load(data_path + '/' + resources_file) + if resource: + Gio.resources_register(resource) + logging.info(__name__, f'Loaded GResources from "{data_path}/{resources_file}"') + break +else: + logging.error(__name__, 'Failed to load GResources: not found') + sys.exit(1) + +logging.info(__name__, 'Installing translation...') +for path in os.getenv('XDG_DATA_DIRS', '/usr/share/').split(':'): + locale_path = f'{path}locale' if path.endswith('/') else f'{path}/locale' + logging.info(__name__, f'Trying to install translation from "{locale_path}"...') + if 'share' in path and os.path.isdir(locale_path): + gettext.translation(NAME, locale_path, fallback=True).install() + logging.info( + __name__, f'Installed translation from "{locale_path}/{NAME}/"' + ) + break +else: + logging.error(__name__, 'Failed to install translation: not found') + sys.exit(1) + +Application().run() +logging.info(__name__, 'Exited') diff --git a/source/data/manifest.json b/source/data/manifest.json index 294247da2e5cd566c1c5776dac1914392e2fc527..06fba11c512fbba6927ae12035624c904cbd5b2b 100644 --- a/source/data/manifest.json +++ b/source/data/manifest.json @@ -1,7 +1,7 @@ { "app-id": "io.gitlab.zehkira.Monophony", "runtime": "org.gnome.Platform", - "runtime-version": "48", + "runtime-version": "49", "sdk": "org.gnome.Sdk", "command": "monophony", "finish-args": [ @@ -14,7 +14,90 @@ "--own-name=org.mpris.MediaPlayer2.Monophony" ], "modules": [ - "python3-modules.json", + { + "name": "ytmusicapi", + "buildsystem": "simple", + "build-commands": [ + "pip3 install ytmusicapi --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} --no-build-isolation" + ], + "sources": [ + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", + "sha256": "f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", + "sha256": "6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", + "sha256": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", + "sha256": "2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", + "sha256": "e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/88/ac/a91784a8f9be3eff10959ccd2a680181941194d7ee7da2c7be10e9c32ab5/ytmusicapi-1.11.1-py3-none-any.whl", + "sha256": "bcaf208fd18c9b5b8971bc4436b17af132a4296de98708049d7638fdba90f6c9" + } + ] + }, + { + "name": "mprisify", + "buildsystem": "simple", + "build-commands": [ + "pip3 install . --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} --no-build-isolation" + ], + "sources": [ + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", + "sha256": "a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/40/d9/412da520de9052b7e80bfc810ec10f5cb3dbfa4aa3e23c2820dc61cdb3d0/pycairo-1.28.0.tar.gz", + "sha256": "26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/92/56/27148014c2f85ce70332f18612f921f682395c7d4e91ec103783be4fce00/pydbus-0.6.0-py2.py3-none-any.whl", + "sha256": "66b80106352a718d80d6c681dc2a82588048e30b75aab933e4020eb0660bf85e" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/4a/36/fec530a313d3d48f12e112ac0a65ee3ccc87f385123a0493715609e8e99c/pygobject-3.52.3.tar.gz", + "sha256": "00e427d291e957462a8fad659a9f9c8be776ff82a8b76bdf402f1eaeec086d82" + }, + { + "type": "git", + "url": "https://gitlab.com/zehkira/mprisify.git", + "tag": "v1.0.0", + "commit": "f1ea109c499055e2a4563ec6680fb31ee766e59a" + } + ] + }, + { + "name": "adwaita-icon-theme", + "buildsystem": "meson", + "sources": [{ + "type": "git", + "url": "https://gitlab.gnome.org/GNOME/adwaita-icon-theme.git", + "tag": "49.0", + "commit": "084370e79b7f02cec50ab70c82da49f64b45f120" + }] + }, { "name": "yt-dlp", "buildsystem": "simple", @@ -32,13 +115,13 @@ "name": "monophony", "buildsystem": "simple", "build-commands": [ - "pip install --prefix=${FLATPAK_DEST} --no-build-isolation .", - "make prefix=${FLATPAK_DEST} install" + "cd source && make prefix=${FLATPAK_DEST} install" ], "sources": [{ "type": "dir", - "path": ".." + "path": "../.." }] } ] } + diff --git a/source/data/metainfo.xml b/source/data/metainfo.xml index 4e5a1acd8f639e23bf3e5e3fa36e7c7dacd3885e..0d7be22957ccc13ab374f6c6dc0b8a691adf5f70 100644 --- a/source/data/metainfo.xml +++ b/source/data/metainfo.xml @@ -1,17 +1,36 @@ - + io.gitlab.zehkira.Monophony 0BSD - GPL-2.0-or-later + 0BSD Monophony - Zehkira - https://gitlab.com/zehkira/monophony + + Zehkira + + + AudioVideo + Audio + Music + GNOME + GTK + + + music + stream + youtube + yt + + https://zeh-kira.itch.io/monophony https://gitlab.com/zehkira/monophony/-/issues + https://zeh-kira.itch.io/monophony/purchase pointing keyboard touch + + always + 360 @@ -20,7 +39,6 @@ #911221 Stream music from YouTube Music - Musik von YouTube streamen

Monophony allows you to stream and download music from YouTube Music without ads, as well as create and import playlists without signing in. diff --git a/source/data/monophony.desktop b/source/data/monophony.desktop index a973074920fed185db0d72d8291b1a114182c7d1..54f35015273efe64dafb0f187f730a93a1ce7158 100644 --- a/source/data/monophony.desktop +++ b/source/data/monophony.desktop @@ -1,12 +1,13 @@ [Desktop Entry] Name=Monophony -GenericName=Music streaming app -Comment=Music streaming app +GenericName=Client for YouTube Music +Comment=Stream music from YouTube Music Exec=monophony Terminal=false Type=Application StartupNotify=false +SingleMainWindow=true Icon=io.gitlab.zehkira.Monophony -Categories=AudioVideo;Audio;Music; +Categories=AudioVideo;Audio;Music;GNOME;GTK; X-Purism-FormFactor=Workstation;Mobile; -Keywords=Music;Stream;Youtube;yt; +Keywords=music;stream;youtube;yt; diff --git a/source/data/python3-modules.json b/source/data/python3-modules.json deleted file mode 100644 index 8b8fef228082180d2b7578308bcd82e52bc79369..0000000000000000000000000000000000000000 --- a/source/data/python3-modules.json +++ /dev/null @@ -1,90 +0,0 @@ -{ - "name": "python3-requirements", - "buildsystem": "simple", - "build-commands": [], - "modules": [ - { - "name": "python3-mpris_server", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"mpris_server==0.9.0\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", - "sha256": "a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", - "sha256": "c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/91/db/a0335710caaa6d0aebdaa65ad4df789c15d89b7babd9a30277838a7d9aac/emoji-2.14.1-py3-none-any.whl", - "sha256": "35a8a486c1460addb1499e3bf7929d3889b2e2841a57401903699fef595e942b" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/5f/ef/6a87603849b242bbf4080d1dfbec0a82ccf0348b60793376bdeb0226f669/mpris_server-0.9.0-py2.py3-none-any.whl", - "sha256": "97b6764b05fc35f8ae30c78d7b7b7b55a12796aa22979404bec6db63d039e842" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/40/d9/412da520de9052b7e80bfc810ec10f5cb3dbfa4aa3e23c2820dc61cdb3d0/pycairo-1.28.0.tar.gz", - "sha256": "26ec5c6126781eb167089a123919f87baa2740da2cca9098be8b3a6b91cc5fbc" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/92/56/27148014c2f85ce70332f18612f921f682395c7d4e91ec103783be4fce00/pydbus-0.6.0-py2.py3-none-any.whl", - "sha256": "66b80106352a718d80d6c681dc2a82588048e30b75aab933e4020eb0660bf85e" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/4a/36/fec530a313d3d48f12e112ac0a65ee3ccc87f385123a0493715609e8e99c/pygobject-3.52.3.tar.gz", - "sha256": "00e427d291e957462a8fad659a9f9c8be776ff82a8b76bdf402f1eaeec086d82" - } - ] - }, - { - "name": "python3-ytmusicapi", - "buildsystem": "simple", - "build-commands": [ - "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"ytmusicapi==1.11.1\" --no-build-isolation" - ], - "sources": [ - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", - "sha256": "f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/83/2d/5fd176ceb9b2fc619e63405525573493ca23441330fcdaee6bef9460e924/charset_normalizer-3.4.3.tar.gz", - "sha256": "6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", - "sha256": "946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", - "sha256": "2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", - "sha256": "e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc" - }, - { - "type": "file", - "url": "https://files.pythonhosted.org/packages/88/ac/a91784a8f9be3eff10959ccd2a680181941194d7ee7da2c7be10e9c32ab5/ytmusicapi-1.11.1-py3-none-any.whl", - "sha256": "bcaf208fd18c9b5b8971bc4436b17af132a4296de98708049d7638fdba90f6c9" - } - ] - } - ] -} diff --git a/source/data/resources.gresource.xml b/source/data/resources.gresource.xml new file mode 100644 index 0000000000000000000000000000000000000000..5f9ef2025caabbfdd03ca37954f71ce4313c5a2e --- /dev/null +++ b/source/data/resources.gresource.xml @@ -0,0 +1,6 @@ + + + + metainfo.xml + + diff --git a/source/locales/all.pot b/source/locales/all.pot index d6d405cbfc1e3be269f5d043d65b5f7161ce51ea..517c891113ef84a0110c865abe312b28ad9203a0 100644 --- a/source/locales/all.pot +++ b/source/locales/all.pot @@ -1,317 +1,327 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8\n" -#: monophony/frontend/pages/artist_page.py:19 -#: monophony/frontend/pages/results_page.py:20 -msgid "No Results" +#: monophony/ui/pages/artist_page.py:11 +msgid "Artist Page" msgstr "" -#: monophony/frontend/pages/artist_page.py:58 -msgid "Other" +#: monophony/ui/pages/home_page.py:26 +msgid "Recommended" msgstr "" -#: monophony/frontend/pages/artist_page.py:59 -#: monophony/frontend/pages/results_page.py:89 -msgid "Albums" +#: monophony/ui/pages/home_page.py:76 monophony/ui/pages/home_page.py:131 +msgid "Your Playlists" msgstr "" -#: monophony/frontend/pages/artist_page.py:60 -msgid "Playlists" +#: monophony/ui/pages/home_page.py:133 +msgid "Playlists you create will appear here" msgstr "" -#: monophony/frontend/pages/artist_page.py:83 -#: monophony/frontend/pages/results_page.py:88 -msgid "Songs" +#: monophony/ui/pages/home_page.py:148 monophony/ui/windows/import_window.py:52 +msgid "Import" msgstr "" -#: monophony/frontend/pages/artist_page.py:89 -#: monophony/frontend/pages/results_page.py:90 -msgid "Videos" +#: monophony/ui/pages/home_page.py:161 +msgid "Synchronized Playlists" msgstr "" -#: monophony/frontend/pages/artist_page.py:95 -msgid "Artist Not Found" +#: monophony/ui/pages/home_page.py:217 +msgid "Downloads Directory" msgstr "" -#: monophony/frontend/pages/results_page.py:61 -msgid "Show All" +#: monophony/ui/pages/home_page.py:227 +msgid "Downloads" msgstr "" -#: monophony/frontend/pages/results_page.py:87 -msgid "Top Result" +#: monophony/ui/pages/home_page.py:259 +msgid "Clear" msgstr "" -#: monophony/frontend/pages/results_page.py:91 -msgid "Community Playlists" +#: monophony/ui/pages/home_page.py:267 +msgid "Recently Played" msgstr "" -#: monophony/frontend/pages/results_page.py:92 -msgid "Artists" +#: monophony/ui/pages/home_page.py:304 monophony/ui/windows/main_window.py:712 +msgid "Donate" msgstr "" -#: monophony/frontend/widgets/recent_searches.py:36 -msgid "Remove" +#: monophony/ui/pages/home_page.py:348 +msgid "Home" msgstr "" -#: monophony/frontend/widgets/player.py:68 -msgid "Toggle pause" +#: monophony/ui/pages/home_page.py:407 monophony/ui/pages/home_page.py:436 +#, python-brace-format +msgid "Deleted playlist \"{name}\"" msgstr "" -#: monophony/frontend/widgets/player.py:80 -msgid "Next song" +#: monophony/ui/pages/home_page.py:411 monophony/ui/pages/home_page.py:440 +msgid "Undo" msgstr "" -#: monophony/frontend/widgets/player.py:85 -msgid "Previous song" +#: monophony/ui/pages/loading_page.py:21 +msgid "Loading..." msgstr "" -#: monophony/frontend/widgets/player.py:97 -msgid "Change volume" +#: monophony/ui/pages/results_page.py:27 monophony/ui/pages/results_page.py:47 +#: monophony/ui/pages/results_page.py:67 monophony/ui/pages/results_page.py:87 +#: monophony/ui/pages/results_page.py:107 +msgid "Show All" msgstr "" -#: monophony/frontend/widgets/player.py:103 -msgid "Playback mode" +#: monophony/ui/pages/results_page.py:126 +msgid "Songs" msgstr "" -#: monophony/frontend/widgets/player.py:188 -msgid "Normal Playback" +#: monophony/ui/pages/results_page.py:129 +msgid "Videos" msgstr "" -#: monophony/frontend/widgets/player.py:195 -msgid "Radio Mode" +#: monophony/ui/pages/results_page.py:132 +msgid "Albums and Singles" msgstr "" -#: monophony/frontend/widgets/player.py:205 -msgid "Repeat Song" +#: monophony/ui/pages/results_page.py:135 +msgid "Playlists" msgstr "" -#: monophony/frontend/widgets/player.py:215 -msgid "Repeat Queue" +#: monophony/ui/pages/results_page.py:138 +msgid "Artists" msgstr "" -#: monophony/frontend/windows/import_window.py:21 -msgid "Enter Playlist Name..." +#: monophony/ui/pages/results_page.py:147 +msgid "Top Result" msgstr "" -#: monophony/frontend/windows/import_window.py:28 -msgid "Enter Playlist URL..." +#: monophony/ui/pages/results_page.py:221 +msgid "Search Results" msgstr "" -#: monophony/frontend/windows/import_window.py:33 -msgid "Synchronized" +#: monophony/ui/rows/artist_row.py:15 monophony/ui/rows/artist_row.py:20 +#: monophony/ui/popovers/group_row_popover.py:23 +#: monophony/ui/popovers/song_row_popover.py:23 +msgid "View Artist" msgstr "" -#: monophony/frontend/windows/import_window.py:34 -msgid "Editable" +#: monophony/ui/rows/group_row.py:22 monophony/ui/rows/song_row.py:34 +msgid "More" msgstr "" -#: monophony/frontend/windows/import_window.py:47 -#: monophony/frontend/windows/add_window.py:23 -msgid "Cancel" +#: monophony/ui/rows/song_row.py:29 +msgid "Downloaded" msgstr "" -#: monophony/frontend/windows/import_window.py:49 -#: monophony/frontend/tabs/library_tab.py:59 -msgid "Import" +#: monophony/ui/rows/song_row.py:46 +msgid "Play" msgstr "" -#: monophony/frontend/windows/import_window.py:71 -msgid "Import Playlist..." +#: monophony/ui/windows/add_window.py:24 +msgid "New Playlist" +msgstr "" + +#: monophony/ui/windows/add_window.py:40 +msgid "Add" +msgstr "" + +#: monophony/ui/windows/add_window.py:55 +msgid "Add to Playlists..." +msgstr "" + +#: monophony/ui/windows/import_window.py:18 +msgid "Playlist URL" msgstr "" -#: monophony/frontend/windows/import_window.py:100 -#: monophony/frontend/windows/import_window.py:118 -#: monophony/frontend/windows/import_window.py:124 -msgid "Could not import playlist" +#: monophony/ui/windows/import_window.py:29 +#: monophony/ui/windows/rename_window.py:15 +msgid "Playlist Name" msgstr "" -#: monophony/frontend/windows/import_window.py:101 -msgid "Failed to retrieve playlist data from server." +#: monophony/ui/windows/import_window.py:33 +msgid "Synchronized" msgstr "" -#: monophony/frontend/windows/import_window.py:118 -msgid "A name is required." +#: monophony/ui/windows/import_window.py:35 +msgid "Synchronized playlists are updated automatically and can't be edited" msgstr "" -#: monophony/frontend/windows/import_window.py:124 -msgid "A URL is required." +#: monophony/ui/windows/import_window.py:69 +msgid "Import Playlist..." msgstr "" -#: monophony/frontend/windows/message_window.py:13 +#: monophony/ui/windows/message_window.py:12 msgid "Ok" msgstr "" -#: monophony/frontend/windows/main_window.py:38 -msgid "Library" +#: monophony/ui/windows/rename_window.py:35 +msgid "Rename Playlist..." msgstr "" -#: monophony/frontend/windows/main_window.py:43 -msgid "Search" +#: monophony/ui/windows/main_window.py:373 +msgid "Playing" msgstr "" -#: monophony/frontend/windows/main_window.py:49 -#: monophony/frontend/windows/add_window.py:36 -#: monophony/frontend/tabs/queue_tab.py:44 -msgid "Queue" +#: monophony/ui/windows/main_window.py:454 +msgid "Download Failed" msgstr "" -#: monophony/frontend/windows/main_window.py:58 -msgid "About" +#: monophony/ui/windows/main_window.py:454 +msgid "Some songs could not be downloaded" msgstr "" -#: monophony/frontend/windows/main_window.py:192 -msgid "translator-credits" +#: monophony/ui/windows/main_window.py:503 +msgid "Failed to Import" msgstr "" -#: monophony/frontend/windows/main_window.py:239 -#, python-brace-format -msgid "Deleted playlist \"{playlist_name}\"" +#: monophony/ui/windows/main_window.py:504 +#: monophony/ui/windows/main_window.py:619 +#: monophony/ui/windows/main_window.py:836 +msgid "Check your internet connection and try again" msgstr "" -#: monophony/frontend/windows/main_window.py:244 -msgid "Undo" +#: monophony/ui/windows/main_window.py:618 +msgid "Failed to Search" msgstr "" -#: monophony/frontend/windows/main_window.py:262 -msgid "Added" +#: monophony/ui/windows/main_window.py:625 +msgid "No Results" msgstr "" -#: monophony/frontend/windows/add_window.py:20 -#: monophony/frontend/popovers/song_popover.py:31 -msgid "Add to..." +#: monophony/ui/windows/main_window.py:626 +msgid "Try searching for something else" msgstr "" -#: monophony/frontend/windows/add_window.py:25 -msgid "Add" +#: monophony/ui/windows/main_window.py:709 +msgid "translator-credits" msgstr "" -#: monophony/frontend/windows/add_window.py:43 -#: monophony/frontend/tabs/library_tab.py:79 -msgid "Your Playlists" +#: monophony/ui/windows/main_window.py:835 +msgid "Failed to Load Artist Page" msgstr "" -#: monophony/frontend/windows/add_window.py:49 -msgid "New Playlist Name..." +#: monophony/ui/windows/main_window.py:842 +msgid "Empty Artist Page" msgstr "" -#: monophony/frontend/windows/add_window.py:51 -msgid "Create" +#: monophony/ui/windows/main_window.py:843 +msgid "No content found from this artist" msgstr "" -#: monophony/frontend/popovers/local_song_popover.py:17 -msgid "Remove From Playlist" +#: monophony/ui/bars/header_bar.py:15 +msgid "About" msgstr "" -#: monophony/frontend/popovers/queue_song_popover.py:18 -msgid "Remove From Queue" +#: monophony/ui/bars/player_bar.py:93 monophony/ui/queue_sidebar.py:138 +msgid "Queue" msgstr "" -#: monophony/frontend/popovers/song_popover.py:21 -msgid "Remove From Downloads" +#: monophony/ui/bars/player_bar.py:120 +msgid "Playback Mode" msgstr "" -#: monophony/frontend/popovers/song_popover.py:26 -#: monophony/frontend/rows/importable_group_row.py:29 -#: monophony/frontend/rows/local_group_row.py:37 -#: monophony/frontend/rows/external_group_row.py:47 -msgid "Download" +#: monophony/ui/bars/player_bar.py:127 +msgid "Previous" msgstr "" -#: monophony/frontend/popovers/song_popover.py:35 -msgid "View Artist" +#: monophony/ui/bars/player_bar.py:143 +msgid "Pause" msgstr "" -#: monophony/frontend/rows/artist_row.py:14 -#: monophony/frontend/rows/artist_row.py:23 -msgid "View artist" +#: monophony/ui/bars/player_bar.py:152 +msgid "Next" msgstr "" -#: monophony/frontend/rows/song_row.py:18 -#: monophony/frontend/rows/group_row.py:24 -msgid "Play" +#: monophony/ui/bars/player_bar.py:165 +msgid "Volume" msgstr "" -#: monophony/frontend/rows/song_row.py:36 -msgid "Downloaded" +#: monophony/ui/bars/player_bar.py:236 +msgid "Normal Playback" msgstr "" -#: monophony/frontend/rows/song_row.py:44 -#: monophony/frontend/rows/importable_group_row.py:17 -#: monophony/frontend/rows/local_group_row.py:23 -#: monophony/frontend/rows/external_group_row.py:18 -msgid "More actions" +#: monophony/ui/bars/player_bar.py:237 +msgid "Repeat Song" msgstr "" -#: monophony/frontend/rows/importable_group_row.py:35 -msgid "Import..." +#: monophony/ui/bars/player_bar.py:238 +msgid "Repeat Queue" msgstr "" -#: monophony/frontend/rows/local_group_row.py:35 -#: monophony/frontend/rows/external_group_row.py:43 -msgid "Delete" +#: monophony/ui/bars/player_bar.py:239 +msgid "Autoplay Similar" msgstr "" -#: monophony/frontend/rows/local_group_row.py:43 -msgid "Duplicate" +#: monophony/ui/bars/search_bar.py:11 +msgid "Search..." msgstr "" -#: monophony/frontend/rows/local_group_row.py:49 -#: monophony/frontend/rows/external_group_row.py:53 +#: monophony/ui/popovers/editable_group_row_popover.py:14 msgid "Rename..." msgstr "" -#: monophony/frontend/rows/local_group_row.py:66 -#: monophony/frontend/rows/external_group_row.py:70 -msgid "Rename" +#: monophony/ui/popovers/editable_group_row_popover.py:17 +#: monophony/ui/popovers/synchronized_group_row_popover.py:14 +msgid "Delete" msgstr "" -#: monophony/frontend/rows/local_group_row.py:96 -#: monophony/frontend/rows/external_group_row.py:103 -msgid "Could not Rename" +#: monophony/ui/popovers/editable_song_row_popover.py:14 +msgid "Remove From Playlist" msgstr "" -#: monophony/frontend/rows/local_group_row.py:97 -#: monophony/frontend/rows/external_group_row.py:104 -msgid "Playlist already exists" +#: monophony/ui/popovers/group_row_popover.py:20 +#: monophony/ui/popovers/song_row_popover.py:21 +msgid "Add to Queue" msgstr "" -#: monophony/frontend/rows/external_group_row.py:25 -msgid "(Synchronized)" +#: monophony/ui/popovers/group_row_popover.py:21 +#: monophony/ui/popovers/song_row_popover.py:22 +#: monophony/ui/queue_sidebar.py:41 +msgid "Add to..." msgstr "" -#: monophony/frontend/tabs/library_tab.py:55 -msgid "Recommended" +#: monophony/ui/popovers/group_row_popover.py:24 +#: monophony/ui/popovers/song_row_popover.py:29 +msgid "Download" msgstr "" -#: monophony/frontend/tabs/library_tab.py:70 -msgid "Play all" +#: monophony/ui/popovers/importable_group_row_popover.py:14 +msgid "Import..." msgstr "" -#: monophony/frontend/tabs/library_tab.py:85 -#: monophony/frontend/tabs/queue_tab.py:30 -msgid "Clear" +#: monophony/ui/popovers/queue_song_row_popover.py:15 +msgid "Remove From Queue" msgstr "" -#: monophony/frontend/tabs/library_tab.py:92 -msgid "Recently Played" +#: monophony/ui/popovers/song_row_popover.py:26 +msgid "Remove From Downloads" msgstr "" -#: monophony/frontend/tabs/library_tab.py:98 -msgid "Show Downloaded Songs" +#: monophony/ui/row_groups/queueable_row_group.py:20 +msgid "Play All" msgstr "" -#: monophony/frontend/tabs/search_tab.py:18 -msgid "Enter text or paste a URL..." +#: monophony/ui/queue_sidebar.py:18 +msgid "Stop" +msgstr "" + +#: monophony/ui/queue_sidebar.py:31 +msgid "Shuffle" msgstr "" -#: monophony/frontend/tabs/search_tab.py:24 -msgid "Go back" +#: monophony/ui/queue_sidebar.py:65 +msgid "Currently Playing" msgstr "" -#: monophony/frontend/tabs/queue_tab.py:25 +#: monophony/ui/queue_sidebar.py:116 msgid "Queue Empty" msgstr "" -#: monophony/frontend/tabs/queue_tab.py:34 -msgid "Shuffle" +#: monophony/ui/queue_sidebar.py:117 +msgid "Nothing is playing right now" +msgstr "" + +#: monophony/ui/queue_sidebar.py:135 +msgid "Back" +msgstr "" + +#: monophony/playlists.py:167 +msgid "Playlist" msgstr "" diff --git a/source/locales/be/LC_MESSAGES/all.mo b/source/locales/be/LC_MESSAGES/all.mo deleted file mode 100644 index 481cc0097b035a03d69051b27567b92bd6be73d7..0000000000000000000000000000000000000000 Binary files a/source/locales/be/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/be/LC_MESSAGES/all.po b/source/locales/be/LC_MESSAGES/all.po deleted file mode 100644 index 597fb5beac46756ed4ba892874fd23882b51b07c..0000000000000000000000000000000000000000 --- a/source/locales/be/LC_MESSAGES/all.po +++ /dev/null @@ -1,433 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: \n" -"Last-Translator: \n" -"Language-Team: \n" -"Language: be\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.4\n" - -#: monophony/frontend/pages/results_page.py:20 -#: monophony/frontend/pages/results_page.py:81 -#: monophony/frontend/pages/results_page.py:21 -#: monophony/frontend/pages/artist_page.py:20 -msgid "No Results" -msgstr "Няма вынікаў" - -#: monophony/frontend/pages/results_page.py:90 -#: monophony/frontend/pages/results_page.py:91 -#: monophony/frontend/pages/results_page.py:72 -msgid "Top Result" -msgstr "Лепшы вынік" - -#: monophony/frontend/pages/results_page.py:91 -#: monophony/frontend/pages/results_page.py:92 -#: monophony/frontend/pages/results_page.py:73 -msgid "Songs" -msgstr "Песні" - -#: monophony/frontend/pages/results_page.py:92 -#: monophony/frontend/pages/results_page.py:93 -#: monophony/frontend/pages/results_page.py:74 -#: monophony/frontend/pages/artist_page.py:77 -msgid "Albums" -msgstr "Альбомы" - -#: monophony/frontend/pages/results_page.py:93 -#: monophony/frontend/pages/results_page.py:94 -#: monophony/frontend/pages/results_page.py:75 -msgid "Videos" -msgstr "Відэа" - -#: monophony/frontend/pages/results_page.py:94 -#: monophony/frontend/pages/results_page.py:95 -#: monophony/frontend/pages/results_page.py:76 -#: monophony/frontend/pages/artist_page.py:78 -msgid "Community Playlists" -msgstr "Супольныя плэйлісты" - -#: monophony/frontend/pages/results_page.py:95 -#: monophony/frontend/pages/results_page.py:96 -#: monophony/frontend/pages/results_page.py:77 -msgid "Artists" -msgstr "Выканаўцы" - -#: monophony/frontend/pages/results_page.py:99 -#: monophony/frontend/pages/results_page.py:107 -#: monophony/frontend/pages/results_page.py:115 -#: monophony/frontend/pages/results_page.py:123 -#: monophony/frontend/pages/results_page.py:131 -#: monophony/frontend/pages/results_page.py:100 -#: monophony/frontend/pages/results_page.py:108 -#: monophony/frontend/pages/results_page.py:116 -#: monophony/frontend/pages/results_page.py:124 -#: monophony/frontend/pages/results_page.py:132 -#: monophony/frontend/pages/results_page.py:81 -#: monophony/frontend/pages/results_page.py:89 -#: monophony/frontend/pages/results_page.py:97 -#: monophony/frontend/pages/results_page.py:105 -#: monophony/frontend/pages/results_page.py:113 -msgid "More" -msgstr "Больш" - -#: monophony/frontend/pages/library_page.py:31 -#: monophony/frontend/pages/library_page.py:35 -msgid "Your Library is Empty" -msgstr "Ваша бібліятэка пустая" - -#: monophony/frontend/pages/library_page.py:33 -#: monophony/frontend/pages/library_page.py:37 -msgid "Find songs to play using the search bar above" -msgstr "Знайдзіце песні для прайгравання з дапамогай радка пошуку вышэй" - -#: monophony/frontend/pages/library_page.py:45 -#: monophony/frontend/pages/library_page.py:51 -msgid "Play All" -msgstr "Прайграць усе" - -#: monophony/frontend/pages/library_page.py:48 -#: monophony/frontend/windows/add_window.py:48 -#: monophony/frontend/pages/library_page.py:54 -msgid "Your Playlists" -msgstr "Вашыя плэйлісты" - -#: monophony/frontend/pages/library_page.py:54 -#: monophony/frontend/pages/library_page.py:60 -msgid "Recently Played" -msgstr "Нядаўна прайграныя" - -#: monophony/frontend/windows/main_window.py:44 -#: monophony/frontend/windows/main_window.py:45 -#: monophony/frontend/windows/main_window.py:46 -msgid "Go back" -msgstr "Назад" - -#: monophony/frontend/windows/main_window.py:49 -msgid "About" -msgstr "Аб праграме" - -#: monophony/frontend/windows/main_window.py:54 -#: monophony/frontend/windows/main_window.py:64 -#: monophony/frontend/windows/main_window.py:65 -#: monophony/frontend/windows/main_window.py:66 -msgid "Search for Content..." -msgstr "Пошук змесціва..." - -#: monophony/frontend/windows/main_window.py:138 -#: monophony/frontend/windows/main_window.py:148 -#: monophony/frontend/windows/main_window.py:149 -#: monophony/frontend/windows/main_window.py:150 -msgid "translator-credits" -msgstr "yahor Haurylenka 2023" - -#: monophony/frontend/windows/main_window.py:174 -#: monophony/frontend/windows/main_window.py:191 -#: monophony/frontend/windows/main_window.py:192 -#: monophony/frontend/windows/main_window.py:193 -#, python-brace-format -msgid "Deleted \"{playlist_name}\"" -msgstr "Выдалены \"{playlist_name}\"" - -#: monophony/frontend/windows/main_window.py:177 -#: monophony/frontend/windows/main_window.py:194 -#: monophony/frontend/windows/main_window.py:195 -#: monophony/frontend/windows/main_window.py:196 -msgid "Undo" -msgstr "Адрабіць" - -#: monophony/frontend/windows/main_window.py:199 -#: monophony/frontend/windows/main_window.py:222 -#: monophony/frontend/windows/main_window.py:223 -#: monophony/frontend/windows/main_window.py:224 -msgid "Added" -msgstr "Дададзена" - -#: monophony/frontend/windows/add_window.py:19 -#: monophony/frontend/popovers/song_popover.py:36 -#: monophony/frontend/widgets/player.py:183 -#: monophony/frontend/widgets/player.py:167 -#: monophony/frontend/widgets/player.py:187 -#: monophony/frontend/widgets/player.py:188 -msgid "Add to..." -msgstr "Дадаць да..." - -#: monophony/frontend/windows/add_window.py:25 -#: monophony/frontend/windows/import_window.py:53 -msgid "Cancel" -msgstr "Скасаваць" - -#: monophony/frontend/windows/add_window.py:27 -msgid "Add" -msgstr "Дадаць" - -#: monophony/frontend/windows/add_window.py:54 -msgid "New Playlist Name..." -msgstr "Новая назва плэйліста..." - -#: monophony/frontend/windows/add_window.py:56 -msgid "Create" -msgstr "Стварыць" - -#: monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "Ок" - -#: monophony/frontend/popovers/queue_song_popover.py:18 -msgid "Remove From Queue" -msgstr "Выдаліць з чэргі" - -#: monophony/frontend/popovers/local_song_popover.py:17 -msgid "Remove From Playlist" -msgstr "Выдаліць з плэйліста" - -#: monophony/frontend/popovers/song_popover.py:22 -msgid "Remove From Downloads" -msgstr "Выдаліць з запамповак" - -#: monophony/frontend/popovers/song_popover.py:29 -#: monophony/frontend/rows/local_group_row.py:42 -#: monophony/frontend/rows/importable_group_row.py:28 -#: monophony/frontend/rows/external_group_row.py:44 -msgid "Download" -msgstr "Спампаваць" - -#: monophony/frontend/popovers/song_popover.py:42 -#: monophony/frontend/widgets/player.py:195 -#: monophony/frontend/widgets/player.py:179 -#: monophony/frontend/widgets/player.py:199 -#: monophony/frontend/widgets/player.py:200 -msgid "Show Artist" -msgstr "Паказаць выканаўцу" - -#: monophony/frontend/widgets/player.py:82 -#: monophony/frontend/widgets/player.py:66 -#: monophony/frontend/widgets/player.py:76 -#: monophony/frontend/widgets/player.py:77 -msgid "Toggle pause" -msgstr "Пераключыць паўзу" - -#: monophony/frontend/widgets/player.py:86 -#: monophony/frontend/widgets/player.py:70 -#: monophony/frontend/widgets/player.py:89 -#: monophony/frontend/widgets/player.py:90 -msgid "Next song" -msgstr "Наступная песня" - -#: monophony/frontend/widgets/player.py:91 -#: monophony/frontend/widgets/player.py:75 -#: monophony/frontend/widgets/player.py:94 -#: monophony/frontend/widgets/player.py:95 -msgid "Previous song" -msgstr "Папярэдняя песня" - -#: monophony/frontend/widgets/player.py:98 -#: monophony/frontend/rows/song_row.py:42 -#: monophony/frontend/rows/local_group_row.py:23 -#: monophony/frontend/widgets/player.py:82 -#: monophony/frontend/rows/importable_group_row.py:17 -#: monophony/frontend/rows/external_group_row.py:24 -#: monophony/frontend/widgets/player.py:101 -#: monophony/frontend/widgets/player.py:102 -msgid "More actions" -msgstr "Больш дзеянняў" - -#: monophony/frontend/widgets/player.py:189 -#: monophony/frontend/widgets/player.py:173 -#: monophony/frontend/widgets/player.py:193 -#: monophony/frontend/widgets/player.py:194 -msgid "Show Queue" -msgstr "Паказаць чаргу" - -#: monophony/frontend/widgets/player.py:203 -#: monophony/frontend/widgets/player.py:187 -#: monophony/frontend/widgets/player.py:207 -#: monophony/frontend/widgets/player.py:208 -msgid "Normal Playback" -msgstr "Звычайнае прайграванне" - -#: monophony/frontend/widgets/player.py:210 -#: monophony/frontend/widgets/player.py:194 -#: monophony/frontend/widgets/player.py:214 -#: monophony/frontend/widgets/player.py:215 -msgid "Radio Mode" -msgstr "Рэжым радыё" - -#: monophony/frontend/widgets/player.py:220 -#: monophony/frontend/widgets/player.py:204 -#: monophony/frontend/widgets/player.py:224 -#: monophony/frontend/widgets/player.py:225 -msgid "Repeat Song" -msgstr "Паўтарэнне песні" - -#: monophony/frontend/widgets/player.py:230 -#: monophony/frontend/widgets/player.py:214 -#: monophony/frontend/widgets/player.py:234 -#: monophony/frontend/widgets/player.py:235 -msgid "Shuffle" -msgstr "Ператасаваць" - -#: monophony/frontend/rows/song_row.py:18 -#: monophony/frontend/rows/group_row.py:23 -msgid "Play" -msgstr "Прайграць" - -#: monophony/frontend/rows/artist_row.py:14 -#: monophony/frontend/rows/artist_row.py:23 -msgid "View Artist" -msgstr "Паказаць выканаўцу" - -#: monophony/frontend/rows/local_group_row.py:36 -#: monophony/frontend/rows/external_group_row.py:38 -msgid "Delete" -msgstr "Выдаліць" - -#: monophony/frontend/rows/local_group_row.py:48 -msgid "Duplicate" -msgstr "Дубляваць" - -#: monophony/frontend/rows/local_group_row.py:54 -#: monophony/frontend/rows/external_group_row.py:50 -msgid "Rename..." -msgstr "Перайменаваць..." - -#: monophony/frontend/rows/local_group_row.py:71 -#: monophony/frontend/rows/external_group_row.py:67 -msgid "Rename" -msgstr "Перайменаваць" - -#: monophony/frontend/rows/local_group_row.py:100 -#: monophony/frontend/rows/external_group_row.py:96 -msgid "Could not Rename" -msgstr "Не ўдалося перайменаваць" - -#: monophony/frontend/rows/local_group_row.py:101 -#: monophony/frontend/rows/external_group_row.py:97 -msgid "Playlist already exists" -msgstr "Плэйліст ужо існуе" - -#: monophony/frontend/rows/external_group_row.py:18 -msgid "Add to library" -msgstr "Дадаць у бібліятэку" - -#: monophony/frontend/pages/queue_page.py:24 -#: monophony/frontend/windows/add_window.py:42 -msgid "Queue" -msgstr "Чарга" - -#: monophony/frontend/pages/results_page.py:31 -msgid "Searching..." -msgstr "Пошук..." - -#: monophony/frontend/pages/results_page.py:62 -#: monophony/frontend/pages/artist_page.py:30 -msgid "Loading..." -msgstr "Загрузка..." - -#: monophony/frontend/pages/results_page.py:68 -#: monophony/frontend/pages/artist_page.py:56 -msgid "Artist Not Found" -msgstr "Выканаўца не знойдзены" - -#: monophony/frontend/pages/library_page.py:41 -msgid "Loading Library..." -msgstr "Загрузка бібліятэкі..." - -#: monophony/frontend/windows/main_window.py:50 -#: monophony/frontend/windows/main_window.py:51 -msgid "Import Playlist" -msgstr "Імпарт плэйліста" - -#: monophony/frontend/windows/main_window.py:54 -#: monophony/frontend/windows/main_window.py:55 -msgid "About Monophony" -msgstr "Пра Манафонію" - -#: monophony/frontend/windows/import_window.py:21 -msgid "Enter Playlist Name..." -msgstr "Увядзіце назву плэйліста..." - -#: monophony/frontend/windows/import_window.py:28 -msgid "Enter Playlist URL..." -msgstr "Увядзіце URL плэйліста..." - -#: monophony/frontend/windows/import_window.py:33 -msgid "External Playlist" -msgstr "Знешні плэйліст" - -#: monophony/frontend/windows/import_window.py:34 -msgid "Local Playlist" -msgstr "Лакальны плэйліст" - -#: monophony/frontend/windows/import_window.py:55 -msgid "Import" -msgstr "Імпарт" - -#: monophony/frontend/windows/import_window.py:73 -msgid "Import playlist..." -msgstr "Імпарт плэйліста..." - -#: monophony/frontend/windows/import_window.py:97 -#: monophony/frontend/windows/import_window.py:115 -#: monophony/frontend/windows/import_window.py:121 -msgid "Could not Import Playlist" -msgstr "Не атрымалася імпартаваць плэйліст" - -#: monophony/frontend/windows/import_window.py:98 -msgid "Failed to retrieve playlist data from server." -msgstr "Не ўдалося атрымаць даныя плэйліста з сервера." - -#: monophony/frontend/windows/import_window.py:115 -msgid "A name is required." -msgstr "Патрабуецца назва." - -#: monophony/frontend/windows/import_window.py:121 -msgid "A URL is required." -msgstr "Патрабуецца URL." - -#: monophony/frontend/rows/importable_group_row.py:34 -msgid "Import..." -msgstr "Імпарт..." - -#: monophony/frontend/rows/external_group_row.py:31 -msgid "External" -msgstr "Знешні" - -#: monophony/backend/yt.py:14 -msgid "Parsing Results..." -msgstr "Апрацоўка вынікаў..." - -#: monophony/frontend/pages/artist_page.py:76 -msgid "Other" -msgstr "Іншае" - -#: monophony/frontend/pages/artist_page.py:101 -msgid "All Songs" -msgstr "Усе песні" - -#: monophony/frontend/pages/artist_page.py:107 -msgid "All Videos" -msgstr "Усе відэа" - -#: monophony/frontend/rows/external_group_row.py:31 -msgid "Synchronized" -msgstr "Сінхранізавана" - -#: monophony/frontend/widgets/player.py:56 -msgid "Volume" -msgstr "Гучнасць" - -#: monophony/frontend/windows/import_window.py:33 -msgid "As Synchronized Playlist" -msgstr "Як сінхранізаваны плэйліст" - -#: monophony/frontend/windows/import_window.py:34 -msgid "As Editable Playlist" -msgstr "Як рэдагуемы плэйліст" - -#: monophony/frontend/windows/main_window.py:60 -msgid "Primary Menu" -msgstr "Галоўнае меню" diff --git a/source/locales/cs/LC_MESSAGES/all.mo b/source/locales/cs/LC_MESSAGES/all.mo deleted file mode 100644 index 3f52d06bd7d7332a977c04598a7e7acd38611bec..0000000000000000000000000000000000000000 Binary files a/source/locales/cs/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/cs/LC_MESSAGES/all.po b/source/locales/cs/LC_MESSAGES/all.po deleted file mode 100644 index b5b6430794342f579ca15f34b2fe9d15875dac42..0000000000000000000000000000000000000000 --- a/source/locales/cs/LC_MESSAGES/all.po +++ /dev/null @@ -1,268 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: \n" -"Last-Translator: \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=3; plural=(n==1 ? 0 : n>=2 && n<=4 ? 1 : 2);\n" -"X-Generator: Poedit 3.1.1\n" - -#: monophony/frontend/pages/search_page.py:24 -#: monophony/frontend/pages/search_page.py:96 -#: monophony/frontend/pages/search_page.py:20 -#: monophony/frontend/pages/search_page.py:123 -#: monophony/frontend/pages/search_page.py:129 -msgid "No Results" -msgstr "Žádné výsledky" - -#: monophony/frontend/pages/search_page.py:108 -#: monophony/frontend/pages/search_page.py:133 -#: monophony/frontend/pages/search_page.py:139 -msgid "Songs" -msgstr "Skladby" - -#: monophony/frontend/pages/search_page.py:109 -#: monophony/frontend/pages/search_page.py:118 -#: monophony/frontend/pages/search_page.py:127 -#: monophony/frontend/pages/search_page.py:136 -#: monophony/frontend/pages/search_page.py:139 -#: monophony/frontend/pages/search_page.py:147 -#: monophony/frontend/pages/search_page.py:156 -#: monophony/frontend/pages/search_page.py:164 -#: monophony/frontend/pages/search_page.py:172 -#: monophony/frontend/pages/search_page.py:145 -#: monophony/frontend/pages/search_page.py:153 -#: monophony/frontend/pages/search_page.py:162 -#: monophony/frontend/pages/search_page.py:170 -#: monophony/frontend/pages/search_page.py:178 -msgid "More" -msgstr "Více" - -#: monophony/frontend/pages/search_page.py:117 -#: monophony/frontend/pages/search_page.py:134 -#: monophony/frontend/pages/search_page.py:140 -msgid "Albums" -msgstr "Alba" - -#: monophony/frontend/pages/search_page.py:126 -#: monophony/frontend/pages/search_page.py:155 -#: monophony/frontend/pages/search_page.py:161 -msgid "Community Playlists" -msgstr "Komunitní playlisty" - -#: monophony/frontend/pages/search_page.py:135 -#: monophony/frontend/pages/search_page.py:141 -msgid "Videos" -msgstr "Videa" - -#: monophony/frontend/pages/library_page.py:25 -msgid "Your Library is Empty" -msgstr "Vaše knihovna je prázdná" - -#: monophony/frontend/pages/library_page.py:26 -msgid "Find songs to play using the search bar above" -msgstr "Najděte skladby k přehrání pomocí vyhledávací lišty výše" - -#: monophony/frontend/pages/library_page.py:29 -#: monophony/frontend/pages/library_page.py:37 -msgid "Play All" -msgstr "Přehrát vše" - -#: monophony/frontend/pages/library_page.py:33 -#: monophony/frontend/pages/library_page.py:41 -msgid "Your Playlists" -msgstr "Vaše playlisty" - -#: monophony/frontend/windows/delete_window.py:15 -msgid "Delete Playlist?" -msgstr "Odstranit playlist?" - -#: monophony/frontend/windows/delete_window.py:16 -#: monophony/frontend/windows/rename_window.py:18 -msgid "Cancel" -msgstr "Zrušit" - -#: monophony/frontend/windows/delete_window.py:17 -#: monophony/frontend/widgets/group_row.py:60 -#: monophony/frontend/widgets/group_row.py:79 -msgid "Delete" -msgstr "Odstranit" - -#: monophony/frontend/windows/main_window.py:41 -#: monophony/frontend/windows/main_window.py:50 -msgid "Go back" -msgstr "Zpět" - -#: monophony/frontend/windows/main_window.py:46 -#: monophony/frontend/windows/main_window.py:55 -msgid "About" -msgstr "O aplikaci" - -#: monophony/frontend/windows/main_window.py:51 -#: monophony/frontend/windows/main_window.py:60 -msgid "Search for Content..." -msgstr "Hledat obsah..." - -#: monophony/frontend/windows/main_window.py:141 -#: monophony/frontend/windows/main_window.py:138 -#: monophony/frontend/windows/main_window.py:147 -msgid "translator-credits" -msgstr "translator-credits" - -#: monophony/frontend/windows/main_window.py:143 -#: monophony/frontend/windows/main_window.py:140 -#: monophony/frontend/windows/main_window.py:149 -msgid "Donate" -msgstr "Přispět" - -#: monophony/frontend/windows/main_window.py:174 -#: monophony/frontend/windows/main_window.py:171 -#: monophony/frontend/windows/main_window.py:180 -msgid "New Playlist" -msgstr "Nový playlist" - -#: monophony/frontend/windows/main_window.py:191 -#: monophony/frontend/windows/main_window.py:188 -#: monophony/frontend/windows/main_window.py:197 -msgid "Could not Rename" -msgstr "Nepodařilo se přejmenovat" - -#: monophony/frontend/windows/main_window.py:192 -#: monophony/frontend/windows/main_window.py:189 -#: monophony/frontend/windows/main_window.py:198 -msgid "Playlist already exists" -msgstr "Playlist již existuje" - -#: monophony/frontend/windows/main_window.py:196 -#: monophony/frontend/windows/main_window.py:193 -#: monophony/frontend/windows/main_window.py:202 -msgid "Rename Playlist" -msgstr "Přejmenovat playlist" - -#: monophony/frontend/windows/main_window.py:201 -msgid "Saved" -msgstr "Uloženo" - -#: monophony/frontend/windows/rename_window.py:15 -msgid "Enter Name..." -msgstr "Zadejte název..." - -#: monophony/frontend/windows/rename_window.py:19 -#: monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "Budiž" - -#: monophony/frontend/widgets/player.py:33 -#: monophony/frontend/widgets/player.py:61 -msgid "Toggle pause" -msgstr "Přepnout pozastavení" - -#: monophony/frontend/widgets/player.py:37 -#: monophony/frontend/widgets/player.py:65 -msgid "Next song" -msgstr "Další skladba" - -#: monophony/frontend/widgets/player.py:42 -#: monophony/frontend/widgets/player.py:70 -msgid "Previous song" -msgstr "Předchozí skladba" - -#: monophony/frontend/widgets/player.py:47 -#: monophony/frontend/widgets/player.py:75 -msgid "Add to playlist" -msgstr "Přidat do playlistu" - -#: monophony/frontend/widgets/player.py:53 -#: monophony/frontend/widgets/player.py:81 -msgid "Remove From Queue" -msgstr "Odebrat z fronty" - -#: monophony/frontend/widgets/player.py:59 -#: monophony/frontend/widgets/player.py:87 -msgid "Volume" -msgstr "Hlasitost" - -#: monophony/frontend/widgets/player.py:82 -#: monophony/frontend/widgets/player.py:110 -msgid "Radio Mode" -msgstr "Režim rádia" - -#: monophony/frontend/widgets/player.py:92 -#: monophony/frontend/widgets/player.py:120 -msgid "Loop" -msgstr "Smyčka" - -#: monophony/frontend/widgets/player.py:99 -#: monophony/frontend/widgets/player.py:127 -msgid "Shuffle" -msgstr "Náhodně" - -#: monophony/frontend/widgets/player.py:115 -#: monophony/frontend/widgets/song_row.py:41 -#: monophony/frontend/widgets/group_row.py:34 -#: monophony/frontend/widgets/player.py:143 -#: monophony/frontend/widgets/group_row.py:41 -msgid "More actions" -msgstr "Další akce" - -#: monophony/frontend/widgets/song_row.py:21 -#: monophony/frontend/widgets/group_row.py:33 -msgid "Play" -msgstr "Přehrát" - -#: monophony/frontend/widgets/song_popover.py:23 -msgid "Move Up" -msgstr "Posunout výše" - -#: monophony/frontend/widgets/song_popover.py:24 -msgid "Move Down" -msgstr "Posunout níže" - -#: monophony/frontend/widgets/song_popover.py:39 -msgid "Remove From Downloads" -msgstr "Odebrat ze stažených" - -#: monophony/frontend/widgets/song_popover.py:46 -msgid "Download to Music Folder" -msgstr "Stáhnout do hudební složky" - -#: monophony/frontend/widgets/song_popover.py:53 -msgid "Add to Queue" -msgstr "Přidat do fronty" - -#: monophony/frontend/widgets/song_popover.py:59 -msgid "New Playlist..." -msgstr "Nový playlist..." - -#: monophony/frontend/widgets/group_row.py:45 -#: monophony/frontend/widgets/group_row.py:52 -msgid "Add to library" -msgstr "Přidat do knihovny" - -#: monophony/frontend/widgets/group_row.py:66 -#: monophony/frontend/widgets/group_row.py:85 -msgid "Rename..." -msgstr "Přejmenovat..." - -#: monophony/frontend/pages/search_page.py:136 -#: monophony/frontend/pages/search_page.py:142 -msgid "Artists" -msgstr "Umělci" - -#: monophony/frontend/windows/main_window.py:198 -#: monophony/frontend/windows/main_window.py:207 -msgid "Added" -msgstr "Přidáno" - -#: monophony/frontend/widgets/artist_row.py:14 -msgid "View Artist" -msgstr "Zobrazit umělce" - -#: monophony/frontend/pages/search_page.py:138 -msgid "Top Result" -msgstr "Nejlepší výsledek" diff --git a/source/locales/en/LC_MESSAGES/all.mo b/source/locales/en/LC_MESSAGES/all.mo deleted file mode 100644 index 60423fa3af6c86b0862813727a4ac4afdc3b8906..0000000000000000000000000000000000000000 Binary files a/source/locales/en/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/en/LC_MESSAGES/all.po b/source/locales/en/LC_MESSAGES/all.po new file mode 100644 index 0000000000000000000000000000000000000000..0923b60c11d8d6c3059d2b5b2125cf1368c3319c --- /dev/null +++ b/source/locales/en/LC_MESSAGES/all.po @@ -0,0 +1,2 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" diff --git a/source/locales/en_GB/LC_MESSAGES/all.mo b/source/locales/en_GB/LC_MESSAGES/all.mo deleted file mode 100644 index 0476f4235a1e2917aea4e1695e3b333e1481b3be..0000000000000000000000000000000000000000 Binary files a/source/locales/en_GB/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/en_GB/LC_MESSAGES/all.po b/source/locales/en_GB/LC_MESSAGES/all.po deleted file mode 100644 index 8391235a3042deab37a735a62fe720b5c21e5850..0000000000000000000000000000000000000000 --- a/source/locales/en_GB/LC_MESSAGES/all.po +++ /dev/null @@ -1,321 +0,0 @@ -# -# Bruce Cowan , 2024. -# -msgid "" -msgstr "" -"Content-Type: text/plain; charset=UTF-8\n" -"MIME-Version: 1.0\n" -"Project-Id-Version: monophony\n" -"Last-Translator: Bruce Cowan \n" -"Language-Team: English - United Kingdom \n" -"Language: en_GB\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1);\n" -"PO-Revision-Date: 2024-08-16 22:56+0100\n" -"X-Generator: Gtranslator 46.1\n" - -#: monophony/frontend/popovers/local_song_popover.py:17 -msgid "Remove From Playlist" -msgstr "Remove From Playlist" - -#: monophony/frontend/popovers/queue_song_popover.py:18 -msgid "Remove From Queue" -msgstr "Remove From Queue" - -#: monophony/frontend/popovers/song_popover.py:22 -msgid "Remove From Downloads" -msgstr "Remove From Downloads" - -#: monophony/frontend/popovers/song_popover.py:29 -#: monophony/frontend/rows/external_group_row.py:44 -#: monophony/frontend/rows/importable_group_row.py:28 -#: monophony/frontend/rows/local_group_row.py:42 -msgid "Download" -msgstr "Download" - -#: monophony/frontend/popovers/song_popover.py:36 -#: monophony/frontend/windows/add_window.py:19 -msgid "Add to..." -msgstr "Add to..." - -#: monophony/frontend/popovers/song_popover.py:42 -msgid "View Artist" -msgstr "View Artist" - -#: monophony/frontend/rows/external_group_row.py:24 -#: monophony/frontend/rows/importable_group_row.py:17 -#: monophony/frontend/rows/local_group_row.py:23 -#: monophony/frontend/rows/song_row.py:45 -msgid "More actions" -msgstr "More actions" - -#: monophony/frontend/rows/external_group_row.py:31 -#: monophony/frontend/windows/import_window.py:33 -msgid "Synchronized" -msgstr "Synchronised" - -#: monophony/frontend/rows/external_group_row.py:38 -#: monophony/frontend/rows/local_group_row.py:36 -msgid "Delete" -msgstr "Delete" - -#: monophony/frontend/rows/external_group_row.py:50 -#: monophony/frontend/rows/local_group_row.py:54 -msgid "Rename..." -msgstr "Rename..." - -#: monophony/frontend/rows/external_group_row.py:67 -#: monophony/frontend/rows/local_group_row.py:71 -msgid "Rename" -msgstr "Rename" - -#: monophony/frontend/rows/external_group_row.py:96 -#: monophony/frontend/rows/local_group_row.py:97 -msgid "Could not Rename" -msgstr "Could not Rename" - -#: monophony/frontend/rows/external_group_row.py:97 -#: monophony/frontend/rows/local_group_row.py:98 -msgid "Playlist already exists" -msgstr "Playlist already exists" - -#: monophony/frontend/rows/importable_group_row.py:34 -msgid "Import..." -msgstr "Import..." - -#: monophony/frontend/rows/local_group_row.py:48 -msgid "Duplicate" -msgstr "Duplicate" - -#: monophony/frontend/rows/artist_row.py:14 -#: monophony/frontend/rows/artist_row.py:23 -msgid "View artist" -msgstr "View artist" - -#: monophony/frontend/rows/group_row.py:23 -#: monophony/frontend/rows/song_row.py:18 -msgid "Play" -msgstr "Play" - -#: monophony/frontend/rows/song_row.py:36 -msgid "Downloaded" -msgstr "Downloaded" - -#: monophony/frontend/widgets/recent_searches.py:36 -msgid "Remove" -msgstr "Remove" - -#: monophony/frontend/widgets/player.py:70 -msgid "Volume" -msgstr "Volume" - -#: monophony/frontend/widgets/player.py:91 -msgid "Toggle pause" -msgstr "Toggle pause" - -#: monophony/frontend/widgets/player.py:103 -msgid "Next song" -msgstr "Next song" - -#: monophony/frontend/widgets/player.py:108 -msgid "Previous song" -msgstr "Previous song" - -#: monophony/frontend/widgets/player.py:115 -msgid "Playback mode" -msgstr "Playback mode" - -#: monophony/frontend/widgets/player.py:200 -msgid "Normal Playback" -msgstr "Normal Playback" - -#: monophony/frontend/widgets/player.py:207 -msgid "Radio Mode" -msgstr "Radio Mode" - -#: monophony/frontend/widgets/player.py:217 -msgid "Repeat Song" -msgstr "Repeat Song" - -#: monophony/frontend/widgets/player.py:227 -msgid "Shuffle" -msgstr "Shuffle" - -#: monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "Ok" - -#: monophony/frontend/windows/main_window.py:37 -msgid "Library" -msgstr "Library" - -#: monophony/frontend/windows/main_window.py:43 -msgid "Search" -msgstr "Search" - -#: monophony/frontend/windows/main_window.py:49 -#: monophony/frontend/windows/add_window.py:42 -msgid "Queue" -msgstr "Queue" - -#: monophony/frontend/windows/main_window.py:58 -msgid "About" -msgstr "About" - -#: monophony/frontend/windows/main_window.py:145 -msgid "translator-credits" -msgstr "Bruce Cowan " - -#: monophony/frontend/windows/main_window.py:188 -#, python-brace-format -msgid "Deleted \"{playlist_name}\"" -msgstr "Deleted \"{playlist_name}\"" - -#: monophony/frontend/windows/main_window.py:191 -msgid "Undo" -msgstr "Undo" - -#: monophony/frontend/windows/main_window.py:219 -msgid "Added" -msgstr "Added" - -#: monophony/frontend/windows/add_window.py:25 -#: monophony/frontend/windows/import_window.py:53 -msgid "Cancel" -msgstr "Cancel" - -#: monophony/frontend/windows/add_window.py:27 -msgid "Add" -msgstr "Add" - -#: monophony/frontend/windows/add_window.py:48 -#: monophony/frontend/tabs/library_tab.py:73 -msgid "Your Playlists" -msgstr "Your Playlists" - -#: monophony/frontend/windows/add_window.py:54 -msgid "New Playlist Name..." -msgstr "New Playlist Name..." - -#: monophony/frontend/windows/add_window.py:56 -msgid "Create" -msgstr "Create" - -#: monophony/frontend/windows/import_window.py:21 -msgid "Enter Playlist Name..." -msgstr "Enter Playlist Name..." - -#: monophony/frontend/windows/import_window.py:28 -msgid "Enter Playlist URL..." -msgstr "Enter Playlist URL..." - -#: monophony/frontend/windows/import_window.py:34 -msgid "Editable" -msgstr "Editable" - -#: monophony/frontend/windows/import_window.py:55 -#: monophony/frontend/tabs/library_tab.py:55 -msgid "Import" -msgstr "Import" - -#: monophony/frontend/windows/import_window.py:77 -msgid "Import Playlist..." -msgstr "Import Playlist..." - -#: monophony/frontend/windows/import_window.py:105 -#: monophony/frontend/windows/import_window.py:123 -#: monophony/frontend/windows/import_window.py:129 -msgid "Could not import playlist" -msgstr "Could not import playlist" - -#: monophony/frontend/windows/import_window.py:106 -msgid "Failed to retrieve playlist data from server." -msgstr "Failed to retrieve playlist data from server." - -#: monophony/frontend/windows/import_window.py:123 -msgid "A name is required." -msgstr "A name is required." - -#: monophony/frontend/windows/import_window.py:129 -msgid "A URL is required." -msgstr "A URL is required." - -#: monophony/frontend/pages/artist_page.py:20 -#: monophony/frontend/pages/results_page.py:21 -msgid "No Results" -msgstr "No Results" - -#: monophony/frontend/pages/artist_page.py:55 -msgid "Other" -msgstr "Other" - -#: monophony/frontend/pages/artist_page.py:56 -#: monophony/frontend/pages/results_page.py:66 -msgid "Albums" -msgstr "Albums" - -#: monophony/frontend/pages/artist_page.py:57 -msgid "Playlists" -msgstr "Playlists" - -#: monophony/frontend/pages/artist_page.py:80 -#: monophony/frontend/pages/results_page.py:65 -msgid "Songs" -msgstr "Songs" - -#: monophony/frontend/pages/artist_page.py:86 -#: monophony/frontend/pages/results_page.py:67 -msgid "Videos" -msgstr "Videos" - -#: monophony/frontend/pages/artist_page.py:92 -msgid "Artist Not Found" -msgstr "Artist Not Found" - -#: monophony/frontend/pages/results_page.py:64 -msgid "Top Result" -msgstr "Top Result" - -#: monophony/frontend/pages/results_page.py:68 -msgid "Community Playlists" -msgstr "Community Playlists" - -#: monophony/frontend/pages/results_page.py:69 -msgid "Artists" -msgstr "Artists" - -#: monophony/frontend/pages/results_page.py:74 -#: monophony/frontend/pages/results_page.py:83 -#: monophony/frontend/pages/results_page.py:92 -#: monophony/frontend/pages/results_page.py:101 -#: monophony/frontend/pages/results_page.py:110 -msgid "Show All" -msgstr "Show All" - -#: monophony/frontend/tabs/queue_tab.py:24 -msgid "Queue Empty" -msgstr "Queue Empty" - -#: monophony/frontend/tabs/library_tab.py:51 -msgid "Recommended" -msgstr "Recommended" - -#: monophony/frontend/tabs/library_tab.py:64 -msgid "Play all" -msgstr "Play all" - -#: monophony/frontend/tabs/library_tab.py:78 -msgid "Clear" -msgstr "Clear" - -#: monophony/frontend/tabs/library_tab.py:85 -msgid "Recently Played" -msgstr "Recently Played" - -#: monophony/frontend/tabs/search_tab.py:18 -msgid "Enter text or paste a URL..." -msgstr "Enter text or paste a URL..." - -#: monophony/frontend/tabs/search_tab.py:24 -msgid "Go back" -msgstr "Go back" diff --git a/source/locales/fr/LC_MESSAGES/all.mo b/source/locales/fr/LC_MESSAGES/all.mo deleted file mode 100644 index e6d3594e71854e07c654c6ba0276e0645000a673..0000000000000000000000000000000000000000 Binary files a/source/locales/fr/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/fr/LC_MESSAGES/all.po b/source/locales/fr/LC_MESSAGES/all.po deleted file mode 100644 index 5ed61856e20aeff5c0bc14d7b037bf459d50b4cc..0000000000000000000000000000000000000000 --- a/source/locales/fr/LC_MESSAGES/all.po +++ /dev/null @@ -1,416 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: \n" -"Last-Translator: Irénée Thirion \n" -"Language-Team: \n" -"Language: fr\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.4.1\n" - -#: monophony/frontend/popovers/local_song_popover.py:17 -msgid "Remove From Playlist" -msgstr "Retirer de la playlist" - -#: monophony/frontend/popovers/queue_song_popover.py:18 -msgid "Remove From Queue" -msgstr "Retirer de la file d’attente" - -#: monophony/frontend/popovers/song_popover.py:22 -msgid "Remove From Downloads" -msgstr "Supprimer des téléchargements" - -#: monophony/frontend/popovers/song_popover.py:29 -#: monophony/frontend/rows/external_group_row.py:44 -#: monophony/frontend/rows/importable_group_row.py:28 -#: monophony/frontend/rows/local_group_row.py:42 -msgid "Download" -msgstr "Télécharger" - -#: monophony/frontend/popovers/song_popover.py:36 -#: monophony/frontend/windows/add_window.py:19 -msgid "Add to..." -msgstr "Ajouter à…" - -#: monophony/frontend/popovers/song_popover.py:42 -msgid "View Artist" -msgstr "Voir l’artiste" - -#: monophony/frontend/rows/external_group_row.py:24 -#: monophony/frontend/rows/importable_group_row.py:17 -#: monophony/frontend/rows/local_group_row.py:23 -#: monophony/frontend/rows/song_row.py:42 -msgid "More actions" -msgstr "Plus d’actions" - -#: monophony/frontend/rows/external_group_row.py:31 -#: monophony/frontend/windows/import_window.py:33 -msgid "Synchronized" -msgstr "Synchronisé" - -#: monophony/frontend/rows/external_group_row.py:38 -#: monophony/frontend/rows/local_group_row.py:36 -msgid "Delete" -msgstr "Supprimer" - -#: monophony/frontend/rows/external_group_row.py:50 -#: monophony/frontend/rows/local_group_row.py:54 -msgid "Rename..." -msgstr "Renommer…" - -#: monophony/frontend/rows/external_group_row.py:67 -#: monophony/frontend/rows/local_group_row.py:71 -msgid "Rename" -msgstr "Renommer" - -#: monophony/frontend/rows/external_group_row.py:96 -#: monophony/frontend/rows/local_group_row.py:100 -msgid "Could not Rename" -msgstr "Impossible de renommer" - -#: monophony/frontend/rows/external_group_row.py:97 -#: monophony/frontend/rows/local_group_row.py:101 -msgid "Playlist already exists" -msgstr "La playlist existe déjà" - -#: monophony/frontend/rows/importable_group_row.py:34 -msgid "Import..." -msgstr "Importer…" - -#: monophony/frontend/rows/local_group_row.py:48 -msgid "Duplicate" -msgstr "Dupliquer" - -#: monophony/frontend/rows/song_row.py:18 -#: monophony/frontend/rows/group_row.py:23 -msgid "Play" -msgstr "Jouer" - -#: monophony/frontend/rows/artist_row.py:14 -#: monophony/frontend/rows/artist_row.py:23 -msgid "View artist" -msgstr "Voir l’artiste" - -#: monophony/frontend/widgets/player.py:67 -msgid "Volume" -msgstr "Volume" - -#: monophony/frontend/widgets/player.py:88 -msgid "Toggle pause" -msgstr "Mettre en pause" - -#: monophony/frontend/widgets/player.py:101 -msgid "Next song" -msgstr "Titre suivant" - -#: monophony/frontend/widgets/player.py:106 -msgid "Previous song" -msgstr "Titre précédent" - -#: monophony/frontend/widgets/player.py:113 -msgid "Playback mode" -msgstr "Mode lecture" - -#: monophony/frontend/widgets/player.py:196 -msgid "Normal Playback" -msgstr "Lecture normale" - -#: monophony/frontend/widgets/player.py:203 -msgid "Radio Mode" -msgstr "Mode radio" - -#: monophony/frontend/widgets/player.py:213 -msgid "Repeat Song" -msgstr "Répéter le titre" - -#: monophony/frontend/widgets/player.py:223 -msgid "Shuffle" -msgstr "Mélanger" - -#: monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "OK" - -#: monophony/frontend/windows/import_window.py:21 -msgid "Enter Playlist Name..." -msgstr "Entrez un nom de playlist…" - -#: monophony/frontend/windows/import_window.py:28 -msgid "Enter Playlist URL..." -msgstr "Entrez une URL de playlist…" - -#: monophony/frontend/windows/import_window.py:34 -msgid "Editable" -msgstr "Modifiable" - -#: monophony/frontend/windows/import_window.py:53 -#: monophony/frontend/windows/add_window.py:25 -msgid "Cancel" -msgstr "Annuler" - -#: monophony/frontend/windows/import_window.py:55 -#: monophony/frontend/tabs/library_tab.py:47 -msgid "Import" -msgstr "Importer" - -#: monophony/frontend/windows/import_window.py:73 -msgid "Import playlist..." -msgstr "Importer une playlist…" - -#: monophony/frontend/windows/import_window.py:97 -#: monophony/frontend/windows/import_window.py:115 -#: monophony/frontend/windows/import_window.py:121 -msgid "Could not Import Playlist" -msgstr "Impossible d’importer la playlist" - -#: monophony/frontend/windows/import_window.py:98 -msgid "Failed to retrieve playlist data from server." -msgstr "Échec de la récupération des données de la playlist depuis le serveur." - -#: monophony/frontend/windows/import_window.py:115 -msgid "A name is required." -msgstr "Un nom est requis." - -#: monophony/frontend/windows/import_window.py:121 -msgid "A URL is required." -msgstr "Une URL est requise." - -#: monophony/frontend/windows/add_window.py:27 -msgid "Add" -msgstr "Ajouter" - -#: monophony/frontend/windows/add_window.py:42 -#: monophony/frontend/windows/main_window.py:49 -msgid "Queue" -msgstr "File d’attente" - -#: monophony/frontend/windows/add_window.py:48 -#: monophony/frontend/tabs/library_tab.py:62 -#: monophony/frontend/tabs/library_tab.py:65 -msgid "Your Playlists" -msgstr "Vos playlists" - -#: monophony/frontend/windows/add_window.py:54 -msgid "New Playlist Name..." -msgstr "Nouveau nom de playlist…" - -#: monophony/frontend/windows/add_window.py:56 -msgid "Create" -msgstr "Créer" - -#: monophony/frontend/windows/main_window.py:37 -msgid "Library" -msgstr "Bibliothèque" - -#: monophony/frontend/windows/main_window.py:43 -msgid "Search" -msgstr "Rechercher" - -#: monophony/frontend/windows/main_window.py:58 -msgid "About" -msgstr "À propos" - -#: monophony/frontend/windows/main_window.py:133 -msgid "translator-credits" -msgstr "Irénée Thirion" - -#: monophony/frontend/windows/main_window.py:176 -#, python-brace-format -msgid "Deleted \"{playlist_name}\"" -msgstr "Supprimer « {playlist_name} »" - -#: monophony/frontend/windows/main_window.py:179 -msgid "Undo" -msgstr "Annuler" - -#: monophony/frontend/windows/main_window.py:207 -msgid "Added" -msgstr "Ajouté" - -#: monophony/frontend/pages/artist_page.py:20 -#: monophony/frontend/pages/results_page.py:21 -msgid "No Results" -msgstr "Aucun résultat" - -#: monophony/frontend/pages/artist_page.py:49 -msgid "Artist Not Found" -msgstr "Artiste non trouvé" - -#: monophony/frontend/pages/artist_page.py:69 -msgid "Other" -msgstr "Autre" - -#: monophony/frontend/pages/artist_page.py:70 -#: monophony/frontend/pages/results_page.py:72 -msgid "Albums" -msgstr "Albums" - -#: monophony/frontend/pages/artist_page.py:71 -#: monophony/frontend/pages/results_page.py:74 -msgid "Community Playlists" -msgstr "Playlists de la communauté" - -#: monophony/frontend/pages/artist_page.py:94 -msgid "All Songs" -msgstr "Tous les titres" - -#: monophony/frontend/pages/artist_page.py:100 -msgid "All Videos" -msgstr "Toutes les vidéos" - -#: monophony/frontend/pages/results_page.py:70 -msgid "Top Result" -msgstr "Résultat en tête" - -#: monophony/frontend/pages/results_page.py:71 -#: monophony/frontend/pages/artist_page.py:94 -msgid "Songs" -msgstr "Titres" - -#: monophony/frontend/pages/results_page.py:73 -#: monophony/frontend/pages/artist_page.py:100 -msgid "Videos" -msgstr "Vidéos" - -#: monophony/frontend/pages/results_page.py:75 -msgid "Artists" -msgstr "Artistes" - -#: monophony/frontend/pages/results_page.py:80 -#: monophony/frontend/pages/results_page.py:89 -#: monophony/frontend/pages/results_page.py:98 -#: monophony/frontend/pages/results_page.py:107 -#: monophony/frontend/pages/results_page.py:116 -msgid "Show all" -msgstr "Afficher tout" - -#: monophony/frontend/tabs/library_tab.py:47 -msgid "Import playlist" -msgstr "Importer une playlist" - -#: monophony/frontend/tabs/library_tab.py:53 -#: monophony/frontend/tabs/library_tab.py:56 -msgid "Play all" -msgstr "Jouer tout" - -#: monophony/frontend/tabs/library_tab.py:68 -#: monophony/frontend/tabs/library_tab.py:77 -msgid "Recently Played" -msgstr "Joué récemment" - -#: monophony/frontend/tabs/queue_tab.py:23 -msgid "Queue Empty" -msgstr "File d’attente vide" - -#: monophony/frontend/tabs/search_tab.py:15 -#: monophony/frontend/tabs/search_tab.py:18 -msgid "Search..." -msgstr "Rechercher…" - -#: monophony/frontend/tabs/search_tab.py:21 -#: monophony/frontend/tabs/search_tab.py:24 -msgid "Go back" -msgstr "Retour" - -#: monophony/frontend/widgets/recent_searches.py:36 -msgid "Remove" -msgstr "Supprimer" - -#: monophony/frontend/pages/artist_page.py:71 -msgid "Playlists" -msgstr "Playlists" - -#: monophony/frontend/tabs/library_tab.py:70 -msgid "Clear" -msgstr "Effacer" - -#~ msgid "More" -#~ msgstr "Plus" - -#~ msgid "Your Library is Empty" -#~ msgstr "Votre bibliothèque est vide" - -#~ msgid "Find songs to play using the search bar above" -#~ msgstr "Recherchez des titres à jouer depuis la barre de recherche ci-dessus" - -#~ msgid "Search for Content..." -#~ msgstr "Rechercher du contenu…" - -#~ msgid "Show Artist" -#~ msgstr "Afficher l’artiste" - -#~ msgid "Show Queue" -#~ msgstr "Afficher la file d’attente" - -#~ msgid "Add to library" -#~ msgstr "Ajouter à la bibliothèque" - -#~ msgid "Loading..." -#~ msgstr "Chargement…" - -#~ msgid "Loading Library..." -#~ msgstr "Chargement de la bibliothèque…" - -#~ msgid "About Monophony" -#~ msgstr "À propos de Monophony" - -#~ msgid "External Playlist" -#~ msgstr "Playlist externe" - -#~ msgid "Local Playlist" -#~ msgstr "Playlists locales" - -#~ msgid "External" -#~ msgstr "Externe" - -#~ msgid "Parsing Results..." -#~ msgstr "Analyse des résultats…" - -#~ msgid "As Synchronized Playlist" -#~ msgstr "Playlist synchronisée" - -#~ msgid "As Editable Playlist" -#~ msgstr "Playlist modifiable" - -#~ msgid "Primary Menu" -#~ msgstr "Menu principal" - -#~ msgid "Donate" -#~ msgstr "Faire un don" - -#~ msgid "New Playlist" -#~ msgstr "Nouvelle playlist" - -#~ msgid "Saved" -#~ msgstr "Enregistré" - -#~ msgid "Add to playlist" -#~ msgstr "Ajouter à la playlist" - -#~ msgid "Loop" -#~ msgstr "Jouer en boucle" - -#~ msgid "Move Up" -#~ msgstr "Déplacer vers le haut" - -#~ msgid "Move Down" -#~ msgstr "Déplacer vers le bas" - -#~ msgid "Download to Music Folder" -#~ msgstr "Télécharger dans le dossier Musique" - -#~ msgid "Add to Queue" -#~ msgstr "Ajouter à la file d’attente" - -#~ msgid "Enter new Name..." -#~ msgstr "Entrez un nouveau nom…" - -#~ msgid "No playlists found" -#~ msgstr "Aucune playlist trouvée" - -#~ msgid "Patrons" -#~ msgstr "Donateurs" diff --git a/source/locales/ja/LC_MESSAGES/all.mo b/source/locales/ja/LC_MESSAGES/all.mo deleted file mode 100644 index e6b7865263dc2e230d475025db1f883c05728c7f..0000000000000000000000000000000000000000 Binary files a/source/locales/ja/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/ja/LC_MESSAGES/all.po b/source/locales/ja/LC_MESSAGES/all.po deleted file mode 100644 index 2cf99c4df2dccf18399678a6dc7d6430a8d9b125..0000000000000000000000000000000000000000 --- a/source/locales/ja/LC_MESSAGES/all.po +++ /dev/null @@ -1,328 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: \n" -"Last-Translator: maboroshin \n" -"Language-Team: \n" -"Language: ja\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\n" -"X-Generator: Poedit 3.6\n" - -#: monophony/frontend/pages/artist_page.py:19 -#: monophony/frontend/pages/results_page.py:20 -msgid "No Results" -msgstr "見つかりませんでした" - -#: monophony/frontend/pages/artist_page.py:58 -msgid "Other" -msgstr "ほか" - -#: monophony/frontend/pages/artist_page.py:59 -#: monophony/frontend/pages/results_page.py:89 -msgid "Albums" -msgstr "アルバム" - -#: monophony/frontend/pages/artist_page.py:60 -msgid "Playlists" -msgstr "再生リスト" - -#: monophony/frontend/pages/artist_page.py:83 -#: monophony/frontend/pages/results_page.py:88 -msgid "Songs" -msgstr "曲" - -#: monophony/frontend/pages/artist_page.py:89 -#: monophony/frontend/pages/results_page.py:90 -msgid "Videos" -msgstr "動画" - -#: monophony/frontend/pages/artist_page.py:95 -msgid "Artist Not Found" -msgstr "アーティストが見つかりません" - -#: monophony/frontend/pages/results_page.py:61 -msgid "Show All" -msgstr "すべて表示" - -#: monophony/frontend/pages/results_page.py:87 -msgid "Top Result" -msgstr "検索上位" - -#: monophony/frontend/pages/results_page.py:91 -msgid "Community Playlists" -msgstr "コミュニティの再生リスト" - -#: monophony/frontend/pages/results_page.py:92 -msgid "Artists" -msgstr "アーティスト" - -#: monophony/frontend/widgets/recent_searches.py:36 -msgid "Remove" -msgstr "除去" - -#: monophony/frontend/widgets/player.py:68 -msgid "Toggle pause" -msgstr "再生/停止" - -#: monophony/frontend/widgets/player.py:80 -msgid "Next song" -msgstr "次の曲" - -#: monophony/frontend/widgets/player.py:85 -msgid "Previous song" -msgstr "前の曲" - -#: monophony/frontend/widgets/player.py:97 -msgid "Change volume" -msgstr "音量を変更" - -#: monophony/frontend/widgets/player.py:103 -msgid "Playback mode" -msgstr "再生モード" - -#: monophony/frontend/widgets/player.py:188 -msgid "Normal Playback" -msgstr "通常の再生" - -#: monophony/frontend/widgets/player.py:195 -msgid "Radio Mode" -msgstr "ラジオモード" - -#: monophony/frontend/widgets/player.py:205 -msgid "Repeat Song" -msgstr "曲をリピート" - -#: monophony/frontend/widgets/player.py:215 -msgid "Repeat Queue" -msgstr "キューをリピート" - -#: monophony/frontend/windows/import_window.py:21 -msgid "Enter Playlist Name..." -msgstr "再生リストの名前を入力..." - -#: monophony/frontend/windows/import_window.py:28 -msgid "Enter Playlist URL..." -msgstr "再生リストのURLを入力..." - -#: monophony/frontend/windows/import_window.py:33 -msgid "Synchronized" -msgstr "同期される" - -#: monophony/frontend/windows/import_window.py:34 -msgid "Editable" -msgstr "編集を許可" - -#: monophony/frontend/windows/import_window.py:47 -#: monophony/frontend/windows/add_window.py:23 -msgid "Cancel" -msgstr "キャンセル" - -#: monophony/frontend/windows/import_window.py:49 -#: monophony/frontend/tabs/library_tab.py:59 -msgid "Import" -msgstr "インポート" - -#: monophony/frontend/windows/import_window.py:71 -msgid "Import Playlist..." -msgstr "再生リストをインポート..." - -#: monophony/frontend/windows/import_window.py:100 -#: monophony/frontend/windows/import_window.py:118 -#: monophony/frontend/windows/import_window.py:124 -msgid "Could not import playlist" -msgstr "再生リストをインポートできませんでした" - -#: monophony/frontend/windows/import_window.py:101 -msgid "Failed to retrieve playlist data from server." -msgstr "サーバーから再生リストの取得に失敗しました。" - -#: monophony/frontend/windows/import_window.py:118 -msgid "A name is required." -msgstr "名前は必須です。" - -#: monophony/frontend/windows/import_window.py:124 -msgid "A URL is required." -msgstr "URLは必須です。" - -#: monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "OK" - -#: monophony/frontend/windows/main_window.py:38 -msgid "Library" -msgstr "ライブラリ" - -#: monophony/frontend/windows/main_window.py:43 -msgid "Search" -msgstr "検索" - -#: monophony/frontend/windows/main_window.py:49 -#: monophony/frontend/windows/add_window.py:36 -#: monophony/frontend/tabs/queue_tab.py:44 -msgid "Queue" -msgstr "再生キュー" - -#: monophony/frontend/windows/main_window.py:58 -msgid "About" -msgstr "情報" - -#: monophony/frontend/windows/main_window.py:192 -msgid "translator-credits" -msgstr "maboroshin" - -#: monophony/frontend/windows/main_window.py:239 -#, python-brace-format -msgid "Deleted playlist \"{playlist_name}\"" -msgstr "「{playlist_name}」を削除しました" - -#: monophony/frontend/windows/main_window.py:244 -msgid "Undo" -msgstr "取り消す" - -#: monophony/frontend/windows/main_window.py:262 -msgid "Added" -msgstr "追加しました" - -#: monophony/frontend/windows/add_window.py:20 -#: monophony/frontend/popovers/song_popover.py:31 -msgid "Add to..." -msgstr "追加先を選択..." - -#: monophony/frontend/windows/add_window.py:25 -msgid "Add" -msgstr "追加" - -#: monophony/frontend/windows/add_window.py:43 -#: monophony/frontend/tabs/library_tab.py:79 -msgid "Your Playlists" -msgstr "再生リスト" - -#: monophony/frontend/windows/add_window.py:49 -msgid "New Playlist Name..." -msgstr "新しい再生リスト名..." - -#: monophony/frontend/windows/add_window.py:51 -msgid "Create" -msgstr "作成" - -#: monophony/frontend/popovers/local_song_popover.py:17 -msgid "Remove From Playlist" -msgstr "再生リストから除去" - -#: monophony/frontend/popovers/queue_song_popover.py:18 -msgid "Remove From Queue" -msgstr "再生キューから除去" - -#: monophony/frontend/popovers/song_popover.py:21 -msgid "Remove From Downloads" -msgstr "ダウンロードから削除" - -#: monophony/frontend/popovers/song_popover.py:26 -#: monophony/frontend/rows/importable_group_row.py:29 -#: monophony/frontend/rows/local_group_row.py:37 -#: monophony/frontend/rows/external_group_row.py:47 -msgid "Download" -msgstr "ダウンロード" - -#: monophony/frontend/popovers/song_popover.py:35 -msgid "View Artist" -msgstr "アーティストを表示" - -#: monophony/frontend/rows/artist_row.py:14 -#: monophony/frontend/rows/artist_row.py:23 -msgid "View artist" -msgstr "アーティストを表示" - -#: monophony/frontend/rows/song_row.py:18 -#: monophony/frontend/rows/group_row.py:24 -msgid "Play" -msgstr "再生" - -#: monophony/frontend/rows/song_row.py:36 -msgid "Downloaded" -msgstr "ダウンロード済み" - -#: monophony/frontend/rows/song_row.py:44 -#: monophony/frontend/rows/importable_group_row.py:17 -#: monophony/frontend/rows/local_group_row.py:23 -#: monophony/frontend/rows/external_group_row.py:18 -msgid "More actions" -msgstr "ほかの操作" - -#: monophony/frontend/rows/importable_group_row.py:35 -msgid "Import..." -msgstr "インポート" - -#: monophony/frontend/rows/local_group_row.py:35 -#: monophony/frontend/rows/external_group_row.py:43 -msgid "Delete" -msgstr "削除" - -#: monophony/frontend/rows/local_group_row.py:43 -msgid "Duplicate" -msgstr "複製" - -#: monophony/frontend/rows/local_group_row.py:49 -#: monophony/frontend/rows/external_group_row.py:53 -msgid "Rename..." -msgstr "名前の変更..." - -#: monophony/frontend/rows/local_group_row.py:66 -#: monophony/frontend/rows/external_group_row.py:70 -msgid "Rename" -msgstr "名前の変更" - -#: monophony/frontend/rows/local_group_row.py:96 -#: monophony/frontend/rows/external_group_row.py:103 -msgid "Could not Rename" -msgstr "名前を変更できません" - -#: monophony/frontend/rows/local_group_row.py:97 -#: monophony/frontend/rows/external_group_row.py:104 -msgid "Playlist already exists" -msgstr "再生リストは既に存在します" - -#: monophony/frontend/rows/external_group_row.py:25 -msgid "(Synchronized)" -msgstr "(同期)" - -#: monophony/frontend/tabs/library_tab.py:55 -msgid "Recommended" -msgstr "おすすめ" - -#: monophony/frontend/tabs/library_tab.py:70 -msgid "Play all" -msgstr "すべて再生" - -#: monophony/frontend/tabs/library_tab.py:85 -#: monophony/frontend/tabs/queue_tab.py:30 -msgid "Clear" -msgstr "消去" - -#: monophony/frontend/tabs/library_tab.py:92 -msgid "Recently Played" -msgstr "最近聴いた曲" - -#: monophony/frontend/tabs/library_tab.py:98 -msgid "Show Downloaded Songs" -msgstr "ダウンロード済みの曲を表示" - -#: monophony/frontend/tabs/search_tab.py:18 -msgid "Enter text or paste a URL..." -msgstr "検索語句か、URLを入力..." - -#: monophony/frontend/tabs/search_tab.py:24 -msgid "Go back" -msgstr "戻る" - -#: monophony/frontend/tabs/queue_tab.py:25 -msgid "Queue Empty" -msgstr "キューなし" - -#: monophony/frontend/tabs/queue_tab.py:34 -msgid "Shuffle" -msgstr "シャッフル" diff --git a/source/locales/nl/LC_MESSAGES/all.mo b/source/locales/nl/LC_MESSAGES/all.mo deleted file mode 100644 index d2c21cc2bc282a3d804db2cbde0b02c1d4d676b5..0000000000000000000000000000000000000000 Binary files a/source/locales/nl/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/nl/LC_MESSAGES/all.po b/source/locales/nl/LC_MESSAGES/all.po deleted file mode 100644 index aaae606111559e11648a3eab9be96d2bac9a0b73..0000000000000000000000000000000000000000 --- a/source/locales/nl/LC_MESSAGES/all.po +++ /dev/null @@ -1,141 +0,0 @@ -# Heimen Stoffels , 2023. -msgid "" -msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: 2023-02-20 13:15+0100\n" -"Last-Translator: Heimen Stoffels \n" -"Language-Team: Dutch\n" -"Language: nl\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.2.2\n" - -#: ../monophony/frontend/pages/search_page.py:23 -#: ../monophony/frontend/pages/search_page.py:89 -msgid "No results" -msgstr "Er zijn geen zoekresultaten" - -#: ../monophony/frontend/pages/search_page.py:101 -msgid "Songs" -msgstr "Nummers" - -#: ../monophony/frontend/pages/search_page.py:102 -#: ../monophony/frontend/pages/search_page.py:111 -#: ../monophony/frontend/pages/search_page.py:120 -#: ../monophony/frontend/pages/search_page.py:129 -msgid "More" -msgstr "Meer" - -#: ../monophony/frontend/pages/search_page.py:110 -msgid "Albums" -msgstr "Albums" - -#: ../monophony/frontend/pages/search_page.py:119 -msgid "Community playlists" -msgstr "Afspeellijsten van anderen" - -#: ../monophony/frontend/pages/search_page.py:128 -msgid "Videos" -msgstr "Video's" - -#: ../monophony/frontend/pages/library_page.py:26 -msgid "No playlists found" -msgstr "Er zijn geen afspeellijsten gevonden" - -#: ../monophony/frontend/pages/library_page.py:29 -msgid "Play all" -msgstr "Alles beluisteren" - -#: ../monophony/frontend/pages/library_page.py:32 -msgid "Playlists" -msgstr "Afspeellijsten" - -#: ../monophony/frontend/windows/delete_window.py:15 -msgid "Delete playlist?" -msgstr "Afspeellijst verwijderen?" - -#: ../monophony/frontend/windows/delete_window.py:16 -#: ../monophony/frontend/windows/rename_window.py:18 -msgid "Cancel" -msgstr "Annuleren" - -#: ../monophony/frontend/windows/delete_window.py:17 -#: ../monophony/frontend/widgets/group_row.py:24 -msgid "Delete" -msgstr "Verwijderen" - -#: ../monophony/frontend/windows/main_window.py:29 -msgid "About" -msgstr "Over" - -#: ../monophony/frontend/windows/main_window.py:119 -msgid "translator-credits" -msgstr "Heimen Stoffels " - -#: ../monophony/frontend/windows/main_window.py:120 -msgid "Patrons" -msgstr "Patrons" - -#: ../monophony/frontend/windows/main_window.py:122 -msgid "Donate" -msgstr "Doneren" - -#: ../monophony/frontend/windows/rename_window.py:15 -msgid "Enter name..." -msgstr "Voer een naam in…" - -#: ../monophony/frontend/windows/rename_window.py:19 -#: ../monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "Oké" - -#: ../monophony/frontend/widgets/player.py:62 -msgid "Remove from queue" -msgstr "Verwijderen uit wachtrij" - -#: ../monophony/frontend/widgets/player.py:65 -msgid "Volume" -msgstr "Volumeniveau" - -#: ../monophony/frontend/widgets/player.py:81 -msgid "Radio mode" -msgstr "Radiomodus" - -#: ../monophony/frontend/widgets/song_row.py:21 -msgid "Play" -msgstr "Beluisteren" - -#: ../monophony/frontend/widgets/song_popover.py:37 -msgid "Remove from downloads" -msgstr "Verwijderen uit downloads" - -#: ../monophony/frontend/widgets/song_popover.py:39 -msgid "Download to Music folder" -msgstr "Opslaan in muziekmap" - -#: ../monophony/frontend/widgets/song_popover.py:51 -msgid "Add to queue" -msgstr "Toevoegen aan wachtrij" - -#: ../monophony/frontend/widgets/song_popover.py:55 -msgid "New playlist..." -msgstr "Nieuwe afspeellijst…" - -#: ../monophony/frontend/widgets/group_row.py:27 -msgid "Rename..." -msgstr "Naam wijzigen…" - -#: ../monophony/frontend/widgets/group_row.py:35 -msgid "Save to library" -msgstr "Toevoegen aan verzameling" - -#: ../monophony/frontend/widgets/group_row.py:82 -msgid "Could not rename" -msgstr "De naam kan niet worden gewijzigd" - -#: ../monophony/frontend/widgets/group_row.py:83 -msgid "Playlist already exists" -msgstr "Deze afspeellijst bestaat al" diff --git a/source/locales/pl/LC_MESSAGES/all.po b/source/locales/pl/LC_MESSAGES/all.po new file mode 100644 index 0000000000000000000000000000000000000000..43079e1fbc6381fd723e9c2dd3f1de2f2487fa9a --- /dev/null +++ b/source/locales/pl/LC_MESSAGES/all.po @@ -0,0 +1,329 @@ +msgid "" +msgstr "Content-Type: text/plain; charset=UTF-8\n" + +#: monophony/ui/pages/artist_page.py:11 +msgid "Artist Page" +msgstr "Strona Wykonawcy" + +#: monophony/ui/pages/home_page.py:26 +msgid "Recommended" +msgstr "Polecane" + +#: monophony/ui/pages/home_page.py:76 monophony/ui/pages/home_page.py:131 +msgid "Your Playlists" +msgstr "Twoje Playlisty" + +#: monophony/ui/pages/home_page.py:133 +msgid "Playlists you create will appear here" +msgstr "Utworzone playlisty pojawią się tutaj" + +#: monophony/ui/pages/home_page.py:148 monophony/ui/windows/import_window.py:52 +msgid "Import" +msgstr "Importuj" + +#: monophony/ui/pages/home_page.py:161 +msgid "Synchronized Playlists" +msgstr "Zsynchronizowane Playlisty" + +#: monophony/ui/pages/home_page.py:217 +msgid "Downloads Directory" +msgstr "Katalog Pobranych" + +#: monophony/ui/pages/home_page.py:227 +msgid "Downloads" +msgstr "Pobrane" + +#: monophony/ui/pages/home_page.py:259 +msgid "Clear" +msgstr "Wyczyść" + +#: monophony/ui/pages/home_page.py:267 +msgid "Recently Played" +msgstr "Ostatnio Odtwarzane" + +#: monophony/ui/pages/home_page.py:304 monophony/ui/windows/main_window.py:712 +msgid "Donate" +msgstr "Przekaż Darowiznę" + +#: monophony/ui/pages/home_page.py:348 +msgid "Home" +msgstr "Strona Główna" + +#: monophony/ui/pages/home_page.py:407 monophony/ui/pages/home_page.py:436 +#, python-brace-format +msgid "Deleted playlist \"{name}\"" +msgstr "Usunięto playlistę \"{name}\"" + +#: monophony/ui/pages/home_page.py:411 monophony/ui/pages/home_page.py:440 +msgid "Undo" +msgstr "Cofnij" + +#: monophony/ui/pages/loading_page.py:21 +msgid "Loading..." +msgstr "Ładowanie..." + +#: monophony/ui/pages/results_page.py:27 monophony/ui/pages/results_page.py:47 +#: monophony/ui/pages/results_page.py:67 monophony/ui/pages/results_page.py:87 +#: monophony/ui/pages/results_page.py:107 +msgid "Show All" +msgstr "Pokaż Wszystkie" + +#: monophony/ui/pages/results_page.py:126 +msgid "Songs" +msgstr "Utwory" + +#: monophony/ui/pages/results_page.py:129 +msgid "Videos" +msgstr "Wideo" + +#: monophony/ui/pages/results_page.py:132 +msgid "Albums and Singles" +msgstr "Albumy i Single" + +#: monophony/ui/pages/results_page.py:135 +msgid "Playlists" +msgstr "Playlisty" + +#: monophony/ui/pages/results_page.py:138 +msgid "Artists" +msgstr "Wyonawcy" + +#: monophony/ui/pages/results_page.py:147 +msgid "Top Result" +msgstr "Najlepszy Wynik" + +#: monophony/ui/pages/results_page.py:221 +msgid "Search Results" +msgstr "Wyniki Wyszukiwania" + +#: monophony/ui/rows/artist_row.py:15 monophony/ui/rows/artist_row.py:20 +#: monophony/ui/popovers/group_row_popover.py:23 +#: monophony/ui/popovers/song_row_popover.py:23 +msgid "View Artist" +msgstr "Zobacz Wykonawcę" + +#: monophony/ui/rows/group_row.py:22 monophony/ui/rows/song_row.py:34 +msgid "More" +msgstr "Więcej" + +#: monophony/ui/rows/song_row.py:29 +msgid "Downloaded" +msgstr "Pobrane" + +#: monophony/ui/rows/song_row.py:46 +msgid "Play" +msgstr "Odtwarzaj" + +#: monophony/ui/windows/add_window.py:24 +msgid "New Playlist" +msgstr "Nowa Playlista" + +#: monophony/ui/windows/add_window.py:40 +msgid "Add" +msgstr "Dodaj" + +#: monophony/ui/windows/add_window.py:55 +msgid "Add to Playlists..." +msgstr "Dodaj do Playlist..." + +#: monophony/ui/windows/import_window.py:18 +msgid "Playlist URL" +msgstr "URL Playlisty" + +#: monophony/ui/windows/import_window.py:29 +#: monophony/ui/windows/rename_window.py:15 +msgid "Playlist Name" +msgstr "Nazwa Playlisty" + +#: monophony/ui/windows/import_window.py:33 +msgid "Synchronized" +msgstr "Zsynchronizowane" + +#: monophony/ui/windows/import_window.py:35 +msgid "Synchronized playlists are updated automatically and can't be edited" +msgstr "" +"Zsynchronizowane playlisty są aktualizowane automatycznie i nie mogą być " +"edytowane" + +#: monophony/ui/windows/import_window.py:69 +msgid "Import Playlist..." +msgstr "Importuj Playlistę..." + +#: monophony/ui/windows/message_window.py:12 +msgid "Ok" +msgstr "Ok" + +#: monophony/ui/windows/rename_window.py:35 +msgid "Rename Playlist..." +msgstr "Zmień Nazwę Playlisty..." + +#: monophony/ui/windows/main_window.py:373 +msgid "Playing" +msgstr "Odtwarzanie" + +#: monophony/ui/windows/main_window.py:454 +msgid "Download Failed" +msgstr "Nie Udało Się Pobrać" + +#: monophony/ui/windows/main_window.py:454 +msgid "Some songs could not be downloaded" +msgstr "Niektóre utwory nie mogły być pobrane" + +#: monophony/ui/windows/main_window.py:503 +msgid "Failed to Import" +msgstr "Nie Udało Się Importować" + +#: monophony/ui/windows/main_window.py:504 +#: monophony/ui/windows/main_window.py:619 +#: monophony/ui/windows/main_window.py:836 +msgid "Check your internet connection and try again" +msgstr "Sprawdź swoje połączenie z internetem i spróbuj ponownie" + +#: monophony/ui/windows/main_window.py:618 +msgid "Failed to Search" +msgstr "Nie Udało Się Wyszukać" + +#: monophony/ui/windows/main_window.py:625 +msgid "No Results" +msgstr "Brak Wyników" + +#: monophony/ui/windows/main_window.py:626 +msgid "Try searching for something else" +msgstr "Spróbuj wyszukać coś innego" + +#: monophony/ui/windows/main_window.py:709 +msgid "translator-credits" +msgstr "Zehkira" + +#: monophony/ui/windows/main_window.py:835 +msgid "Failed to Load Artist Page" +msgstr "Nie Udało Się Załadować Strony Wykonawcy" + +#: monophony/ui/windows/main_window.py:842 +msgid "Empty Artist Page" +msgstr "Pusta Strona Wykonawcy" + +#: monophony/ui/windows/main_window.py:843 +msgid "No content found from this artist" +msgstr "Nie znaleziono żadnych treści od tego wykonawcy" + +#: monophony/ui/bars/header_bar.py:15 +msgid "About" +msgstr "O Aplikacji" + +#: monophony/ui/bars/player_bar.py:93 monophony/ui/queue_sidebar.py:138 +msgid "Queue" +msgstr "Kolejka" + +#: monophony/ui/bars/player_bar.py:120 +msgid "Playback Mode" +msgstr "Tryb Odtwarzania" + +#: monophony/ui/bars/player_bar.py:127 +msgid "Previous" +msgstr "Poprzednie" + +#: monophony/ui/bars/player_bar.py:143 +msgid "Pause" +msgstr "Pauza" + +#: monophony/ui/bars/player_bar.py:152 +msgid "Next" +msgstr "Następne" + +#: monophony/ui/bars/player_bar.py:165 +msgid "Volume" +msgstr "Głośność" + +#: monophony/ui/bars/player_bar.py:236 +msgid "Normal Playback" +msgstr "Normalne Odtwarzanie" + +#: monophony/ui/bars/player_bar.py:237 +msgid "Repeat Song" +msgstr "Powtarzaj Utwór" + +#: monophony/ui/bars/player_bar.py:238 +msgid "Repeat Queue" +msgstr "Powtarzaj Kolejkę" + +#: monophony/ui/bars/player_bar.py:239 +msgid "Autoplay Similar" +msgstr "Autoodtwarzaj Podobne" + +#: monophony/ui/bars/search_bar.py:11 +msgid "Search..." +msgstr "Wyszukaj..." + +#: monophony/ui/popovers/editable_group_row_popover.py:14 +msgid "Rename..." +msgstr "Zmień Nazwę..." + +#: monophony/ui/popovers/editable_group_row_popover.py:17 +#: monophony/ui/popovers/synchronized_group_row_popover.py:14 +msgid "Delete" +msgstr "Usuń" + +#: monophony/ui/popovers/editable_song_row_popover.py:14 +msgid "Remove From Playlist" +msgstr "Usuń z Playlisty" + +#: monophony/ui/popovers/group_row_popover.py:20 +#: monophony/ui/popovers/song_row_popover.py:21 +msgid "Add to Queue" +msgstr "Dodaj do Kolejki" + +#: monophony/ui/popovers/group_row_popover.py:21 +#: monophony/ui/popovers/song_row_popover.py:22 +#: monophony/ui/queue_sidebar.py:41 +msgid "Add to..." +msgstr "Dodaj do..." + +#: monophony/ui/popovers/group_row_popover.py:24 +#: monophony/ui/popovers/song_row_popover.py:29 +msgid "Download" +msgstr "Pobierz" + +#: monophony/ui/popovers/importable_group_row_popover.py:14 +msgid "Import..." +msgstr "Importuj..." + +#: monophony/ui/popovers/queue_song_row_popover.py:15 +msgid "Remove From Queue" +msgstr "Usuń z Kolejki" + +#: monophony/ui/popovers/song_row_popover.py:26 +msgid "Remove From Downloads" +msgstr "Usuń z Pobranych" + +#: monophony/ui/row_groups/queueable_row_group.py:20 +msgid "Play All" +msgstr "Odtwórz Wszystkie" + +#: monophony/ui/queue_sidebar.py:18 +msgid "Stop" +msgstr "Zatrzymaj" + +#: monophony/ui/queue_sidebar.py:31 +msgid "Shuffle" +msgstr "Wymieszaj" + +#: monophony/ui/queue_sidebar.py:65 +msgid "Currently Playing" +msgstr "Aktualnie Odtwarzane" + +#: monophony/ui/queue_sidebar.py:116 +msgid "Queue Empty" +msgstr "Kolejka Pusta" + +#: monophony/ui/queue_sidebar.py:117 +msgid "Nothing is playing right now" +msgstr "Nic nie jest w tej chwili odtwarzane" + +#: monophony/ui/queue_sidebar.py:135 +msgid "Back" +msgstr "Wróć" + +#: monophony/playlists.py:167 +msgid "Playlist" +msgstr "Playlista" diff --git a/source/locales/pt_BR/LC_MESSAGES/all.mo b/source/locales/pt_BR/LC_MESSAGES/all.mo deleted file mode 100644 index 10058cf4a52312ff27160f333a3218b66dede766..0000000000000000000000000000000000000000 Binary files a/source/locales/pt_BR/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/pt_BR/LC_MESSAGES/all.po b/source/locales/pt_BR/LC_MESSAGES/all.po deleted file mode 100644 index 46a4a1dc73c67f88943de682a9b6de744cb2497d..0000000000000000000000000000000000000000 --- a/source/locales/pt_BR/LC_MESSAGES/all.po +++ /dev/null @@ -1,301 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: \n" -"Last-Translator: Diego C. Sampaio \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-Generator: Poedit 3.1.1\n" - -#: monophony/frontend/pages/search_page.py:24 -#: monophony/frontend/pages/search_page.py:96 -#: monophony/frontend/pages/search_page.py:20 -#: monophony/frontend/pages/search_page.py:123 -#: monophony/frontend/pages/search_page.py:129 -#: monophony/frontend/pages/search_page.py:130 -msgid "No Results" -msgstr "Sem Resultados" - -#: monophony/frontend/pages/search_page.py:108 -#: monophony/frontend/pages/search_page.py:133 -#: monophony/frontend/pages/search_page.py:139 -#: monophony/frontend/pages/search_page.py:140 -msgid "Songs" -msgstr "Músicas" - -#: monophony/frontend/pages/search_page.py:109 -#: monophony/frontend/pages/search_page.py:118 -#: monophony/frontend/pages/search_page.py:127 -#: monophony/frontend/pages/search_page.py:136 -#: monophony/frontend/pages/search_page.py:139 -#: monophony/frontend/pages/search_page.py:147 -#: monophony/frontend/pages/search_page.py:156 -#: monophony/frontend/pages/search_page.py:164 -#: monophony/frontend/pages/search_page.py:172 -#: monophony/frontend/pages/search_page.py:145 -#: monophony/frontend/pages/search_page.py:153 -#: monophony/frontend/pages/search_page.py:162 -#: monophony/frontend/pages/search_page.py:170 -#: monophony/frontend/pages/search_page.py:178 -#: monophony/frontend/pages/search_page.py:146 -#: monophony/frontend/pages/search_page.py:154 -#: monophony/frontend/pages/search_page.py:163 -#: monophony/frontend/pages/search_page.py:171 -#: monophony/frontend/pages/search_page.py:179 -msgid "More" -msgstr "Mais" - -#: monophony/frontend/pages/search_page.py:117 -#: monophony/frontend/pages/search_page.py:134 -#: monophony/frontend/pages/search_page.py:140 -#: monophony/frontend/pages/search_page.py:141 -msgid "Albums" -msgstr "Álbuns" - -#: monophony/frontend/pages/search_page.py:126 -#: monophony/frontend/pages/search_page.py:155 -#: monophony/frontend/pages/search_page.py:161 -#: monophony/frontend/pages/search_page.py:162 -msgid "Community Playlists" -msgstr "Playlists Comunitárias" - -#: monophony/frontend/pages/search_page.py:135 -#: monophony/frontend/pages/search_page.py:141 -#: monophony/frontend/pages/search_page.py:142 -msgid "Videos" -msgstr "Vídeos" - -#: monophony/frontend/pages/library_page.py:25 -msgid "Your Library is Empty" -msgstr "Sua Biblioteca está Vazia" - -#: monophony/frontend/pages/library_page.py:26 -msgid "Find songs to play using the search bar above" -msgstr "Encontre músicas para ouvir usando a barra de pesquisa acima" - -#: monophony/frontend/pages/library_page.py:29 -#: monophony/frontend/pages/library_page.py:37 -msgid "Play All" -msgstr "Tocar Todas" - -#: monophony/frontend/pages/library_page.py:33 -#: monophony/frontend/pages/library_page.py:41 -msgid "Your Playlists" -msgstr "Suas Playlists" - -#: monophony/frontend/windows/delete_window.py:15 -msgid "Delete Playlist?" -msgstr "Excluir Playlist?" - -#: monophony/frontend/windows/delete_window.py:16 -#: monophony/frontend/windows/rename_window.py:18 -msgid "Cancel" -msgstr "Cancelar" - -#: monophony/frontend/windows/delete_window.py:17 -#: monophony/frontend/widgets/group_row.py:60 -#: monophony/frontend/widgets/group_row.py:79 -msgid "Delete" -msgstr "Excluir" - -#: monophony/frontend/windows/main_window.py:41 -#: monophony/frontend/windows/main_window.py:50 -msgid "Go back" -msgstr "Voltar" - -#: monophony/frontend/windows/main_window.py:46 -#: monophony/frontend/windows/main_window.py:55 -msgid "About" -msgstr "Sobre" - -#: monophony/frontend/windows/main_window.py:51 -#: monophony/frontend/windows/main_window.py:60 -msgid "Search for Content..." -msgstr "Pesquisar Conteúdo..." - -#: monophony/frontend/windows/main_window.py:141 -#: monophony/frontend/windows/main_window.py:138 -#: monophony/frontend/windows/main_window.py:147 -#: monophony/frontend/windows/main_window.py:152 -msgid "translator-credits" -msgstr "Diego C. Sampaio" - -#: monophony/frontend/windows/main_window.py:143 -#: monophony/frontend/windows/main_window.py:140 -#: monophony/frontend/windows/main_window.py:149 -#: monophony/frontend/windows/main_window.py:154 -msgid "Donate" -msgstr "Doar" - -#: monophony/frontend/windows/main_window.py:174 -#: monophony/frontend/windows/main_window.py:171 -#: monophony/frontend/windows/main_window.py:180 -#: monophony/frontend/windows/main_window.py:185 -msgid "New Playlist" -msgstr "Nova Playlist" - -#: monophony/frontend/windows/main_window.py:191 -#: monophony/frontend/windows/main_window.py:188 -#: monophony/frontend/windows/main_window.py:197 -#: monophony/frontend/windows/main_window.py:202 -msgid "Could not Rename" -msgstr "Não foi possível renomear" - -#: monophony/frontend/windows/main_window.py:192 -#: monophony/frontend/windows/main_window.py:189 -#: monophony/frontend/windows/main_window.py:198 -#: monophony/frontend/windows/main_window.py:203 -msgid "Playlist already exists" -msgstr "Playlist já existe" - -#: monophony/frontend/windows/main_window.py:196 -#: monophony/frontend/windows/main_window.py:193 -#: monophony/frontend/windows/main_window.py:202 -#: monophony/frontend/windows/main_window.py:207 -msgid "Rename Playlist" -msgstr "Renomear Playlist" - -#: monophony/frontend/windows/main_window.py:201 -msgid "Saved" -msgstr "Salvo" - -#: monophony/frontend/windows/rename_window.py:15 -msgid "Enter Name..." -msgstr "Insira o nome..." - -#: monophony/frontend/windows/rename_window.py:19 -#: monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "Ok" - -#: monophony/frontend/widgets/player.py:33 -#: monophony/frontend/widgets/player.py:61 -#: monophony/frontend/widgets/player.py:62 -msgid "Toggle pause" -msgstr "Alternar Pausa" - -#: monophony/frontend/widgets/player.py:37 -#: monophony/frontend/widgets/player.py:65 -#: monophony/frontend/widgets/player.py:66 -msgid "Next song" -msgstr "Próxima música" - -#: monophony/frontend/widgets/player.py:42 -#: monophony/frontend/widgets/player.py:70 -#: monophony/frontend/widgets/player.py:71 -msgid "Previous song" -msgstr "Música anterior" - -#: monophony/frontend/widgets/player.py:47 -#: monophony/frontend/widgets/player.py:75 -#: monophony/frontend/widgets/player.py:76 -msgid "Add to playlist" -msgstr "Adicionar à playlist" - -#: monophony/frontend/widgets/player.py:53 -#: monophony/frontend/widgets/player.py:81 -#: monophony/frontend/widgets/player.py:82 -msgid "Remove From Queue" -msgstr "Remover Da Fila" - -#: monophony/frontend/widgets/player.py:59 -#: monophony/frontend/widgets/player.py:87 -#: monophony/frontend/widgets/player.py:94 -msgid "Volume" -msgstr "Volume" - -#: monophony/frontend/widgets/player.py:82 -#: monophony/frontend/widgets/player.py:110 -#: monophony/frontend/widgets/player.py:117 -msgid "Radio Mode" -msgstr "Modo de rádio" - -#: monophony/frontend/widgets/player.py:92 -#: monophony/frontend/widgets/player.py:120 -#: monophony/frontend/widgets/player.py:127 -msgid "Loop" -msgstr "Loop" - -#: monophony/frontend/widgets/player.py:99 -#: monophony/frontend/widgets/player.py:127 -#: monophony/frontend/widgets/player.py:134 -msgid "Shuffle" -msgstr "Aleatório" - -#: monophony/frontend/widgets/player.py:115 -#: monophony/frontend/widgets/song_row.py:41 -#: monophony/frontend/widgets/group_row.py:34 -#: monophony/frontend/widgets/player.py:143 -#: monophony/frontend/widgets/group_row.py:41 -#: monophony/frontend/widgets/player.py:150 -msgid "More actions" -msgstr "Mais Ações" - -#: monophony/frontend/widgets/song_row.py:21 -#: monophony/frontend/widgets/group_row.py:33 -msgid "Play" -msgstr "Tocar" - -#: monophony/frontend/widgets/song_popover.py:23 -msgid "Move Up" -msgstr "Mover para Cima" - -#: monophony/frontend/widgets/song_popover.py:24 -msgid "Move Down" -msgstr "Mover para Baixo" - -#: monophony/frontend/widgets/song_popover.py:39 -msgid "Remove From Downloads" -msgstr "Remover Dos Downloads" - -#: monophony/frontend/widgets/song_popover.py:46 -msgid "Download to Music Folder" -msgstr "Baixar para a Pasta de Música" - -#: monophony/frontend/widgets/song_popover.py:53 -msgid "Add to Queue" -msgstr "Adicionar à Fila" - -#: monophony/frontend/widgets/song_popover.py:59 -msgid "New Playlist..." -msgstr "Nova Playlist..." - -#: monophony/frontend/widgets/group_row.py:45 -#: monophony/frontend/widgets/group_row.py:52 -msgid "Add to library" -msgstr "Adicionar à Biblioteca" - -#: monophony/frontend/widgets/group_row.py:66 -#: monophony/frontend/widgets/group_row.py:85 -msgid "Rename..." -msgstr "Renomear..." - -#: monophony/frontend/pages/search_page.py:136 -#: monophony/frontend/pages/search_page.py:142 -#: monophony/frontend/pages/search_page.py:143 -msgid "Artists" -msgstr "Artistas" - -#: monophony/frontend/windows/main_window.py:198 -#: monophony/frontend/windows/main_window.py:207 -#: monophony/frontend/windows/main_window.py:212 -msgid "Added" -msgstr "Adicionardo" - -#: monophony/frontend/widgets/artist_row.py:14 -msgid "View Artist" -msgstr "Ver Artista" - -#: monophony/frontend/pages/search_page.py:138 -#: monophony/frontend/pages/search_page.py:139 -msgid "Top Result" -msgstr "Principais Resultados" - -#: monophony/frontend/widgets/player.py:88 -msgid "Show Artist" -msgstr "Exibir Artista" diff --git a/source/locales/ru/LC_MESSAGES/all.mo b/source/locales/ru/LC_MESSAGES/all.mo deleted file mode 100644 index e589a0246767740fb7aac9955607237ec986aa3d..0000000000000000000000000000000000000000 Binary files a/source/locales/ru/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/ru/LC_MESSAGES/all.po b/source/locales/ru/LC_MESSAGES/all.po deleted file mode 100644 index 20328a956ab299977c653a8c5198fe813b336002..0000000000000000000000000000000000000000 --- a/source/locales/ru/LC_MESSAGES/all.po +++ /dev/null @@ -1,428 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: \n" -"POT-Creation-Date: \n" -"PO-Revision-Date: \n" -"Last-Translator: \n" -"Language-Team: \n" -"Language: ru_RU\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=UTF-8\n" -"Content-Transfer-Encoding: 8bit\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" -"X-Generator: Poedit 3.4.2\n" - -#: monophony/frontend/popovers/local_song_popover.py:17 -msgid "Remove From Playlist" -msgstr "Удалить из Плейлиста" - -#: monophony/frontend/popovers/queue_song_popover.py:18 -msgid "Remove From Queue" -msgstr "Удалить из Очереди" - -#: monophony/frontend/popovers/song_popover.py:22 -msgid "Remove From Downloads" -msgstr "Удалить из Загрузок" - -#: monophony/frontend/popovers/song_popover.py:29 -#: monophony/frontend/rows/external_group_row.py:44 -#: monophony/frontend/rows/importable_group_row.py:28 -#: monophony/frontend/rows/local_group_row.py:42 -msgid "Download" -msgstr "Скачать" - -#: monophony/frontend/popovers/song_popover.py:36 -#: monophony/frontend/windows/add_window.py:19 -msgid "Add to..." -msgstr "Добавить в..." - -#: monophony/frontend/popovers/song_popover.py:42 -msgid "View Artist" -msgstr "Просмотр Исполнителя" - -#: monophony/frontend/rows/external_group_row.py:24 -#: monophony/frontend/rows/importable_group_row.py:17 -#: monophony/frontend/rows/local_group_row.py:23 -#: monophony/frontend/rows/song_row.py:45 -msgid "More actions" -msgstr "Больше действий" - -#: monophony/frontend/rows/external_group_row.py:31 -#: monophony/frontend/windows/import_window.py:33 -msgid "Synchronized" -msgstr "Синхронизированный" - -#: monophony/frontend/rows/external_group_row.py:38 -#: monophony/frontend/rows/local_group_row.py:36 -msgid "Delete" -msgstr "Удалить" - -#: monophony/frontend/rows/external_group_row.py:50 -#: monophony/frontend/rows/local_group_row.py:54 -msgid "Rename..." -msgstr "Переименовать..." - -#: monophony/frontend/rows/external_group_row.py:67 -#: monophony/frontend/rows/local_group_row.py:71 -msgid "Rename" -msgstr "Переименовать" - -#: monophony/frontend/rows/external_group_row.py:96 -#: monophony/frontend/rows/local_group_row.py:97 -msgid "Could not Rename" -msgstr "Не Удалось Переименовать" - -#: monophony/frontend/rows/external_group_row.py:97 -#: monophony/frontend/rows/local_group_row.py:98 -msgid "Playlist already exists" -msgstr "Список воспроизведения уже существует" - -#: monophony/frontend/rows/importable_group_row.py:34 -msgid "Import..." -msgstr "Импорт..." - -#: monophony/frontend/rows/local_group_row.py:48 -msgid "Duplicate" -msgstr "Дублировать" - -#: monophony/frontend/rows/artist_row.py:14 -#: monophony/frontend/rows/artist_row.py:23 -msgid "View artist" -msgstr "Просмотр исполнителя" - -#: monophony/frontend/rows/group_row.py:23 -#: monophony/frontend/rows/song_row.py:18 -msgid "Play" -msgstr "Воспроизвести" - -#: monophony/frontend/rows/song_row.py:36 -msgid "Downloaded" -msgstr "Загруженный" - -#: monophony/frontend/widgets/recent_searches.py:36 -msgid "Remove" -msgstr "Удалить" - -#: monophony/frontend/widgets/player.py:70 -msgid "Volume" -msgstr "Громкость" - -#: monophony/frontend/widgets/player.py:91 -msgid "Toggle pause" -msgstr "Переключить паузу" - -#: monophony/frontend/widgets/player.py:103 -msgid "Next song" -msgstr "Следующая песня" - -#: monophony/frontend/widgets/player.py:108 -msgid "Previous song" -msgstr "Предыдущая песня" - -#: monophony/frontend/widgets/player.py:115 -msgid "Playback mode" -msgstr "Режим воспроизведения" - -#: monophony/frontend/widgets/player.py:200 -msgid "Normal Playback" -msgstr "Обычное Воспроизведение" - -#: monophony/frontend/widgets/player.py:207 -msgid "Radio Mode" -msgstr "Режим Радио" - -#: monophony/frontend/widgets/player.py:217 -msgid "Repeat Song" -msgstr "Повторять Песню" - -#: monophony/frontend/widgets/player.py:227 -msgid "Shuffle" -msgstr "Перемешать" - -#: monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "Ok" - -#: monophony/frontend/windows/main_window.py:37 -msgid "Library" -msgstr "Библиотека" - -#: monophony/frontend/windows/main_window.py:43 -msgid "Search" -msgstr "Поиск" - -#: monophony/frontend/windows/main_window.py:49 -#: monophony/frontend/windows/add_window.py:42 -msgid "Queue" -msgstr "Очередь" - -#: monophony/frontend/windows/main_window.py:58 -msgid "About" -msgstr "О Программе" - -#: monophony/frontend/windows/main_window.py:145 -msgid "translator-credits" -msgstr "Alex Kryuchkov https://github.com/alexkdeveloper" - -#: monophony/frontend/windows/main_window.py:188 -#, python-brace-format -msgid "Deleted \"{playlist_name}\"" -msgstr "\"{playlist_name}\" удалено" - -#: monophony/frontend/windows/main_window.py:191 -msgid "Undo" -msgstr "Отменить" - -#: monophony/frontend/windows/main_window.py:219 -msgid "Added" -msgstr "Добавлен" - -#: monophony/frontend/windows/add_window.py:25 -#: monophony/frontend/windows/import_window.py:53 -msgid "Cancel" -msgstr "Отмена" - -#: monophony/frontend/windows/add_window.py:27 -msgid "Add" -msgstr "Добавить" - -#: monophony/frontend/windows/add_window.py:48 -#: monophony/frontend/tabs/library_tab.py:73 -msgid "Your Playlists" -msgstr "Ваши Плейлисты" - -#: monophony/frontend/windows/add_window.py:54 -msgid "New Playlist Name..." -msgstr "Новое Название Плейлиста..." - -#: monophony/frontend/windows/add_window.py:56 -msgid "Create" -msgstr "Создать" - -#: monophony/frontend/windows/import_window.py:21 -msgid "Enter Playlist Name..." -msgstr "Введите Название Плейлиста..." - -#: monophony/frontend/windows/import_window.py:28 -msgid "Enter Playlist URL..." -msgstr "Введите URL Плейлиста..." - -#: monophony/frontend/windows/import_window.py:34 -msgid "Editable" -msgstr "Редактируемый" - -#: monophony/frontend/windows/import_window.py:55 -#: monophony/frontend/tabs/library_tab.py:55 -msgid "Import" -msgstr "Импорт" - -#: monophony/frontend/windows/import_window.py:77 -msgid "Import Playlist..." -msgstr "Импорт списка воспроизведения..." - -#: monophony/frontend/windows/import_window.py:105 -#: monophony/frontend/windows/import_window.py:123 -#: monophony/frontend/windows/import_window.py:129 -msgid "Could not import playlist" -msgstr "Не удалось импортировать список воспроизведения" - -#: monophony/frontend/windows/import_window.py:106 -msgid "Failed to retrieve playlist data from server." -msgstr "Не удалось получить данные плейлиста с сервера." - -#: monophony/frontend/windows/import_window.py:123 -msgid "A name is required." -msgstr "Требуется указать имя." - -#: monophony/frontend/windows/import_window.py:129 -msgid "A URL is required." -msgstr "Требуется указать URL-адрес." - -#: monophony/frontend/pages/artist_page.py:20 -#: monophony/frontend/pages/results_page.py:21 -msgid "No Results" -msgstr "Нет Результатов" - -#: monophony/frontend/pages/artist_page.py:55 -msgid "Other" -msgstr "Прочее" - -#: monophony/frontend/pages/artist_page.py:56 -#: monophony/frontend/pages/results_page.py:66 -msgid "Albums" -msgstr "Альбомы" - -#: monophony/frontend/pages/artist_page.py:57 -msgid "Playlists" -msgstr "Плейлисты" - -#: monophony/frontend/pages/artist_page.py:80 -#: monophony/frontend/pages/results_page.py:65 -msgid "Songs" -msgstr "Песни" - -#: monophony/frontend/pages/artist_page.py:86 -#: monophony/frontend/pages/results_page.py:67 -msgid "Videos" -msgstr "Видео" - -#: monophony/frontend/pages/artist_page.py:92 -msgid "Artist Not Found" -msgstr "Исполнитель не Найден" - -#: monophony/frontend/pages/results_page.py:64 -msgid "Top Result" -msgstr "Лучший Результат" - -#: monophony/frontend/pages/results_page.py:68 -msgid "Community Playlists" -msgstr "Плейлисты Сообщества" - -#: monophony/frontend/pages/results_page.py:69 -msgid "Artists" -msgstr "Исполнители" - -#: monophony/frontend/pages/results_page.py:74 -#: monophony/frontend/pages/results_page.py:83 -#: monophony/frontend/pages/results_page.py:92 -#: monophony/frontend/pages/results_page.py:101 -#: monophony/frontend/pages/results_page.py:110 -msgid "Show All" -msgstr "Показать Всё" - -#: monophony/frontend/tabs/queue_tab.py:24 -msgid "Queue Empty" -msgstr "Очередь Пуста" - -#: monophony/frontend/tabs/library_tab.py:51 -msgid "Recommended" -msgstr "Рекомендуемый" - -#: monophony/frontend/tabs/library_tab.py:64 -msgid "Play all" -msgstr "Воспроизвести всё" - -#: monophony/frontend/tabs/library_tab.py:78 -msgid "Clear" -msgstr "Очистить" - -#: monophony/frontend/tabs/library_tab.py:85 -msgid "Recently Played" -msgstr "Последние Запущенные" - -#: monophony/frontend/tabs/search_tab.py:18 -msgid "Enter text or paste a URL..." -msgstr "Введите текст или вставьте URL-адрес..." - -#: monophony/frontend/tabs/search_tab.py:24 -msgid "Go back" -msgstr "Вернуться назад" - -#, fuzzy -#~| msgid "Searching..." -#~ msgid "Search..." -#~ msgstr "Поиск..." - -#~ msgid "All Songs" -#~ msgstr "Все Песни" - -#~ msgid "All Videos" -#~ msgstr "Все Видео" - -#~ msgid "Import playlist" -#~ msgstr "Импорт списка воспроизведения" - -#~ msgid "More" -#~ msgstr "Больше" - -#~ msgid "Your Library is Empty" -#~ msgstr "Ваша Библиотека Пуста" - -#~ msgid "Find songs to play using the search bar above" -#~ msgstr "Найдите песни для воспроизведения, используя строку поиска выше" - -#~ msgid "Search for Content..." -#~ msgstr "Поиск Контента..." - -#~ msgid "Show Artist" -#~ msgstr "Показать Исполнителя" - -#~ msgid "Show Queue" -#~ msgstr "Показать Очередь" - -#~ msgid "Add to library" -#~ msgstr "Добавить в библиотеку" - -#~ msgid "Loading..." -#~ msgstr "Загрузка..." - -#~ msgid "Loading Library..." -#~ msgstr "Загрузка Библиотеки..." - -#~ msgid "About Monophony" -#~ msgstr "О Monophony" - -#~ msgid "External Playlist" -#~ msgstr "Внешний Плейлист" - -#~ msgid "Local Playlist" -#~ msgstr "Локальный Плейлист" - -#~ msgid "External" -#~ msgstr "Внешний" - -#~ msgid "Parsing Results..." -#~ msgstr "Анализ Результатов..." - -#~ msgid "As Synchronized Playlist" -#~ msgstr "Как Синхронизированный Список Воспроизведения" - -#~ msgid "As Editable Playlist" -#~ msgstr "Как Редактируемый Список Воспроизведения" - -#~ msgid "Primary Menu" -#~ msgstr "Основное Меню" - -#~ msgid "Donate" -#~ msgstr "Пожертвовать" - -#~ msgid "New Playlist" -#~ msgstr "Новый Плейлист" - -#~ msgid "Saved" -#~ msgstr "Сохранено" - -#~ msgid "Enter Name..." -#~ msgstr "Введите Название..." - -#~ msgid "Add to playlist" -#~ msgstr "Добавить в плейлист" - -#~ msgid "Loop" -#~ msgstr "Зациклить" - -#, fuzzy -#~| msgid "Move up" -#~ msgid "Move Up" -#~ msgstr "Наверх" - -#, fuzzy -#~| msgid "Move down" -#~ msgid "Move Down" -#~ msgstr "Вниз" - -#~ msgid "Download to Music Folder" -#~ msgstr "Загрузить в Папку \"Музыка\"" - -#~ msgid "Add to Queue" -#~ msgstr "Добавить в Очередь" - -#~ msgid "Enter new Name..." -#~ msgstr "Введите Новое Название..." - -#~ msgid "No playlists found" -#~ msgstr "Плейлисты не найдены" - -#~ msgid "Patrons" -#~ msgstr "Покровители" diff --git a/source/locales/tr/LC_MESSAGES/all.mo b/source/locales/tr/LC_MESSAGES/all.mo deleted file mode 100644 index 656be8733919261089685f16337ab9c209418e80..0000000000000000000000000000000000000000 Binary files a/source/locales/tr/LC_MESSAGES/all.mo and /dev/null differ diff --git a/source/locales/tr/LC_MESSAGES/all.po b/source/locales/tr/LC_MESSAGES/all.po deleted file mode 100644 index 525c45bc1a39fdc2560ba16d60878055aa7d96eb..0000000000000000000000000000000000000000 --- a/source/locales/tr/LC_MESSAGES/all.po +++ /dev/null @@ -1,215 +0,0 @@ -msgid "" -msgstr "Content-Type: text/plain; charset=UTF-8\n" - -#: monophony/frontend/pages/search_page.py:24 -#: monophony/frontend/pages/search_page.py:96 -#: monophony/frontend/pages/search_page.py:20 -#: monophony/frontend/pages/search_page.py:123 -msgid "No Results" -msgstr "Sonuç yok" - -#: monophony/frontend/pages/search_page.py:108 -#: monophony/frontend/pages/search_page.py:133 -msgid "Songs" -msgstr "Şarkılar" - -#: monophony/frontend/pages/search_page.py:109 -#: monophony/frontend/pages/search_page.py:118 -#: monophony/frontend/pages/search_page.py:127 -#: monophony/frontend/pages/search_page.py:136 -#: monophony/frontend/pages/search_page.py:139 -#: monophony/frontend/pages/search_page.py:147 -#: monophony/frontend/pages/search_page.py:156 -#: monophony/frontend/pages/search_page.py:164 -#: monophony/frontend/pages/search_page.py:172 -msgid "More" -msgstr "Daha fazla" - -#: monophony/frontend/pages/search_page.py:117 -#: monophony/frontend/pages/search_page.py:134 -msgid "Albums" -msgstr "Albümler" - -#: monophony/frontend/pages/search_page.py:126 -#: monophony/frontend/pages/search_page.py:155 -msgid "Community Playlists" -msgstr "Topluluğun oluşturduğu oynatma listeleri" - -#: monophony/frontend/pages/search_page.py:135 -msgid "Videos" -msgstr "Videolar" - -#: monophony/frontend/pages/library_page.py:25 -msgid "Your Library is Empty" -msgstr "Kütüphaneniz boş" - -#: monophony/frontend/pages/library_page.py:26 -msgid "Find songs to play using the search bar above" -msgstr "Yukarıdaki arama çubuğunu kullanarak oynatılacak şarkılar arayın" - -#: monophony/frontend/pages/library_page.py:29 -msgid "Play All" -msgstr "Hepsini oynat" - -#: monophony/frontend/pages/library_page.py:33 -msgid "Your Playlists" -msgstr "Oynatma listeleriniz" - -#: monophony/frontend/windows/delete_window.py:15 -msgid "Delete Playlist?" -msgstr "Oynatma listesini silmek istiyor musunuz?" - -#: monophony/frontend/windows/delete_window.py:16 -#: monophony/frontend/windows/rename_window.py:18 -msgid "Cancel" -msgstr "İptal" - -#: monophony/frontend/windows/delete_window.py:17 -#: monophony/frontend/widgets/group_row.py:60 -msgid "Delete" -msgstr "Sil" - -#: monophony/frontend/windows/main_window.py:41 -msgid "Go back" -msgstr "Geri dön" - -#: monophony/frontend/windows/main_window.py:46 -msgid "About" -msgstr "Hakkında" - -#: monophony/frontend/windows/main_window.py:51 -msgid "Search for Content..." -msgstr "İçerik arayın..." - -#: monophony/frontend/windows/main_window.py:141 -#: monophony/frontend/windows/main_window.py:138 -msgid "translator-credits" -msgstr "nxjoseph" - -#: monophony/frontend/windows/main_window.py:143 -#: monophony/frontend/windows/main_window.py:140 -msgid "Donate" -msgstr "Bağış" - -#: monophony/frontend/windows/main_window.py:174 -#: monophony/frontend/windows/main_window.py:171 -msgid "New Playlist" -msgstr "Yeni oynatma listesi" - -#: monophony/frontend/windows/main_window.py:191 -#: monophony/frontend/windows/main_window.py:188 -msgid "Could not Rename" -msgstr "Yeniden adlandırılamadı" - -#: monophony/frontend/windows/main_window.py:192 -#: monophony/frontend/windows/main_window.py:189 -msgid "Playlist already exists" -msgstr "Oynatma listesi zaten mevcut" - -#: monophony/frontend/windows/main_window.py:196 -#: monophony/frontend/windows/main_window.py:193 -msgid "Rename Playlist" -msgstr "Oynatma listesini yeniden adlandır" - -#: monophony/frontend/windows/main_window.py:201 -msgid "Saved" -msgstr "Kaydedildi" - -#: monophony/frontend/windows/rename_window.py:15 -msgid "Enter Name..." -msgstr "İsim girin..." - -#: monophony/frontend/windows/rename_window.py:19 -#: monophony/frontend/windows/message_window.py:13 -msgid "Ok" -msgstr "Tamam" - -#: monophony/frontend/widgets/player.py:33 -msgid "Toggle pause" -msgstr "Durdur" - -#: monophony/frontend/widgets/player.py:37 -msgid "Next song" -msgstr "Sonraki şarkı" - -#: monophony/frontend/widgets/player.py:42 -msgid "Previous song" -msgstr "Önceki şarkı" - -#: monophony/frontend/widgets/player.py:47 -msgid "Add to playlist" -msgstr "Oynatma listesine ekle" - -#: monophony/frontend/widgets/player.py:53 -msgid "Remove From Queue" -msgstr "Sıradan kaldır" - -#: monophony/frontend/widgets/player.py:59 -msgid "Volume" -msgstr "Ses" - -#: monophony/frontend/widgets/player.py:82 -msgid "Radio Mode" -msgstr "Radyo modu" - -#: monophony/frontend/widgets/player.py:92 -msgid "Loop" -msgstr "Tekrarla" - -#: monophony/frontend/widgets/player.py:99 -msgid "Shuffle" -msgstr "Karıştır" - -#: monophony/frontend/widgets/player.py:115 -#: monophony/frontend/widgets/song_row.py:41 -#: monophony/frontend/widgets/group_row.py:34 -msgid "More actions" -msgstr "Daha fazla" - -#: monophony/frontend/widgets/song_row.py:21 -msgid "Play" -msgstr "Oynat" - -#: monophony/frontend/widgets/song_popover.py:23 -msgid "Move Up" -msgstr "Yukarı taşı" - -#: monophony/frontend/widgets/song_popover.py:24 -msgid "Move Down" -msgstr "Aşağı taşı" - -#: monophony/frontend/widgets/song_popover.py:39 -msgid "Remove From Downloads" -msgstr "İndirilenlerden kaldır" - -#: monophony/frontend/widgets/song_popover.py:46 -msgid "Download to Music Folder" -msgstr "Müzik dizinine indir" - -#: monophony/frontend/widgets/song_popover.py:53 -msgid "Add to Queue" -msgstr "Sıraya ekle" - -#: monophony/frontend/widgets/song_popover.py:59 -msgid "New Playlist..." -msgstr "Yeni oynatma listesi..." - -#: monophony/frontend/widgets/group_row.py:45 -msgid "Add to library" -msgstr "Kütüphaneye ekle" - -#: monophony/frontend/widgets/group_row.py:66 -msgid "Rename..." -msgstr "Yeniden adlandır..." - -#: monophony/frontend/pages/search_page.py:136 -msgid "Artists" -msgstr "Sanatçılar" - -#: monophony/frontend/windows/main_window.py:198 -msgid "Added" -msgstr "Eklendi" - -#: monophony/frontend/widgets/artist_row.py:14 -msgid "View Artist" -msgstr "Sanatçıyı gör" diff --git a/source/monophony/__init__.py b/source/monophony/__init__.py index 70ae03bee4a37902be3c77c1512d746a609b1444..8a0cd2825695aed6d1aeef2d8cab2610b562130f 100644 --- a/source/monophony/__init__.py +++ b/source/monophony/__init__.py @@ -1,2 +1,6 @@ __version__ = '3.4.3' -APP_ID = 'io.gitlab.zehkira.Monophony' +ID = 'io.gitlab.zehkira.Monophony' # Full ID per Freedesktop standards +NAME = 'monophony' # Use for app executable, directories and so on +DISPLAY_NAME = 'Monophony' # For window titles and such - do not use in logic +GRESOURCES_PATH = '/io/gitlab/zehkira/Monophony' +MIN_WIDTH = 360 # Same as in metainfo.xml diff --git a/source/monophony/app.py b/source/monophony/app.py new file mode 100644 index 0000000000000000000000000000000000000000..a926de0195a388ed2c1a42ccbd3d8c9cc4831309 --- /dev/null +++ b/source/monophony/app.py @@ -0,0 +1,47 @@ +from monophony import ID +from monophony.ui.windows.main_window import MainWindow + +from gi.repository import Adw, Gio + + +class Application(Adw.Application): + __gtype_name__ = __qualname__ + + def __init__(self): + super().__init__( + application_id=ID, + flags=Gio.ApplicationFlags.DEFAULT_FLAGS + ) + self._window = None + + def do_activate(self): + windows = self.get_windows() + + if len(windows) > 0: + windows[0].props.visible = True + else: + quit_action = Gio.SimpleAction.new('quit', None) + quit_action.connect('activate', self._on_quit) + self.add_action(quit_action) + self.set_accels_for_action('app.quit', ['q']) + + close_window_action = Gio.SimpleAction.new('close-window', None) + close_window_action.connect('activate', self._on_close_window) + self.add_action(close_window_action) + self.set_accels_for_action('app.close-window', ['w']) + + self._window = MainWindow(application=self) + self._window.present() + + self.set_accels_for_action('win.focus-search', ['f']) + + def _on_close_window(self, _action, _param): + windows = self.get_windows() + if windows: + windows[0].close() + + def _on_quit(self, _action, _param): + if self._window: + self._window.close() + + self.quit() diff --git a/source/monophony/asynchronous.py b/source/monophony/asynchronous.py new file mode 100644 index 0000000000000000000000000000000000000000..0f26d8cf0fc8bd38dc5e506d6d4f28c0d01c82f6 --- /dev/null +++ b/source/monophony/asynchronous.py @@ -0,0 +1,62 @@ +import threading +from collections.abc import Callable +from typing import Any + +from monophony.debug import MemoryDebugger + +from gi.repository import GLib + + +class Task(MemoryDebugger): + def __init__( + self, + progress_callback: Callable | None=None, + callback: Callable | None=None, + callback_args: tuple | None=None, + callback_kwargs: dict | None=None, + args: tuple | None=None, + kwargs: dict | None=None + ): + super().__init__() + + self.__args = args or () + self.__kwargs = kwargs or {} + self.__callback = callback + self.__callback_args = callback_args or () + self.__callback_kwargs = callback_kwargs or {} + self.__progress_callback = progress_callback + self.extra_data = None + self.result = None + self._canceled = False + self._thread = threading.Thread( + target=self.__perform, args=self.__args, kwargs=self.__kwargs + ) + self._thread.daemon = True + + def __perform(self, *args, **kwargs): + self.result = self._function(*args, **kwargs) + if self.__callback: + GLib.idle_add( + self.__callback, self, *self.__callback_args, **self.__callback_kwargs + ) + + def _function(self, *args, **kwargs) -> Any: + ... + + def _update_progress(self, *args, **kwargs): + if self.__progress_callback: + GLib.idle_add(self.__progress_callback, self, *args, **kwargs) + + def is_canceled(self) -> bool: + return self._canceled + + def is_running(self) -> bool: + return self._thread.is_alive() and not self._canceled + + def cancel(self): + self._canceled = True + + def start(self): + if not self.is_running(): + self._thread.start() + diff --git a/source/monophony/backend/cache.py b/source/monophony/backend/cache.py deleted file mode 100644 index 3200c1e75a30aad1317c16410d03b164c620c209..0000000000000000000000000000000000000000 --- a/source/monophony/backend/cache.py +++ /dev/null @@ -1,122 +0,0 @@ -import contextlib, glob, json, os, subprocess - - -### --- CACHE FUNCTIONS --- ### - - -def is_song_being_cached(video_id: str) -> bool: - has_temp = False - has_result = False - for file in os.listdir(get_cache_directory()): - parts = file.split('.') - if parts[0] == video_id: - if parts[-1] == 'monophony': - has_temp = True - elif parts[-1] == video_id: - has_result = True - - if not has_result: - return has_temp - - return False - - -def is_song_cached(video_id: str) -> bool: - if video_id is None: - return False - - return os.path.exists(get_cache_directory() + video_id) - - -def get_song_uri(video_id: str) -> str: - if video_id is None: - return '' - - local_path = get_cache_directory() + video_id - if os.path.exists(local_path): - return 'file://' + local_path - - return '' - - -def cache_songs(songs: list): - path = get_cache_directory() - needed_ids = [] - for song in songs: - if not is_song_cached(song['id']): - needed_ids.append(song['id']) - open(f'{path}{song["id"]}.monophony', 'w').close() - new_songs = read_songs() - new_songs.append(song) - write_songs(new_songs) - - - subprocess.Popen( - 'yt-dlp -x ' - '--no-cache-dir --audio-quality 0 --add-metadata ' - f'-o "{path}%(id)s.%(ext)s" https://music.youtube.com/watch?v=' + - (' https://music.youtube.com/watch?v='.join(needed_ids)), - shell = True, - stdout = subprocess.PIPE - ).communicate() - - for video_id in needed_ids: - with contextlib.suppress(OSError, FileNotFoundError): - os.remove(f'{path}{video_id}.monophony') - - # rename id.* files to id - for file in glob.glob(path + '*.*'): - os.rename(file, '.'.join(file.split('.')[:-1])) - - -def uncache_song(song: dict): - write_songs([s for s in read_songs() if s['id'] != song['id']]) - - with contextlib.suppress(OSError, FileNotFoundError): - os.remove(get_cache_directory() + song['id']) - - -def clean_up(): - path = get_cache_directory() - for file in os.listdir(path): - if file.endswith(('.part', '.monophony')): - os.remove(path + file) - - write_songs([s for s in read_songs() if is_song_cached(s['id'])]) - - -### --- UTILITY FUNCTIONS --- ### - - -def get_cache_directory() -> str: - path = os.getenv( - 'XDG_DATA_HOME', os.path.expanduser('~/.local/share') - ) + '/monophony/' - os.makedirs(path, exist_ok=True) - return path - - -def write_songs(songs: list): - dir_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony' - downloads_path = dir_path + '/downloads.json' - - try: - with open(str(downloads_path), 'w') as downloads_file: - json.dump(songs, downloads_file, indent='\t') - except FileNotFoundError: - os.makedirs(str(dir_path)) - write_songs(songs) - - -def read_songs() -> list: - songs_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony/downloads.json' - - try: - with open(songs_path) as songs_file: - return json.load(songs_file) - except (OSError, json.decoder.JSONDecodeError): - return [] diff --git a/source/monophony/backend/history.py b/source/monophony/backend/history.py deleted file mode 100644 index da0135b69f5a3647a6c02afb744f411f0276a084..0000000000000000000000000000000000000000 --- a/source/monophony/backend/history.py +++ /dev/null @@ -1,95 +0,0 @@ -import json, os - - -### --- HISTORY FUNCTIONS --- ### - - -def add_search(query: str) -> bool: - new_searches = read_searches() - if query not in new_searches: - new_searches.insert(0, query) - if len(new_searches) > 3: - new_searches = new_searches[:-1] - - write_searches(new_searches) - return True - - return False - - -def remove_search(query: str): - new_searches = read_searches() - new_searches.remove(query) - write_searches(new_searches) - - -def add_song(song: dict): - new_songs = read_songs() - if song not in new_songs: - new_songs.append(song) - if len(new_songs) > 15: - new_songs = new_songs[1:] - else: - new_songs.remove(song) - new_songs.append(song) - - write_songs(new_songs) - - -def clear_songs(): - write_songs([]) - - -### --- UTILITY FUNCTIONS --- ### - - -def write_searches(searches: list): - dir_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony' - recents_path = dir_path + '/recent_searches.json' - - try: - with open(str(recents_path), 'w') as recents_file: - json.dump(searches, recents_file, indent='\t') - except FileNotFoundError: - os.makedirs(str(dir_path)) - write_songs(searches) - - -def read_searches() -> list: - recents_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony/recent_searches.json' - - try: - with open(recents_path) as recents_file: - return json.load(recents_file) - except (OSError, json.decoder.JSONDecodeError): - return [] - - -def write_songs(songs: list): - dir_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony' - recents_path = dir_path + '/recent_songs.json' - - try: - with open(str(recents_path), 'w') as recents_file: - json.dump(songs, recents_file, indent='\t') - except FileNotFoundError: - os.makedirs(str(dir_path)) - write_songs(songs) - - -def read_songs() -> list: - songs_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony/recent_songs.json' - - try: - with open(songs_path) as songs_file: - return json.load(songs_file) - except (OSError, json.decoder.JSONDecodeError): - return [] diff --git a/source/monophony/backend/mpris.py b/source/monophony/backend/mpris.py deleted file mode 100644 index cbb90dd20d097ce31a4a51f9a465528a569fa4e4..0000000000000000000000000000000000000000 --- a/source/monophony/backend/mpris.py +++ /dev/null @@ -1,103 +0,0 @@ -from monophony import APP_ID -from monophony.backend.player import PlaybackMode - -from gi.repository import GLib -from mpris_server.adapters import PlayState, MprisAdapter -from mpris_server.server import Server -from mpris_server.events import PlayerEventAdapter - - -class Adapter(MprisAdapter): - def __init__(self, monophony_player: object): - super().__init__() - self.monophony_player = monophony_player - - def get_desktop_entry(self) -> str: - return APP_ID - - def can_quit(self) -> bool: - return False - - def get_current_position(self) -> float: - return self.monophony_player.get_position_ns() / 1000 - - def next(self): - GLib.Thread.new(None, self.monophony_player.next_song, True) - - def previous(self): - GLib.Thread.new(None, self.monophony_player.previous_song) - - def pause(self): - self.monophony_player.set_pause(True) - - def resume(self): - self.monophony_player.set_pause(False) - - def stop(self): - self.monophony_player.clear_queue() - - def get_playstate(self) -> PlayState: - if self.monophony_player.is_paused(): - return PlayState.PAUSED - return PlayState.PLAYING - - def is_repeating(self) -> bool: - return self.monophony_player.mode == PlaybackMode.LOOP_SONG - - def get_shuffle(self) -> bool: - return False - - def get_volume(self): - return self.monophony_player.get_volume() - - def set_volume(self, volume: float): - self.monophony_player.set_volume(volume, True) - - def is_mute(self) -> bool: - return False - - def can_go_next(self) -> bool: - return True - - def can_go_previous(self) -> bool: - return True - - def can_play(self) -> bool: - return bool(self.monophony_player.get_current_song()) - - def can_pause(self) -> bool: - return bool(self.monophony_player.get_current_song()) - - def can_seek(self) -> bool: - return False - - def can_control(self) -> bool: - return True - - def can_raise(self) -> bool: - return True - - def set_raise(self, val: bool): - if val: - self.monophony_player.raise_callback() - - def metadata(self) -> dict: - song = self.monophony_player.get_current_song() - if song: - duration_ns = self.monophony_player.get_duration_ns() - return { - 'mpris:trackid': '/track/1', - 'mpris:artUrl': song.get('thumbnail', ''), - 'mpris:length': duration_ns / 1000 if duration_ns > 0 else None, - 'xesam:title': song.get('title', ''), - 'xesam:artist': [song['author']] if 'author' in song else [] - } - - return {'mpris:trackid': '/org/mpris/MediaPlayer2/TrackList/NoTrack'} - - -def init(player: object): - mpris = Server('Monophony', adapter=Adapter(player)) - player.mpris_adapter = PlayerEventAdapter(root=mpris.root, player=mpris.player) - player.mpris_server = mpris - player.mpris_server.loop() diff --git a/source/monophony/backend/player.py b/source/monophony/backend/player.py deleted file mode 100644 index adeca0ab5ec043daaf4de564b6add113959d18b8..0000000000000000000000000000000000000000 --- a/source/monophony/backend/player.py +++ /dev/null @@ -1,495 +0,0 @@ -import contextlib, random, time - -import monophony.backend.cache -import monophony.backend.history -import monophony.backend.settings -import monophony.backend.yt - -import gi -gi.require_version('Gst', '1.0') -gi.require_version('GstAudio', '1.0') -from gi.repository import GLib, Gst, GstAudio - - -class PlaybackMode: - NORMAL = 0 - LOOP_SONG = 1 - LOOP_QUEUE = 2 - RADIO = 3 - - -class Player: - def __init__(self): - Gst.init([]) - self.lock = GLib.Mutex() - self.interrupt = False - self.paused = False - self.buffering = False - self.index = 0 - self.queue = [] - self.recent_songs = [] - self.next_fetch_lock = GLib.Mutex() - self.next_stream_url = '' - self.next_expected_id = '' - self.next_fetch_time = 0 - self.last_progress = 0 - self.mode = int( - monophony.backend.settings.get_value('mode', PlaybackMode.NORMAL) - ) - self.mpris_adapter = None - self.mpris_server = None - self.ui_update_callback = None - self.queue_change_callback = None - self.queue_end_callback = None - self.raise_callback = None - self.playbin = Gst.ElementFactory.make('playbin3', 'playbin3') - self.playbin.set_state(Gst.State.READY) - self.playbin.get_bus().add_signal_watch() - self.playbin.get_bus().connect('message::error', self._on_bus_error) - self.playbin.get_bus().connect('message::stream-start', self._on_song_start) - self.playbin.get_bus().connect('message::buffering', self._on_buffering) - self.playbin.get_bus().connect( - 'message::eos', lambda *_: GLib.Thread.new(None, self._on_song_end) - ) - - ### --- UTILITY METHODS --- ### - - def terminate(self): - self.lock.lock() - self.playbin.set_state(Gst.State.NULL) - self.mpris_server.unpublish() - - def is_busy(self) -> bool: - if not self.lock.trylock(): - return True - - self.lock.unlock() - return self.buffering - - def is_paused(self) -> bool: - return self.paused - - def get_current_song(self, lock: bool=True) -> dict: - if lock and not self.lock.trylock(): - try: - return self.queue[self.index] - except IndexError: - return {} - - state = self.playbin.get_state(Gst.CLOCK_TIME_NONE)[1] - acceptable_states = {Gst.State.PAUSED, Gst.State.PLAYING} - result = {} - - if state in acceptable_states: - with contextlib.suppress(IndexError): - result = self.queue[self.index] - - if lock: - self.lock.unlock() - return result - - def get_duration_ns(self) -> float: - return self.playbin.query_duration(Gst.Format.TIME)[1] - - def get_position_ns(self) -> float: - return self.playbin.query_position(Gst.Format.TIME)[1] - - def get_progress(self) -> float: - duration = self.get_duration_ns() - position = self.get_position_ns() - return (position / duration) if duration > 0 else 0.0 - - def set_volume(self, volume: float, from_mpris: bool): - self.lock.lock() - monophony.backend.settings.set_value('volume', volume) - self.playbin.set_property('volume', self.playbin.convert_volume( - GstAudio.StreamVolumeFormat.CUBIC, - GstAudio.StreamVolumeFormat.LINEAR, - volume - )) - self.lock.unlock() - - if not from_mpris: - self.mpris_adapter.on_volume() - - def get_volume(self) -> float: - return self.playbin.convert_volume( - GstAudio.StreamVolumeFormat.LINEAR, - GstAudio.StreamVolumeFormat.CUBIC, - self.playbin.get_property('volume') - ) - - ### --- EVENT HANDLERS --- ### - - def _on_buffering(self, _bus, msg): - self.lock.lock() - percent = msg.parse_buffering() - if not self.buffering and percent < 100: - print('Buffering...') - self.buffering = True - self.playbin.set_state(Gst.State.PAUSED) - GLib.idle_add( - self.ui_update_callback, - self.get_current_song(), - True, - self.paused, - False - ) - elif percent >= 100: - print('Done buffering') - self.buffering = False - if not self.paused: - self.playbin.set_state(Gst.State.PLAYING) - GLib.idle_add( - self.ui_update_callback, - self.get_current_song(), - False, - self.paused, - False - ) - - self.lock.unlock() - - def _on_bus_error(self, _bus, err): - print('Playback error:', err.parse_error().gerror.message) - self.last_progress = self.playbin.query_position(Gst.Format.TIME)[1] - print('Failed at', self.last_progress) - GLib.Thread.new( - None, - self.play_song, - self.queue[self.index], - True, - True - ) - - def _on_song_start(self, _bus, _msg): - self.lock.lock() - if not self.buffering: - print('No buffering occured at start of stream') - self.playbin.set_state(Gst.State.PLAYING) - GLib.idle_add( - self.ui_update_callback, self.get_current_song(), False, False, True - ) - - if self.last_progress > 0: - print('Seeking to', self.last_progress) - self.playbin.seek_simple( - Gst.Format.TIME, - Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, - self.last_progress - ) - - self.last_progress = 0 - self.lock.unlock() - - def _on_song_end(self): - self.lock.lock() - if self.playbin.get_bus().have_pending(): - self.lock.unlock() - return - - print('Song has ended') - self.next_song(lock=False) - self.lock.unlock() - - ### --- MISC --- ### - - def fetch_next_song_url(self): - self.lock.lock() - i = None - if self.mode == PlaybackMode.LOOP_SONG: - i = self.index - elif self.index < len(self.queue) - 1: - i = self.index + 1 - elif self.mode == PlaybackMode.LOOP_QUEUE: - i = 0 - if i is None: - self.lock.unlock() - return - song_id = self.queue[i]['id'] - print(f'Fetching stream URL for predicted song {song_id}...') - self.lock.unlock() - - self.next_fetch_lock.lock() - url = monophony.backend.yt.get_song_uri(song_id) - if url: - self.next_expected_id = song_id - self.next_stream_url = url - self.next_fetch_time = time.time() - - print('Done fetching') - self.next_fetch_lock.unlock() - - ### --- PLAYBACK CONTROLS --- ### - - def play_song(self, song: dict, lock: bool=False, resume: bool=False): - if lock: - self.lock.lock() - - GLib.idle_add(self.ui_update_callback, song, True, False, True) - GLib.idle_add(self.queue_change_callback) - if not resume: - print('Playing', song['id']) - self.last_progress = 0 - else: - print('Resuming', song['id']) - - if song['id'] not in self.recent_songs: - self.recent_songs.append(song['id']) - monophony.backend.history.add_song(song) - self.playbin.set_state(Gst.State.READY) - - print('Attempting to get song from cache...') - uri = monophony.backend.cache.get_song_uri(song['id']) - - if not uri: - print('Attempting to use prepared stream URL...') - if self.next_fetch_lock.trylock(): - if self.next_stream_url: - if time.time() - self.next_fetch_time < 60 * 5: - if song['id'] == self.next_expected_id: - uri = self.next_stream_url - print('Using prepared stream URL for predicted song') - else: - print('Predicted song ID is incorrect!') - else: - print('Predicted song stream URL is too old!') - else: - print('No stream URL prepeared!') - - self.next_fetch_lock.unlock() - else: - print('Predicted song stream URL is not yet ready!') - - if not uri: - print('Fetching stream from YT...') - while True: - uri = monophony.backend.yt.get_song_uri(song['id']) - if uri is not None: - break - if self.interrupt: - if lock: - self.lock.unlock() - return - - print('Starting playback') - self.playbin.set_property('uri', uri) - self.paused = False - # buffering expected, so don't actually start yet (unless offline) - self.playbin.set_state(Gst.State.PAUSED) - self.mpris_server.unpublish() - self.mpris_server.publish() - self.mpris_adapter.emit_all() - self.mpris_adapter.on_playback() - GLib.idle_add(self.ui_update_callback, song, True, False, True) - - GLib.Thread.new(None, self.fetch_next_song_url) - - if lock: - self.lock.unlock() - - def play_radio_song(self): - id_queue = [s['id'] for s in self.queue] - random.shuffle(id_queue) - - song = None - for id_ in id_queue: - song = monophony.backend.yt.get_similar_song(id_, ignore=id_queue) - if song and song['id']: - break - - if song: - self.queue.append(song) - self.index += 1 - self.play_song(song) - - def set_pause(self, pause: bool): - if not self.lock.trylock(): - return - if self.buffering: - self.lock.unlock() - return - - if pause: - self.playbin.set_state(Gst.State.PAUSED) - self.paused = True - else: - self.playbin.set_state(Gst.State.PLAYING) - self.paused = False - - self.mpris_adapter.on_playpause() - GLib.idle_add( - self.ui_update_callback, - self.get_current_song(), - False, - self.paused, - False - ) - self.lock.unlock() - - def toggle_pause(self): - self.set_pause(not self.paused) - - def next_song(self, ignore_loop: bool=False, lock: bool=True): - if lock and not self.lock.trylock(): - return - - queue_length = len(self.queue) - song = None - if self.mode == PlaybackMode.LOOP_SONG and not ignore_loop: - song = self.queue[self.index] - elif queue_length - 1 > self.index: - self.index += 1 - song = self.queue[self.index] - elif self.mode == PlaybackMode.LOOP_QUEUE: - self.index = 0 - song = self.queue[self.index] - - if song: - self.play_song(song) - elif self.mode == PlaybackMode.RADIO: - self.play_radio_song() - else: - self.clear_queue(lock=False) - - if lock: - self.lock.unlock() - - def previous_song(self): - if not self.lock.trylock(): - return - self.playbin.set_state(Gst.State.READY) - - self.index = max(self.index - 1, 0) - if len(self.recent_songs) > 1: - recent = self.recent_songs[-2] - for i, queue_song in enumerate(self.queue): - if queue_song['id'] == recent: - self.index = i - self.recent_songs.pop(-1) - break - - if len(self.queue) > 0: - song = self.queue[self.index] - self.play_song(song) - - self.lock.unlock() - - def play_queue(self, queue: list, index: int): - if not self.lock.trylock(): - self.interrupt = True - self.lock.lock() - self.interrupt = False - - if len(queue) == 0: - self.lock.unlock() - return - - self.recent_songs = [] - self.queue = queue - self.index = index - song = queue[index] - self.play_song(song) - self.lock.unlock() - - def clear_queue(self, lock: bool=True): - if lock: - self.lock.lock() - - self.playbin.set_state(Gst.State.NULL) - self.playbin.set_property('uri', '') - self.queue = [] - self.index = 0 - self.mpris_server.unpublish() - GLib.idle_add(self.queue_change_callback) - GLib.idle_add(self.ui_update_callback, None, False, False, False) - GLib.idle_add(self.queue_end_callback) - - if lock: - self.lock.unlock() - - def shuffle_queue(self): - self.lock.lock() - song = self.get_current_song(lock=False) - random.shuffle(self.queue) - if song: - self.queue.remove(song) - self.queue.insert(0, song) - self.index = 0 - - GLib.Thread.new(None, self.fetch_next_song_url) - GLib.idle_add(self.queue_change_callback) - - self.lock.unlock() - - - def unqueue_song(self, song_id: str): - self.lock.lock() - if not self.queue: - self.lock.unlock() - return - - for i, song in enumerate(self.queue): - if song['id'] == song_id: - old_index = self.index - self.queue.pop(i) - if old_index < i: - break - self.index -= 1 - if old_index == i: - self.next_song(True, lock=False) - break - - GLib.Thread.new(None, self.fetch_next_song_url) - GLib.idle_add(self.queue_change_callback) - - self.lock.unlock() - - def move_song(self, from_i: int, to_i: int): - if not self.lock.trylock(): - return - - to_song = self.queue[to_i] - from_song = self.queue.pop(from_i) - self.queue.insert(self.queue.index(to_song), from_song) - if from_i == self.index: - if to_i > self.index: - self.index = to_i - 1 - else: - self.index = to_i - elif from_i < self.index and to_i > self.index: - self.index -= 1 - elif from_i > self.index and to_i <= self.index: - self.index += 1 - - GLib.Thread.new(None, self.fetch_next_song_url) - GLib.idle_add(self.queue_change_callback) - - self.lock.unlock() - - def queue_song(self, song: dict): - self.lock.lock() - - if not self.queue: - self.play_song(song) - - self.queue.append(song) - - GLib.Thread.new(None, self.fetch_next_song_url) - GLib.idle_add(self.queue_change_callback) - - self.lock.unlock() - - def seek(self, target: float): - if not self.lock.trylock(): - return - - duration = self.get_duration_ns() - if duration > 0 and self.get_current_song(lock=False): - self.playbin.seek_simple( - Gst.Format.TIME, - Gst.SeekFlags.FLUSH | Gst.SeekFlags.KEY_UNIT, - round(duration * target) - ) - - self.lock.unlock() diff --git a/source/monophony/backend/playlists.py b/source/monophony/backend/playlists.py deleted file mode 100644 index 9dfcb3afaf5a412db894ec5fe183e42f2afb798e..0000000000000000000000000000000000000000 --- a/source/monophony/backend/playlists.py +++ /dev/null @@ -1,280 +0,0 @@ -import json, os - -import monophony.backend.yt - -import ytmusicapi - - -# playlists = { -# 'my playlist': [ -# {'id': 'ASvGDFQwe', 'title': 'Cool song'} -# ] -# } - -# external_playlists = [ -# { -# 'title': 'my playlist', -# 'url': 'ASDFfghjklZXbcnmhjklzxbcn', -# 'contents': [ -# {'id': 'ASvGDFQwe', 'title': 'Cool song'} -# ] -# } -# ] - - -### --- PLAYLIST FUNCTIONS --- ### - - -def add_playlist(name: str, songs: list | None = None): - new_lists = read_playlists() - name = get_unique_name(name) - - songs = songs if songs else [] - new_lists[name] = [] - for song in songs: - if song not in new_lists[name]: - new_lists[name].append(song) - - write_playlists(playlists=new_lists) - - -def add_external_playlist(playlist: dict): - lists = read_external_playlists() - lists.append(playlist) - write_playlists(ext_playlists=lists) - - -def rename_playlist(name: str, new_name: str, local: bool=True) -> bool: - if name == new_name: - return True - if get_unique_name(new_name) != new_name: - return False - - if local: - new_lists = read_playlists() - new_lists[new_name] = new_lists.pop(name) - write_playlists(playlists=new_lists) - return True - - new_lists = read_external_playlists() - for i, playlist in enumerate(new_lists): - if playlist['title'] == name: - new_lists[i]['title'] = new_name - write_playlists(ext_playlists=new_lists) - return True - - return False - - -def import_playlist(name: str, url: str, local: bool, overwrite: bool=False) -> bool: - new_lists = read_playlists() - new_ext_lists = read_external_playlists() - songs = [] - playlist_id = url.split('list=')[-1].split('&')[0] - is_album = playlist_id.startswith('MPREb_') - - try: - yt = ytmusicapi.YTMusic() - if is_album: - album = yt.get_album(playlist_id) - data = album['tracks'] - else: - data = yt.get_playlist(playlist_id, limit=None)['tracks'] - except: - print('Could not get playlist') - return False - - for item in data: - if not item['videoId']: - continue - - parsed_song = { - 'title': item['title'], - 'author': item['artists'][0]['name'], - 'author_id': item['artists'][0]['id'], - 'length': item.get('duration', ''), - 'id': item['videoId'] - } - if is_album: - parsed_song['thumbnail'] = album['thumbnails'][0]['url'] - else: - parsed_song['thumbnail'] = item['thumbnails'][0]['url'] - - if parsed_song not in songs: - songs.append(parsed_song) - - name = get_unique_name(name) if not overwrite else name - if local: - new_lists[name] = songs - write_playlists(playlists=new_lists) - else: - new_ext_lists = [l for l in new_ext_lists if l['title'] != name] - new_ext_lists.append({'title': name, 'id': playlist_id, 'contents': songs}) - write_playlists(ext_playlists=new_ext_lists) - - return True - - -def remove_playlist(name: str): - new_lists = read_playlists() - new_lists.pop(name) - write_playlists(playlists=new_lists) - - -def remove_external_playlist(name: str): - write_playlists( - ext_playlists=[ - l for l in read_external_playlists() if l['title'] != name - ] - ) - - -def update_external_playlists(): - lists = read_external_playlists() - for playlist in lists: - import_playlist(playlist['title'], playlist['id'], False, True) - - clean_up_playlists() - - -def clean_up_playlists(): - lists = read_playlists() - ext_lists = read_external_playlists() - - new_lists = {} - for k, l in lists.items(): - new_lists[k] = [s for s in l if s['id']] - - new_ext_lists = [] - for l in ext_lists: - new_l = l.copy() - new_l['contents'] = [s for s in new_l['contents'] if s['id']] - new_ext_lists.append(new_l) - - write_playlists(playlists=new_lists, ext_playlists=new_ext_lists) - - -### --- SONG FUNCTIONS --- ### - - -def add_song(song: dict, playlist: str): - new_lists = read_playlists() - if song not in new_lists[playlist]: - new_lists[playlist].append(song) - write_playlists(playlists=new_lists) - - -def rename_song(index: int, playlist: str, new_name: str): - new_lists = read_playlists() - new_lists[playlist][index]['title'] = new_name - write_playlists(playlists=new_lists) - - -def swap_songs(p_name: str, i: int, j: int): - lists = read_playlists() - i = 0 if i >= len(lists[p_name]) else i - j = 0 if j >= len(lists[p_name]) else j - lists[p_name][i], lists[p_name][j] = lists[p_name][j], lists[p_name][i] - write_playlists(playlists=lists) - - -def move_song(p_name: str, from_i: int, to_i: int): - lists = read_playlists() - - to_song = lists[p_name][to_i] - from_song = lists[p_name].pop(from_i) - lists[p_name].insert(lists[p_name].index(to_song), from_song) - - write_playlists(playlists=lists) - - -def remove_song(id_: str, playlist: str): - new_lists = read_playlists() - new_lists[playlist] = [s for s in new_lists[playlist] if s['id'] != id_] - write_playlists(playlists=new_lists) - - -### --- UTILITY FUNCTIONS --- ### - - -def get_unique_name(base: str) -> str: - taken_names = ( - list(read_playlists().keys()) + - [p['title'] for p in read_external_playlists()] - ) - name = base - - if name in taken_names: - i = 1 - while f'{name} ({i})' in taken_names: - i += 1 - - name = f'{name} ({i})' - - return name - - -def write_playlists(playlists: dict | None = None, ext_playlists: list | None = None): - dir_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony' - lists_path = dir_path + '/playlists.json' - ext_lists_path = dir_path + '/external-playlists.json' - - try: - if playlists is not None: - with open(str(lists_path), 'w') as lists_file: - json.dump(playlists, lists_file, indent='\t') - if ext_playlists is not None: - with open(str(ext_lists_path), 'w') as ext_lists_file: - json.dump(ext_playlists, ext_lists_file, indent='\t') - except FileNotFoundError: - os.makedirs(str(dir_path)) - write_playlists(playlists=playlists, ext_playlists=ext_playlists) - - -def read_playlists() -> dict: - lists_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony/playlists.json' - - lists = {} - try: - with open(lists_path) as lists_file: - lists = json.load(lists_file) - except (OSError, json.decoder.JSONDecodeError): - return {} - - # backwards compatibility - updated = False - for name, playlist in lists.items(): - for i, song in enumerate(playlist): - if 'author_id' not in song: - print(f'Updating song {song["id"]}...') - song_details = monophony.backend.yt.get_song(song['id']) - if song_details: - lists[name][i]['author_id'] = song_details['author_id'] - print('Updated song', song['id']) - else: - print('Failed to update song', song['id']) - updated = True - - if updated: - write_playlists(playlists=lists) - - return lists - - -def read_external_playlists() -> list: - lists_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony/external-playlists.json' - - lists = [] - try: - with open(lists_path) as lists_file: - lists = json.load(lists_file) - except (OSError, json.decoder.JSONDecodeError): - return [] - - return lists diff --git a/source/monophony/backend/settings.py b/source/monophony/backend/settings.py deleted file mode 100644 index 3d01de356efd20c6c0860b9c29d83f475ad7fe8a..0000000000000000000000000000000000000000 --- a/source/monophony/backend/settings.py +++ /dev/null @@ -1,37 +0,0 @@ -import json, os - - -def set_value(key: str, value): - settings = read_settings() - settings[key] = str(value) - write_settings(settings) - - -def get_value(key: str, default='') -> str: - return read_settings().get(key, default) - - -def write_settings(settings: dict): - dir_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony' - sets_path = dir_path + '/settings.json' - - try: - with open(str(sets_path), 'w') as sets_file: - json.dump(settings, sets_file, indent='\t') - except FileNotFoundError: - os.makedirs(dir_path) - write_settings(settings) - - -def read_settings() -> dict: - sets_path = os.getenv( - 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') - ) + '/monophony/settings.json' - - try: - with open(sets_path) as sets_file: - return json.load(sets_file) - except (OSError, json.decoder.JSONDecodeError): - return {} diff --git a/source/monophony/backend/utils.py b/source/monophony/backend/utils.py deleted file mode 100644 index c2939fd86aa6dc8e2d40e5ba4a827d6933a9710b..0000000000000000000000000000000000000000 --- a/source/monophony/backend/utils.py +++ /dev/null @@ -1,32 +0,0 @@ -import contextlib, datetime - - -def time_str_to_sec(string: str) -> int: - if not string: - return 0 - - seconds = 0 - parts = string.split(':') - if len(parts) > 0: - with contextlib.suppress(ValueError): - seconds += int(parts[-1]) - if len(parts) > 1: - with contextlib.suppress(ValueError): - seconds += int(parts[-2]) * 60 - if len(parts) > 2: - with contextlib.suppress(ValueError): - seconds += int(parts[-3]) * 60 * 60 - - return seconds - - -def sec_to_time_str(seconds: int) -> str: - return str(datetime.timedelta(seconds=seconds)) - - -def sanitize_str(string: str) -> str: - bad_unicode = ['\u3011'] - for symbol in bad_unicode: - string = string.replace(symbol, '') - - return string diff --git a/source/monophony/backend/yt.py b/source/monophony/backend/yt.py deleted file mode 100644 index 927c263679e5a2482ad90916a674a6ebb41b2a37..0000000000000000000000000000000000000000 --- a/source/monophony/backend/yt.py +++ /dev/null @@ -1,346 +0,0 @@ -import random, subprocess, traceback - -import ytmusicapi - - -def _connect() -> ytmusicapi.YTMusic | None: - try: - return ytmusicapi.YTMusic() - except: - print('Failed to connect to YTM:\033[0;33m') - traceback.print_exc() - print('\033[0m') - - return None - - -def _get_artist_names(artists: list) -> list: - return [artist['name'] for artist in artists if artist['id']] - - -def _get_artist_id(artists: list) -> str: - a_id = '' - for i in range(len(artists)): - a_id = artists[i]['id'] - if a_id: - break - - return a_id - - -def _parse_single_result(yt: ytmusicapi.YTMusic, result: dict) -> dict | None: - if result['resultType'] == 'single': - result['resultType'] = 'album' - - item = {'type': result['resultType'], 'top': False} - if 'category' in result: - item['top'] = (result['category'] == 'Top result') - if result['category'] in ['Profiles', 'Episodes']: - return None - - if result['resultType'] == 'artist': - try: - if result['category'] == 'Top result': - item['author'] = ', '.join( - _get_artist_names(result['artists']) - ) - item['id'] = _get_artist_id(result['artists']) - else: - item['author'] = result['artist'] - item['id'] = result['browseId'] - except: - print('Failed to parse artist result:\033[0;33m') - traceback.print_exc() - print('\033[0m') - return None - elif result['resultType'] == 'album': - try: - item['author'] = result['artists'][0]['name'] - item['id'] = result['browseId'] - item['title'] = result['title'] - - album = ( - yt.get_playlist(result['playlistId']) if - result['playlistId'] else - yt.get_album(result['browseId']) - ) - item['contents'] = [ - { - 'id': str(s['videoId']), - 'title': s['title'], - 'type': 'song', - 'author': ', '.join(_get_artist_names(s['artists'])), - 'author_id': _get_artist_id(s['artists']), - 'length': s['duration'], - 'thumbnail': result['thumbnails'][0]['url'] - } for s in album['tracks'] if s['videoId'] - ] - except: - print('Failed to parse album result:\033[0;33m') - traceback.print_exc() - print('\033[0m') - return None - elif result['resultType'] == 'playlist': - try: - album = yt.get_playlist(result['browseId'], limit=None) - if 'author' in result: - item['author'] = result['author'] - else: - item['author'] = ', '.join( - _get_artist_names(result['artists']) - ) - item['id'] = result['browseId'] - item['title'] = result['title'] - item['contents'] = [ - { - 'id': str(s['videoId']), - 'title': s['title'], - 'type': 'song', - 'author': ', '.join(_get_artist_names(s['artists'])), - 'author_id': _get_artist_id(s['artists']), - 'length': s['duration'], - 'thumbnail': s['thumbnails'][0]['url'] - } for s in album['tracks'] if s['videoId'] - ] - except: - print('Failed to parse playlist result:\033[0;33m') - traceback.print_exc() - print('\033[0m') - return None - elif result['resultType'] in {'song', 'video'}: - try: - if not result['videoId']: - return None - item['id'] = str(result['videoId']) - item['title'] = result['title'] - item['author'] = ', '.join( - _get_artist_names(result['artists']) - ) - item['author_id'] = _get_artist_id(result['artists']) - if 'duration' in result: - item['length'] = result['duration'] - item['thumbnail'] = result['thumbnails'][0]['url'] - - # ytm sometimes returns videos as song results when filtered - if 'category' in result and result['category'] == 'Songs': - item['type'] = 'song' - except: - print('Failed to parse song/video result:\033[0;33m') - traceback.print_exc() - print('\033[0m') - return None - - return item - - -def _parse_results(data: list) -> list: - yt = _connect() - if yt is None: - return [] - - exp_types = {'album', 'song', 'video', 'playlist', 'artist', 'single'} - results = [ - _parse_single_result(yt, item) - for item in data if item.get('resultType', '') in exp_types - ] - - return [r for r in results if r] - - -def get_song_uri(video_id: str) -> str | None: - out, err = subprocess.Popen( - f'yt-dlp -g -x --no-warnings https://music.youtube.com/watch?v={video_id}', - shell=True, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ).communicate() - if err: - print(err.decode()) - return None - - return out.decode().split('\n')[0] - - -def get_similar_song(video_id: str, ignore: list | None = None) -> dict: - yt = _connect() - if yt is None: - return {} - - ignore = ignore if ignore else [] - try: - data = yt.get_watch_playlist(video_id, radio=True)['tracks'] - except: - return {} - - acceptable_tracks = [] - for item in data: - track = { - 'title': item['title'], - 'author': ', '.join(_get_artist_names(item['artists'])), - 'author_id': _get_artist_id(item['artists']), - 'length': item['length'], - 'id': item['videoId'], - 'thumbnail': item['thumbnail'][0]['url'] - } - if not track['id']: - continue - - for id_ in ignore: - if id_ == track['id']: - break - else: # nobreak - acceptable_tracks.append(track) - - if acceptable_tracks: - return random.choice(acceptable_tracks) - return {} - - -def get_recommendations() -> dict: - yt = _connect() - if yt is None: - return {} - - try: - data = yt.get_home() - except: - return {} - - categories = {} - for group in data: - songs = [ - { - 'title': item['title'], - 'author': ', '.join(_get_artist_names(item['artists'])), - 'author_id': _get_artist_id(item['artists']), - 'id': item['videoId'], - } for item in group['contents'] if item.get('videoId') - ] - - if songs: - categories[group['title']] = songs - - return categories - - -def get_song(id_: str) -> dict | None: - yt = _connect() - if yt is None: - return None - - try: - result = yt.get_song(id_)['videoDetails'] - except: - return None - - seconds = int(result['lengthSeconds']) - minutes = seconds // 60 - seconds %= 60 - return { - 'top': True, - 'type': 'video', - 'id': result['videoId'], - 'title': result['title'], - 'author': result['author'], - 'author_id': result['channelId'], - 'length': f'{minutes}:{seconds:02}', - 'thumbnail': result['thumbnail']['thumbnails'][0] - } - - -def get_artist(browse_id: str) -> list: - yt = _connect() - if yt is None: - return [] - - try: - metadata = yt.get_artist(browse_id) - artist = {'name': metadata['name']} - for group in ['albums', 'singles']: - artist[group] = {} - artist[group]['results'] = [] - if group not in metadata: - continue - - if 'params' in metadata[group]: - artist[group]['results'] = yt.get_artist_albums( - metadata[group]['browseId'], metadata[group]['params'] - ) - else: - artist[group]['results'] = metadata[group]['results'] - - for group in ['songs', 'videos', 'playlists']: - if group in metadata: - artist[group] = metadata[group] - else: - print('Artist has no', group) - except: - try: - artist = yt.get_user(browse_id) - except: - print('Could not get artist:\033[0;31m') - traceback.print_exc() - print('\033[0m') - return [] - - data = [] - for group in ['songs', 'albums', 'singles', 'videos', 'playlists']: - content = [] - if group in artist: - if group in {'songs', 'videos'}: - try: - yt.get_playlist( - artist[group]['browseId'], limit=None - )['tracks'] - except: - content = artist[group].get('results', []) - else: - content = [] - for alb in artist[group]['results']: - if not ('browseId' in alb or 'playlistId' in alb): - print(f'Failed to get artist {group}:\033[0;33m') - print('browseId/playlistId missing') - print('\033[0m') - continue - - content.append({ - 'title': alb['title'], - 'browseId': ( - alb['browseId' if 'browseId' in alb else 'playlistId'] - ), - 'playlistId': alb.get( - 'audioPlaylistId', alb.get('playlistId', None) - ), - 'artists': [{'name': artist['name'], 'id': browse_id}], - 'thumbnails': alb['thumbnails'] - }) - - for item in content: - item['resultType'] = group[:-1] - - data.extend(content) - - return _parse_results(data) - - -def search(query: str, filter_: str='') -> list: - yt = _connect() - if yt is None: - return [] - - try: - if '?v=' in query and '/' in query: - song = get_song(query.split('?v=')[-1].split('&')[0]) - return [song] if song else [] - if 'youtu.be/' in query: - song = get_song(query.split('youtu.be/')[-1].split('?')[0]) - return [song] if song else [] - - data = ( - yt.search(query, filter=filter_, limit=100) if filter_ - else yt.search(query) - ) - except: - return [] - - return _parse_results(data) diff --git a/source/monophony/data.py b/source/monophony/data.py new file mode 100644 index 0000000000000000000000000000000000000000..6f6f92bb1972c039cc7072395784f451e50c387f --- /dev/null +++ b/source/monophony/data.py @@ -0,0 +1,146 @@ +import contextlib +import datetime +from typing import Any + +from monophony import logging + + +class YTItem: + def __eq__(self, other: Any): + if isinstance(other, YTItem): + return self.yt_id == other.yt_id + + return False + + def __hash__(self): + return hash(self.yt_id) + + def __init__(self, yt_id: str=''): + self.yt_id = yt_id or '' + + def serialize(self) -> dict: + return {'id': self.yt_id} + + +class Artist(YTItem): + def __init__(self, name: str='', yt_id: str=''): + super().__init__(yt_id) + self.name = name or '' + + def serialize(self) -> dict: + return {'name': self.name, 'id': self.yt_id} + + +class Song(YTItem): + def __init__( + self, + title: str='', + author: Artist | None=None, + length: str='', + thumbnail: str='', + yt_id: str='' + ): + super().__init__(yt_id) + self.title = title or '' + self.author = author if author else Artist() + self.length = length or '' + self.thumbnail = thumbnail or '' + + def serialize(self) -> dict: + return { + 'title': self.title, + 'author': self.author.name, + 'author_id': self.author.yt_id, + 'length': self.length, + 'thumbnail': self.thumbnail, + 'id': self.yt_id + } + + +class Group(YTItem): + def __init__( + self, + title: str='', + author: Artist | None=None, + songs: list[Song] | None=None, + yt_id: str='' + ): + super().__init__(yt_id) + + self._songs = [] + self.title = title or '' + self.author = author if author else Artist() + self.songs = songs if songs else [] + + @property + def songs(self) -> list: + return self._songs + + @songs.setter + def songs(self, song_list: list[Song]): + ids = [] + unique_songs = [] + for song in song_list: + if not song.yt_id or song.yt_id in ids: + continue + + unique_songs.append(song) + ids.append(song.yt_id) + + self._songs = unique_songs + + def serialize(self) -> dict: + return { + 'title': self.title, + 'author': self.author.name, + 'author_id': self.author.yt_id, + 'contents': [s.serialize() for s in self.songs], + 'id': self.yt_id + } + + +class TimeString: + SECONDS_POS = 0 + MINUTES_POS = 1 + HOURS_POS = 2 + + def __init__(self, string: str='', seconds: int=0): + if string: + self._string = string + else: + self._string = str(datetime.timedelta(seconds=abs(seconds))) + + def as_seconds(self) -> int: + seconds = 0 + parts = self._string.split(':') + if len(parts) > TimeString.SECONDS_POS: + with contextlib.suppress(ValueError): + seconds += int(parts[-1]) + if len(parts) > TimeString.MINUTES_POS: + with contextlib.suppress(ValueError): + seconds += int(parts[-2]) * 60 + if len(parts) > TimeString.HOURS_POS: + with contextlib.suppress(ValueError): + seconds += int(parts[-3]) * 60 * 60 + if len(parts) > TimeString.HOURS_POS + 1: + logging.warning( + __name__, f'TimeString "{self._string}" has too many parts' + ) + + return abs(seconds) + + def as_string(self) -> str: + return self._string + + +class PlaybackMode: + NORMAL = 0 + LOOP_SONG = 1 + LOOP_QUEUE = 2 + RADIO = 3 + + +class PlaybackState: + NONE = 0 + PLAYING = 1 + LOADING = 2 diff --git a/source/monophony/debug.py b/source/monophony/debug.py new file mode 100644 index 0000000000000000000000000000000000000000..9926d7c814681b243d25aafd276c6d29d0924a65 --- /dev/null +++ b/source/monophony/debug.py @@ -0,0 +1,48 @@ +import gc +import os + +from monophony import logging + +from gi.repository import GLib, GObject + + +_DEBUG_VARIABLE = 'DEBUG' + + +def is_active() -> bool: + return bool(os.getenv(_DEBUG_VARIABLE)) + + +def log_memory_status() -> bool: + gobject_count = 0 + other_count = 0 + gc.collect() + for obj in gc.get_objects(): + if isinstance(obj, GObject.Object): + gobject_count += 1 + else: + other_count += 1 + + logging.info( + __name__, + f'{gobject_count} GObjects and {other_count} other objects in memory' + ) + return True + + +# Use with multiple inheriance: class Class(MemoryDebugger, ...) +class MemoryDebugger: + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if is_active(): + logging.info(__name__, f'Initialized {self.__class__.__name__}') + + def __del__(self): + if is_active(): + logging.info(__name__, f'Collected {self.__class__.__name__}') + + +if is_active(): + logging.warning(__name__, 'Debug mode enabled, expect low performance') + GLib.timeout_add_seconds(2, log_memory_status) diff --git a/source/monophony/downloads.py b/source/monophony/downloads.py new file mode 100644 index 0000000000000000000000000000000000000000..5b97cdb25fe1087e28edb0dfcecca0c20325cfd1 --- /dev/null +++ b/source/monophony/downloads.py @@ -0,0 +1,292 @@ +import glob +import json +import os +import subprocess +import traceback + +from monophony import NAME, logging +from monophony.asynchronous import Task +from monophony.data import Artist, Group, Song + +from gi.repository import GLib + + +def get_directory() -> str: + return os.getenv( + 'XDG_DATA_HOME', os.path.expanduser('~/.local/share') + ) + f'/{NAME}/' + + +def get_temp_directory() -> str: + return os.getenv('XDG_RUNTIME_DIR', '/var/tmp') + f'/{NAME}/downloads/' + + +def get_file(song: Song) -> str | None: + files = [ + file for file in glob.glob(get_directory() + '*' + song.yt_id + '*') + if not file.endswith('.' + NAME) + ] + + if files: + if len(files) > 1: + logging.warning( + __name__, + f'Multiple song files match id "{song.yt_id}"', '\n'.join(files) + ) + return files[0] + + return None + + +def is_being_downloaded(song: Song) -> bool: + return os.path.exists(get_directory() + song.yt_id + '.' + NAME) + + +def is_downloaded(song: Song) -> bool: + return ( + song.yt_id and get_file(song) and not is_being_downloaded(song) + ) + + +class DownloadTask(Task): + def _function(self, downloader: '_Downloader', group: Group) -> bool: + downloader.lock.lock() + logging.info(__name__, f'Downloading {len(group.songs)} songs...') + + path = get_directory() + needed_ids = [] + new_group = Group() + for song in group.songs: + if not song.yt_id: + logging.error( + __name__, f'Failed to download song "{song.title}" - no id' + ) + continue + + if is_downloaded(song) or is_being_downloaded(song): + logging.info( + __name__, + f'Skipped download of song "{song.yt_id}" - already taken care of' + ) + continue + + downloader.create_lock_file(song.yt_id) + needed_ids.append(song.yt_id) + new_group.songs.append(song) + + # *.NAME files act as locks for this part + self._update_progress() + downloader.lock.unlock() + + if not needed_ids: + logging.info( + __name__, 'Canceled download as there are no songs to download' + ) + return True + + song_urls = [ + f'https://music.youtube.com/watch?v={yt_id}' for yt_id in needed_ids + ] + ytdlp = subprocess.Popen( + [ + 'yt-dlp', + '--extract-audio', + '--no-cache-dir', + '--audio-quality', + '0', + '--add-metadata', + '--paths', + 'home:' + path, + '--paths', + 'temp:' + get_temp_directory(), + '--restrict-filenames', + '--output', + '%(title)s_-_%(creators)s_%(id)s.%(ext)s', + *song_urls + ], + text=True, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE + ) + return_code = ytdlp.wait() + + downloader.lock.lock() + for yt_id in needed_ids: + downloader.delete_lock_file(yt_id) + + if return_code != 0: + logging.error(__name__, 'Failed to download songs', ytdlp.stdout.read()) + downloader.lock.unlock() + return False + + new_group.songs = [song for song in new_group.songs if is_downloaded(song)] + logging.info( + __name__, f'Downloaded {len(new_group.songs)}/{len(group.songs)} songs' + ) + logging.info(__name__, 'Saving data about newly downloaded songs...') + downloader.write(Group(songs=new_group.songs + downloader.read().songs)) + logging.info(__name__, 'Saved newly downloaded song data') + + downloader.lock.unlock() + return True + + +class _Downloader: + def __init__(self): + self.lock = GLib.Mutex() + + self.lock.lock() + logging.info(__name__, 'Cleaning up downloads...') + downloads_group = self.read() + path = get_directory() + os.makedirs(path, exist_ok=True) + for file in os.listdir(path): + if file.endswith(('.part', '.' + NAME)): + os.remove(path + file) + logging.info(__name__, f'Removed abandoned temp file "{file}"') + continue + + for song in downloads_group.songs: + if path + file == get_file(song): + break + else: + os.remove(path + file) + logging.warning(__name__, f'Removed unexpected file "{file}"') + + self.write( + Group(songs=[song for song in self.read().songs if is_downloaded(song)]) + ) + logging.info(__name__, 'Cleaned up downloads') + self.lock.unlock() + + def create_lock_file(self, name: str): + logging.info(__name__, f'Creating lock file "{name}.{NAME}"...') + + # Always lock self.lock before calling this to prevent race conditions + if self.lock.trylock(): + logging.warning( + __name__, 'Creating lock file while self.lock is unlocked' + ) + self.lock.unlock() + + try: + open(f'{get_directory()}{name}.{NAME}', 'w').close() + except OSError: + logging.error( + __name__, 'Failed to create lock file', traceback.format_exc() + ) + return + + logging.info(__name__, 'Created lock file') + + def delete_lock_file(self, name: str): + logging.info(__name__, f'Deleting lock file "{name}.{NAME}"...') + + # Always lock self.lock before calling this to prevent race conditions + if self.lock.trylock(): + logging.warning( + __name__, 'Deleting lock file while self.lock is unlocked' + ) + self.lock.unlock() + + try: + os.remove(f'{get_directory()}{name}.{NAME}') + except (OSError, FileNotFoundError): + logging.error( + __name__, + f'Failed to remove lock file "{name}.{NAME}"', + traceback.format_exc() + ) + + logging.info(__name__, 'Deleted lock file') + + def read(self) -> Group: + # Always lock self.lock before calling this to prevent race conditions + if self.lock.trylock(): + logging.warning(__name__, 'Reading downloads while self.lock is unlocked') + self.lock.unlock() + + songs_path = os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + f'/{NAME}/downloads.json' + + try: + with open(songs_path) as songs_file: + return Group( + songs=[ + Song( + title=item.get('title', ''), + author=Artist( + name=item.get('author', ''), + yt_id=item.get('author_id', '') + ), + length=item.get('length', ''), + thumbnail=item.get('thumbnail', ''), + yt_id=item.get('id', '') + ) for item in json.load(songs_file) + ] + ) + except (OSError, json.decoder.JSONDecodeError): + return Group() + + def write(self, group: Group): + logging.info(__name__, f'Writing {len(group.songs)} songs to downloads...') + + # Always lock self.lock before calling this to prevent race conditions + if self.lock.trylock(): + logging.warning(__name__, 'Writing downloads while self.lock is unlocked') + self.lock.unlock() + + dir_path = os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + '/' + NAME + downloads_path = dir_path + '/downloads.json' + + os.makedirs(dir_path, exist_ok=True) + with open(downloads_path, 'w') as downloads_file: + json.dump(group.serialize()['contents'], downloads_file, indent='\t') + + logging.info(__name__, 'Done writing to downloads') + + def get_downloads(self) -> Group: + self.lock.lock() + downloads = self.read() + self.lock.unlock() + return downloads + + def remove(self, song: Song): + self.lock.lock() + logging.info(__name__, f'Removing song "{song.yt_id}" from downloads...') + + # No race conditions here as long as lock files are only created and + # deleted while holding the lock + if not is_downloaded(song): + logging.error( + __name__, 'Failed remove song from downloads - not downloaded' + ) + self.lock.unlock() + return + if is_being_downloaded(song): + logging.error( + __name__, 'Failed remove song from downloads - download in progress' + ) + self.lock.unlock() + return + + self.write( + Group(songs=[s for s in self.read().songs if s.yt_id != song.yt_id]) + ) + + file = get_file(song) + if not file: + logging.error( + __name__, 'Failed remove song from downloads - file not found' + ) + os.remove(file) + + logging.info(__name__, 'Removed song from downloads') + self.lock.unlock() + + +# Singleton for thread safety +downloader = _Downloader() diff --git a/source/monophony/frontend/app.py b/source/monophony/frontend/app.py deleted file mode 100644 index 5f97a4a6b70b7cbf09ad49ef518596628fb2842c..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/app.py +++ /dev/null @@ -1,24 +0,0 @@ -from monophony import APP_ID -from monophony.frontend.windows.main_window import MonophonyMainWindow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gio - - -class MonophonyApplication(Adw.Application): - def __init__(self): - super().__init__( - application_id=APP_ID, - flags=Gio.ApplicationFlags.DEFAULT_FLAGS - ) - - def do_activate(self): - windows = self.get_windows() - - if len(windows) > 0: - windows[0].set_visible(True) - else: - self.window = MonophonyMainWindow(application=self) - self.window.present() diff --git a/source/monophony/frontend/pages/artist_page.py b/source/monophony/frontend/pages/artist_page.py deleted file mode 100644 index 59839d62944652795601d61778d6495f8a9a9908..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/pages/artist_page.py +++ /dev/null @@ -1,99 +0,0 @@ -import monophony.backend.yt -from monophony.frontend.rows.importable_group_row import MonophonyImportableGroupRow -from monophony.frontend.rows.locked_group_row import MonophonyLockedGroupRow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib, Gtk - - -class MonophonyArtistPage(Gtk.Box): - def __init__(self, player: object, artist: str): - super().__init__(orientation=Gtk.Orientation.VERTICAL) - - self.pge_status = Adw.StatusPage() - self.pge_status.set_vexpand(True) - self.pge_status.set_valign(Gtk.Align.FILL) - self.pge_status.set_icon_name('system-search-symbolic') - self.pge_status.set_title(_('No Results')) - self.pge_status.set_visible(False) - self.append(self.pge_status) - - self.pge_results = Adw.PreferencesPage.new() - self.pge_results.set_vexpand(True) - self.pge_results.set_valign(Gtk.Align.FILL) - self.pge_results.set_visible(False) - self.append(self.pge_results) - - spn_big = Adw.Spinner() - spn_big.set_hexpand(True) - spn_big.set_vexpand(True) - - self.box_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.box_loading.set_margin_bottom(10) - self.box_loading.append(spn_big) - self.box_loading.set_visible(True) - self.append(self.box_loading) - - self.set_vexpand(True) - self.artist = artist - self.results = [] - self.player = player - - GLib.Thread.new(None, self.do_get_artist) - - def do_get_artist(self): - self.results = monophony.backend.yt.get_artist(self.artist) - GLib.idle_add(self.present_results) - - def present_results(self) -> bool: - self.box_loading.set_visible(False) - self.pge_status.set_visible(len(self.results) == 0) - if self.results: - self.pge_results.set_visible(True) - box_other = Adw.PreferencesGroup.new() - box_albums = Adw.PreferencesGroup.new() - box_playlists = Adw.PreferencesGroup.new() - box_other.set_title(_('Other')) - box_albums.set_title(_('Albums')) - box_playlists.set_title(_('Playlists')) - - songs = [] - videos = [] - non_empty = [] - for item in self.results: - if item['type'] == 'song': - songs.append(item) - elif item['type'] == 'video': - videos.append(item) - elif item['type'] == 'album': - box_albums.add(MonophonyImportableGroupRow(item, self.player)) - if box_albums not in non_empty: - non_empty.append(box_albums) - elif item['type'] == 'playlist': - box_playlists.add(MonophonyImportableGroupRow(item, self.player)) - if box_playlists not in non_empty: - non_empty.append(box_playlists) - if songs or videos: - non_empty.append(box_other) - if songs: - box_other.add( - MonophonyLockedGroupRow( - {'title': _('Songs'), 'contents': songs}, self.player - ) - ) - if videos: - box_other.add( - MonophonyLockedGroupRow( - {'title': _('Videos'), 'contents': videos}, self.player - ) - ) - for box in non_empty: - self.pge_results.add(box) - else: - self.pge_status.set_title(_('Artist Not Found')) - self.box_loading.set_visible(False) - self.pge_status.set_visible(True) - - return False diff --git a/source/monophony/frontend/pages/results_page.py b/source/monophony/frontend/pages/results_page.py deleted file mode 100644 index b6a0d37876608d58f60757660580340f0ff02914..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/pages/results_page.py +++ /dev/null @@ -1,144 +0,0 @@ -import weakref - -import monophony.backend.yt -from monophony.frontend.rows.importable_group_row import MonophonyImportableGroupRow -from monophony.frontend.rows.song_row import MonophonySongRow -from monophony.frontend.rows.artist_row import MonophonyArtistRow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib, Gtk - - -class MonophonyResultsPage(Gtk.Box): - def __init__(self, player: object, query: str='', filter_: str=''): - super().__init__(orientation=Gtk.Orientation.VERTICAL) - - self.pge_status = Adw.StatusPage() - self.pge_status.set_vexpand(True) - self.pge_status.set_valign(Gtk.Align.FILL) - self.pge_status.set_icon_name('system-search-symbolic') - self.pge_status.set_title(_('No Results')) - self.pge_status.set_visible(False) - self.append(self.pge_status) - - self.pge_results = Adw.PreferencesPage.new() - self.pge_results.set_vexpand(True) - self.pge_results.set_valign(Gtk.Align.FILL) - self.pge_results.set_visible(False) - self.append(self.pge_results) - - spn_big = Adw.Spinner() - spn_big.set_hexpand(True) - spn_big.set_vexpand(True) - - self.box_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.box_loading.set_margin_bottom(10) - self.box_loading.append(spn_big) - self.box_loading.set_visible(bool(query)) - self.append(self.box_loading) - - self.set_vexpand(True) - self.query = query - self.filter = filter_ - self.player = player - - if query: - GLib.Thread.new('search', self.do_search) - else: - self.pge_status.set_visible(True) - self.pge_status.set_title('') - - def do_search(self): - results = monophony.backend.yt.search(self.query, self.filter) - GLib.idle_add(self.show_results, results) - - def show_results(self, results: list) -> bool: - def create_result_box(query: str, result_type: str, filtered: bool): - box = Adw.PreferencesGroup.new() - if not filtered: - img_icon = Gtk.Image.new_from_icon_name('go-next-symbolic') - lbl_text = Gtk.Label.new(_('Show All')) - box_btn = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_btn.set_spacing(8) - box_btn.append(lbl_text) - box_btn.append(img_icon) - btn_more = Gtk.Button() - btn_more.add_css_class('suggested-action') - btn_more.set_child(box_btn) - btn_more.connect( - 'clicked', - lambda _b, f: ref_window()._on_show_more(query, f), - result_type - ) - box.set_header_suffix(btn_more) - return box - - self.box_loading.set_visible(False) - self.pge_status.set_visible(len(results) == 0) - if results: - self.pge_results.set_visible(True) - box_top = Adw.PreferencesGroup.new() - filtered = self.filter != '' - box_songs = create_result_box(self.query, 'songs', filtered) - box_videos = create_result_box(self.query, 'videos', filtered) - box_albums = create_result_box(self.query, 'albums', filtered) - box_playlists = create_result_box(self.query, 'playlists', filtered) - box_artists = create_result_box(self.query, 'artists', filtered) - box_top.set_title(_('Top Result')) - box_songs.set_title(_('Songs')) - box_albums.set_title(_('Albums')) - box_videos.set_title(_('Videos')) - box_playlists.set_title(_('Community Playlists')) - box_artists.set_title(_('Artists')) - ref_window = weakref.ref(self.get_ancestor(Gtk.Window)) - - non_empty = [] - for item in results: - if item['type'] == 'song': - if item['top']: - box_top.add(MonophonySongRow(item, self.player)) - non_empty.append(box_top) - continue - box_songs.add(MonophonySongRow(item, self.player)) - if box_songs not in non_empty: - non_empty.append(box_songs) - elif item['type'] == 'video': - if item['top']: - box_top.add(MonophonySongRow(item, self.player)) - non_empty.append(box_top) - continue - box_videos.add(MonophonySongRow(item, self.player)) - if box_videos not in non_empty: - non_empty.append(box_videos) - elif item['type'] == 'album': - if item['top']: - box_top.add(MonophonyImportableGroupRow(item, self.player)) - non_empty.append(box_top) - continue - box_albums.add(MonophonyImportableGroupRow(item, self.player)) - if box_albums not in non_empty: - non_empty.append(box_albums) - elif item['type'] == 'playlist': - if item['top']: - box_top.add(MonophonyImportableGroupRow(item, self.player)) - non_empty.append(box_top) - continue - box_playlists.add(MonophonyImportableGroupRow(item, self.player)) - if box_playlists not in non_empty: - non_empty.append(box_playlists) - elif item['type'] == 'artist': - if item['top']: - box_top.add(MonophonyArtistRow(item)) - non_empty.append(box_top) - continue - box_artists.add(MonophonyArtistRow(item)) - if box_artists not in non_empty: - non_empty.append(box_artists) - for box in non_empty: - self.pge_results.add(box) - - non_empty.clear() - results.clear() - return False diff --git a/source/monophony/frontend/popovers/importable_group_popover.py b/source/monophony/frontend/popovers/importable_group_popover.py deleted file mode 100644 index b385f30af1f0b3182ffb88182435bf5b83a856ac..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/popovers/importable_group_popover.py +++ /dev/null @@ -1,25 +0,0 @@ -import gi -gi.require_version('Gtk', '4.0') -from gi.repository import Gio, Gtk - - -class MonophonyImportableGroupPopover(Gtk.PopoverMenu): - def __init__(self, btn: Gtk.MenuButton, group: dict): - super().__init__() - - window = btn.get_ancestor(Gtk.Window) - mnu_actions = Gio.Menu() - mnu_actions.append(_('Download'), 'cache-playlist') - window.install_action( - 'cache-playlist', - None, - lambda w, *_: w._on_cache_playlist(group['contents']) - ) - mnu_actions.append(_('Import...'), 'import-playlist') - window.install_action( - 'import-playlist', - None, - lambda w, *_: w._on_import_clicked(group=group) - ) - self.set_menu_model(mnu_actions) - btn.set_popover(self) diff --git a/source/monophony/frontend/popovers/local_song_popover.py b/source/monophony/frontend/popovers/local_song_popover.py deleted file mode 100644 index cce663a6d289c76d622204668be578e9c96f3dfd..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/popovers/local_song_popover.py +++ /dev/null @@ -1,18 +0,0 @@ -from monophony.frontend.popovers.song_popover import MonophonySongPopover - -import gi -gi.require_version('Gtk', '4.0') -from gi.repository import Gtk - - -class MonophonyLocalSongPopover(MonophonySongPopover): - def __init__(self, btn: Gtk.MenuButton, song: dict, group: list): - super().__init__(btn, song) - - btn.get_ancestor(Gtk.Window).install_action( - 'remove-song', - None, - lambda w, *_: w._on_remove_song(song['id'], group['title']) - ) - self.get_menu_model().append(_('Remove From Playlist'), 'remove-song') - diff --git a/source/monophony/frontend/popovers/queue_song_popover.py b/source/monophony/frontend/popovers/queue_song_popover.py deleted file mode 100644 index 5d21aeddfd40feaa72fba26fd299e7c6ceca34f1..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/popovers/queue_song_popover.py +++ /dev/null @@ -1,21 +0,0 @@ -from monophony.frontend.popovers.song_popover import MonophonySongPopover - -import gi -gi.require_version('Gtk', '4.0') -from gi.repository import Gtk - - -class MonophonyQueueSongPopover(MonophonySongPopover): - def __init__(self, btn: Gtk.MenuButton, song: dict, player: object): - super().__init__(btn, song) - - self.player = player - self.install_action( - 'unqueue-song', - None, - lambda p, *_: p._on_unqueue_song() - ) - self.get_menu_model().append(_('Remove From Queue'), 'unqueue-song') - - def _on_unqueue_song(self): - self.player.unqueue_song(self.song['id']) diff --git a/source/monophony/frontend/popovers/song_popover.py b/source/monophony/frontend/popovers/song_popover.py deleted file mode 100644 index 58205816f306d5c3a13036c86c00b6abf3d0aa73..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/popovers/song_popover.py +++ /dev/null @@ -1,53 +0,0 @@ -import monophony.backend.cache -import monophony.backend.playlists - -import gi -gi.require_version('Gtk', '4.0') -from gi.repository import Gio, GLib, Gtk - - -class MonophonySongPopover(Gtk.PopoverMenu): - def __init__(self, btn: Gtk.MenuButton, song: dict): - super().__init__() - - self.song = song - menu = Gio.Menu() - btn.set_popover(self) - window = self.get_ancestor(Gtk.Window) - - if monophony.backend.cache.is_song_being_cached(song['id']): - pass - elif monophony.backend.cache.is_song_cached(song['id']): - menu.append(_('Remove From Downloads'), 'uncache-song') - window.install_action( - 'uncache-song', None, lambda *_: self._on_uncache(song) - ) - else: - menu.append(_('Download'), 'cache-song') - window.install_action( - 'cache-song', None, lambda *_: self._on_cache(song) - ) - - menu.append(_('Add to...'), 'add-song-to') - window.install_action( - 'add-song-to', None, lambda w, *_: w._on_add_clicked(song) - ) - menu.append(_('View Artist'), 'view-artist') - window.install_action( - 'view-artist', None, lambda w, *_: w._on_show_artist(song['author_id']) - ) - self.set_menu_model(menu) - - def _on_cache(self, song): - window = self.get_ancestor(Gtk.Window) - row = self.get_ancestor(Gtk.ListBoxRow) - window._on_cache_song(song) - row.spinner.set_visible(True) - GLib.timeout_add_seconds(1, row.update_download_status) - - def _on_uncache(self, song): - window = self.get_ancestor(Gtk.Window) - row = self.get_ancestor(Gtk.ListBoxRow) - window._on_uncache_song(song) - row.spinner.set_visible(False) - row.checkmark.set_visible(False) diff --git a/source/monophony/frontend/rows/artist_row.py b/source/monophony/frontend/rows/artist_row.py deleted file mode 100644 index 3bb356c8f46aae3a035218c88c7b51e2249e362e..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/artist_row.py +++ /dev/null @@ -1,33 +0,0 @@ -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib, Gtk - - -class MonophonyArtistRow(Adw.ActionRow): - def __init__(self, artist: dict): - super().__init__() - - self.artist = artist - artist_id = self.artist['id'] - - btn_view = Gtk.Button.new_from_icon_name('go-next-symbolic') - btn_view.set_tooltip_text(_('View artist')) - btn_view.set_vexpand(False) - btn_view.set_valign(Gtk.Align.CENTER) - btn_view.set_has_frame(False) - btn_view.connect( - 'clicked', - lambda b: b.get_ancestor(Gtk.Window)._on_show_artist(artist_id), - ) - self.add_suffix(btn_view) - self.set_tooltip_text(_('View artist')) - self.set_property('activatable', True) - self.connect( - 'activated', - lambda b: b.get_ancestor(Gtk.Window)._on_show_artist(artist_id), - ) - - self.set_title(GLib.markup_escape_text( - artist.get('author', '') or '', -1 - )) diff --git a/source/monophony/frontend/rows/external_group_row.py b/source/monophony/frontend/rows/external_group_row.py deleted file mode 100644 index 85406c7274dd4773e4f8a796ec4dd692287ed7d1..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/external_group_row.py +++ /dev/null @@ -1,110 +0,0 @@ -import monophony.backend.playlists -from monophony.backend.utils import sanitize_str - -from monophony.frontend.rows.group_row import MonophonyGroupRow -from monophony.frontend.rows.song_row import MonophonySongRow -from monophony.frontend.windows.message_window import MonophonyMessageWindow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gtk, Gio - - -class MonophonyExternalGroupRow(MonophonyGroupRow): - def __init__(self, group: dict, player: object): - super().__init__(group, player) - - btn_more = Gtk.MenuButton() - btn_more.set_tooltip_text(_('More actions')) - btn_more.set_icon_name('view-more-symbolic') - btn_more.set_has_frame(False) - btn_more.set_vexpand(False) - btn_more.set_valign(Gtk.Align.CENTER) - btn_more.set_create_popup_func(self._on_show_actions) - self.add_action(btn_more) - self.set_enable_expansion(False) - - playlists = monophony.backend.playlists.read_external_playlists() - for playlist in playlists: - if playlist['title'] == self.group['title']: - self.group['contents'] = playlist['contents'] - break - for song in self.group['contents']: - self.add_row(MonophonySongRow(song, self.player, self.group)) - self.set_enable_expansion(True) - - self.set_expanded(False) - self.update() - - def _on_show_actions(self, btn: Gtk.MenuButton): - window = self.get_ancestor(Gtk.Window) - mnu_actions = Gio.Menu() - mnu_actions.append(_('Delete'), 'delete-playlist') - window.install_action( - 'delete-playlist', None, lambda *_: self._on_delete() - ) - mnu_actions.append(_('Download'), 'cache-playlist') - window.install_action( - 'cache-playlist', - None, - lambda w, *_: w._on_cache_playlist(self.group['contents']) - ) - mnu_actions.append(_('Rename...'), 'rename-playlist') - window.install_action( - 'rename-playlist', - None, - lambda *_: self._on_open_rename_menu(btn) - ) - pop_menu = Gtk.PopoverMenu() - pop_menu.set_menu_model(mnu_actions) - btn.set_popover(pop_menu) - - def _on_open_rename_menu(self, btn: Gtk.MenuButton): - pop_rename = Gtk.Popover.new() - ent_name = Gtk.Entry.new() - ent_name.set_text(self.group['title']) - ent_name.connect( - 'activate', lambda e: self._on_rename(e.get_text(), pop_rename) - ) - btn_rename = Gtk.Button.new_with_label(_('Rename')) - btn_rename.add_css_class('suggested-action') - btn_rename.connect( - 'clicked', lambda _b: self._on_rename(ent_name.get_text(), pop_rename) - ) - box_rename = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_rename.set_spacing(5) - box_rename.set_margin_top(5) - box_rename.set_margin_bottom(5) - box_rename.set_margin_start(5) - box_rename.set_margin_end(5) - box_rename.append(ent_name) - box_rename.append(btn_rename) - pop_rename.set_child(box_rename) - pop_rename.set_parent(btn) - btn.popdown() - pop_rename.popup() - - def _on_delete(self): - self.get_ancestor(Gtk.Window)._on_delete_playlist(self, local=False) - self.get_ancestor(Adw.PreferencesGroup).remove(self) - - def _on_rename(self, name: str, pop: Gtk.Popover): - pop.popdown() - success = monophony.backend.playlists.rename_playlist( - self.group['title'], name, False - ) - if success: - self.group['title'] = name - self.set_title(sanitize_str(name)) - else: - MonophonyMessageWindow( - self.get_ancestor(Gtk.Window), - _('Could not Rename'), - _('Playlist already exists') - ).present() - - def update(self): - super().update() - self.set_subtitle(self.get_subtitle() + ' ' + _('(Synchronized)')) - diff --git a/source/monophony/frontend/rows/group_row.py b/source/monophony/frontend/rows/group_row.py deleted file mode 100644 index 3378bc8c7e37325b1ca45cc6cf94286ad9cbfa53..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/group_row.py +++ /dev/null @@ -1,32 +0,0 @@ -from monophony.backend.utils import sanitize_str, sec_to_time_str, time_str_to_sec - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib - - -class MonophonyGroupRow(Adw.ExpanderRow): - def __init__(self, group: dict, player: object): - super().__init__() - - self.player = player - self.group = group - - self.set_title(GLib.markup_escape_text( - sanitize_str(group.get('title', '') or ''), -1 - )) - self.set_expanded(False) - - def update(self): - total_seconds = 0 - for song in self.group.get('contents', []): - total_seconds += time_str_to_sec(song.get('length', '0')) - - self.set_subtitle( - sec_to_time_str(total_seconds) + - ' ' + - GLib.markup_escape_text( - sanitize_str(self.group.get('author', '') or ''), -1 - ) - ) diff --git a/source/monophony/frontend/rows/importable_group_row.py b/source/monophony/frontend/rows/importable_group_row.py deleted file mode 100644 index d16d1bbfe56279a178367b2712ad93fff792a0bf..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/importable_group_row.py +++ /dev/null @@ -1,27 +0,0 @@ -from monophony.frontend.popovers.importable_group_popover import \ - MonophonyImportableGroupPopover -from monophony.frontend.rows.group_row import MonophonyGroupRow -from monophony.frontend.rows.song_row import MonophonySongRow - -import gi -gi.require_version('Gtk', '4.0') -from gi.repository import Gtk - - -class MonophonyImportableGroupRow(MonophonyGroupRow): - def __init__(self, group: dict, player: object): - super().__init__(group, player) - - for item in group['contents']: - self.add_row(MonophonySongRow(item, player, group)) - - btn_more = Gtk.MenuButton() - btn_more.set_tooltip_text(_('More actions')) - btn_more.set_icon_name('view-more-symbolic') - btn_more.set_has_frame(False) - btn_more.set_vexpand(False) - btn_more.set_valign(Gtk.Align.CENTER) - group = self.group.copy() - btn_more.set_create_popup_func(MonophonyImportableGroupPopover, group) - self.add_action(btn_more) - super().update() diff --git a/source/monophony/frontend/rows/local_group_row.py b/source/monophony/frontend/rows/local_group_row.py deleted file mode 100644 index 3d84d914e5e13b6de62084b9d5c8c691096d4634..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/local_group_row.py +++ /dev/null @@ -1,126 +0,0 @@ -import monophony.backend.playlists -from monophony.frontend.windows.message_window import MonophonyMessageWindow -from monophony.frontend.rows.group_row import MonophonyGroupRow -from monophony.frontend.rows.local_song_row import MonophonyLocalSongRow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gio, Gtk - - -class MonophonyLocalGroupRow(MonophonyGroupRow): - def __init__(self, group: dict, player: object): - super().__init__(group, player) - - self.song_widgets = [] - for item in group['contents']: - row = MonophonyLocalSongRow(item, player, group) - self.add_row(row) - self.song_widgets.append(row) - - btn_more = Gtk.MenuButton() - btn_more.set_tooltip_text(_('More actions')) - btn_more.set_icon_name('view-more-symbolic') - btn_more.set_has_frame(False) - btn_more.set_vexpand(False) - btn_more.set_valign(Gtk.Align.CENTER) - btn_more.set_create_popup_func(self._on_show_actions) - self.add_action(btn_more) - super().update() - - def _on_show_actions(self, btn: Gtk.MenuButton): - window = self.get_ancestor(Gtk.Window) - mnu_actions = Gio.Menu() - mnu_actions.append(_('Delete'), 'delete-playlist') - window.install_action('delete-playlist', None, lambda *_: self._on_delete()) - mnu_actions.append(_('Download'), 'cache-playlist') - window.install_action( - 'cache-playlist', - None, - lambda w, *_: w._on_cache_playlist(self.group['contents']) - ) - mnu_actions.append(_('Duplicate'), 'duplicate-playlist') - window.install_action( - 'duplicate-playlist', - None, - lambda w, *_: w._on_duplicate_playlist(self) - ) - mnu_actions.append(_('Rename...'), 'rename-playlist') - window.install_action( - 'rename-playlist', - None, - lambda *_: self._on_open_rename_menu(btn) - ) - pop_menu = Gtk.PopoverMenu() - pop_menu.set_menu_model(mnu_actions) - btn.set_popover(pop_menu) - - def _on_open_rename_menu(self, btn: Gtk.MenuButton): - pop_rename = Gtk.Popover.new() - ent_name = Gtk.Entry.new() - ent_name.set_text(self.group['title']) - ent_name.connect( - 'activate', lambda e: self._on_rename(e.get_text(), pop_rename) - ) - btn_rename = Gtk.Button.new_with_label(_('Rename')) - btn_rename.add_css_class('suggested-action') - btn_rename.connect( - 'clicked', lambda _b: self._on_rename(ent_name.get_text(), pop_rename) - ) - box_rename = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_rename.set_spacing(5) - box_rename.set_margin_top(5) - box_rename.set_margin_bottom(5) - box_rename.set_margin_start(5) - box_rename.set_margin_end(5) - box_rename.append(ent_name) - box_rename.append(btn_rename) - pop_rename.set_child(box_rename) - pop_rename.set_parent(btn) - btn.popdown() - pop_rename.popup() - - def _on_delete(self): - self.get_ancestor(Gtk.Window)._on_delete_playlist(self, local=True) - self.get_ancestor(Adw.PreferencesGroup).remove(self) - - def _on_rename(self, name: str, pop: Gtk.Popover): - pop.popdown() - success = monophony.backend.playlists.rename_playlist( - self.group['title'], name - ) - if not success: - MonophonyMessageWindow( - self.get_ancestor(Gtk.Window), - _('Could not Rename'), - _('Playlist already exists') - ).present() - return - - self.get_ancestor(Adw.PreferencesGroup).remove(self) - - def update(self) -> bool: - super().update() - - self.set_enable_expansion(self.song_widgets != []) - playlists = monophony.backend.playlists.read_playlists() - if self.group['title'] not in playlists: - self.get_ancestor(Adw.PreferencesGroup).remove(self) - return False - - if self.group['contents'] == playlists[self.group['title']]: - return True - - for widget in self.song_widgets: - self.remove(widget) - - self.song_widgets = [] - self.group['contents'] = playlists[self.group['title']] - for song in self.group['contents']: - row = MonophonyLocalSongRow(song, self.player, self.group) - self.add_row(row) - self.song_widgets.append(row) - self.set_enable_expansion(True) - - return True diff --git a/source/monophony/frontend/rows/local_song_row.py b/source/monophony/frontend/rows/local_song_row.py deleted file mode 100644 index efec8a7ebc5ec47fd546458c5b0d68c87e066dbd..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/local_song_row.py +++ /dev/null @@ -1,79 +0,0 @@ -import monophony.backend.playlists -from monophony.frontend.popovers.local_song_popover import MonophonyLocalSongPopover -from monophony.frontend.rows.song_row import MonophonySongRow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gdk, Gtk - - -class MonophonyLocalSongRow(MonophonySongRow): - def __init__(self, song: dict, player: object, group: dict | None = None): - super().__init__(song, player, group) - - img_handle = Gtk.Image.new_from_icon_name('list-drag-handle-symbolic') - img_handle.add_css_class('drag-handle') - css = Gtk.CssProvider.new() - css.load_from_data(''' - .drag-handle { - opacity: 0.5; - } - - .dnd-item { - background-color: #00000022; - } - ''', -1) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - css, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - self.add_prefix(img_handle) - self.drg_handle = Gtk.DragSource.new() - self.drg_handle.set_actions(Gdk.DragAction.MOVE) - self.drg_handle.connect('prepare', self._on_dnd_prepare) - self.drg_handle.connect('drag-end', self._on_dnd_cancel_or_end) - self.drg_handle.connect('drag-cancel', self._on_dnd_cancel_or_end) - img_handle.add_controller(self.drg_handle) - self.drp_target = Gtk.DropTarget.new(self.__gtype__, Gdk.DragAction.MOVE) - self.drp_target.connect('drop', self._on_dnd_drop) - self.drp_target.connect('enter', self._on_dnd_enter) - self.add_controller(self.drp_target) - - self.btn_more.set_create_popup_func( - MonophonyLocalSongPopover, self.song, self.group - ) - - def _on_dnd_drop(self, _t, song_row: Adw.ActionRow, _x: float, _y: float) -> bool: - monophony.backend.playlists.move_song( - self.group['title'], - self.group['contents'].index(song_row.song), - self.group['contents'].index(self.song), - ) - self.get_ancestor(Gtk.Window).library_tab.update_playlists() - return True - - def _on_dnd_prepare(self, *_) -> Gdk.ContentProvider: - self.add_css_class('dnd-item') - self.drg_handle.set_icon(Gtk.WidgetPaintable.new(self), 0, 0) - return Gdk.ContentProvider.new_for_value(self) - - def _on_dnd_enter(self, *_) -> int: - if self.drg_handle.get_drag(): - self.drp_target.reject() - return 0 - - # reject if no drag was started in the local group - child_index = 0 - while True: - child = self.get_parent().get_row_at_index(child_index) - child_index += 1 - if not child: - self.drp_target.reject() - return 0 - if child.drg_handle.get_drag(): - return Gdk.DragAction.MOVE - - def _on_dnd_cancel_or_end(self, *_): - self.remove_css_class('dnd-item') diff --git a/source/monophony/frontend/rows/locked_group_row.py b/source/monophony/frontend/rows/locked_group_row.py deleted file mode 100644 index b254761bf370eb8eace52f9d69cda5e624ea2975..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/locked_group_row.py +++ /dev/null @@ -1,16 +0,0 @@ -from monophony.frontend.rows.group_row import MonophonyGroupRow -from monophony.frontend.rows.song_row import MonophonySongRow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') - - -class MonophonyLockedGroupRow(MonophonyGroupRow): - def __init__(self, group: dict, player: object): - super().__init__(group, player) - - for item in group['contents']: - self.add_row(MonophonySongRow(item, player, group)) - - super().update() diff --git a/source/monophony/frontend/rows/queue_song_row.py b/source/monophony/frontend/rows/queue_song_row.py deleted file mode 100644 index b08333f0d5f96e35aeef868212c5f0d1788949e1..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/queue_song_row.py +++ /dev/null @@ -1,80 +0,0 @@ -from monophony.frontend.popovers.queue_song_popover import MonophonyQueueSongPopover -from monophony.frontend.rows.song_row import MonophonySongRow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gdk, Gtk - - -class MonophonyQueueSongRow(MonophonySongRow): - def __init__(self, song: dict, player: object, queue: list): - super().__init__(song, player, queue) - self.btn_more.set_create_popup_func( - MonophonyQueueSongPopover, self.song, self.player - ) - - img_handle = Gtk.Image.new_from_icon_name('list-drag-handle-symbolic') - img_handle.add_css_class('drag-handle') - css = Gtk.CssProvider.new() - css.load_from_data(''' - .drag-handle { - opacity: 0.5; - } - - .dnd-item { - background-color: #00000022; - } - - .current-queue-item { - color: @accent_color; - } - ''', -1) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - css, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - self.add_prefix(img_handle) - self.drg_handle = Gtk.DragSource.new() - self.drg_handle.set_actions(Gdk.DragAction.MOVE) - self.drg_handle.connect('prepare', self._on_dnd_prepare) - self.drg_handle.connect('drag-end', self._on_dnd_cancel_or_end) - self.drg_handle.connect('drag-cancel', self._on_dnd_cancel_or_end) - img_handle.add_controller(self.drg_handle) - self.drp_target = Gtk.DropTarget.new(self.__gtype__, Gdk.DragAction.MOVE) - self.drp_target.connect('drop', self._on_dnd_drop) - self.drp_target.connect('enter', self._on_dnd_enter) - self.add_controller(self.drp_target) - - def _on_dnd_drop(self, _t, song_row: Adw.ActionRow, *_) -> bool: - queue = self.player.queue.copy() - self.player.move_song( - queue.index(song_row.song), - queue.index(self.song), - ) - return True - - def _on_dnd_prepare(self, *_) -> Gdk.ContentProvider: - self.add_css_class('dnd-item') - self.drg_handle.set_icon(Gtk.WidgetPaintable.new(self), 0, 0) - return Gdk.ContentProvider.new_for_value(self) - - def _on_dnd_enter(self, *_) -> int: - if self.drg_handle.get_drag(): - self.drp_target.reject() - return 0 - - # reject if no drag was started in the local group - child_index = 0 - while True: - child = self.get_parent().get_row_at_index(child_index) - child_index += 1 - if not child: - self.drp_target.reject() - return 0 - if child.drg_handle.get_drag(): - return Gdk.DragAction.MOVE - - def _on_dnd_cancel_or_end(self, *_): - self.remove_css_class('dnd-item') diff --git a/source/monophony/frontend/rows/song_row.py b/source/monophony/frontend/rows/song_row.py deleted file mode 100644 index ca57c35f175684566f151219f168982a688a6229..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/rows/song_row.py +++ /dev/null @@ -1,70 +0,0 @@ -import monophony.backend.cache -from monophony.backend.utils import sanitize_str -from monophony.frontend.popovers.song_popover import MonophonySongPopover - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib, GObject, Gtk - - -class MonophonySongRow(Adw.ActionRow, GObject.Object): - def __init__(self, song: dict, player: object, group: dict | None = None): - super().__init__() - - self.player = player - self.song = song - self.group = group - - self.set_tooltip_text(_('Play')) - self.set_property('activatable', True) - self.connect('activated', lambda row: row._on_play_clicked()) - - title = GLib.markup_escape_text(song.get('title', '') or '', -1) - length = GLib.markup_escape_text(song.get('length', '0:00') or '0:00', -1) - author = GLib.markup_escape_text(song.get('author', '') or '', -1) - subtitle = sanitize_str(author) - if length: - subtitle = length + ' ' + subtitle - - self.checkmark = Gtk.Image.new_from_icon_name('folder-download-symbolic') - self.checkmark.set_tooltip_text(_('Downloaded')) - self.checkmark.set_visible(False) - self.add_suffix(self.checkmark) - self.spinner = Adw.Spinner() - self.spinner.set_visible(False) - self.add_suffix(self.spinner) - self.set_title(sanitize_str(title)) - self.set_subtitle(sanitize_str(subtitle)) - - self.btn_more = Gtk.MenuButton() - self.btn_more.set_tooltip_text(_('More actions')) - self.btn_more.set_icon_name('view-more-symbolic') - self.btn_more.set_has_frame(False) - self.btn_more.set_vexpand(False) - self.btn_more.set_valign(Gtk.Align.CENTER) - self.btn_more.set_create_popup_func(MonophonySongPopover, self.song) - self.add_suffix(self.btn_more) - - self.update_download_status() - - def _on_play_clicked(self): - queue = [self.song] - if self.group: - queue = self.group['contents'] - - GLib.Thread.new( - None, self.player.play_queue, queue, queue.index(self.song) - ) - - def update_download_status(self) -> bool: - if monophony.backend.cache.is_song_being_cached(self.song['id']): - self.spinner.set_visible(True) - return True - - self.spinner.set_visible(False) - self.checkmark.set_visible( - monophony.backend.cache.is_song_cached(self.song['id']) - ) - - return False diff --git a/source/monophony/frontend/tabs/library_tab.py b/source/monophony/frontend/tabs/library_tab.py deleted file mode 100644 index b2576964003642f769d33f2230ec8c6b70f4d90e..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/tabs/library_tab.py +++ /dev/null @@ -1,231 +0,0 @@ -import monophony.backend.cache -import monophony.backend.history -import monophony.backend.playlists -import monophony.backend.yt -from monophony.frontend.rows.external_group_row import MonophonyExternalGroupRow -from monophony.frontend.rows.local_group_row import MonophonyLocalGroupRow -from monophony.frontend.rows.locked_group_row import MonophonyLockedGroupRow -from monophony.frontend.rows.song_row import MonophonySongRow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gio, GLib, GObject, Gtk - - -class MonophonyLibraryTab(Gtk.Box): - def __init__(self, player: object): - super().__init__(orientation=Gtk.Orientation.VERTICAL) - - self.player = player - self.playlist_widgets = [] - self.downloads_widgets = [] - self.old_downloads = [] - self.recents_widgets = [] - self.old_recents = [] - self.recommendations = {} - self.loading_lock = GLib.Mutex() - self.set_vexpand(True) - - self.box_meta = Adw.PreferencesPage.new() - self.box_meta.set_vexpand(True) - self.box_meta.set_visible(False) - self.box_meta.set_valign(Gtk.Align.FILL) - self.append(self.box_meta) - - spn_big = Adw.Spinner() - spn_big.set_hexpand(True) - spn_big.set_vexpand(True) - - self.box_loading = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.box_loading.set_margin_bottom(10) - self.box_loading.append(spn_big) - self.box_loading.set_visible(True) - self.append(self.box_loading) - - self.box_meta.bind_property( - 'visible', - self.box_loading, - 'visible', - GObject.BindingFlags.SYNC_CREATE | - GObject.BindingFlags.INVERT_BOOLEAN | - GObject.BindingFlags.BIDIRECTIONAL - ) - - self.box_recommendations = Adw.PreferencesGroup() - self.box_recommendations.set_visible(False) - self.box_recommendations.set_title(_('Recommended')) - self.box_meta.add(self.box_recommendations) - - con_import = Adw.ButtonContent.new() - con_import.set_label(_('Import')) - con_import.set_icon_name('list-add-symbolic') - btn_import = Gtk.Button.new() - btn_import.set_child(con_import) - btn_import.add_css_class('suggested-action') - btn_import.connect( - 'clicked', lambda _b: self.get_ancestor(Gtk.Window)._on_import_clicked() - ) - - self.btn_play = Gtk.Button.new_from_icon_name('media-playback-start-symbolic') - self.btn_play.add_css_class('suggested-action') - self.btn_play.set_tooltip_text(_('Play all')) - self.btn_play.connect('clicked', self._on_play_all) - - box_suffix = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_suffix.set_spacing(5) - box_suffix.append(btn_import) - box_suffix.append(self.btn_play) - - self.box_playlists = Adw.PreferencesGroup() - self.box_playlists.set_title(_('Your Playlists')) - self.box_playlists.set_header_suffix(box_suffix) - self.box_meta.add(self.box_playlists) - - btn_downloads = Gtk.Button.new_from_icon_name('folder-symbolic') - btn_downloads.set_tooltip_text(_('Open downloads directory')) - btn_downloads.connect('clicked', lambda _b: self._on_open_downloads()) - - self.box_downloads = Adw.PreferencesGroup() - self.box_downloads.set_visible(False) - self.box_downloads.set_title(_('Downloads')) - self.box_downloads.set_header_suffix(btn_downloads) - self.box_meta.add(self.box_downloads) - - btn_clear = Gtk.Button.new_from_icon_name('edit-clear-all-symbolic') - btn_clear.add_css_class('destructive-action') - btn_clear.set_tooltip_text(_('Clear')) - btn_clear.connect( - 'clicked', lambda _b: monophony.backend.history.clear_songs() - ) - - self.box_recents = Adw.PreferencesGroup() - self.box_recents.set_visible(False) - self.box_recents.set_title(_('Recently Played')) - self.box_recents.set_header_suffix(btn_clear) - self.box_meta.add(self.box_recents) - - GLib.Thread.new('library-load', self.load) - GLib.timeout_add(200, self.update) - - def _on_open_downloads(self): - Gio.AppInfo.launch_default_for_uri( - 'file://' + monophony.backend.cache.get_cache_directory(), None - ) - - def _on_play_all(self, _b): - all_songs = [] - for content in monophony.backend.playlists.read_playlists().values(): - all_songs.extend(content) - for playlist in monophony.backend.playlists.read_external_playlists(): - all_songs.extend(playlist['contents']) - - GLib.Thread.new(None, self.player.play_queue, all_songs, 0) - - def load(self): - self.loading_lock.lock() - monophony.backend.playlists.update_external_playlists() - self.recommendations = monophony.backend.yt.get_recommendations() - self.loading_lock.unlock() - - def update_playlists(self): - for w in self.playlist_widgets: - if isinstance(w, MonophonyLocalGroupRow): - w.update() - - def update(self) -> bool: - if not self.get_ancestor(Gtk.Window).get_visible(): - return True - - if not self.loading_lock.trylock(): - return True - self.loading_lock.unlock() - self.box_loading.set_visible(False) - - new_playlists = monophony.backend.playlists.read_playlists() - new_ext_lists = monophony.backend.playlists.read_external_playlists() - self.btn_play.set_visible(new_playlists or new_ext_lists) - - remaining_widgets = [ - w for w in self.playlist_widgets if w.is_ancestor(self.box_meta) - ] - self.playlist_widgets = remaining_widgets - - for title in new_playlists: - for widget in self.playlist_widgets: - if widget.get_title() == GLib.markup_escape_text(title, -1): - for song_row in widget.song_widgets: - song_row.update_download_status() - break - else: # nobreak - new_widget = MonophonyLocalGroupRow( - {'title': title, 'contents': new_playlists[title]}, self.player - ) - self.playlist_widgets.append(new_widget) - self.box_playlists.add(new_widget) - break # one per update call - - for playlist in new_ext_lists: - title = GLib.markup_escape_text(playlist['title'], -1) - for widget in self.playlist_widgets: - if widget.get_title() == title: - break - else: # nobreak - new_widget = MonophonyExternalGroupRow(playlist, self.player) - self.playlist_widgets.append(new_widget) - self.box_playlists.add(new_widget) - break # one per update call - - if self.recommendations: - self.box_recommendations.set_visible(True) - for group in self.recommendations: - widget = MonophonyLockedGroupRow( - {'title': group, 'contents': self.recommendations[group]}, - self.player - ) - self.box_recommendations.add(widget) - - self.recommendations = {} - - new_downloads = monophony.backend.cache.read_songs() - if new_downloads != self.old_downloads: - self.box_downloads.set_visible(True) - - for widget in self.downloads_widgets: - self.box_downloads.remove(widget) - - self.downloads_widgets = [] - self.old_downloads = new_downloads - for song in new_downloads: - widget = MonophonySongRow(song, self.player) - self.box_downloads.add(widget) - self.downloads_widgets.append(widget) - else: - for widget in self.downloads_widgets: - if widget.spinner.get_visible(): - widget.update_download_status() - - self.box_downloads.set_visible(bool(new_downloads)) - - # player could be adding to recents at this moment - if self.player.is_busy(): - return True - - new_recents = monophony.backend.history.read_songs() - new_recents.reverse() - if new_recents != self.old_recents: - self.box_recents.set_visible(True) - - for widget in self.recents_widgets: - self.box_recents.remove(widget) - - self.recents_widgets = [] - self.old_recents = new_recents - for song in new_recents: - widget = MonophonySongRow(song, self.player) - self.box_recents.add(widget) - self.recents_widgets.append(widget) - - self.box_recents.set_visible(bool(new_recents)) - - return True diff --git a/source/monophony/frontend/tabs/queue_tab.py b/source/monophony/frontend/tabs/queue_tab.py deleted file mode 100644 index bb22b01bd7e6f9a18fe87e4a5731103ae3f37bda..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/tabs/queue_tab.py +++ /dev/null @@ -1,91 +0,0 @@ -from monophony.backend.utils import time_str_to_sec, sec_to_time_str -from monophony.frontend.rows.queue_song_row import MonophonyQueueSongRow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gtk - - -class MonophonyQueueTab(Gtk.Box): - def __init__(self, player: object): - super().__init__(orientation=Gtk.Orientation.VERTICAL) - - self.player = player - self.player.queue_change_callback = self.update - self.old_queue = [] - self.old_index = -1 - self.queue_widgets = [] - self.set_vexpand(True) - - self.pge_status = Adw.StatusPage() - self.pge_status.set_vexpand(True) - self.pge_status.set_valign(Gtk.Align.FILL) - self.pge_status.set_icon_name('view-list-symbolic') - self.pge_status.set_title(_('Queue Empty')) - self.pge_status.set_visible(True) - - btn_clear = Gtk.Button.new_from_icon_name('edit-clear-all-symbolic') - btn_clear.add_css_class('destructive-action') - btn_clear.set_tooltip_text(_('Clear')) - btn_clear.connect('clicked', lambda _b: player.clear_queue()) - - btn_shuffle = Gtk.Button.new_from_icon_name('media-playlist-shuffle-symbolic') - btn_shuffle.set_tooltip_text(_('Shuffle')) - btn_shuffle.connect('clicked', lambda _b: self.player.shuffle_queue()) - - box_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_actions.set_spacing(5) - box_actions.append(btn_clear) - box_actions.append(btn_shuffle) - - self.box_queue = Adw.PreferencesGroup() - self.box_queue.set_header_suffix(box_actions) - self.box_queue.set_title(_('Queue')) - - self.box_meta = Adw.PreferencesPage.new() - self.box_meta.set_visible(False) - self.box_meta.set_vexpand(True) - self.box_meta.set_valign(Gtk.Align.FILL) - self.box_meta.add(self.box_queue) - - self.append(self.box_meta) - self.append(self.pge_status) - - def update(self) -> bool: - new_queue = self.player.queue.copy() - new_index = self.player.index - - if new_queue != self.old_queue: - for widget in self.queue_widgets: - self.box_queue.remove(widget) - - self.box_meta.set_visible(bool(new_queue)) - self.pge_status.set_visible(not bool(new_queue)) - - self.queue_widgets = [] - self.old_queue = new_queue.copy() - self.old_index = new_index - total_seconds = 0 - for i, song in enumerate(new_queue): - widget = MonophonyQueueSongRow( - song, - self.player, - {'title': '', 'contents': new_queue} - ) - total_seconds += time_str_to_sec(song.get('length', '0')) - if i == new_index: - widget.add_css_class('current-queue-item') - self.box_queue.add(widget) - self.queue_widgets.append(widget) - - self.box_queue.set_description(sec_to_time_str(total_seconds)) - elif new_index != self.old_index: - self.old_index = new_index - for i, widget in enumerate(self.queue_widgets): - if i == new_index: - widget.add_css_class('current-queue-item') - else: - widget.remove_css_class('current-queue-item') - - return False diff --git a/source/monophony/frontend/tabs/search_tab.py b/source/monophony/frontend/tabs/search_tab.py deleted file mode 100644 index 4f614e7e5c2b16992684377e208b17ff0309a49b..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/tabs/search_tab.py +++ /dev/null @@ -1,109 +0,0 @@ -import monophony.backend.history - -from monophony.frontend.pages.artist_page import MonophonyArtistPage -from monophony.frontend.pages.results_page import MonophonyResultsPage -from monophony.frontend.widgets.recent_searches import MonophonyRecentSearches - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gtk - - -class MonophonySearchTab(Gtk.Box): - def __init__(self, player: object): - super().__init__(orientation=Gtk.Orientation.VERTICAL) - - self.ent_search = Gtk.SearchEntry() - self.ent_search.set_placeholder_text(_('Enter text or paste a URL...')) - self.ent_search.set_hexpand(True) - self.ent_search.set_halign(Gtk.Align.FILL) - self.ent_search.connect('activate', lambda e: self._on_search(e.get_text())) - - self.btn_back = Gtk.Button.new_from_icon_name('go-previous-symbolic') - self.btn_back.set_tooltip_text(_('Go back')) - self.btn_back.set_visible(False) - self.btn_back.connect('clicked', lambda _b: self._on_back_clicked()) - - box_search = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_search.set_spacing(5) - box_search.append(self.btn_back) - box_search.append(self.ent_search) - - self.box_recents = MonophonyRecentSearches(self._on_search) - - box_search_meta = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box_search_meta.set_spacing(8) - box_search_meta.append(box_search) - box_search_meta.append(self.box_recents) - - clm_search = Adw.Clamp() - clm_search.set_child(box_search_meta) - - search_bar = Gtk.SearchBar() - search_bar.add_css_class('inline') - search_bar.set_show_close_button(False) - search_bar.set_search_mode(True) - search_bar.set_child(clm_search) - search_bar.set_key_capture_widget(self.ent_search) - self.append(search_bar) - - self.pge_results = MonophonyResultsPage(player) - self.pge_results.set_vexpand(True) - self.pge_results.set_valign(Gtk.Align.FILL) - self.append(self.pge_results) - self.pge_detail_results = None - - self.set_vexpand(True) - self.player = player - - def _on_back_clicked(self): - self.btn_back.set_visible(False) - self.pge_results.set_visible(True) - if self.pge_detail_results: - self.remove(self.pge_detail_results) - self.pge_detail_results = None - - def show_artist(self, artist: str): - self.btn_back.set_visible(True) - self.pge_results.set_visible(False) - if self.pge_detail_results: - self.remove(self.pge_detail_results) - self.pge_detail_results = None - - self.pge_detail_results = MonophonyArtistPage(self.player, artist) - self.pge_detail_results.set_vexpand(True) - self.pge_detail_results.set_valign(Gtk.Align.FILL) - self.append(self.pge_detail_results) - - def show_more(self, query: str, filter_: str): - self.btn_back.set_visible(True) - self.pge_results.set_visible(False) - if self.pge_detail_results: - self.remove(self.pge_detail_results) - self.pge_detail_results = None - - self.pge_detail_results = MonophonyResultsPage(self.player, query, filter_) - self.pge_detail_results.set_vexpand(True) - self.pge_detail_results.set_valign(Gtk.Align.FILL) - self.append(self.pge_detail_results) - - def _on_search(self, text: str): - self.ent_search.set_text(text) - if not text: - return - - if monophony.backend.history.add_search(text): - self.box_recents.add_search(text) - self.btn_back.set_visible(False) - if self.pge_results: - self.remove(self.pge_results) - self.pge_results = None - if self.pge_detail_results: - self.remove(self.pge_detail_results) - self.pge_detail_results = None - - self.pge_results = MonophonyResultsPage(self.player, text) - self.pge_results.set_vexpand(True) - self.pge_results.set_valign(Gtk.Align.FILL) - self.append(self.pge_results) diff --git a/source/monophony/frontend/widgets/__init__.py b/source/monophony/frontend/widgets/__init__.py deleted file mode 100644 index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/widgets/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/source/monophony/frontend/widgets/player.py b/source/monophony/frontend/widgets/player.py deleted file mode 100644 index 4b36991af71f8ba83d29eabf9606804db9f9daf0..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/widgets/player.py +++ /dev/null @@ -1,335 +0,0 @@ -from typing import ClassVar - -import monophony.backend.player -import monophony.backend.settings - -import gi -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk, Pango - - -class MonophonyPlayer(Gtk.Box): - playback_icons: ClassVar = { - monophony.backend.player.PlaybackMode.NORMAL: - 'media-playlist-consecutive-symbolic', - monophony.backend.player.PlaybackMode.LOOP_SONG: - 'media-playlist-repeat-song-symbolic', - monophony.backend.player.PlaybackMode.LOOP_QUEUE: - 'media-playlist-repeat-symbolic', - monophony.backend.player.PlaybackMode.RADIO: - 'io.gitlab.zehkira.Monophony-symbolic', - } - - def __init__(self, window: Gtk.Window, player: object): - super().__init__(orientation=Gtk.Orientation.VERTICAL) - volume = float(monophony.backend.settings.get_value('volume', 1)) - - self.window = window - self.player = player - self.player.set_volume(volume, False) - self.player.ui_update_callback = self.update - self.inhibit_cookie = None - - box_info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - box_info.set_margin_start(11) - box_info.set_spacing(5) - box_info.set_margin_end(5) - box_info.set_halign(Gtk.Align.START) - box_info.set_valign(Gtk.Align.CENTER) - - self.lnk_title = Gtk.LinkButton.new_with_label('', '') - self.lnk_title.set_margin_bottom(2) - self.lnk_title.set_margin_top(4) - - self.lnk_title.set_halign(Gtk.Align.START) - self.lnk_title.get_child().set_ellipsize(Pango.EllipsizeMode.END) - self.lnk_title.add_css_class('title-link') - - self.lbl_author = Gtk.Label(label='') - self.lbl_author.set_margin_top(2) - self.lbl_author.set_halign(Gtk.Align.START) - self.lbl_author.add_css_class('caption') - self.lbl_author.add_css_class('dim-label') - self.lbl_author.set_ellipsize(Pango.EllipsizeMode.END) - - self.box_sng_info = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.box_sng_info.append(self.lnk_title) - self.box_sng_info.append(self.lbl_author) - box_info.append(self.box_sng_info) - - self.spn_loading = Adw.Spinner() - self.spn_loading.set_halign(Gtk.Align.CENTER) - self.spn_loading.set_margin_start(9) - self.spn_loading.set_margin_end(9) - self.spn_loading.set_visible(False) - self.btn_pause = Gtk.Button.new_from_icon_name('media-playback-start-symbolic') - self.btn_pause.set_valign(Gtk.Align.CENTER) - self.btn_pause.set_tooltip_text(_('Toggle pause')) - self.btn_pause.bind_property( - 'visible', - self.spn_loading, - 'visible', - GObject.BindingFlags.BIDIRECTIONAL | - GObject.BindingFlags.SYNC_CREATE | - GObject.BindingFlags.INVERT_BOOLEAN - ) - self.btn_pause.connect('clicked', self._on_pause_clicked) - btn_next = Gtk.Button.new_from_icon_name('media-skip-forward-symbolic') - btn_next.set_valign(Gtk.Align.CENTER) - btn_next.set_tooltip_text(_('Next song')) - btn_next.connect('clicked', self._on_next_clicked) - btn_next.set_has_frame(False) - btn_prev = Gtk.Button.new_from_icon_name('media-skip-backward-symbolic') - btn_prev.set_valign(Gtk.Align.CENTER) - btn_prev.set_tooltip_text(_('Previous song')) - btn_prev.connect('clicked', self._on_previous_clicked) - btn_prev.set_has_frame(False) - - self.btn_vol = Gtk.ScaleButton.new(0, 1, 0.02, [ - 'audio-volume-muted-symbolic', - 'audio-volume-high-symbolic', - 'audio-volume-low-symbolic', - 'audio-volume-medium-symbolic', - 'audio-volume-high-symbolic' - ]) - self.btn_vol.set_value(volume) - self.btn_vol.set_tooltip_text(_('Change volume')) - self.btn_vol.connect('value-changed', self._on_volume_changed) - - self.btn_mode = Gtk.MenuButton() - self.btn_mode.set_valign(Gtk.Align.CENTER) - self.btn_mode.set_icon_name(MonophonyPlayer.playback_icons[player.mode]) - self.btn_mode.set_tooltip_text(_('Playback mode')) - self.btn_mode.set_create_popup_func(self.build_menu_popup) - self.btn_mode.set_has_frame(False) - - box_controls = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_controls.set_spacing(2) - box_controls.set_valign(Gtk.Align.CENTER) - box_controls.set_halign(Gtk.Align.END) - box_controls.set_hexpand(True) - box_controls.append(self.btn_vol) - box_controls.append(btn_prev) - box_controls.append(self.btn_pause) - box_controls.append(self.spn_loading) - box_controls.append(btn_next) - box_controls.append(self.btn_mode) - - box_meta = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_meta.set_margin_top(10) - box_meta.set_margin_bottom(10) - box_meta.set_margin_start(5) - box_meta.set_margin_end(8) - box_meta.set_valign(Gtk.Align.END) - box_meta.set_halign(Gtk.Align.FILL) - box_meta.set_hexpand(True) - box_meta.append(box_info) - box_meta.append(box_controls) - - self.scl_progress = Gtk.Scale.new_with_range( - Gtk.Orientation.HORIZONTAL, 0, 1, 0.01 - ) - self.scl_progress.add_css_class('seekbar') - self.scl_progress.set_draw_value(False) - self.scl_progress.set_halign(Gtk.Align.FILL) - self.scl_progress.set_valign(Gtk.Align.END) - self.scl_progress.connect('change-value', self._on_seek_performed) - - self.set_hexpand(True) - self.append(self.scl_progress) - self.append(box_meta) - self.add_css_class('playerbar') - self.add_css_class('toolbar') - - css = Gtk.CssProvider.new() - css.load_from_data(''' - .seekbar { - margin-bottom: -6px; - padding: 0px; - min-height: 10px; - } - - .seekbar trough, .seekbar highlight { - border-radius: 0px; - border-left: none; - border-right: none; - min-height: 10px; - } - - .seekbar highlight { - border-left: none; - border-right: none; - } - - .title-link { - padding-top: 0px; - padding-bottom: 0px; - padding-left: 0px; - padding-right: 0px; - margin-bottom: -8px; - margin-top: -8px; - } - - .playerbar { - padding: 0px; - } - ''', -1) - Gtk.StyleContext.add_provider_for_display( - Gdk.Display.get_default(), - css, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION - ) - - GLib.timeout_add(1000, self.update_progress) - - def build_menu_popup(self, btn: Gtk.MenuButton): - mnu_more = Gio.Menu() - chk_normal = Gtk.CheckButton.new_with_label(_('Normal Playback')) - chk_normal.set_active( - self.player.mode == monophony.backend.player.PlaybackMode.NORMAL - ) - chk_normal.connect('toggled', self._on_normal_toggled) - itm_normal = Gio.MenuItem() - itm_normal.set_attribute_value('custom', GLib.Variant.new_string('normal')) - chk_autoplay = Gtk.CheckButton.new_with_label(_('Radio Mode')) - chk_autoplay.set_group(chk_normal) - chk_autoplay.set_active( - self.player.mode == monophony.backend.player.PlaybackMode.RADIO - ) - chk_autoplay.connect('toggled', self._on_radio_toggled) - itm_autoplay = Gio.MenuItem() - itm_autoplay.set_attribute_value( - 'custom', GLib.Variant.new_string('autoplay') - ) - chk_loop = Gtk.CheckButton.new_with_label(_('Repeat Song')) - chk_loop.set_group(chk_normal) - chk_loop.connect('toggled', self._on_loop_toggled) - chk_loop.set_active( - self.player.mode == monophony.backend.player.PlaybackMode.LOOP_SONG - ) - itm_loop = Gio.MenuItem() - itm_loop.set_attribute_value( - 'custom', GLib.Variant.new_string('loop') - ) - chk_loop_q = Gtk.CheckButton.new_with_label(_('Repeat Queue')) - chk_loop_q.set_group(chk_normal) - chk_loop_q.set_active( - self.player.mode == monophony.backend.player.PlaybackMode.LOOP_QUEUE - ) - chk_loop_q.connect('toggled', self._on_loop_q_toggled) - itm_loop_q = Gio.MenuItem() - itm_loop_q.set_attribute_value( - 'custom', GLib.Variant.new_string('loop_q') - ) - mnu_more.append_item(itm_normal) - mnu_more.append_item(itm_loop) - mnu_more.append_item(itm_loop_q) - mnu_more.append_item(itm_autoplay) - pop_menu = Gtk.PopoverMenu() - pop_menu.set_menu_model(mnu_more) - pop_menu.add_child(chk_normal, 'normal') - pop_menu.add_child(chk_loop, 'loop') - pop_menu.add_child(chk_loop_q, 'loop_q') - pop_menu.add_child(chk_autoplay, 'autoplay') - btn.set_popover(pop_menu) - - def _on_volume_changed(self, _b, volume): - self.player.set_volume(volume, False) - - def _on_seek_performed(self, _s, _t, target: float): - GLib.Thread.new(None, self.player.seek, target) - - def _on_pause_clicked(self, _b): - self.player.toggle_pause() - - def _on_next_clicked(self, _b): - GLib.Thread.new(None, self.player.next_song, True) - - def _on_previous_clicked(self, _b): - GLib.Thread.new(None, self.player.previous_song) - - def _on_loop_q_toggled(self, btn: Gtk.CheckButton): - if btn.get_active(): - mode = monophony.backend.player.PlaybackMode.LOOP_QUEUE - self.player.mode = mode - monophony.backend.settings.set_value('mode', mode) - self.btn_mode.set_icon_name( - MonophonyPlayer.playback_icons[self.player.mode] - ) - - def _on_loop_toggled(self, btn: Gtk.CheckButton): - if btn.get_active(): - mode = monophony.backend.player.PlaybackMode.LOOP_SONG - self.player.mode = mode - monophony.backend.settings.set_value('mode', mode) - self.btn_mode.set_icon_name( - MonophonyPlayer.playback_icons[self.player.mode] - ) - - def _on_normal_toggled(self, btn: Gtk.CheckButton): - if btn.get_active(): - mode = monophony.backend.player.PlaybackMode.NORMAL - self.player.mode = mode - monophony.backend.settings.set_value('mode', mode) - self.btn_mode.set_icon_name( - MonophonyPlayer.playback_icons[self.player.mode] - ) - - def _on_radio_toggled(self, btn: Gtk.CheckButton): - if btn.get_active(): - mode = monophony.backend.player.PlaybackMode.RADIO - self.player.mode = mode - monophony.backend.settings.set_value('mode', mode) - self.btn_mode.set_icon_name( - MonophonyPlayer.playback_icons[self.player.mode] - ) - - def _on_show_artist_clicked(self): - song = self.player.get_current_song() - if song: - self.window._on_show_artist(song['author_id']) - - def update_progress(self) -> bool: - if self.player.buffering or not self.lnk_title.get_label(): - return True - - progress = self.player.get_progress() - if progress: - self.scl_progress.set_value(progress) - - return True - - def update(self, song: dict, busy: bool, paused: bool, starting: bool) -> bool: - if starting: - self.scl_progress.set_value(0) - - if song: - self.lnk_title.set_label(song['title']) - self.lnk_title.get_child().set_ellipsize(Pango.EllipsizeMode.END) - self.lnk_title.set_uri( - 'https://music.youtube.com/watch?v=' + song['id'] - ) - self.lbl_author.set_label(song['author']) - self.window.toolbar_view.set_reveal_bottom_bars(True) - if self.inhibit_cookie is None and not paused: - self.inhibit_cookie = self.window.get_application().inhibit( - self.window, Gtk.ApplicationInhibitFlags.SUSPEND, None - ) - else: - self.lnk_title.set_label('') - self.lnk_title.set_uri('') - self.lbl_author.set_label('') - self.window.toolbar_view.set_reveal_bottom_bars(False) - - if (paused or not song) and self.inhibit_cookie is not None: - self.window.get_application().uninhibit(self.inhibit_cookie) - self.inhibit_cookie = None - - self.scl_progress.set_sensitive(not busy) - self.btn_pause.set_visible(not busy) - self.btn_pause.set_icon_name( - 'media-playback-start-symbolic' if paused else - 'media-playback-pause-symbolic' - ) - - return False diff --git a/source/monophony/frontend/widgets/recent_searches.py b/source/monophony/frontend/widgets/recent_searches.py deleted file mode 100644 index 124c80f576b8d356d766afbbe55adf1cc9c396c6..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/widgets/recent_searches.py +++ /dev/null @@ -1,63 +0,0 @@ -import monophony.backend.history - -import gi -gi.require_version('Gtk', '4.0') -from gi.repository import Gtk, Pango - - -class MonophonyRecentSearches(Gtk.Box): - def __init__(self, search_callback: callable): - super().__init__(orientation=Gtk.Orientation.HORIZONTAL) - - self.children = [] - self.set_visible(False) - self.set_spacing(8) - self.set_hexpand(True) - self.set_halign(Gtk.Align.FILL) - self.search_callback = search_callback - - searches = monophony.backend.history.read_searches() - searches.reverse() - for query in searches: - self.add_search(query) - - def add_search(self, query: str): - self.set_visible(True) - lbl_query = Gtk.Label.new(query) - lbl_query.set_ellipsize(Pango.EllipsizeMode.END) - btn_search = Gtk.Button.new() - btn_search.set_child(lbl_query) - btn_search.set_hexpand(True) - btn_search.set_halign(Gtk.Align.FILL) - btn_search.connect( - 'clicked', lambda b: self._on_search(b.get_child().get_label()) - ) - btn_remove = Gtk.Button.new_from_icon_name('edit-delete-symbolic') - btn_remove.set_tooltip_text(_('Remove')) - btn_remove.connect( - 'clicked', - lambda b, q: self._on_remove_search(b, q), - query - ) - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box.set_hexpand(True) - box.set_halign(Gtk.Align.FILL) - box.append(btn_search) - box.append(btn_remove) - box.add_css_class('linked') - - if len(self.children) > 2: - self.remove(self.children[0]) - self.children.pop(0) - self.children.append(box) - self.prepend(box) - - def _on_search(self, query: str): - self.search_callback(query) - - def _on_remove_search(self, btn: Gtk.Button, query: str): - monophony.backend.history.remove_search(query) - self.children.remove(btn.get_parent()) - self.remove(btn.get_parent()) - if not monophony.backend.history.read_searches(): - self.set_visible(False) diff --git a/source/monophony/frontend/windows/__init__.py b/source/monophony/frontend/windows/__init__.py deleted file mode 100644 index 8b137891791fe96927ad78e64b0aad7bded08bdc..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/windows/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/source/monophony/frontend/windows/add_window.py b/source/monophony/frontend/windows/add_window.py deleted file mode 100644 index 526563fa87601ee22a4ffa1dc3561e77898f1174..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/windows/add_window.py +++ /dev/null @@ -1,143 +0,0 @@ -import monophony.backend.playlists - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib, Gtk - - -class MonophonyAddWindow(Adw.Window): - def __init__(self, song: dict, player, callback): - super().__init__() - - self.song = song - self.player = player - self.callback = callback - self.add_to_queue = False - self.add_to_playlists = [] - self.playlists = [] - - self.set_title(_('Add to...')) - self.set_modal(True) - - btn_cancel = Gtk.Button.new_with_label(_('Cancel')) - btn_cancel.connect('clicked', lambda _b: self.destroy()) - btn_add = Gtk.Button.new_with_label(_('Add')) - btn_add.add_css_class('suggested-action') - btn_add.connect('clicked', self._on_submit) - headerbar = Adw.HeaderBar.new() - headerbar.set_decoration_layout('') - headerbar.pack_start(btn_cancel) - headerbar.pack_end(btn_add) - - box_queue = Adw.PreferencesGroup() - self.chk_queue = Gtk.CheckButton() - self.row_queue = Adw.ActionRow() - self.row_queue.set_title(_('Queue')) - self.row_queue.add_suffix(self.chk_queue) - self.row_queue.set_property('activatable-widget', self.chk_queue) - self.chk_queue.connect('toggled', self._on_add_to_queue_toggled) - box_queue.add(self.row_queue) - - self.box_list = Adw.PreferencesGroup() - self.box_list.set_title(_('Your Playlists')) - - ent_name = Gtk.Entry.new() - ent_name.connect('activate', self._on_create) - ent_name.set_hexpand(True) - ent_name.set_halign(Gtk.Align.FILL) - ent_name.set_placeholder_text(_('New Playlist Name...')) - - btn_create = Gtk.Button.new_with_label(_('Create')) - btn_create.connect('clicked', lambda _b: self._on_create(ent_name)) - - box_create = Gtk.Box() - box_create.set_spacing(6) - box_create.set_margin_start(6) - box_create.set_margin_end(6) - box_create.set_margin_top(6) - box_create.set_margin_bottom(6) - box_create.set_hexpand(True) - box_create.append(ent_name) - box_create.append(btn_create) - - clm_create = Adw.Clamp() - clm_create.set_hexpand(True) - clm_create.set_child(box_create) - - pge_list = Adw.PreferencesPage() - pge_list.set_vexpand(True) - pge_list.add(box_queue) - pge_list.add(self.box_list) - - toolbar_view = Adw.ToolbarView() - toolbar_view.add_top_bar(headerbar) - toolbar_view.set_content(pge_list) - toolbar_view.add_bottom_bar(clm_create) - - self.add_shortcut(Gtk.Shortcut.new( - Gtk.ShortcutTrigger.parse_string('Escape'), - Gtk.CallbackAction.new((lambda w, _: w.close())) - )) - self.set_content(toolbar_view) - self.update_groups() - - def update_groups(self): - for playlist in self.playlists: - self.box_list.remove(playlist) - - self.playlists.clear() - - for queue_song in self.player.queue.copy(): - if queue_song['id'] == self.song['id']: - self.add_to_queue = False - self.chk_queue.set_active(True) - self.chk_queue.set_sensitive(False) - self.row_queue.set_sensitive(False) - break - - for playlist, contents in monophony.backend.playlists.read_playlists().items(): - chk_list = Gtk.CheckButton.new() - row_list = Adw.ActionRow() - row_list.set_title(playlist) - row_list.add_suffix(chk_list) - row_list.set_property('activatable-widget', chk_list) - self.box_list.add(row_list) - self.playlists.append(row_list) - - for check_song in contents: - if check_song['id'] == self.song['id']: - chk_list.set_active(True) - chk_list.set_sensitive(False) - row_list.set_sensitive(False) - break - - chk_list.connect('toggled', self._on_add_to_playlist_toggled) - - def _on_add_to_queue_toggled(self, btn: Gtk.CheckButton): - self.add_to_queue = btn.get_active() - - def _on_add_to_playlist_toggled(self, chk: Gtk.CheckButton): - toggled_list = chk.get_ancestor(Gtk.ListBoxRow).get_title() - if chk.get_active(): - self.add_to_playlists.append(toggled_list) - else: - self.add_to_playlists.remove(toggled_list) - - def _on_submit(self, _btn: Gtk.CheckButton): - for playlist in self.add_to_playlists: - monophony.backend.playlists.add_song(self.song, playlist) - - if self.add_to_queue: - GLib.Thread.new(None, self.player.queue_song, self.song) - - self.destroy() - self.callback() - - def _on_create(self, ent: Gtk.Entry): - text = ent.get_text() - ent.set_text('') - - if text.strip(): - monophony.backend.playlists.add_playlist(text) - self.update_groups() diff --git a/source/monophony/frontend/windows/import_window.py b/source/monophony/frontend/windows/import_window.py deleted file mode 100644 index 8600bf8148112eacdf2fea47815adf9d717b1adb..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/windows/import_window.py +++ /dev/null @@ -1,132 +0,0 @@ -import monophony.backend.playlists -from monophony.frontend.windows.message_window import MonophonyMessageWindow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib, Gtk - - -class MonophonyImportWindow(Adw.Window): - def __init__(self, url: str='', group: list | None = None): - super().__init__() - - self.import_lock = GLib.Mutex() - self.error = False - self.group = group - - self.ent_name = Gtk.Entry.new() - self.ent_name.set_text(group['title'] if group else '') - self.ent_name.set_placeholder_text(_('Enter Playlist Name...')) - self.ent_name.set_hexpand(True) - self.ent_name.set_margin_start(10) - self.ent_name.set_margin_end(10) - self.ent_name.set_halign(Gtk.Align.FILL) - self.ent_url = Gtk.Entry.new() - self.ent_url.set_text(url) - self.ent_url.set_placeholder_text(_('Enter Playlist URL...')) - self.ent_url.set_hexpand(True) - self.ent_url.set_halign(Gtk.Align.FILL) - self.ent_url.set_margin_start(10) - self.ent_url.set_margin_end(10) - chk_sync = Gtk.CheckButton.new_with_label(_('Synchronized')) - self.chk_local = Gtk.CheckButton.new_with_label(_('Editable')) - self.chk_local.set_group(chk_sync) - self.chk_local.set_active(True) - box_type = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) - box_type.set_spacing(10) - box_type.set_margin_start(10) - box_type.set_margin_end(10) - box_type.append(self.chk_local) - box_type.append(chk_sync) - - self.spn_import = Adw.Spinner() - self.spn_import.set_margin_end(5) - self.spn_import.set_visible(False) - btn_cancel = Gtk.Button.new_with_label(_('Cancel')) - btn_cancel.connect('clicked', lambda _b: self.destroy()) - self.btn_import = Gtk.Button.new_with_label(_('Import')) - self.btn_import.add_css_class('suggested-action') - self.btn_import.connect('clicked', lambda _b: self._on_submit()) - headerbar = Adw.HeaderBar.new() - headerbar.set_decoration_layout('') - headerbar.pack_start(btn_cancel) - headerbar.pack_end(self.btn_import) - headerbar.pack_end(self.spn_import) - - self.box_content = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) - self.box_content.set_spacing(10) - self.box_content.set_margin_bottom(10) - self.box_content.set_margin_top(10) - self.box_content.append(self.ent_name) - if not self.group: - self.box_content.append(self.ent_url) - self.box_content.append(box_type) - - toolbar_view = Adw.ToolbarView() - toolbar_view.add_top_bar(headerbar) - toolbar_view.set_content(self.box_content) - - self.set_title(_('Import Playlist...')) - self.set_modal(True) - self.set_resizable(False) - self.props.height_request = 100 - self.set_content(toolbar_view) - self.connect('close-request', lambda w: not w.box_content.get_sensitive()) - self.add_shortcut(Gtk.Shortcut.new( - Gtk.ShortcutTrigger.parse_string('Escape'), - Gtk.CallbackAction.new((lambda w, _: w.close())) - )) - - def do_import(self, name: str, url: str, local: bool): - self.import_lock.lock() - if self.group and local: - monophony.backend.playlists.add_playlist(name, self.group['contents']) - elif not monophony.backend.playlists.import_playlist(name, url, local): - self.error = True - self.import_lock.unlock() - - def await_import(self) -> bool: - if self.import_lock.trylock(): - self.import_lock.unlock() - if self.error: - self.error = False - self.box_content.set_sensitive(True) - self.spn_import.set_visible(False) - self.btn_import.set_visible(True) - MonophonyMessageWindow( - self, - _('Could not import playlist'), - _('Failed to retrieve playlist data from server.') - ).present() - else: - self.destroy() - - return False - return True - - def _on_submit(self): - name = self.ent_name.get_text() - url = ( - 'https://www.youtube.com/playlist?list=' + self.group['id'] - ) if self.group else self.ent_url.get_text() - local = self.chk_local.get_active() - - if not name: - MonophonyMessageWindow( - self, _('Could not import playlist'), _('A name is required.') - ).present() - return - - if not url and not self.group: - MonophonyMessageWindow( - self, _('Could not import playlist'), _('A URL is required.') - ).present() - return - - self.box_content.set_sensitive(False) - self.spn_import.set_visible(True) - self.btn_import.set_visible(False) - - GLib.Thread.new(None, self.do_import, name, url, local) - GLib.timeout_add(1000, self.await_import) diff --git a/source/monophony/frontend/windows/main_window.py b/source/monophony/frontend/windows/main_window.py deleted file mode 100644 index f0c4a4e0db8965395948d2fa7227fb6b32a8e776..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/windows/main_window.py +++ /dev/null @@ -1,277 +0,0 @@ -import monophony.backend.cache -import monophony.backend.mpris -import monophony.backend.player -import monophony.backend.playlists -import monophony.backend.settings -from monophony import __version__, APP_ID -from monophony.frontend.tabs.library_tab import MonophonyLibraryTab -from monophony.frontend.tabs.queue_tab import MonophonyQueueTab -from monophony.frontend.tabs.search_tab import MonophonySearchTab -from monophony.frontend.widgets.player import MonophonyPlayer -from monophony.frontend.windows.add_window import MonophonyAddWindow -from monophony.frontend.windows.import_window import MonophonyImportWindow - -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, GLib, Gtk - - -class MonophonyMainWindow(Adw.ApplicationWindow): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.set_default_size( - int(monophony.backend.settings.get_value('window-width', 600)), - int(monophony.backend.settings.get_value('window-height', 500)) - ) - self.set_title('Monophony') - self.set_icon_name(APP_ID) - self.player = monophony.backend.player.Player() - self.player.queue_end_callback = self._on_queue_end - self.player.raise_callback = self.present - self.deleted_playlists = [] - GLib.Thread.new(None, monophony.backend.mpris.init, self.player) - - self.stack = Adw.ViewStack() - self.library_tab = MonophonyLibraryTab(self.player) - self.stack.add_titled_with_icon( - self.library_tab, 'library', _('Library'), 'audio-x-generic-symbolic' - ) - self.stack.add_titled_with_icon( - MonophonySearchTab(self.player), - 'search', - _('Search'), - 'system-search-symbolic' - ) - self.stack.add_titled_with_icon( - MonophonyQueueTab(self.player), - 'queue', - _('Queue'), - 'view-list-symbolic' - ) - self.stack.set_visible_child_name('library') - - self.toaster = Adw.ToastOverlay.new() - self.toaster.set_child(self.stack) - - btn_about = Gtk.Button.new_from_icon_name('help-about-symbolic') - btn_about.set_tooltip_text(_('About')) - btn_about.connect('clicked', lambda _b: self._on_about_clicked()) - - switcher = Adw.ViewSwitcher() - switcher.set_stack(self.stack) - - header_bar = Adw.HeaderBar() - header_bar.set_title_widget(switcher) - header_bar.pack_start(btn_about) - - self.toolbar_view = Adw.ToolbarView() - self.toolbar_view.add_top_bar(header_bar) - self.toolbar_view.add_bottom_bar(MonophonyPlayer(self, self.player)) - self.toolbar_view.set_content(self.toaster) - self.toolbar_view.set_reveal_bottom_bars(False) - self.set_content(self.toolbar_view) - - self.install_action( - 'quit-app', None, (lambda w, *_: w._on_quit()) - ) - self.install_action( - 'focus-library', - None, - (lambda w, *_: w.stack.set_visible_child_name('library')) - ) - self.install_action( - 'focus-search', - None, - (lambda w, *_: w._on_search()) - ) - self.install_action( - 'focus-queue', - None, - (lambda w, *_: w.stack.set_visible_child_name('queue')) - ) - self.install_action( - 'playlist-delete-undo', None, (lambda w, *_: w._on_undo_delete()) - ) - self.get_application().set_accels_for_action( - 'quit-app', ['w', 'q'] - ) - self.get_application().set_accels_for_action('focus-library', ['1']) - self.get_application().set_accels_for_action( - 'focus-search', ['f', '2'] - ) - self.get_application().set_accels_for_action('focus-queue', ['3']) - self.connect('close-request', MonophonyMainWindow.run_background) - - def append_page(self, widget: Gtk.Widget): - while child := self.stack.get_adjacent_child(Adw.NavigationDirection.FORWARD): - self.stack.remove(child) - - self.stack.append(widget) - self.stack.navigate(Adw.NavigationDirection.FORWARD) - - def run_background(self) -> bool: - if self.player.get_current_song(): - self.set_visible(False) - return True - - self._on_quit() - return False - - def _on_quit(self): - self.player.terminate() - size = self.get_default_size() - monophony.backend.settings.set_value('window-width', size.width) - monophony.backend.settings.set_value('window-height', size.height) - self.get_application().quit() - - def _on_search(self): - self.stack.set_visible_child_name('search') - self.stack.get_visible_child().ent_search.grab_focus() - - def _on_show_more(self, query: str, filter_: str): - self.stack.set_visible_child_name('search') - self.stack.get_visible_child().show_more(query, filter_) - - def _on_show_artist(self, artist: str): - self.stack.set_visible_child_name('search') - self.stack.get_visible_child().show_artist(artist) - - def _on_about_clicked(self): - win_about = Adw.AboutWindow.new() - win_about.set_application_icon(APP_ID) - win_about.set_application_name('Monophony') - win_about.set_version(__version__) - win_about.set_copyright('Copyright © Zehkira and contributors') - win_about.set_license_type(Gtk.License.GPL_2_0) - win_about.add_legal_section( - 'ytmusicapi', 'Copyright © 2024 sigma67', Gtk.License.MIT_X11 - ) - win_about.add_legal_section( - 'mpris_server', 'Copyright © Alex DeLorenzo', Gtk.License.LGPL_3_0 - ) - win_about.add_legal_section( - 'StrEnum', 'Copyright © 2019 James C Sinclair', Gtk.License.MIT_X11 - ) - win_about.add_legal_section( - 'Unidecode', 'Copyright © 2024, Tomaž Šolc', Gtk.License.GPL_2_0 - ) - win_about.add_legal_section( - 'emoji', - 'Copyright © 2014-2024, Taehoon Kim, Kevin Wurster', - Gtk.License.BSD_3 - ) - win_about.add_legal_section('pycairo', '', Gtk.License.LGPL_2_1_ONLY) - win_about.add_legal_section( - 'pydbus', - 'Copyright © 2014, 2015, 2016 Linus Lewandowski', - Gtk.License.LGPL_2_1 - ) - win_about.add_legal_section('certifi', '', Gtk.License.MPL_2_0) - win_about.add_legal_section( - 'charset_normalizer', - 'Copyright © 2025 TAHRI Ahmed R.', - Gtk.License.MIT_X11 - ) - win_about.add_legal_section( - 'idna', - 'Copyright © 2013-2024, Kim Davies and contributors', - Gtk.License.BSD_3 - ) - win_about.add_legal_section( - 'requests', - 'Copyright © 2019 Kenneth Reitz', - Gtk.License.APACHE_2_0 - ) - win_about.add_legal_section( - 'urllib3', - 'Copyright © 2008-2020 Andrey Petrov and contributors', - Gtk.License.MIT_X11 - ) - win_about.add_legal_section('PyGObject', '', Gtk.License.LGPL_2_1) - win_about.set_translator_credits(_('translator-credits')) - win_about.set_issue_url('https://gitlab.com/zehkira/monophony/-/issues') - win_about.set_website('https://gitlab.com/zehkira/monophony') - win_about.set_transient_for(self) - win_about.present() - - def _on_import_clicked(self, url: str='', group: list | None = None): - popup = MonophonyImportWindow(url=url, group=group) - popup.set_transient_for(self) - popup.present() - - def _on_add_clicked(self, song: dict): - popup = MonophonyAddWindow( - song, self.player, self.library_tab.update_playlists - ) - popup.set_transient_for(self) - popup.present() - - def _on_remove_song(self, song: str, playlist: str): - monophony.backend.playlists.remove_song(song, playlist) - self.library_tab.update_playlists() - - def _on_move_song(self, song: dict, group: dict, direction: int): - index = group['contents'].index(song) - monophony.backend.playlists.swap_songs( - group['title'], index, index + direction - ) - self.library_tab.update_playlists() - - def _on_uncache_song(self, song: dict): - monophony.backend.cache.uncache_song(song) - - def _on_cache_song(self, song: dict): - GLib.Thread.new( - None, monophony.backend.cache.cache_songs, [song] - ) - - def _on_cache_playlist(self, songs: list): - GLib.Thread.new(None, monophony.backend.cache.cache_songs, songs) - - def _on_delete_playlist(self, widget: object, local: bool=True): - group = widget.group.copy() - group['local'] = local - - tst_undo = Adw.Toast.new( - _('Deleted playlist "{playlist_name}"').format( - playlist_name=group['title'] - ) - ) - tst_undo.set_priority(Adw.ToastPriority.HIGH) - tst_undo.set_button_label(_('Undo')) - tst_undo.set_action_name('playlist-delete-undo') - tst_undo.connect('dismissed', self._on_toast_dismissed) - self.toaster.add_toast(tst_undo) - self.deleted_playlists.append(group) - - if local: - monophony.backend.playlists.remove_playlist(group['title']) - else: - monophony.backend.playlists.remove_external_playlist(group['title']) - - def _on_duplicate_playlist(self, widget: object): - monophony.backend.playlists.add_playlist( - widget.group['title'], widget.group['contents'] - ) - - def _on_save_playlist(self, name: str, contents: list): - monophony.backend.playlists.add_playlist(name, contents) - self.toaster.add_toast(Adw.Toast.new(_('Added'))) - - def _on_queue_end(self): - if not self.is_visible(): - self._on_quit() - - def _on_toast_dismissed(self, _t: Adw.Toast): - self.deleted_playlists.pop() - - def _on_undo_delete(self): - playlist = self.deleted_playlists[-1] - if playlist['local']: - monophony.backend.playlists.add_playlist( - playlist['title'], playlist['contents'] - ) - return - - monophony.backend.playlists.add_external_playlist(playlist) diff --git a/source/monophony/frontend/windows/message_window.py b/source/monophony/frontend/windows/message_window.py deleted file mode 100644 index 5f51cd349dc19244e4ce46ae3758131db78b8522..0000000000000000000000000000000000000000 --- a/source/monophony/frontend/windows/message_window.py +++ /dev/null @@ -1,23 +0,0 @@ -import gi -gi.require_version('Adw', '1') -gi.require_version('Gtk', '4.0') -from gi.repository import Adw, Gtk - - -class MonophonyMessageWindow(Adw.MessageDialog): - def __init__(self, parent: Adw.Window, title: str, text: str): - super().__init__() - - self.set_heading(title) - self.set_body(text) - self.add_response('ok', _('Ok')) - self.set_transient_for(parent) - self.set_modal(True) - self.connect('response', self._on_response) - self.add_shortcut(Gtk.Shortcut.new( - Gtk.ShortcutTrigger.parse_string('Escape'), - Gtk.CallbackAction.new((lambda w, _: w.close())) - )) - - def _on_response(self, _w, _response: str): - self.destroy() diff --git a/source/monophony/logging.py b/source/monophony/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..925da2745f63e26120834ead21e2c42c12068588 --- /dev/null +++ b/source/monophony/logging.py @@ -0,0 +1,133 @@ +import os +import platform +import sys +import threading +import time +import traceback +from typing import Any + +from monophony import NAME, __version__ + +from gi.repository import GLib + + +_LOG_LEVELS_VARIABLE = 'LOG_LEVELS' +_DEFAULT_LOG_LEVELS = 'INFO,WARN,ERRO' + + +class LogLevel: + name = '' + color = '' + + +class InfoLevel(LogLevel): + name = 'INFO' + color = '\033[0m' + + +class WarningLevel(LogLevel): + name = 'WARN' + color = '\033[1;33m' + + +class ErrorLevel(LogLevel): + name = 'ERRO' + color = '\033[1;31m' + + +def get_log() -> str: + if threading.current_thread() is not threading.main_thread(): + warning(__name__, 'Reading log from non-main thread') + + try: + with open(_get_directory() + '/log') as log_file: + return log_file.read() + except OSError: + error(__name__, 'Failed to read log file', traceback.format_exc()) + + return '' + + +# For status updates +def info(source: str, text: Any, details: Any=''): + _log(InfoLevel, source, str(text), str(details)) + + +# For unusual but possibly acceptable things +def warning(source: str, text: Any, details: Any=''): + _log(WarningLevel, source, str(text), str(details)) + + +# For failures of all sorts +def error(source: str, text: Any, details: Any=''): + _log(ErrorLevel, source, str(text), str(details)) + + +def _log(level: type, source: str, text: str, details: str): + if threading.current_thread() is not threading.main_thread(): + GLib.idle_add(_log, level, source, text, details) + return + + if level.name not in os.getenv( + _LOG_LEVELS_VARIABLE, _DEFAULT_LOG_LEVELS + ).split(','): + return + + text = text.strip() + details = details.strip() + line = f'{time.strftime("%H:%M")} [{level.name}] {source}: {text}' + sys.stdout.write(f'{level.color}{line}\n') + if details: + sys.stdout.write(details + '\n') + sys.stdout.write('\033[0m') + + log_directory = _get_directory() + os.makedirs(log_directory, exist_ok=True) + try: + with open(f'{log_directory}/log', 'a+') as log: + log.write(line + '\n') + if details: + log.write(details + '\n') + except (OSError, ValueError): + sys.stdout.write( + f'{ErrorLevel.color}[{ErrorLevel.name}] {__name__}: ' + 'Failed to write to log file\033[0m\n' + f'{traceback.format_exc()}\n' + ) + + +def _get_directory() -> str: + return os.getenv('XDG_RUNTIME_DIR', '/var/tmp') + '/' + NAME + + +log_directory = _get_directory() +os.makedirs(log_directory, exist_ok=True) +open(f'{log_directory}/log', 'a+').close() + +max_log_lines = 1000 +with open(f'{log_directory}/log', 'r+') as log_file: + lines = log_file.readlines() + if len(lines) > max_log_lines: + lines = lines[-max_log_lines:] + log_file.seek(0) + log_file.truncate() + log_file.writelines(lines) + +info(__name__, f'Logging initiated. Logs will be written to {log_directory}/log') + +os_release = {} +try: + os_release = platform.freedesktop_os_release() +except OSError: + warning(__name__, 'Could not read OS release file') + +os_info_string = '' +for key in ('PRETTY_NAME', 'NAME', 'ID', 'ID_LIKE', 'VERSION', 'VERSION_ID'): + if value := os_release.get(key): + os_info_string += f'{key}: {value}\n' + +info( + __name__, + f'{NAME} {__version__} on {platform.platform(aliased=True)}', + os_info_string.strip('\n') +) diff --git a/source/monophony/mpris.py b/source/monophony/mpris.py new file mode 100644 index 0000000000000000000000000000000000000000..a92ad8450462710daf76faf027d3a25adc1d4ce3 --- /dev/null +++ b/source/monophony/mpris.py @@ -0,0 +1,112 @@ +from monophony import ID, logging +from monophony.data import PlaybackMode, PlaybackState + +from mprisify.adapters import MprisAdapter +from mprisify.adapters import PlayState as MprisPlayState +from mprisify.events import PlayerEventAdapter as MprisPlayerEventAdapter +from mprisify.server import Server as MprisServer + + +class EventHandler(MprisAdapter): + def __init__(self, player: object): + super().__init__() + self._player = player + + def get_desktop_entry(self) -> str: + return ID + + def can_quit(self) -> bool: + return False + + def get_current_position(self) -> float: + return self._player.get_position_ns() / 1000 + + def next(self): + self._player.next(from_user=True) + + def previous(self): + self._player.previous() + + def pause(self): + self._player.set_pause(True) + + def resume(self): + self._player.set_pause(False) + + def stop(self): + self._player.stop() + + def get_playstate(self) -> MprisPlayState: + playstate = MprisPlayState.PLAYING + if self._player.paused: + playstate = MprisPlayState.PAUSED + elif self._player.state == PlaybackState.NONE: + playstate = MprisPlayState.STOPPED + + logging.info(__name__, f'Reported playstate as "{playstate}"') + return playstate + + def is_repeating(self) -> bool: + return self._player.mode == PlaybackMode.LOOP_SONG + + def get_shuffle(self) -> bool: + return False + + def get_volume(self): + return self._player.get_volume() + + def set_volume(self, volume: float): + self._player.set_volume(volume, notify_mpris=False) + + def is_mute(self) -> bool: + return False + + def can_go_next(self) -> bool: + return True + + def can_go_previous(self) -> bool: + return True + + def can_play(self) -> bool: + return bool(self._player.get_current_song()) + + def can_pause(self) -> bool: + return bool(self._player.get_current_song()) + + def can_seek(self) -> bool: + return False + + def can_control(self) -> bool: + return True + + def can_raise(self) -> bool: + return True + + def set_raise(self, value: bool): + if value: + self._player.emit('raise') + + def metadata(self) -> dict: + metadata = {'mpris:trackid': '/org/mpris/MediaPlayer2/TrackList/NoTrack'} + if song := self._player.get_current_song(): + duration_ns = self._player.get_duration_ns() + metadata = { + 'mpris:trackid': '/track/1', + 'mpris:artUrl': song.thumbnail, + 'mpris:length': duration_ns / 1000, + 'xesam:title': song.title, + 'xesam:artist': [song.author.name] + } + + logging.info(__name__, 'Reported metadata for current song', metadata) + return metadata + + +class Server(MprisServer): + def __init__(self, id_: str, event_handler: EventHandler): + super().__init__(id_, adapter=event_handler) + + +class EventSender(MprisPlayerEventAdapter): + def __init__(self, server: Server): + super().__init__(root=server.root, player=server.player) diff --git a/source/monophony/player.py b/source/monophony/player.py new file mode 100644 index 0000000000000000000000000000000000000000..f6e953fe29ff39376a25dc8820fda6499d975ffa --- /dev/null +++ b/source/monophony/player.py @@ -0,0 +1,503 @@ +import copy +import random +import time + +from monophony import DISPLAY_NAME, ID, downloads, logging, recents, settings, yt +from monophony.asynchronous import Task +from monophony.data import Group, PlaybackMode, PlaybackState, Song +from monophony.mpris import EventHandler, EventSender, Server + +from gi.repository import GObject, Gst + + +class ReportProgressTask(Task): + def _function(self): + logging.info(__name__, 'Progress reporting started') + while not self.is_canceled(): + self._update_progress() + time.sleep(1) + + logging.info(__name__, 'Progress reporting stopped') + + +class FindRadioSongsTask(Task): + def _function(self, from_song: Song, ignore_songs: Group) -> Group | None: + return yt.get_similar_songs(from_song, ignore_songs) + + +class FindURITask(Task): + def _function(self, song: Song, known_uris: dict) -> str | None: + logging.info( + __name__, f'Looking for "{song.yt_id}" song URI locally and online...' + ) + if uri := known_uris.get(song.yt_id): + logging.info(__name__, 'Found already known song URI') + return uri + + if downloads.is_downloaded(song): + song_path = downloads.get_file(song) + if song_path: + logging.info(__name__, 'Found local song URI') + return 'file://' + song_path + + if self.is_canceled(): + logging.info(__name__, 'Canceled URI lookup') + return None + + if uri := yt.get_song_uri(song): + logging.info(__name__, 'Found online song URI') + return uri + + logging.error(__name__, 'Failed to find song URI') + return None + + +class Player(GObject.Object): + def __init__(self): + super().__init__() + + Gst.init([]) + + self._find_uri_task = Task() + self._progress_task = Task() + self._radio_task = Task() + self._last_known_position = 0 + self._start_position = 0 + self._song_uris = {} + self._queue = Group() + self._queue_index = 0 + self.mode = PlaybackMode.NORMAL + self.state = PlaybackState.NONE + self.paused = False + self.buffering = False + + pulse_sink = Gst.ElementFactory.make('pulsesink', None) + pulse_sink.props.client_name = DISPLAY_NAME + pulse_sink.props.stream_properties = Gst.Structure.new_from_string( + 'props,' + f'application.name={DISPLAY_NAME},' + f'application.id={ID},' + f'application.icon_name={ID},' + 'media.role=music,' + ) + + self._playbin = Gst.ElementFactory.make('playbin3', None) + self._playbin.props.audio_sink = pulse_sink + self._playbin.set_state(Gst.State.READY) + self._playbin.get_bus().add_signal_watch() + self._playbin.get_bus().connect('message::error', self._on_bus_error) + self._playbin.get_bus().connect('message::latency', self._on_latency) + self._playbin.get_bus().connect('message::state-changed', self._on_state_change) + self._playbin.get_bus().connect('message::stream-start', self._on_stream_start) + self._playbin.get_bus().connect('message::buffering', self._on_buffering) + self._playbin.get_bus().connect('message::eos', self._on_stream_end) + + # Last part of ID and not DISPLAY_NAME as that can differ + self._mpris_server = Server(ID.split('.')[-1], EventHandler(self)) + self._mpris_event_sender = EventSender(self._mpris_server) + self._mpris_server.publish() + + self.set_mode(int(settings.load('mode', PlaybackMode.NORMAL))) + self.set_volume(float(settings.load('volume', 1.0))) + + @GObject.Signal(name='queue-changed') + def _queue_changed(self, _queue: object, _index: int): + return + + @GObject.Signal(name='recents-changed') + def _recents_changed(self): + return + + @GObject.Signal(name='progress-changed') + def _progress_changed(self, _progress: float): + return + + @GObject.Signal(name='buffering-changed') + def _buffering_changed(self, _progress: float): + return + + @GObject.Signal(name='state-changed') + def _state_changed(self, _state: int): + return + + @GObject.Signal(name='volume-changed') + def _volume_changed(self, _volume: float): + return + + @GObject.Signal(name='mode-changed') + def _mode_changed(self, _mode: int): + return + + @GObject.Signal(name='pause-changed') + def _pause_changed(self, _pause: bool): + return + + @GObject.Signal(name='raise') + def _raise(self): + return + + def _on_background_uri_search_done(self, task: FindURITask): + if task.result: + self._save_uri(task.extra_data.yt_id, task.result) + + if self._find_uri_task.is_running(): + return + + for song in self._queue.songs: + if song.yt_id not in self._song_uris: + self._find_uri_task = FindURITask( + callback=self._on_background_uri_search_done, + args=(song, self._song_uris) + ) + self._find_uri_task.extra_data = song + self._find_uri_task.start() + return + + logging.info(__name__, 'Found all song URIs for current queue') + + def _on_buffering(self, _bus: Gst.Bus, message: Gst.Message): + percentage = message.parse_buffering() + self.emit('buffering-changed', percentage / 100.0) + if percentage < 100: # noqa: PLR2004 - 100% + if not self.buffering: + logging.info(__name__, 'Buffering...') + self._playbin.set_state(Gst.State.PAUSED) + self.buffering = True + + return + + logging.info(__name__, 'Done buffering') + self.buffering = False + if self.state != PlaybackState.NONE: + self._playbin.set_state(Gst.State.PLAYING) + self.state = PlaybackState.PLAYING + self.emit('state-changed', self.state) + if self._start_position > 0: + logging.info(__name__, f'Seeking to {self._start_position}ns') + self._playbin.seek_simple( + Gst.Format.TIME, + Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, + self._start_position + ) + self._start_position = 0 + return + + logging.info( + __name__, 'Ignoring end of buffering as state is already NONE' + ) + + def _on_bus_error(self, _bus: Gst.Bus, message: Gst.Message): + logging.error(__name__, 'Bus error', message.parse_error().gerror.message) + self.pop_uri(self._queue.songs[self._queue_index].yt_id) + self.play( + self._queue.songs[self._queue_index], + self._queue, + self._last_known_position + ) + + def _on_latency(self, _bus: Gst.Bus, _message: Gst.Message): + if self._playbin.recalculate_latency(): + logging.info(__name__, 'Recalculated latency') + else: + logging.error(__name__, 'Failed to recalculate latency') + + def _on_radio_songs_found(self, task: FindRadioSongsTask): + if task.is_canceled() or self._radio_task is not task: + return + + if not task.result: + logging.warning(__name__, 'No radio songs found') + self.play(self._queue.songs[self._queue_index], self._queue) + return + + self.play(task.result.songs[0], task.result) + + def _on_state_change(self, _bus: Gst.Bus, _message: Gst.Message): + success, state, _d = self._playbin.get_state(1) + if success != Gst.StateChangeReturn.SUCCESS: + return + if state == Gst.State.PLAYING and self.paused: + logging.info(__name__, 'Adjusted state to paused after change') + self._playbin.set_state(Gst.State.PAUSED) + + def _on_stream_start(self, _bus: Gst.Bus, _message: Gst.Message): + if self.state == PlaybackState.LOADING and not self.buffering: + logging.info(__name__, 'Stream started') + self._playbin.set_state(Gst.State.PLAYING) + self.state = PlaybackState.PLAYING + self.emit('state-changed', self.state) + if self._start_position: + logging.info(__name__, f'Seeking to {self._start_position}ns') + self._playbin.seek_simple( + Gst.Format.TIME, + Gst.SeekFlags.FLUSH | Gst.SeekFlags.ACCURATE, + self._start_position + ) + self._start_position = 0 + + def _on_stream_end(self, _bus: Gst.Bus, _message): + logging.info(__name__, 'Stream has ended') + self.next() + + def _report_progress(self, _task: ReportProgressTask): + duration = self.get_duration_ns() + position = self.get_position_ns() + if duration > 0 and not self.buffering: + self.emit('progress-changed', position / duration) + if position > 0: + self._last_known_position = position + + def _save_uri(self, yt_id: str, uri: str): + while len(self._song_uris) > len(self._queue.songs): + self._song_uris.pop(next(iter(self._song_uris.keys()))) + + self._song_uris[yt_id] = uri + logging.info(__name__, f'Added URI for song "{yt_id}" to known') + + def _start_playback(self, task: FindURITask, position: int=0): + if task.is_canceled() or self._find_uri_task is not task: + logging.info(__name__, 'Ignoring callback from canceled URI lookup task') + return + + song = self._queue.songs[self._queue_index] + uri = task.result + if not uri: + logging.error(__name__, f'Failed to find URI for song "{song.yt_id}"') + self.play(song, self._queue, position) + return + + self._save_uri(song.yt_id, uri) + self._find_uri_task = FindURITask( + callback=self._on_background_uri_search_done, + args=(song, self._song_uris) + ) + self._find_uri_task.extra_data = song + self._find_uri_task.start() + + self._progress_task = ReportProgressTask( + progress_callback=self._report_progress + ) + self._progress_task.start() + + # Don't actually start yet - wait for messages on the bus + self._playbin.props.uri = uri + self._playbin.set_state(Gst.State.PAUSED) + logging.info(__name__, 'Started playback') + + def add_to_queue(self, group: Group): + if self._queue.songs: + self._queue.songs += group.songs + self.emit('queue-changed', self._queue, self._queue_index) + return + + self.play(group.songs[0], group) + + def get_current_song(self) -> Song | None: + if self._queue.songs: + return self._queue.songs[self._queue_index] + + return None + + def get_duration_ns(self) -> float: + return self._playbin.query_duration(Gst.Format.TIME)[1] + + def get_position_ns(self) -> float: + return self._playbin.query_position(Gst.Format.TIME)[1] + + def get_queue(self) -> Group: + return self._queue + + def get_volume(self) -> float: + return self._playbin.props.volume + + def move_song(self, song: Song, target: Song): + logging.info( + __name__, f'Moving song "{song.yt_id}" to "{target.yt_id}" in queue...' + ) + current_song = self._queue.songs[self._queue_index] + from_index = self._queue.songs.index(song) + to_index = self._queue.songs.index(target) + if abs(from_index - to_index) > 1: + self._queue.songs.pop(from_index) + self._queue.songs.insert(self._queue.songs.index(target), song) + else: + self._queue.songs[from_index], self._queue.songs[to_index] = ( + self._queue.songs[to_index], self._queue.songs[from_index] + ) + + self._queue_index = self._queue.songs.index(current_song) + self.emit('queue-changed', self._queue, self._queue_index) + logging.info(__name__, 'Moved song in queue') + + def next(self, from_user: bool=False): + if self.mode == PlaybackMode.RADIO: + if len(self._queue.songs) > self._queue_index + 1: + self.play(self._queue.songs[self._queue_index + 1], self._queue) + return + + self.state = PlaybackState.LOADING + self.emit('state-changed', self.state) + self._radio_task = FindRadioSongsTask( + callback=self._on_radio_songs_found, + args=(self._queue.songs[self._queue_index], self._queue) + ) + self._radio_task.start() + return + + if self.mode == PlaybackMode.LOOP_QUEUE: + if len(self._queue.songs) > self._queue_index + 1: + self.play(self._queue.songs[self._queue_index + 1], self._queue) + return + + self.play(self._queue.songs[0], self._queue) + return + + if self.mode == PlaybackMode.LOOP_SONG: + if from_user: + if len(self._queue.songs) > self._queue_index + 1: + self.play(self._queue.songs[self._queue_index + 1], self._queue) + return + + self.stop() + return + + self.play(self._queue.songs[self._queue_index], self._queue) + return + + if len(self._queue.songs) > self._queue_index + 1: + self.play(self._queue.songs[self._queue_index + 1], self._queue) + return + + self.stop() + + def play(self, song: Song, group: Group, position: int=0): + logging.info( + __name__, f'Playback of song "{song.yt_id}" at {position}ns requested' + ) + + recents.add(song) + self.emit('recents-changed') + self.state = PlaybackState.LOADING + self.emit('state-changed', self.state) + self._queue = copy.deepcopy(group) + self._queue_index = self._queue.songs.index(song) + self.emit('queue-changed', self._queue, self._queue_index) + self._playbin.set_state(Gst.State.NULL) + self._playbin.props.uri = '' + self.buffering = False + self.paused = False + if position <= 0: + self._start_position = 0 + self.emit('progress-changed', 0) + else: + self._start_position = position + self.emit('buffering-changed', 0) + + self._mpris_event_sender.emit_all() + + self._find_uri_task.cancel() + self._progress_task.cancel() + self._radio_task.cancel() + self._find_uri_task = FindURITask( + callback=self._start_playback, + callback_args=(position,), + args=(song, self._song_uris) + ) + self._find_uri_task.extra_data = song + self._find_uri_task.start() + + def pop_uri(self, yt_id: str) -> str | None: + if yt_id in self._song_uris: + logging.info(__name__, f'Popped known URI for song "{yt_id}"') + return self._song_uris.pop(yt_id) + + return None + + def previous(self): + if self._queue_index > 0: + self.play(self._queue.songs[self._queue_index - 1], self._queue) + return + + self.seek(0) + + def remove_from_queue(self, song: Song): + if len(self._queue.songs) > 1: + current_song = self._queue.songs[self._queue_index] + self._queue.songs.remove(song) + self._queue_index = self._queue.songs.index(current_song) + self.emit('queue-changed', self._queue, self._queue_index) + return + + self.stop() + + def seek(self, value: float): + seek_position = round(self.get_duration_ns() * value) + self._playbin.seek_simple( + Gst.Format.TIME, Gst.SeekFlags.FLUSH, max(seek_position, 0) + ) + self._mpris_event_sender.on_seek(value) + + def set_pause(self, pause: bool): + logging.info(__name__, f'Setting pause to "{pause}"...') + self.paused = pause + + if not self.buffering and self.state != PlaybackState.LOADING: + self._playbin.set_state( + Gst.State.PAUSED if self.paused else Gst.State.PLAYING + ) + + self.emit('pause-changed', pause) + self._mpris_event_sender.on_playpause() + logging.info(__name__, f'Set pause to "{pause}"') + + def set_volume( + self, volume: float, notify_frontend: bool=True, notify_mpris: bool=True + ): + settings.save({'volume': volume}) + self._playbin.props.volume = volume + if notify_mpris: + self._mpris_event_sender.on_volume() + if notify_frontend: + self.emit('volume-changed', volume) + + def set_mode(self, mode: int): + self.mode = mode + settings.save({'mode': mode}) + self.emit('mode-changed', self.mode) + + def shuffle(self): + logging.info(__name__, 'Shuffling songs...') + back_part = self._queue.songs[:self._queue_index] + front_part = self._queue.songs[self._queue_index + 1:] + + shuffled = [] + if len(back_part) > 1 or len(front_part) > 1: + while True: + shuffled = [ + *random.sample(back_part, k=len(back_part)), + self._queue.songs[self._queue_index], + *random.sample(front_part, k=len(front_part)), + ] + if shuffled != self._queue.songs: + self._queue.songs = shuffled + self.emit('queue-changed', self._queue, self._queue_index) + break + + logging.info(__name__, 'Shuffled songs') + + def stop(self): + logging.info(__name__, 'Stopping playback...') + self._find_uri_task.cancel() + self._progress_task.cancel() + self._radio_task.cancel() + self._playbin.set_state(Gst.State.NULL) + self._playbin.props.uri = '' + self.state = PlaybackState.NONE + self.emit('state-changed', self.state) + self.paused = False + self.buffering = False + self._queue = Group() + self._queue_index = 0 + self.emit('queue-changed', self._queue, self._queue_index) + self._mpris_event_sender.emit_all() + logging.info(__name__, 'Stopped playback') diff --git a/source/monophony/playlists.py b/source/monophony/playlists.py new file mode 100644 index 0000000000000000000000000000000000000000..467d7590f8143d89d29868095971bb3549c487ae --- /dev/null +++ b/source/monophony/playlists.py @@ -0,0 +1,320 @@ +import json +import os +import time + +from monophony import NAME, logging, yt +from monophony.asynchronous import Task +from monophony.data import Artist, Group, Song + +from gi.repository import GLib + + +def _get_directory() -> str: + return os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + '/' + NAME + + +def _get_file_path() -> str: + return _get_directory() + '/playlists.json' + + +def _get_external_file_path() -> str: + return _get_directory() + '/external-playlists.json' + + +def add(playlist: Group) -> str: + logging.info(__name__, f'Adding playlist "{playlist.title}"...') + new_lists = read() + old_title = playlist.title + playlist.title = make_unique_name(playlist.title) + + song_ids = [] + unique_songs = [] + for song in playlist.songs: + if song.yt_id not in song_ids: + unique_songs.append(song) + song_ids.append(song.yt_id) + + playlist.songs = unique_songs + new_lists.append(playlist) + _write(playlists=new_lists) + logging.info(__name__, f'Added playlist "{old_title}" as "{playlist.title}"') + return playlist.title + + +def add_external(playlist: Group): + logging.info(__name__, f'Adding external playlist "{playlist.yt_id}"...') + lists = read_external() + + song_ids = [] + unique_songs = [] + for song in playlist.songs: + if song.yt_id not in song_ids: + unique_songs.append(song) + song_ids.append(song.yt_id) + + playlist.songs = unique_songs + lists.append(playlist) + _write(ext_playlists=lists) + logging.info(__name__, 'Added external playlist') + + +def rename(name: str, new_name: str) -> str: + new_name = make_unique_name(new_name) + logging.info(__name__, f'Renaming playlist "{name}" to "{new_name}"...') + + new_lists = read() + for playlist in new_lists: + if playlist.title == name: + playlist.title = new_name + _write(playlists=new_lists) + logging.info(__name__, 'Renamed playlist') + return new_name + + logging.error(__name__, 'Failed to rename: playlist not found') + return name + + +def delete(playlist_name: str): + logging.info(__name__, f'Deleting playlist "{playlist_name}"...') + _write( + playlists=[playlist for playlist in read() if playlist.title != playlist_name] + ) + logging.info(__name__, 'Deleted playlist') + + +def delete_external(playlist_name: str): + logging.info(__name__, f'Deleting external playlist "{playlist_name}"...') + _write( + ext_playlists=[ + playlist for playlist in read_external() if playlist.title != playlist_name + ] + ) + logging.info(__name__, 'Deleted external playlist') + + +def add_songs(songs: Group, playlist_name: str): + logging.info( + __name__, f'Adding {len(songs.songs)} songs to playlist "{playlist_name}"...' + ) + new_lists = read() + for song in songs.songs: + for playlist in new_lists: + if playlist.title == playlist_name: + for existing_song in playlist.songs: + if song.yt_id == existing_song.yt_id: + return + playlist.songs.append(song) + break + + _write(playlists=new_lists) + logging.info(__name__, 'Added songs to playlist') + + +def swap_songs(playlist_name: str, i: int, j: int): + logging.info( + __name__, f'Swapping songs #{i} and #{j} in playlist "{playlist_name}"...' + ) + new_lists = read() + for playlist in new_lists: + if playlist.title == playlist_name: + i = 0 if i >= len(playlist.songs) else i + j = 0 if j >= len(playlist.songs) else j + playlist.songs[i], playlist.songs[j] = playlist.songs[j], playlist.songs[i] + break + + _write(playlists=new_lists) + logging.info(__name__, 'Swapped songs') + + +def move_song(playlist_name: str, from_i: int, to_i: int): + logging.info( + __name__, + f'Moving song from #{from_i} to #{to_i} in playlist "{playlist_name}"...' + ) + new_lists = read() + for playlist in new_lists: + if playlist.title == playlist_name: + to_song = playlist.songs[to_i] + from_song = playlist.songs.pop(from_i) + playlist.songs.insert(playlist.songs.index(to_song), from_song) + break + + _write(playlists=new_lists) + logging.info(__name__, 'Moved song') + + +def remove_song(song: Song, playlist_name: str): + logging.info( + __name__, f'Removing song "{song.yt_id}" from playlist "{playlist_name}"...' + ) + new_lists = read() + for playlist in new_lists: + if playlist.title == playlist_name: + playlist.songs = [s for s in playlist.songs if s.yt_id != song.yt_id] + break + + _write(playlists=new_lists) + logging.info(__name__, 'Removed song') + + +def make_unique_name(name: str) -> str: + taken_names = ( + [playlist.title for playlist in read()] + + [playlist.title for playlist in read_external()] + ) + new_name = name or _('Playlist') + + i = 1 + while new_name in taken_names: + new_name = f'{name} ({i})' + i += 1 + + return new_name + + +def _write(playlists: list[Group] | None=None, ext_playlists: list[Group] | None=None): + lock.lock() + logging.info( + __name__, + f'Writing {len(playlists) if playlists else "no"} playlists and ' + f'{len(ext_playlists) if ext_playlists else "no"} external playlists...' + ) + lists_path = _get_file_path() + ext_lists_path = _get_external_file_path() + os.makedirs(_get_directory(), exist_ok=True) + + if playlists is not None: + serialized_playlists = {} + for playlist in playlists: + serialized_playlists[playlist.title] = playlist.serialize()['contents'] + with open(lists_path, 'w') as lists_file: + json.dump(serialized_playlists, lists_file, indent='\t') + + if ext_playlists is not None: + with open(ext_lists_path, 'w') as ext_lists_file: + json.dump( + [playlist.serialize() for playlist in ext_playlists], + ext_lists_file, + indent='\t' + ) + + logging.info(__name__, 'Done writing playlist and external playlists') + lock.unlock() + + +def read() -> list[Group]: + lock.lock() + try: + with open(_get_file_path()) as lists_file: + result = [ + Group( + title=name, + songs=[ + Song( + title=song.get('title', ''), + author=Artist( + name=song.get('author', ''), + yt_id=song.get('author_id', '') + ), + length=song.get('length', ''), + thumbnail=song.get('thumbnail', ''), + yt_id=song.get('id', '') + ) for song in songs + ] + ) for name, songs in json.load(lists_file).items() + ] + lock.unlock() + return result + except (OSError, json.decoder.JSONDecodeError): + lock.unlock() + return [] + + +def read_external() -> list[Group]: + lock.lock() + try: + with open(_get_external_file_path()) as lists_file: + result = [ + Group( + title=playlist.get('title', ''), + yt_id=playlist.get('id', ''), + songs=[ + Song( + title=song.get('title', ''), + author=Artist( + name=song.get('author', ''), + yt_id=song.get('author_id', '') + ), + length=song.get('length', ''), + thumbnail=song.get('thumbnail', ''), + yt_id=song.get('id', '') + ) for song in playlist.get('contents', []) + ] + ) for playlist in json.load(lists_file) + ] + lock.unlock() + return result + except (OSError, json.decoder.JSONDecodeError): + lock.unlock() + return [] + + +class ImportTask(Task): + def _function( + self, name: str, url: str, local: bool, overwrite: bool=False + ) -> bool: + logging.info(__name__, f'Importing playlist "{url}"...') + new_lists = [playlist for playlist in read() if playlist.title != name] + new_ext_lists = [ + playlist for playlist in read_external() if playlist.title != name + ] + + if not ( + playlist := yt.get_album_or_playlist(url.split('list=')[-1].split('&')[0]) + ): + logging.error(__name__, 'Failed to import playlist') + return False + + playlist.title = make_unique_name( + name if local else playlist.title + ) if not overwrite else name + + if local: + new_lists.append(playlist) + _write(playlists=new_lists) + else: + new_ext_lists.append(playlist) + _write(ext_playlists=new_ext_lists) + + logging.info(__name__, 'Imported playlist') + return True + + +class UpdateExternalTask(Task): + def _function(self): + logging.info(__name__, 'Updating external playlists...') + tasks = [] + external = read_external() + for i, playlist in enumerate(external): + task = ImportTask(args=(playlist.title, playlist.yt_id, False, True)) + task.start() + tasks.append(task) + + # Rate limit + if i % 4 == 0: + self._update_progress((i / len(external)) / 2) + while task.is_running(): + time.sleep(0.5) + + for i, task in enumerate(tasks): + while task.is_running(): + self._update_progress((i / len(tasks)) / 2 + 0.5) + time.sleep(0.5) + + logging.info(__name__, 'Updated external playlists') + + +# Signleton +lock = GLib.Mutex() diff --git a/source/monophony/recents.py b/source/monophony/recents.py new file mode 100644 index 0000000000000000000000000000000000000000..b16b721bf1a9481f5f6ec05c8120b4e4f5825623 --- /dev/null +++ b/source/monophony/recents.py @@ -0,0 +1,68 @@ +import json +import os +import traceback + +from monophony import NAME, logging +from monophony.data import Artist, Group, Song + + +MAX_SONGS = 15 + + +def _get_directory() -> str: + return os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + f'/{NAME}' + + +def _get_file_path() -> str: + return _get_directory() + '/recent-songs.json' + + +def _write(group: Group): + if len(group.songs) > MAX_SONGS: + group.songs = group.songs[:MAX_SONGS] + + logging.info(__name__, f'Writing {len(group.songs)} songs to recents...') + os.makedirs(_get_directory(), exist_ok=True) + try: + with open(_get_file_path(), 'w') as recents_file: + json.dump(group.serialize()['contents'], recents_file, indent='\t') + except OSError: + logging.error(__name__, 'Failed to write to recents', traceback.format_exc()) + return + + logging.info(__name__, 'Done writing to recents') + + +def add(song: Song): + logging.info(__name__, f'Adding song "{song.yt_id}" to recents...') + group = read() + group.songs = [song, *group.songs] + _write(group) + logging.info(__name__, 'Added song to recents') + + +def clear(): + _write(Group()) + + +def read() -> Group: + try: + with open(_get_file_path()) as recents_file: + return Group( + songs=[ + Song( + title=item.get('title', ''), + author=Artist( + name=item.get('author', ''), + yt_id=item.get('author_id', '') + ), + length=item.get('length', ''), + thumbnail=item.get('thumbnail', ''), + yt_id=item.get('id', '') + ) for item in json.load(recents_file) + ] + ) + except (OSError, json.decoder.JSONDecodeError): + return Group() diff --git a/source/monophony/recommendations.py b/source/monophony/recommendations.py new file mode 100644 index 0000000000000000000000000000000000000000..b63146a1a1185af30fe7107feb15f00016ea582e --- /dev/null +++ b/source/monophony/recommendations.py @@ -0,0 +1,54 @@ +import json +import os + +from monophony import NAME, logging +from monophony.data import Artist, Group, Song + + +def _get_directory() -> str: + return os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + '/' + NAME + + +def _get_file_path() -> str: + return _get_directory() + '/recommendations.json' + + +def write(recommendations: list[Group]): + logging.info(__name__, f'Writing {len(recommendations)} recommendations...') + recommendations_path = _get_file_path() + os.makedirs(_get_directory(), exist_ok=True) + + serialized_recommendations = {} + for group in recommendations: + serialized_recommendations[group.title] = group.serialize()['contents'] + with open(recommendations_path, 'w') as recommendations_file: + json.dump(serialized_recommendations, recommendations_file, indent='\t') + + logging.info(__name__, 'Done writing recommendations') + + +def read() -> list[Group]: + try: + with open(_get_file_path()) as recommendations_file: + return [ + Group( + title=name, + songs=[ + Song( + title=song.get('title', ''), + author=Artist( + name=song.get('author', ''), + yt_id=song.get('author_id', '') + ), + length=song.get('length', ''), + thumbnail=song.get('thumbnail', ''), + yt_id=song.get('id', '') + ) for song in songs + ] + ) for name, songs in json.load(recommendations_file).items() + ] + except (OSError, json.decoder.JSONDecodeError): + return [] + diff --git a/source/monophony/settings.py b/source/monophony/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..ab7e26d0e2ab267d3b4fbdb43a3e75caf9d74305 --- /dev/null +++ b/source/monophony/settings.py @@ -0,0 +1,46 @@ +import json +import os +from typing import Any + +from monophony import NAME, logging + + +def save(values: dict): + logging.info(__name__, f'Saving settings "{values}"...') + + settings = _read() + for key, value in values.items(): + settings[key] = value + + _write(settings) + logging.info(__name__, 'Saved settings') + + +def load(key: str, default: Any=None) -> Any: + return _read().get(key, default) + + +def _write(settings: dict): + logging.info(__name__, 'Writing settings...') + directory = os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + '/' + NAME + settings_path = directory + '/settings.json' + + os.makedirs(directory, exist_ok=True) + with open(settings_path, 'w') as settings_file: + json.dump(settings, settings_file, indent='\t') + + logging.info(__name__, 'Done writing settings') + + +def _read() -> dict: + settings_path = os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + f'/{NAME}/settings.json' + + try: + with open(settings_path) as settings_file: + return json.load(settings_file) + except (OSError, json.decoder.JSONDecodeError): + return {} diff --git a/source/monophony/backend/__init__.py b/source/monophony/ui/__init__.py similarity index 100% rename from source/monophony/backend/__init__.py rename to source/monophony/ui/__init__.py diff --git a/source/monophony/frontend/pages/__init__.py b/source/monophony/ui/bars/__init__.py similarity index 100% rename from source/monophony/frontend/pages/__init__.py rename to source/monophony/ui/bars/__init__.py diff --git a/source/monophony/ui/bars/header_bar.py b/source/monophony/ui/bars/header_bar.py new file mode 100644 index 0000000000000000000000000000000000000000..90bfdac19e6cb714d9a0a0d8cf6777a894dc9f1d --- /dev/null +++ b/source/monophony/ui/bars/header_bar.py @@ -0,0 +1,29 @@ +import weakref + +from monophony.debug import MemoryDebugger + +from gi.repository import Adw, GObject, Gtk + + +class HeaderBar(MemoryDebugger, Adw.Bin): + __gtype_name__ = __qualname__ + + def __init__(self): + super().__init__() + + about_button = Gtk.Button.new_from_icon_name('help-about-symbolic') + about_button.props.tooltip_text = _('About') + about_button.connect( + 'clicked', + lambda _button, ref: ref().emit('show-about'), + weakref.ref(self) + ) + + header_bar = Adw.HeaderBar() + header_bar.pack_end(about_button) + + self.props.child = header_bar + + @GObject.Signal(name='show-about') + def _show_about(self): + return diff --git a/source/monophony/ui/bars/player_bar.py b/source/monophony/ui/bars/player_bar.py new file mode 100644 index 0000000000000000000000000000000000000000..ab38be9275f3be48c3174bac04b7406fc483d971 --- /dev/null +++ b/source/monophony/ui/bars/player_bar.py @@ -0,0 +1,302 @@ +import weakref + +from monophony import ID +from monophony.data import PlaybackMode, PlaybackState, Song + +from gi.repository import Adw, Gdk, Gio, GLib, GObject, GstAudio, Gtk, Pango + + +class PlayerBar(Gtk.Box): + __gtype_name__ = __qualname__ + + def __init__(self): + super().__init__(orientation=Gtk.Orientation.VERTICAL) + + self._buffer_bar = Gtk.ProgressBar() + self._buffer_bar.add_css_class('buffbar') + + self._progress_bar = Gtk.Scale.new_with_range( + Gtk.Orientation.HORIZONTAL, 0, 1, 0.01 + ) + self._progress_bar.add_css_class('seekbar') + self._progress_bar.props.draw_value = False + self._progress_bar.props.halign = Gtk.Align.FILL + self._progress_bar.props.valign = Gtk.Align.END + self._progress_bar.connect( + 'change-value', + lambda _bar, _scroll, value, ref: ref().emit('seek', value), + weakref.ref(self) + ) + + css = Gtk.CssProvider() + css.load_from_data(''' + .title-link { + padding-top: 0px; + padding-bottom: 0px; + padding-left: 0px; + padding-right: 0px; + margin-bottom: -8px; + margin-top: -8px; + } + + .buffbar { + min-height: 10px; + margin-bottom: -6px; + margin-top: 0px; + } + + .buffbar trough { + border-radius: 0px; + min-height: 10px; + } + + .buffbar progress { + border-radius: 0px; + min-height: 10px; + background-color: var(--sidebar-fg-color); + opacity: 0.5; + } + + .seekbar { + margin-top: -10px; + margin-bottom: 0px; + padding: 0px; + min-height: 10px; + } + + .seekbar trough, .seekbar highlight { + border-radius: 0px; + border-left: none; + border-right: none; + min-height: 10px; + } + + .seekbar highlight { + border-left: none; + border-right: none; + } + + .player { + padding: 0px; + background-color: var(--headerbar-bg-color); + } + ''', -1) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), + css, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + self.queue_button = Gtk.ToggleButton() + self.queue_button.props.icon_name = 'view-list-symbolic' + self.queue_button.props.valign = Gtk.Align.CENTER + self.queue_button.props.tooltip_text = _('Queue') + + self._title_link = Gtk.LinkButton.new_with_label('', '') + self._title_link.props.margin_bottom = 2 + self._title_link.props.margin_top = 6 + self._title_link.props.halign = Gtk.Align.START + self._title_link.props.child.props.ellipsize = Pango.EllipsizeMode.END + self._title_link.add_css_class('title-link') + + self._artist_label = Gtk.Label() + self._artist_label.props.halign = Gtk.Align.START + self._artist_label.props.ellipsize = Pango.EllipsizeMode.END + self._artist_label.add_css_class('caption') + self._artist_label.add_css_class('dim-label') + + info_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + info_box.props.spacing = 4 + info_box.props.margin_start = 6 + info_box.props.margin_end = 6 + info_box.props.halign = Gtk.Align.START + info_box.props.valign = Gtk.Align.CENTER + info_box.props.hexpand = True + info_box.append(self._title_link) + info_box.append(self._artist_label) + + self._mode_button = Gtk.MenuButton() + self._mode_button.props.icon_name = 'media-playlist-repeat-song-symbolic' + self._mode_button.props.tooltip_text = _('Playback Mode') + self._mode_button.props.valign = Gtk.Align.CENTER + self._mode_button.props.halign = Gtk.Align.END + self._mode_button.props.hexpand = True + self._mode_button.set_create_popup_func(self._on_create_mode_popup) + + previous_button = Gtk.Button.new_from_icon_name('media-skip-backward-symbolic') + previous_button.props.tooltip_text = _('Previous') + previous_button.props.valign = Gtk.Align.CENTER + previous_button.connect( + 'clicked', + lambda _button, ref: ref().emit('previous-song'), + weakref.ref(self) + ) + + self._spinner = Adw.Spinner() + self._spinner.props.margin_start = 9 + self._spinner.props.margin_end = 9 + self._spinner.props.visible = False + + self._pause_button = Gtk.Button.new_from_icon_name( + 'media-playback-pause-symbolic' + ) + self._pause_button.props.tooltip_text = _('Pause') + self._pause_button.props.valign = Gtk.Align.CENTER + self._pause_button.connect( + 'clicked', + lambda _button, ref: ref().emit('pause'), + weakref.ref(self) + ) + + next_button = Gtk.Button.new_from_icon_name('media-skip-forward-symbolic') + next_button.props.tooltip_text = _('Next') + next_button.props.valign = Gtk.Align.CENTER + next_button.connect( + 'clicked', lambda _button, ref: ref().emit('next-song'), weakref.ref(self) + ) + + self._volume_button = Gtk.ScaleButton.new(0, 1, 0.02, [ + 'audio-volume-muted-symbolic', + 'audio-volume-high-symbolic', + 'audio-volume-low-symbolic', + 'audio-volume-medium-symbolic', + 'audio-volume-high-symbolic' + ]) + self._volume_button.props.tooltip_text = _('Volume') + self._volume_button.props.valign = Gtk.Align.CENTER + self._volume_button.props.halign = Gtk.Align.END + self._volume_button.connect( + 'value-changed', + lambda _button, value, ref: ref().emit( + 'volume-changed', + GstAudio.stream_volume_convert_volume( + GstAudio.StreamVolumeFormat.CUBIC, + GstAudio.StreamVolumeFormat.LINEAR, + value + ) + ), + weakref.ref(self) + ) + + controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + controls_box.props.spacing = 6 + controls_box.props.margin_top = 2 + controls_box.props.margin_bottom = 8 + controls_box.props.margin_start = 8 + controls_box.props.margin_end = 8 + controls_box.props.halign = Gtk.Align.FILL + controls_box.props.hexpand = True + controls_box.append(self.queue_button) + controls_box.append(info_box) + controls_box.append(self._mode_button) + controls_box.append(previous_button) + controls_box.append(self._spinner) + controls_box.append(self._pause_button) + controls_box.append(next_button) + controls_box.append(self._volume_button) + + self._mode = PlaybackMode.NORMAL + self.add_css_class('toolbar') + self.add_css_class('player') + self.append(self._buffer_bar) + self.append(self._progress_bar) + self.append(controls_box) + + @GObject.Signal(name='mode-changed') + def _mode_changed(self, _mode: int): + return + + @GObject.Signal(name='next-song') + def _next_song(self): + return + + @GObject.Signal(name='pause') + def _pause(self): + return + + @GObject.Signal(name='previous-song') + def _previous_song(self): + return + + @GObject.Signal(name='seek') + def _seek(self, _value: float): + return + + @GObject.Signal(name='volume-changed') + def _volume_changed(self, _volume: float): + return + + def _on_create_mode_popup(self, button: Gtk.MenuButton): + menu = Gio.Menu() + popover_menu = Gtk.PopoverMenu() + popover_menu.props.menu_model = menu + + button_group = None + for mode, label in { + PlaybackMode.NORMAL: _('Normal Playback'), + PlaybackMode.LOOP_SONG: _('Repeat Song'), + PlaybackMode.LOOP_QUEUE: _('Repeat Queue'), + PlaybackMode.RADIO: _('Autoplay Similar') + }.items(): + check_button = Gtk.CheckButton.new_with_label(label) + check_button.props.margin_bottom = 6 + check_button.props.margin_start = 6 + check_button.props.margin_end = 6 + check_button.props.active = self._mode == mode + check_button.connect('toggled', self._on_mode_toggled, mode) + if button_group: + check_button.props.group = button_group + else: + check_button.props.margin_top = 6 + button_group = check_button + + item = Gio.MenuItem() + item.set_attribute_value('custom', GLib.Variant.new_string(str(mode))) + menu.append_item(item) + popover_menu.add_child(check_button, str(mode)) + + button.props.popover = popover_menu + + def _on_mode_toggled(self, _button: Gtk.CheckButton, mode: int): + self.emit('mode-changed', mode) + + def update_song(self, song: Song): + # Simply using .props.label would create a new child label and discard + # previously set properties + self._title_link.props.child.props.label = song.title + self._title_link.props.uri = ( + 'https://music.youtube.com/watch?v=' + song.yt_id + ) + self._artist_label.props.label = song.author.name + + def update_pause(self, pause: bool): + self._pause_button.props.icon_name = ( + f'media-playback-{"start" if pause else "pause"}-symbolic' + ) + + def update_progress(self, progress: float): + self._progress_bar.set_value(progress) + + def update_state(self, state: int): + self._spinner.props.visible = state == PlaybackState.LOADING + self._pause_button.props.visible = not self._spinner.props.visible + self._progress_bar.props.sensitive = state != PlaybackState.LOADING + + def update_buffering(self, progress: float): + self._buffer_bar.props.fraction = progress + + def update_volume(self, volume: float): + self._volume_button.props.value = GstAudio.stream_volume_convert_volume( + GstAudio.StreamVolumeFormat.LINEAR, + GstAudio.StreamVolumeFormat.CUBIC, + volume + ) + + def update_mode(self, mode: int): + self._mode = mode + self._mode_button.props.icon_name = { + PlaybackMode.NORMAL: 'media-playlist-consecutive-symbolic', + PlaybackMode.LOOP_SONG: 'media-playlist-repeat-song-symbolic', + PlaybackMode.LOOP_QUEUE: 'media-playlist-repeat-symbolic', + PlaybackMode.RADIO: ID + '-symbolic', + }[self._mode] diff --git a/source/monophony/ui/bars/search_bar.py b/source/monophony/ui/bars/search_bar.py new file mode 100644 index 0000000000000000000000000000000000000000..955984d497c76bd8a8469cdd0ea668192ddea01d --- /dev/null +++ b/source/monophony/ui/bars/search_bar.py @@ -0,0 +1,37 @@ +from gi.repository import Adw, GObject, Gtk + + +class SearchBar(Adw.Bin): + __gtype_name__ = __qualname__ + + def __init__(self): + super().__init__() + + self._search_entry = Gtk.SearchEntry() + self._search_entry.props.placeholder_text = _('Search...') + self._search_entry.props.margin_start = 18 + self._search_entry.props.margin_end = 18 + self._search_entry.props.hexpand = True + self._search_entry.props.halign = Gtk.Align.FILL + self._search_entry.connect( + 'activate', lambda entry: self.emit('search', entry.props.text, '') + ) + + search_clamp = Adw.Clamp() + search_clamp.props.maximum_size = 590 + search_clamp.props.child = self._search_entry + + search_bar = Adw.HeaderBar() + search_bar.props.title_widget = search_clamp + search_bar.props.show_back_button = False + search_bar.props.show_start_title_buttons = False + search_bar.props.show_end_title_buttons = False + + self.props.child = search_bar + + @GObject.Signal(name='search', arg_types=(str, str)) + def _search(self, _query: str, _filter: str): + return + + def focus_search(self): + self._search_entry.grab_focus() diff --git a/source/monophony/frontend/popovers/__init__.py b/source/monophony/ui/pages/__init__.py similarity index 100% rename from source/monophony/frontend/popovers/__init__.py rename to source/monophony/ui/pages/__init__.py diff --git a/source/monophony/ui/pages/artist_page.py b/source/monophony/ui/pages/artist_page.py new file mode 100644 index 0000000000000000000000000000000000000000..25cfe84d7a8e8cce1b489c3c629b1ab59a1842df --- /dev/null +++ b/source/monophony/ui/pages/artist_page.py @@ -0,0 +1,11 @@ +from monophony.ui.pages.results_page import ResultsPage +from monophony.yt import SearchResult + + +class ArtistPage(ResultsPage): + __gtype_name__ = __qualname__ + + def __init__(self, results: list[SearchResult], filter_: str | None): + super().__init__(results, filter_) + + self.props.title = _('Artist Page') diff --git a/source/monophony/ui/pages/home_page.py b/source/monophony/ui/pages/home_page.py new file mode 100644 index 0000000000000000000000000000000000000000..bc880f585aa9f8042d68364ab794072d7aa6a8dd --- /dev/null +++ b/source/monophony/ui/pages/home_page.py @@ -0,0 +1,493 @@ +import weakref + +from monophony import downloads, playlists, recents, recommendations +from monophony.data import Artist, Group, Song +from monophony.ui.bars.search_bar import SearchBar +from monophony.ui.pages.page import Page +from monophony.ui.row_groups.editable_group_row_group import EditableGroupRowGroup +from monophony.ui.row_groups.group_row_group import GroupRowGroup +from monophony.ui.row_groups.queueable_row_group import QueueableRowGroup +from monophony.ui.row_groups.synchronized_group_row_group import ( + SynchronizedGroupRowGroup, +) + +from gi.repository import Adw, Gdk, Gio, GLib, GObject, Gtk + + +class HomePage(Page): + __gtype_name__ = __qualname__ + + def __init__(self): + super().__init__() + + self._deleted_playlists = [] + + self._recommended_group = GroupRowGroup() + self._recommended_group.props.title = _('Recommended') + self._recommended_group.props.margin_start = 12 + self._recommended_group.props.margin_end = 12 + self._recommended_group.connect( + 'play', + lambda _group, song, group, ref: ref().emit('play', song, group), + weakref.ref(self) + ) + self._recommended_group.connect( + 'queue-song', + lambda _group, song, ref: ref().emit('queue-song', song), + weakref.ref(self) + ) + self._recommended_group.connect( + 'add-song-to', + lambda _group, song, ref: ref().emit('add-song-to', song), + weakref.ref(self) + ) + self._recommended_group.connect( + 'view-artist', + lambda _group, artist, ref: ref().emit('view-artist', artist), + weakref.ref(self) + ) + self._recommended_group.connect( + 'undownload-song', + lambda _group, song, ref: ref().emit('undownload-song', song), + weakref.ref(self) + ) + self._recommended_group.connect( + 'download-song', + lambda _group, song, ref: ref().emit('download-song', song), + weakref.ref(self) + ) + self._recommended_group.connect( + 'queue-group', + lambda _group, group, ref: ref().emit('queue-group', group), + weakref.ref(self) + ) + self._recommended_group.connect( + 'add-group-to', + lambda _group, group, ref: ref().emit('add-group-to', group), + weakref.ref(self) + ) + self._recommended_group.connect( + 'download-group', + lambda _group, group, ref: ref().emit('download-group', group), + weakref.ref(self) + ) + + self._playlists_group = EditableGroupRowGroup() + self._playlists_group.props.title = _('Your Playlists') + self._playlists_group.props.margin_start = 12 + self._playlists_group.props.margin_end = 12 + self._playlists_group.connect( + 'play', + lambda _group, song, group, ref: ref().emit('play', song, group), + weakref.ref(self) + ) + self._playlists_group.connect( + 'queue-song', + lambda _group, song, ref: ref().emit('queue-song', song), + weakref.ref(self) + ) + self._playlists_group.connect( + 'add-song-to', + lambda _group, song, ref: ref().emit('add-song-to', song), + weakref.ref(self) + ) + self._playlists_group.connect( + 'view-artist', + lambda _group, artist, ref: ref().emit('view-artist', artist), + weakref.ref(self) + ) + self._playlists_group.connect( + 'undownload-song', + lambda _group, song, ref: ref().emit('undownload-song', song), + weakref.ref(self) + ) + self._playlists_group.connect( + 'download-song', + lambda _group, song, ref: ref().emit('download-song', song), + weakref.ref(self) + ) + self._playlists_group.connect( + 'queue-group', + lambda _group, group, ref: ref().emit('queue-group', group), + weakref.ref(self) + ) + self._playlists_group.connect( + 'add-group-to', + lambda _group, group, ref: ref().emit('add-group-to', group), + weakref.ref(self) + ) + self._playlists_group.connect( + 'download-group', + lambda _group, group, ref: ref().emit('download-group', group), + weakref.ref(self) + ) + self._playlists_group.connect( + 'delete-playlist', + lambda _group, playlist, ref: HomePage._on_delete_playlist(ref(), playlist), + weakref.ref(self) + ) + + no_playlists_group = Adw.PreferencesGroup() + no_playlists_group.props.title = _('Your Playlists') + no_playlists_group.props.description = _( + 'Playlists you create will appear here' + ) + no_playlists_group.props.margin_start = 12 + no_playlists_group.props.margin_end = 12 + no_playlists_group.bind_property( + 'visible', + self._playlists_group, + 'visible', + GObject.BindingFlags.BIDIRECTIONAL | + GObject.BindingFlags.SYNC_CREATE | + GObject.BindingFlags.INVERT_BOOLEAN + ) + + import_button = Adw.ButtonRow() + import_button.props.start_icon_name = 'list-add-symbolic' + import_button.props.title = _('Import') + import_button.connect( + 'activated', + lambda _button, ref: ref().emit('import-group', Group()), + weakref.ref(self) + ) + + import_group = Adw.PreferencesGroup() + import_group.props.margin_start = 12 + import_group.props.margin_end = 12 + import_group.add(import_button) + + self._external_playlists_group = SynchronizedGroupRowGroup() + self._external_playlists_group.props.title = _('Synchronized Playlists') + self._external_playlists_group.props.margin_start = 12 + self._external_playlists_group.props.margin_end = 12 + self._external_playlists_group.connect( + 'play', + lambda _group, song, group, ref: ref().emit('play', song, group), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'queue-song', + lambda _group, song, ref: ref().emit('queue-song', song), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'add-song-to', + lambda _group, song, ref: ref().emit('add-song-to', song), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'view-artist', + lambda _group, artist, ref: ref().emit('view-artist', artist), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'undownload-song', + lambda _group, song, ref: ref().emit('undownload-song', song), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'download-song', + lambda _group, song, ref: ref().emit('download-song', song), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'queue-group', + lambda _group, group, ref: ref().emit('queue-group', group), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'add-group-to', + lambda _group, group, ref: ref().emit('add-group-to', group), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'download-group', + lambda _group, group, ref: ref().emit('download-group', group), + weakref.ref(self) + ) + self._external_playlists_group.connect( + 'delete-playlist', + lambda _group, playlist, ref: + HomePage._on_delete_external_playlist(ref(), playlist), + weakref.ref(self) + ) + + open_dir_button = Gtk.Button.new_from_icon_name('folder-symbolic') + open_dir_button.props.tooltip_text = _('Downloads Directory') + open_dir_button.connect( + 'clicked', + lambda _button: + Gio.AppInfo.launch_default_for_uri( + 'file://' + downloads.get_directory() + ) + ) + + self._downloads_group = QueueableRowGroup() + self._downloads_group.props.title = _('Downloads') + self._downloads_group.props.header_suffix = open_dir_button + self._downloads_group.props.margin_start = 12 + self._downloads_group.props.margin_end = 12 + self._downloads_group.connect( + 'play', + lambda _group, song, group, ref: ref().emit('play', song, group), + weakref.ref(self) + ) + self._downloads_group.connect( + 'queue-song', + lambda _group, song, ref: ref().emit('queue-song', song), + weakref.ref(self) + ) + self._downloads_group.connect( + 'add-song-to', + lambda _group, song, ref: ref().emit('add-song-to', song), + weakref.ref(self) + ) + self._downloads_group.connect( + 'view-artist', + lambda _group, artist, ref: ref().emit('view-artist', artist), + weakref.ref(self) + ) + self._downloads_group.connect( + 'undownload-song', + lambda _group, song, ref: ref().emit('undownload-song', song), + weakref.ref(self) + ) + + clear_button = Gtk.Button.new_from_icon_name('edit-clear-all-symbolic') + clear_button.add_css_class('destructive-action') + clear_button.props.tooltip_text = _('Clear') + clear_button.connect( + 'clicked', + lambda _button, ref: HomePage._on_clear_history(ref()), + weakref.ref(self) + ) + + self._history_group = QueueableRowGroup() + self._history_group.props.title = _('Recently Played') + self._history_group.props.header_suffix = clear_button + self._history_group.props.margin_start = 12 + self._history_group.props.margin_end = 12 + self._history_group.connect( + 'play', + lambda _group, song, group, ref: ref().emit('play', song, group), + weakref.ref(self) + ) + self._history_group.connect( + 'queue-song', + lambda _group, song, ref: ref().emit('queue-song', song), + weakref.ref(self) + ) + self._history_group.connect( + 'add-song-to', + lambda _group, song, ref: ref().emit('add-song-to', song), + weakref.ref(self) + ) + self._history_group.connect( + 'view-artist', + lambda _group, artist, ref: ref().emit('view-artist', artist), + weakref.ref(self) + ) + self._history_group.connect( + 'undownload-song', + lambda _group, song, ref: ref().emit('undownload-song', song), + weakref.ref(self) + ) + self._history_group.connect( + 'download-song', + lambda _group, song, ref: ref().emit('download-song', song), + weakref.ref(self) + ) + + donate_button = Adw.ButtonRow() + donate_button.props.start_icon_name = 'emote-love-symbolic' + donate_button.props.title = _('Donate') + donate_button.add_css_class('donate-button') + donate_button.connect( + 'activated', + lambda _button, ref: HomePage._on_donate(ref()), + weakref.ref(self) + ) + + css = Gtk.CssProvider() + css.load_from_data(''' + .donate-button { + background-color: var(--accent-red); + color: var(--light-1); + } + ''', -1) + Gtk.StyleContext.add_provider_for_display( + Gdk.Display.get_default(), + css, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION + ) + + donate_group = Adw.PreferencesGroup() + donate_group.props.margin_start = 12 + donate_group.props.margin_end = 12 + donate_group.add(donate_button) + + self._page.add(self._recommended_group) + self._page.add(self._playlists_group) + self._page.add(no_playlists_group) + self._page.add(import_group) + self._page.add(self._external_playlists_group) + self._page.add(self._downloads_group) + self._page.add(self._history_group) + self._page.add(donate_group) + + self._search_bar = SearchBar() + self._search_bar.connect( + 'search', + lambda _bar, query, filter_, ref: ref().emit('search', query, filter_), + weakref.ref(self) + ) + + self._toolbar_view.add_top_bar(self._search_bar) + + self.props.title = _('Home') + self.update_playlists() + self.update_recommendations() + self.update_external_playlists() + self.update_history() + + @GObject.Signal(name='play', arg_types=(object, object)) + def _play(self, _song: Song, _group: Group): + return + + @GObject.Signal(name='search', arg_types=(str, str)) + def _search(self, _query: str, _filter: str): + return + + @GObject.Signal(name='queue-song', arg_types=(object,)) + def _queue_song(self, _song: Song): + return + + @GObject.Signal(name='add-song-to', arg_types=(object,)) + def _add_song_to(self, _song: Song): + return + + @GObject.Signal(name='view-artist', arg_types=(object,)) + def _view_artist(self, _artist: Artist): + return + + @GObject.Signal(name='undownload-song', arg_types=(object,)) + def _undownload_song(self, _song: Song): + return + + @GObject.Signal(name='download-song', arg_types=(object,)) + def _download_song(self, _song: Song): + return + + @GObject.Signal(name='queue-group', arg_types=(object,)) + def _queue_group(self, _group: Group): + return + + @GObject.Signal(name='add-group-to', arg_types=(object,)) + def _add_group_to(self, _group: Group): + return + + @GObject.Signal(name='download-group', arg_types=(object,)) + def _download_group(self, _group: Group): + return + + @GObject.Signal(name='import-group', arg_types=(object,)) + def _import_group(self, _group: Group): + return + + def _on_clear_history(self): + recents.clear() + self.update_history() + + def _on_delete_external_playlist(self, playlist: Group): + playlists.delete_external(playlist.title) + self._deleted_playlists.append(playlist) + + toast = Adw.Toast() + toast.props.title = _('Deleted playlist "{name}"').format( + name=GLib.markup_escape_text(playlist.title, -1) + ) + toast.props.priority = Adw.ToastPriority.HIGH + toast.props.button_label = _('Undo') + toast.connect( + 'button-clicked', + lambda _toast, ref: HomePage._on_delete_toast_undo(ref(), True), + weakref.ref(self) + ) + toast.connect( + 'dismissed', + lambda _toast, ref: HomePage._on_delete_toast_dismissed(ref()), + weakref.ref(self) + ) + self._toast_overlay.add_toast(toast) + self.update_external_playlists() + + def _on_delete_toast_dismissed(self): + self._deleted_playlists.pop() + self.update_playlists() + self.update_external_playlists() + self._toast_overlay.props.sensitive = True + + def _on_delete_playlist(self, playlist: Group): + playlists.delete(playlist.title) + self._deleted_playlists.append(playlist) + + toast = Adw.Toast() + toast.props.title = _('Deleted playlist "{name}"').format( + name=GLib.markup_escape_text(playlist.title, -1) + ) + toast.props.priority = Adw.ToastPriority.HIGH + toast.props.button_label = _('Undo') + toast.connect( + 'button-clicked', + lambda _toast, ref: HomePage._on_delete_toast_undo(ref(), False), + weakref.ref(self) + ) + toast.connect( + 'dismissed', + lambda _toast, ref: HomePage._on_delete_toast_dismissed(ref()), + weakref.ref(self) + ) + self._toast_overlay.add_toast(toast) + self.update_playlists() + + def _on_delete_toast_undo(self, external_playlist: bool): + self._toast_overlay.props.sensitive = False + if external_playlist: + playlists.add_external(self._deleted_playlists[-1]) + else: + playlists.add(self._deleted_playlists[-1]) + + def _on_donate(self): + launcher = Gtk.UriLauncher() + launcher.props.uri = 'https://zeh-kira.itch.io/monophony/purchase' + launcher.launch() + + def focus_search(self): + self._search_bar.focus_search() + + def update_external_playlists(self): + self._external_playlists_group.update_contents(playlists.read_external()) + + def update_downloads(self, downloads: Group): + self._downloads_group.update_contents(downloads.songs) + + def update_download_status(self): + for group in ( + self._recommended_group, + self._playlists_group, + self._external_playlists_group, + self._downloads_group, + self._history_group + ): + group.update_download_status() + + def update_history(self): + new_contents = recents.read().songs + self._history_group.update_contents(new_contents) + + def update_playlists(self): + self._playlists_group.update_contents(playlists.read()) + + def update_recommendations(self): + self._recommended_group.update_contents(recommendations.read()) diff --git a/source/monophony/ui/pages/loading_page.py b/source/monophony/ui/pages/loading_page.py new file mode 100644 index 0000000000000000000000000000000000000000..9b8a638e1bbd7080da9008c3b9777ed5301ebf0f --- /dev/null +++ b/source/monophony/ui/pages/loading_page.py @@ -0,0 +1,24 @@ +from monophony.ui.pages.page import Page + +from gi.repository import Adw + + +class LoadingPage(Page): + __gtype_name__ = __qualname__ + + def __init__(self): + super().__init__() + + spinner = Adw.SpinnerPaintable() + + self._status_page = Adw.StatusPage() + self._status_page.props.paintable = spinner + self._status_page.props.description = '0%' + spinner.props.widget = self._status_page + + self._toolbar_view.props.content = self._status_page + + self.props.title = _('Loading...') + + def update_progress(self, progress: float): + self._status_page.props.description = f'{int(progress * 100)}%' diff --git a/source/monophony/ui/pages/page.py b/source/monophony/ui/pages/page.py new file mode 100644 index 0000000000000000000000000000000000000000..fe716d0523f7ca33a3ce1061c60fd1b9e5defdab --- /dev/null +++ b/source/monophony/ui/pages/page.py @@ -0,0 +1,38 @@ +import weakref + +from monophony.debug import MemoryDebugger +from monophony.ui.bars.header_bar import HeaderBar + +from gi.repository import Adw, GObject + + +class Page(MemoryDebugger, Adw.NavigationPage): + __gtype_name__ = __qualname__ + + def __init__(self): + super().__init__() + + # Must hold reference, otherwise the bar's weakref to self fails + self._header_bar = HeaderBar() + self._header_bar.connect( + 'show-about', + lambda _bar, ref: ref().emit('show-about'), + weakref.ref(self) + ) + + self._page = Adw.PreferencesPage() + + self._toolbar_view = Adw.ToolbarView() + self._toolbar_view.props.top_bar_style = Adw.ToolbarStyle.RAISED + self._toolbar_view.props.bottom_bar_style = Adw.ToolbarStyle.RAISED + self._toolbar_view.props.content = self._page + self._toolbar_view.add_top_bar(self._header_bar) + + self._toast_overlay = Adw.ToastOverlay() + self._toast_overlay.props.child = self._toolbar_view + + self.props.child = self._toast_overlay + + @GObject.Signal(name='show-about') + def _show_about(self): + return diff --git a/source/monophony/ui/pages/results_page.py b/source/monophony/ui/pages/results_page.py new file mode 100644 index 0000000000000000000000000000000000000000..0abe7fa3f07e92a398322d01f61971b041fa3674 --- /dev/null +++ b/source/monophony/ui/pages/results_page.py @@ -0,0 +1,315 @@ +import weakref + +from monophony.data import Artist, Group, Song +from monophony.ui.pages.page import Page +from monophony.ui.row_groups.importable_group_row_group import ImportableGroupRowGroup +from monophony.ui.row_groups.playable_row_group import PlayableRowGroup +from monophony.ui.row_groups.queueable_row_group import QueueableRowGroup +from monophony.ui.row_groups.row_group import RowGroup +from monophony.ui.rows.artist_row import ArtistRow +from monophony.ui.rows.importable_group_row import ImportableGroupRow +from monophony.ui.rows.song_row import SongRow +from monophony.yt import SearchResult + +from gi.repository import Adw, GLib, GObject + + +class ResultsPage(Page): + __gtype_name__ = __qualname__ + + def __init__(self, results: list[SearchResult], filter_: str | None): + super().__init__() + + self._results = results + + songs_button = Adw.ButtonRow() + songs_button.props.end_icon_name = 'go-next-symbolic' + songs_button.props.title = _('Show All') + if filter_ == 'songs': + songs_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', ''), + weakref.ref(self) + ) + else: + songs_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', 'songs'), + weakref.ref(self) + ) + + self._songs_button_group = Adw.PreferencesGroup() + self._songs_button_group.props.visible = False + self._songs_button_group.add(songs_button) + + videos_button = Adw.ButtonRow() + videos_button.props.end_icon_name = 'go-next-symbolic' + videos_button.props.title = _('Show All') + if filter_ == 'videos': + videos_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', ''), + weakref.ref(self) + ) + else: + videos_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', 'videos'), + weakref.ref(self) + ) + + self._videos_button_group = Adw.PreferencesGroup() + self._videos_button_group.props.visible = False + self._videos_button_group.add(videos_button) + + albums_button = Adw.ButtonRow() + albums_button.props.end_icon_name = 'go-next-symbolic' + albums_button.props.title = _('Show All') + if filter_ == 'albums': + albums_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', ''), + weakref.ref(self) + ) + else: + albums_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', 'albums'), + weakref.ref(self) + ) + + self._albums_button_group = Adw.PreferencesGroup() + self._albums_button_group.props.visible = False + self._albums_button_group.add(albums_button) + + playlists_button = Adw.ButtonRow() + playlists_button.props.end_icon_name = 'go-next-symbolic' + playlists_button.props.title = _('Show All') + if filter_ == 'playlists': + playlists_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', ''), + weakref.ref(self) + ) + else: + playlists_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', 'playlists'), + weakref.ref(self) + ) + + self._playlists_button_group = Adw.PreferencesGroup() + self._playlists_button_group.props.visible = False + self._playlists_button_group.add(playlists_button) + + artists_button = Adw.ButtonRow() + artists_button.props.end_icon_name = 'go-next-symbolic' + artists_button.props.title = _('Show All') + if filter_ == 'artists': + artists_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', ''), + weakref.ref(self) + ) + else: + artists_button.connect( + 'activated', + lambda _button, ref: ref().emit('filter-results', 'artists'), + weakref.ref(self) + ) + + self._artists_button_group = Adw.PreferencesGroup() + self._artists_button_group.props.visible = False + self._artists_button_group.add(artists_button) + + self._songs_group = QueueableRowGroup() + self._songs_group.props.title = _('Songs') + + self._videos_group = QueueableRowGroup() + self._videos_group.props.title = _('Videos') + + self._albums_group = ImportableGroupRowGroup() + self._albums_group.props.title = _('Albums and Singles') + + self._playlists_group = ImportableGroupRowGroup() + self._playlists_group.props.title = _('Playlists') + + self._artists_group = RowGroup() + self._artists_group.props.title = _('Artists') + + self._top_group = QueueableRowGroup() + for result in self._results: + if result.top: + if result.type == 'artist': + self._top_group = RowGroup() + elif result.type in ('album', 'playlist'): + self._top_group = ImportableGroupRowGroup() + self._top_group.props.title = _('Top Result') + if self._top_group.props.header_suffix: + self._top_group.props.header_suffix.props.visible = False + + for group, button_group in { + self._top_group: None, + self._songs_group: self._songs_button_group, + self._videos_group: self._videos_button_group, + self._albums_group: self._albums_button_group, + self._playlists_group: self._playlists_button_group, + self._artists_group: self._artists_button_group + }.items(): + group.props.margin_start = 12 + group.props.margin_end = 12 + group.connect( + 'view-artist', + lambda _group, artist, ref: ref().emit('view-artist', artist), + weakref.ref(self) + ) + if isinstance(group, QueueableRowGroup): + group.connect( + 'queue-song', + lambda _group, song, ref: ref().emit('queue-song', song), + weakref.ref(self) + ) + if isinstance(group, PlayableRowGroup): + group.connect( + 'play', + lambda _group, song, group, ref: ref().emit('play', song, group), + weakref.ref(self) + ) + group.connect( + 'add-song-to', + lambda _group, song, ref: ref().emit('add-song-to', song), + weakref.ref(self) + ) + group.connect( + 'undownload-song', + lambda _group, song, ref: ref().emit('undownload-song', song), + weakref.ref(self) + ) + group.connect( + 'download-song', + lambda _group, song, ref: ref().emit('download-song', song), + weakref.ref(self) + ) + if isinstance(group, ImportableGroupRowGroup): + group.connect( + 'import-group', + lambda _group, group, ref: ref().emit('import-group', group), + weakref.ref(self) + ) + group.connect( + 'queue-group', + lambda _group, group, ref: ref().emit('queue-group', group), + weakref.ref(self) + ) + group.connect( + 'add-group-to', + lambda _group, group, ref: ref().emit('add-group-to', group), + weakref.ref(self) + ) + group.connect( + 'download-group', + lambda _group, group, ref: ref().emit('download-group', group), + weakref.ref(self) + ) + + self._page.add(group) + if button_group and not filter_: + button_group.props.margin_start = 12 + button_group.props.margin_end = 12 + self._page.add(button_group) + + self.props.title = _('Search Results') + self.props.sensitive = False + + GLib.idle_add(self._load) + + @GObject.Signal(name='add-group-to', arg_types=(object,)) + def _add_group_to(self, _group: Group): + return + + @GObject.Signal(name='add-song-to', arg_types=(object,)) + def _add_song_to(self, _song: Song): + return + + @GObject.Signal(name='download-group', arg_types=(object,)) + def _download_group(self, _group: Group): + return + + @GObject.Signal(name='download-song', arg_types=(object,)) + def _download_song(self, _song: Song): + return + + @GObject.Signal(name='filter-results', arg_types=(str,)) + def _filter_results(self, _filter: str): + return + + @GObject.Signal(name='import-group', arg_types=(object,)) + def _import_group(self, _group: Group): + return + + @GObject.Signal(name='play', arg_types=(object, object)) + def _play(self, _song: Song, _group: Group): + return + + @GObject.Signal(name='queue-group', arg_types=(object,)) + def _queue_group(self, _group: Group): + return + + @GObject.Signal(name='queue-song', arg_types=(object,)) + def _queue_song(self, _song: Song): + return + + @GObject.Signal(name='undownload-song', arg_types=(object,)) + def _undownload_song(self, _song: Song): + return + + @GObject.Signal(name='view-artist', arg_types=(object,)) + def _view_artist(self, _artist: Artist): + return + + def _load(self) -> bool: + if self._results: + result = self._results.pop(0) + row = None + + if isinstance(result.item, Group): + row = ImportableGroupRow(result.item) + elif isinstance(result.item, Song): + row = SongRow(result.item) + else: + row = ArtistRow(result.item) + + if result.top: + self._top_group.add(row) + elif result.type == 'artist': + self._artists_button_group.props.visible = True + self._artists_group.add(row) + elif result.type == 'album': + self._albums_button_group.props.visible = True + self._albums_group.add(row) + elif result.type == 'playlist': + self._playlists_button_group.props.visible = True + self._playlists_group.add(row) + elif result.type == 'song': + self._songs_button_group.props.visible = True + self._songs_group.add(row) + else: + self._videos_button_group.props.visible = True + self._videos_group.add(row) + + return True + + self.props.sensitive = True + return False + + def update_download_status(self): + for group in ( + self._top_group, + self._songs_group, + self._videos_group, + self._albums_group, + self._playlists_group, + ): + # Top group is non-playable when top result is an artist + if isinstance(group, PlayableRowGroup): + group.update_download_status() diff --git a/source/monophony/ui/pages/status_page.py b/source/monophony/ui/pages/status_page.py new file mode 100644 index 0000000000000000000000000000000000000000..01dd9940151807851624938fa3825ea8c95d6da3 --- /dev/null +++ b/source/monophony/ui/pages/status_page.py @@ -0,0 +1,19 @@ +from monophony.ui.pages.page import Page + +from gi.repository import Adw + + +class StatusPage(Page): + __gtype_name__ = __qualname__ + + def __init__(self, title: str, details: str, icon: str): + super().__init__() + + status_page = Adw.StatusPage() + status_page.props.title = title + status_page.props.description = details + status_page.props.icon_name = icon + + self._toolbar_view.props.content = status_page + + self.props.title = title diff --git a/source/monophony/frontend/__init__.py b/source/monophony/ui/popovers/__init__.py similarity index 100% rename from source/monophony/frontend/__init__.py rename to source/monophony/ui/popovers/__init__.py diff --git a/source/monophony/ui/popovers/editable_group_row_popover.py b/source/monophony/ui/popovers/editable_group_row_popover.py new file mode 100644 index 0000000000000000000000000000000000000000..a3d2aa0bd4ddb8849f79f701afbb0fdb218fff5a --- /dev/null +++ b/source/monophony/ui/popovers/editable_group_row_popover.py @@ -0,0 +1,26 @@ +from monophony.ui.popovers.group_row_popover import GroupRowPopover + +from gi.repository import GObject + + +class EditableGroupRowPopover(GroupRowPopover): + __gtype_name__ = __qualname__ + actions = (*GroupRowPopover.actions, 'rename-playlist', 'delete-playlist') + + def __init__(self): + super().__init__(False) + + self.props.menu_model.append( + _('Rename...'), self.__gtype_name__ + '.rename-playlist' + ) + self.props.menu_model.append( + _('Delete'), self.__gtype_name__ + '.delete-playlist' + ) + + @GObject.Signal(name='rename-playlist') + def _rename_playlist(self): + return + + @GObject.Signal(name='delete-playlist') + def _delete_playlist(self): + return diff --git a/source/monophony/ui/popovers/editable_song_row_popover.py b/source/monophony/ui/popovers/editable_song_row_popover.py new file mode 100644 index 0000000000000000000000000000000000000000..7f619bc53bff740f7fdbbc629043c7d5a0f1eca3 --- /dev/null +++ b/source/monophony/ui/popovers/editable_song_row_popover.py @@ -0,0 +1,19 @@ +from monophony.ui.popovers.song_row_popover import SongRowPopover + +from gi.repository import GObject + + +class EditableSongRowPopover(SongRowPopover): + __gtype_name__ = __qualname__ + actions = (*SongRowPopover.actions, 'remove-song') + + def __init__(self, downloaded: bool, being_downloaded: bool): + super().__init__(downloaded, being_downloaded) + + self.props.menu_model.append( + _('Remove From Playlist'), self.__gtype_name__ + '.remove-song' + ) + + @GObject.Signal(name='remove-song') + def _remove_song(self): + return diff --git a/source/monophony/ui/popovers/group_row_popover.py b/source/monophony/ui/popovers/group_row_popover.py new file mode 100644 index 0000000000000000000000000000000000000000..29d08f11c73e180debdb5a1f9d3ab5101b00019a --- /dev/null +++ b/source/monophony/ui/popovers/group_row_popover.py @@ -0,0 +1,42 @@ +from monophony.ui.popovers.row_popover import RowPopover + +from gi.repository import Gio, GObject + + +class GroupRowPopover(RowPopover): + __gtype_name__ = __qualname__ + actions = ( + *RowPopover.actions, + 'queue-group', + 'add-group-to', + 'view-artist', + 'download-group' + ) + + def __init__(self, viewable_artist: bool): + super().__init__() + + menu = Gio.Menu() + menu.append(_('Add to Queue'), self.__gtype_name__ + '.queue-group') + menu.append(_('Add to...'), self.__gtype_name__ + '.add-group-to') + if viewable_artist: + menu.append(_('View Artist'), self.__gtype_name__ + '.view-artist') + menu.append(_('Download'), self.__gtype_name__ + '.download-group') + + self.props.menu_model = menu + + @GObject.Signal(name='queue-group') + def _queue_group(self): + return + + @GObject.Signal(name='add-group-to') + def _add_group_to(self): + return + + @GObject.Signal(name='view-artist') + def _view_artist(self): + return + + @GObject.Signal(name='download-group') + def _download_group(self): + return diff --git a/source/monophony/ui/popovers/importable_group_row_popover.py b/source/monophony/ui/popovers/importable_group_row_popover.py new file mode 100644 index 0000000000000000000000000000000000000000..bf017096854be8e8ec04c7e6e893ac26335a1b1c --- /dev/null +++ b/source/monophony/ui/popovers/importable_group_row_popover.py @@ -0,0 +1,20 @@ +from monophony.ui.popovers.group_row_popover import GroupRowPopover + +from gi.repository import GObject + + +class ImportableGroupRowPopover(GroupRowPopover): + __gtype_name__ = __qualname__ + actions = (*GroupRowPopover.actions, 'import-group') + + def __init__(self, viewable_artist: bool): + super().__init__(viewable_artist) + + self.props.menu_model.append( + _('Import...'), self.__gtype_name__ + '.import-group' + ) + + @GObject.Signal(name='import-group') + def _import_group(self): + return + diff --git a/source/monophony/ui/popovers/queue_song_row_popover.py b/source/monophony/ui/popovers/queue_song_row_popover.py new file mode 100644 index 0000000000000000000000000000000000000000..70e0a177525e60f49c3d4aebff8b9eef7342d53f --- /dev/null +++ b/source/monophony/ui/popovers/queue_song_row_popover.py @@ -0,0 +1,20 @@ +from monophony.ui.popovers.song_row_popover import SongRowPopover + +from gi.repository import GObject + + +class QueueSongRowPopover(SongRowPopover): + __gtype_name__ = __qualname__ + actions = (*SongRowPopover.actions, 'unqueue-song') + + def __init__(self, downloaded: bool, being_downloaded: bool): + super().__init__(downloaded, being_downloaded) + + self.props.menu_model.remove(0) # queue-song + self.props.menu_model.append( + _('Remove From Queue'), self.__gtype_name__ + '.unqueue-song' + ) + + @GObject.Signal(name='unqueue-song') + def _unqueue_song(self): + return diff --git a/source/monophony/ui/popovers/row_popover.py b/source/monophony/ui/popovers/row_popover.py new file mode 100644 index 0000000000000000000000000000000000000000..110bf02bcea0bdd00b5dc9d4b2c9727c73c6c17c --- /dev/null +++ b/source/monophony/ui/popovers/row_popover.py @@ -0,0 +1,19 @@ +from monophony.debug import MemoryDebugger + +from gi.repository import Gtk + + +class RowPopover(MemoryDebugger, Gtk.PopoverMenu): + __gtype_name__ = __qualname__ + actions = () + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + + for action in cls.actions: + cls.install_action( + cls.__gtype_name__ + '.' + action, None, cls.emit_signal_from_action + ) + + def emit_signal_from_action(self, action: str, _property: None): + self.emit(action.split('.')[-1]) diff --git a/source/monophony/ui/popovers/song_row_popover.py b/source/monophony/ui/popovers/song_row_popover.py new file mode 100644 index 0000000000000000000000000000000000000000..eaaa1f056160e7c78169beeaf02c57e67b871c86 --- /dev/null +++ b/source/monophony/ui/popovers/song_row_popover.py @@ -0,0 +1,51 @@ +from monophony.ui.popovers.row_popover import RowPopover + +from gi.repository import Gio, GObject + + +class SongRowPopover(RowPopover): + __gtype_name__ = __qualname__ + actions = ( + *RowPopover.actions, + 'queue-song', + 'add-song-to', + 'view-artist', + 'undownload-song', + 'download-song' + ) + + def __init__(self, downloaded: bool, being_downloaded: bool): + super().__init__() + + menu = Gio.Menu() + menu.append(_('Add to Queue'), self.__gtype_name__ + '.queue-song') + menu.append(_('Add to...'), self.__gtype_name__ + '.add-song-to') + menu.append(_('View Artist'), self.__gtype_name__ + '.view-artist') + if downloaded: + menu.append( + _('Remove From Downloads'), self.__gtype_name__ + '.undownload-song' + ) + elif not being_downloaded: + menu.append(_('Download'), self.__gtype_name__ + '.download-song') + + self.props.menu_model = menu + + @GObject.Signal(name='queue-song') + def _queue_song(self): + return + + @GObject.Signal(name='add-song-to') + def _add_song_to(self): + return + + @GObject.Signal(name='view-artist') + def _view_artist(self): + return + + @GObject.Signal(name='undownload-song') + def _undownload_song(self): + return + + @GObject.Signal(name='download-song') + def _download_song(self): + return diff --git a/source/monophony/ui/popovers/synchronized_group_row_popover.py b/source/monophony/ui/popovers/synchronized_group_row_popover.py new file mode 100644 index 0000000000000000000000000000000000000000..67e71fddaee344aef7b9dc15b69af39683cb8949 --- /dev/null +++ b/source/monophony/ui/popovers/synchronized_group_row_popover.py @@ -0,0 +1,20 @@ +from monophony.ui.popovers.group_row_popover import GroupRowPopover + +from gi.repository import GObject + + +class SynchronizedGroupRowPopover(GroupRowPopover): + __gtype_name__ = __qualname__ + actions = (*GroupRowPopover.actions, 'delete-playlist') + + def __init__(self): + super().__init__(False) + + self.props.menu_model.append( + _('Delete'), self.__gtype_name__ + '.delete-playlist' + ) + + @GObject.Signal(name='delete-playlist') + def _delete_playlist(self): + return + diff --git a/source/monophony/ui/queue_sidebar.py b/source/monophony/ui/queue_sidebar.py new file mode 100644 index 0000000000000000000000000000000000000000..ec74327c4c72135ac9594f21bf8528f0f1af51bd --- /dev/null +++ b/source/monophony/ui/queue_sidebar.py @@ -0,0 +1,206 @@ +import weakref + +from monophony.data import Artist, Group, Song, TimeString +from monophony.ui.row_groups.queue_row_group import QueueRowGroup +from monophony.ui.rows.queue_song_row import QueueSongRow + +from gi.repository import Adw, GObject, Gtk + + +class QueueSidebar(Adw.Bin): + __gtype_name__ = __qualname__ + _min_songs_for_shuffle = 3 + + def __init__(self): + super().__init__() + + clear_button = Gtk.Button.new_from_icon_name('media-playback-stop-symbolic') + clear_button.props.tooltip_text = _('Stop') + clear_button.props.halign = Gtk.Align.FILL + clear_button.props.hexpand = True + clear_button.add_css_class('destructive-action') + clear_button.connect( + 'clicked', + lambda _button, ref: ref().emit('clear-queue'), + weakref.ref(self) + ) + + self._shuffle_button = Gtk.Button.new_from_icon_name( + 'media-playlist-shuffle-symbolic' + ) + self._shuffle_button.props.tooltip_text = _('Shuffle') + self._shuffle_button.props.halign = Gtk.Align.FILL + self._shuffle_button.props.hexpand = True + self._shuffle_button.connect( + 'clicked', + lambda _button, ref: ref().emit('shuffle-queue'), + weakref.ref(self) + ) + + button_content = Adw.ButtonContent() + button_content.props.label = _('Add to...') + button_content.props.icon_name = 'list-add-symbolic' + add_button = Gtk.Button() + add_button.props.child = button_content + add_button.connect( + 'clicked', + lambda _button, ref: ref().emit('add-group-to', Group()), + weakref.ref(self) + ) + + internal_buttons_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) + internal_buttons_box.props.spacing = 6 + internal_buttons_box.props.halign = Gtk.Align.FILL + internal_buttons_box.props.hexpand = True + internal_buttons_box.append(clear_button) + internal_buttons_box.append(self._shuffle_button) + + buttons_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + buttons_box.props.spacing = 6 + buttons_box.props.margin_start = 6 + buttons_box.append(internal_buttons_box) + buttons_box.append(add_button) + + self._queue_group = QueueRowGroup() + self._queue_group.props.title = _('Currently Playing') + self._queue_group.props.description = '00:00:00' + self._queue_group.props.margin_start = 12 + self._queue_group.props.margin_end = 12 + self._queue_group.props.header_suffix = buttons_box + self._queue_group.connect( + 'play', + lambda _group, song, group, ref: ref().emit('play', song, group), + weakref.ref(self) + ) + self._queue_group.connect( + 'add-song-to', + lambda _group, song, ref: ref().emit('add-song-to', song), + weakref.ref(self) + ) + self._queue_group.connect( + 'view-artist', + lambda _group, artist, ref: ref().emit('view-artist', artist), + weakref.ref(self) + ) + self._queue_group.connect( + 'undownload-song', + lambda _group, song, ref: ref().emit('undownload-song', song), + weakref.ref(self) + ) + self._queue_group.connect( + 'download-song', + lambda _group, song, ref: ref().emit('download-song', song), + weakref.ref(self) + ) + self._queue_group.connect( + 'move-song', + lambda _group, from_s, to_s, ref: + ref().emit('move-song', from_s, to_s), + weakref.ref(self) + ) + self._queue_group.connect( + 'unqueue-song', + lambda _group, song, ref: ref().emit('unqueue-song', song), + weakref.ref(self) + ) + + queue_page = Adw.PreferencesPage() + queue_page.props.valign = Gtk.Align.FILL + queue_page.props.vexpand = True + queue_page.props.visible = False + queue_page.add(self._queue_group) + + self._status_page = Adw.StatusPage() + self._status_page.props.valign = Gtk.Align.FILL + self._status_page.props.vexpand = True + self._status_page.props.title = _('Queue Empty') + self._status_page.props.description = _('Nothing is playing right now') + self._status_page.props.icon_name = 'view-list-symbolic' + self._status_page.bind_property( + 'visible', + queue_page, + 'visible', + GObject.BindingFlags.BIDIRECTIONAL | + GObject.BindingFlags.INVERT_BOOLEAN | + GObject.BindingFlags.SYNC_CREATE + ) + + pages_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL) + pages_box.props.valign = Gtk.Align.FILL + pages_box.props.vexpand = True + pages_box.append(queue_page) + pages_box.append(self._status_page) + + self.hide_button = Gtk.Button.new_from_icon_name('go-previous-symbolic') + self.hide_button.props.tooltip_text = _('Back') + + header_bar = Adw.HeaderBar() + header_bar.props.title_widget = Adw.WindowTitle(title=_('Queue')) + header_bar.pack_start(self.hide_button) + + toolbar_view = Adw.ToolbarView() + toolbar_view.props.content = pages_box + toolbar_view.add_top_bar(header_bar) + + self.props.child = toolbar_view + + @GObject.Signal(name='play', arg_types=(object, object)) + def _play(self, _song: Song, _group: Group): + return + + @GObject.Signal(name='add-song-to', arg_types=(object,)) + def _add_song_to(self, _song: Song): + return + + @GObject.Signal(name='undownload-song', arg_types=(object,)) + def _undownload_song(self, _song: Song): + return + + @GObject.Signal(name='download-song', arg_types=(object,)) + def _download_song(self, _song: Song): + return + + @GObject.Signal(name='move-song', arg_types=(object, object)) + def _move_song(self, _from: Song, _to: Song): + return + + @GObject.Signal(name='unqueue-song', arg_types=(object,)) + def _unqueue_song(self, _song: Song): + return + + @GObject.Signal(name='add-group-to', arg_types=(object,)) + def _add_group_to(self, _group: Group): + return + + @GObject.Signal(name='view-artist', arg_types=(object,)) + def _view_artist(self, _artist: Artist): + return + + @GObject.Signal(name='shuffle-queue') + def _shuffle_queue(self): + return + + @GObject.Signal(name='clear-queue') + def _clear_queue(self): + return + + def add_song_row(self, song: Song): + self._queue_group.add(QueueSongRow(song)) + + def update_contents(self, group: Group, song_index: int): + self._queue_group.update_contents(group.songs, song_index) + self._status_page.props.visible = not bool(group.songs) + self._shuffle_button.props.sensitive = ( + len(group.songs) >= self._min_songs_for_shuffle + ) + + total_seconds = 0 + for song in group.songs: + total_seconds += TimeString(string=song.length).as_seconds() + + self._queue_group.props.description = TimeString( + seconds=total_seconds + ).as_string() + + def update_download_status(self): + self._queue_group.update_download_status() diff --git a/source/monophony/frontend/rows/__init__.py b/source/monophony/ui/row_groups/__init__.py similarity index 100% rename from source/monophony/frontend/rows/__init__.py rename to source/monophony/ui/row_groups/__init__.py diff --git a/source/monophony/ui/row_groups/editable_group_row_group.py b/source/monophony/ui/row_groups/editable_group_row_group.py new file mode 100644 index 0000000000000000000000000000000000000000..fd9057c4bf5cb238713e41fe5204f6004e261154 --- /dev/null +++ b/source/monophony/ui/row_groups/editable_group_row_group.py @@ -0,0 +1,26 @@ +import weakref + +from monophony.data import Group +from monophony.ui.row_groups.group_row_group import GroupRowGroup +from monophony.ui.rows.editable_group_row import EditableGroupRow + +from gi.repository import GObject + + +class EditableGroupRowGroup(GroupRowGroup): + __gtype_name__ = __qualname__ + _row_type = EditableGroupRow + + @GObject.Signal(name='delete-playlist', arg_types=(object,)) + def _delete_playlist(self, _playlist: Group): + return + + def add(self, row: _row_type): + super().add(row) + + row.connect( + 'delete-playlist', + lambda _row, playlist, ref: ref().emit('delete-playlist', playlist), + weakref.ref(self) + ) + diff --git a/source/monophony/ui/row_groups/group_row_group.py b/source/monophony/ui/row_groups/group_row_group.py new file mode 100644 index 0000000000000000000000000000000000000000..84aafd071c690d8a2b3e56300a6e9309956b076b --- /dev/null +++ b/source/monophony/ui/row_groups/group_row_group.py @@ -0,0 +1,64 @@ +import weakref + +from monophony.data import Group +from monophony.ui.row_groups.queueable_row_group import QueueableRowGroup +from monophony.ui.rows.group_row import GroupRow + +from gi.repository import GObject + + +class GroupRowGroup(QueueableRowGroup): + __gtype_name__ = __qualname__ + _row_type = GroupRow + + @GObject.Signal(name='queue-group', arg_types=(object,)) + def _queue_group(self, _group: Group): + return + + @GObject.Signal(name='add-group-to', arg_types=(object,)) + def _add_group_to(self, _group: Group): + return + + @GObject.Signal(name='download-group', arg_types=(object,)) + def _download_group(self, _group: Group): + return + + def add(self, row: _row_type): + super().add(row) + + row.connect( + 'queue-group', + lambda _row, group, ref: ref().emit('queue-group', group), + weakref.ref(self) + ) + row.connect( + 'add-group-to', + lambda _row, group, ref: ref().emit('add-group-to', group), + weakref.ref(self) + ) + row.connect( + 'download-group', + lambda _row, group, ref: ref().emit('download-group', group), + weakref.ref(self) + ) + + def on_play_all(self): + group = Group() + for row_ref in self._rows: + group.songs += row_ref().group.songs + + self.emit('play', group.songs[0], group) + + def update_contents(self, new_groups: list[Group]): + new_titles = [group.title for group in new_groups] + existing_titles = [] + for row in self._rows.copy(): + if row().group.title in new_titles and row().props.expanded: + row().update_contents() + existing_titles.append(row().group.title) + else: + self.remove(row()) + + for group in new_groups: + if group.title not in existing_titles: + self.add(self._row_type(group)) diff --git a/source/monophony/ui/row_groups/importable_group_row_group.py b/source/monophony/ui/row_groups/importable_group_row_group.py new file mode 100644 index 0000000000000000000000000000000000000000..2ebc9243946e4c503fc82ab232c673a85a0dbb7d --- /dev/null +++ b/source/monophony/ui/row_groups/importable_group_row_group.py @@ -0,0 +1,25 @@ +import weakref + +from monophony.data import Group +from monophony.ui.row_groups.group_row_group import GroupRowGroup +from monophony.ui.rows.importable_group_row import ImportableGroupRow + +from gi.repository import GObject + + +class ImportableGroupRowGroup(GroupRowGroup): + __gtype_name__ = __qualname__ + _row_type = ImportableGroupRow + + @GObject.Signal(name='import-group', arg_types=(object,)) + def _import_group(self, _group: Group): + return + + def add(self, row: _row_type): + super().add(row) + + row.connect( + 'import-group', + lambda _row, group, ref: ref().emit('import-group', group), + weakref.ref(self) + ) diff --git a/source/monophony/ui/row_groups/playable_row_group.py b/source/monophony/ui/row_groups/playable_row_group.py new file mode 100644 index 0000000000000000000000000000000000000000..e403b8ff98e240cf7ee209d218a5b49c37b5dd45 --- /dev/null +++ b/source/monophony/ui/row_groups/playable_row_group.py @@ -0,0 +1,55 @@ +import weakref + +from monophony.data import Group, Song +from monophony.ui.row_groups.row_group import RowGroup + +from gi.repository import GObject, Gtk + + +class PlayableRowGroup(RowGroup): + __gtype_name__ = __qualname__ + _row_type = Gtk.ListBoxRow # SongRow | GroupRow + + @GObject.Signal(name='play', arg_types=(object, object)) + def _play(self, _song: Song, _group: Group): + return + + @GObject.Signal(name='add-song-to', arg_types=(object,)) + def _add_song_to(self, _song: Song): + return + + @GObject.Signal(name='undownload-song', arg_types=(object,)) + def _undownload_song(self, _song: Song): + return + + @GObject.Signal(name='download-song', arg_types=(object,)) + def _download_song(self, _song: Song): + return + + def add(self, row: _row_type): + super().add(row) + + row.connect( + 'play', + lambda _row, song, group, ref: ref().emit('play', song, group), + weakref.ref(self) + ) + row.connect( + 'add-song-to', + lambda _row, song, ref: ref().emit('add-song-to', song), + weakref.ref(self) + ) + row.connect( + 'undownload-song', + lambda _row, song, ref: ref().emit('undownload-song', song), + weakref.ref(self) + ) + row.connect( + 'download-song', + lambda _row, song, ref: ref().emit('download-song', song), + weakref.ref(self) + ) + + def update_download_status(self): + for row in self._rows: + row().update_download_status() diff --git a/source/monophony/ui/row_groups/queue_row_group.py b/source/monophony/ui/row_groups/queue_row_group.py new file mode 100644 index 0000000000000000000000000000000000000000..0b75ac08e97cbf58d8fe45338003dcb6d9443827 --- /dev/null +++ b/source/monophony/ui/row_groups/queue_row_group.py @@ -0,0 +1,41 @@ +import weakref + +from monophony.data import Song +from monophony.ui.row_groups.playable_row_group import PlayableRowGroup +from monophony.ui.rows.queue_song_row import QueueSongRow + +from gi.repository import GObject + + +class QueueRowGroup(PlayableRowGroup): + __gtype_name__ = __qualname__ + _row_type = QueueSongRow + + @GObject.Signal(name='move-song', arg_types=(object, object)) + def _move_song(self, _from: Song, _to: Song): + return + + @GObject.Signal(name='unqueue-song', arg_types=(object,)) + def _unqueue_song(self, _song: Song): + return + + def add(self, row: QueueSongRow): + super().add(row) + + row.connect( + 'move-song', + lambda _row, from_s, to_s, ref: ref().emit('move-song', from_s, to_s), + weakref.ref(self) + ) + row.connect( + 'unqueue-song', + lambda _row, song, ref: ref().emit('unqueue-song', song), + weakref.ref(self) + ) + + def update_contents(self, new_songs: list[Song], song_index: int): + super().update_contents(new_songs) + + for i, row_ref in enumerate(self._rows): + if i == song_index: + row_ref().add_css_class('accent') diff --git a/source/monophony/ui/row_groups/queueable_row_group.py b/source/monophony/ui/row_groups/queueable_row_group.py new file mode 100644 index 0000000000000000000000000000000000000000..d1aa64eca8de94ff953e7aec934bc1fb516c89f7 --- /dev/null +++ b/source/monophony/ui/row_groups/queueable_row_group.py @@ -0,0 +1,45 @@ +import weakref + +from monophony.data import Group, Song +from monophony.ui.row_groups.playable_row_group import PlayableRowGroup +from monophony.ui.rows.song_row import SongRow + +from gi.repository import GObject, Gtk + + +class QueueableRowGroup(PlayableRowGroup): + __gtype_name__ = __qualname__ + _row_type = SongRow + + def __init__(self): + super().__init__() + + play_button = Gtk.Button.new_from_icon_name( + 'media-playback-start-symbolic' + ) + play_button.props.tooltip_text = _('Play All') + play_button.connect( + 'clicked', + lambda _button, ref: ref().on_play_all(), + weakref.ref(self) + ) + + self.props.header_suffix = play_button + + @GObject.Signal(name='queue-song', arg_types=(object,)) + def _queue_song(self, _song: Song): + return + + def add(self, row: _row_type): + super().add(row) + + row.connect( + 'queue-song', + lambda _row, song, ref: ref().emit('queue-song', song), + weakref.ref(self) + ) + + def on_play_all(self): + group = Group(songs=[row_ref().song for row_ref in self._rows]) + self.emit('play', group.songs[0], group) + diff --git a/source/monophony/ui/row_groups/row_group.py b/source/monophony/ui/row_groups/row_group.py new file mode 100644 index 0000000000000000000000000000000000000000..30070f90d842cb2ca6cbb8fecc6e87796dbe3179 --- /dev/null +++ b/source/monophony/ui/row_groups/row_group.py @@ -0,0 +1,53 @@ +import weakref + +from monophony.data import Artist, YTItem +from monophony.debug import MemoryDebugger + +from gi.repository import Adw, GObject, Gtk + + +class RowGroup(MemoryDebugger, Adw.PreferencesGroup): + __gtype_name__ = __qualname__ + _row_type = Gtk.ListBoxRow + + def __init__(self): + super().__init__() + + self.props.visible = False + self._rows = [] + + @GObject.Signal(name='view-artist', arg_types=(object,)) + def _view_artist(self, _artist: Artist): + return + + def add(self, row: _row_type): + super().add(row) + + self._rows.append(weakref.ref(row)) + + row.connect( + 'view-artist', + lambda _row, artist, ref: ref().emit('view-artist', artist), + weakref.ref(self) + ) + self.props.visible = True + + def clear(self): + for reference in self._rows: + super().remove(reference()) + + self._rows.clear() + self.props.visible = False + + def remove(self, row: _row_type): + super().remove(row) + + self._rows = [reference for reference in self._rows if reference() is not row] + self.props.visible = bool(self._rows) + + def update_contents(self, new_contents: list[YTItem]): + self.clear() + + for item in new_contents: + self.add(self._row_type(item)) + diff --git a/source/monophony/ui/row_groups/synchronized_group_row_group.py b/source/monophony/ui/row_groups/synchronized_group_row_group.py new file mode 100644 index 0000000000000000000000000000000000000000..e7d3ab3ae22d1a27d8d1add58978e6a91cb261fe --- /dev/null +++ b/source/monophony/ui/row_groups/synchronized_group_row_group.py @@ -0,0 +1,8 @@ +from monophony.ui.row_groups.editable_group_row_group import EditableGroupRowGroup +from monophony.ui.rows.synchronized_group_row import SynchronizedGroupRow + + +class SynchronizedGroupRowGroup(EditableGroupRowGroup): + __gtype_name__ = __qualname__ + _row_type = SynchronizedGroupRow + diff --git a/source/monophony/ui/rows/__init__.py b/source/monophony/ui/rows/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/source/monophony/ui/rows/artist_row.py b/source/monophony/ui/rows/artist_row.py new file mode 100644 index 0000000000000000000000000000000000000000..def40267542d43e0e9b496d03a6c800b16ff4c20 --- /dev/null +++ b/source/monophony/ui/rows/artist_row.py @@ -0,0 +1,29 @@ +from monophony.data import Artist +from monophony.debug import MemoryDebugger + +from gi.repository import Adw, GLib, GObject, Gtk + + +class ArtistRow(MemoryDebugger, Adw.ActionRow): + __gtype_name__ = __qualname__ + + def __init__(self, artist: Artist): + super().__init__() + + self.artist = artist + + self.props.tooltip_text = _('View Artist') + self.props.activatable = True + self.props.title = GLib.markup_escape_text(artist.name, -1) + + view_button = Gtk.Button.new_from_icon_name('go-next-symbolic') + view_button.props.tooltip_text = _('View Artist') + view_button.props.vexpand = False + view_button.props.valign = Gtk.Align.CENTER + view_button.props.has_frame = False + self.add_suffix(view_button) + self.connect('activated', lambda row: row.emit('view-artist', row.artist)) + + @GObject.Signal(name='view-artist', arg_types=(object,)) + def _view_artist(self, _artist: Artist): + return diff --git a/source/monophony/ui/rows/draggable_song_row.py b/source/monophony/ui/rows/draggable_song_row.py new file mode 100644 index 0000000000000000000000000000000000000000..3c699faf304c0cc56a0717e2c60aa5b5ba420fb4 --- /dev/null +++ b/source/monophony/ui/rows/draggable_song_row.py @@ -0,0 +1,80 @@ +import weakref + +from monophony.data import Song +from monophony.ui.rows.song_row import SongRow + +from gi.repository import Gdk, GObject, Gtk + + +class DraggableSongRow(SongRow): + __gtype_name__ = __qualname__ + + def __init__(self, song: Song): + super().__init__(song) + + self.drag_source = Gtk.DragSource() + self.drag_source.props.actions = Gdk.DragAction.MOVE + self.drag_source.set_icon(Gtk.WidgetPaintable.new(self), 0, 0) + self.drag_source.connect( + 'prepare', + lambda _source, _x, _y, row: DraggableSongRow._on_drag_prepare(row()), + weakref.ref(self) + ) + self.drag_source.connect( + 'drag-end', + lambda _source, _drag, _data, row: DraggableSongRow._on_drag_end(row()), + weakref.ref(self) + ) + self.drag_source.connect( + 'drag-cancel', + lambda _source, _drag, _data, row: DraggableSongRow._on_drag_end(row()), + weakref.ref(self) + ) + + handle_image = Gtk.Image.new_from_icon_name('list-drag-handle-symbolic') + handle_image.add_css_class('dimmed') + + drop_target = Gtk.DropTarget.new(self.__gtype__, Gdk.DragAction.MOVE) + drop_target.connect( + 'drop', + lambda _target, drop, _x, _y, row: DraggableSongRow._on_drop(row(), drop), + weakref.ref(self) + ) + drop_target.connect( + 'enter', + lambda target, _x, _y, row: DraggableSongRow._on_drag_enter(row(), target), + weakref.ref(self) + ) + + self.add_controller(self.drag_source) + self.add_controller(drop_target) + self.add_prefix(handle_image) + + @GObject.Signal(name='move-song', arg_types=(object, object)) + def _move_song(self, _from: Song, _to: Song): + return + + def _on_drag_prepare(self) -> Gdk.ContentProvider: + self.add_css_class('background') + return Gdk.ContentProvider.new_for_value(self) + + def _on_drag_enter(self, drop_target: Gtk.DropTarget) -> int: + if self.drag_source.get_drag(): + drop_target.reject() + return 0 + + i = 0 + while row := self.get_parent().get_row_at_index(i): + i += 1 + if row.drag_source.get_drag(): + return Gdk.DragAction.MOVE + + drop_target.reject() + return 0 + + def _on_drag_end(self): + self.remove_css_class('background') + + def _on_drop(self, dropped_row: SongRow) -> bool: + self.emit('move-song', dropped_row.song, self.song) + return True diff --git a/source/monophony/ui/rows/editable_group_row.py b/source/monophony/ui/rows/editable_group_row.py new file mode 100644 index 0000000000000000000000000000000000000000..484faf161692d44fb8110257c2555e9a6664b094 --- /dev/null +++ b/source/monophony/ui/rows/editable_group_row.py @@ -0,0 +1,118 @@ +import weakref + +from monophony import logging, playlists +from monophony.data import Group, Song +from monophony.ui.popovers.editable_group_row_popover import EditableGroupRowPopover +from monophony.ui.rows.editable_song_row import EditableSongRow +from monophony.ui.rows.group_row import GroupRow +from monophony.ui.windows.rename_window import RenameWindow + +from gi.repository import GLib, GObject, Gtk + + +class EditableGroupRow(GroupRow): + __gtype_name__ = __qualname__ + _row_type = EditableSongRow + + def __init__(self, group: Group): + super().__init__(group) + + self._more_button.set_create_popup_func( + lambda button, ref: EditableGroupRow._on_show_more(ref(), button), + weakref.ref(self) + ) + + @GObject.Signal(name='delete-playlist', arg_types=(object,)) + def _delete_playlist(self, _playlist: Group): + return + + def _on_show_more(self, button: Gtk.MenuButton): + self._popover = EditableGroupRowPopover() + self._popover.connect( + 'queue-group', + lambda _popover, row: row().emit('queue-group', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'add-group-to', + lambda _popover, row: row().emit('add-group-to', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'download-group', + lambda _popover, row: row().emit('download-group', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'delete-playlist', + lambda _popover, row: row().emit('delete-playlist', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'rename-playlist', + lambda _popover, row: EditableGroupRow._on_rename_playlist(row()), + weakref.ref(self) + ) + button.set_popover(self._popover) + + def add_row(self, row: _row_type): + super().add_row(row) + row.connect( + 'move-song', + lambda _row, from_s, to_s, ref: + EditableGroupRow._on_move_song(ref(), from_s, to_s), + weakref.ref(self) + ) + row.connect( + 'remove-song', + lambda _row, song, ref: EditableGroupRow._on_remove_song(ref(), song), + weakref.ref(self) + ) + + def update_contents(self): + logging.info( + __name__, f'Updating row contents for playlist "{self.group.title}"...' + ) + + for playlist in playlists.read(): + if playlist.title == self.group.title: + self.group = playlist + break + else: + logging.error( + __name__, + 'Failed to update row contents - ' + f'playlist "{self.group.title}" does not exist' + ) + return + + super().update_contents() + logging.info(__name__, 'Updated row contents') + + def _on_move_song(self, from_song: Song, to_song: Song): + i = self.group.songs.index(from_song) + j = self.group.songs.index(to_song) + if abs(i - j) == 1: + playlists.swap_songs(self.group.title, i, j) + else: + playlists.move_song(self.group.title, i, j) + + self.update_contents() + + def _on_remove_song(self, song: Song): + playlists.remove_song(song, self.group.title) + self.update_contents() + + def _on_rename_confirmed(self, name: str): + self.group.title = playlists.rename(self.group.title, name) + self.props.title = GLib.markup_escape_text(self.group.title, -1) + + def _on_rename_playlist(self): + rename_window = RenameWindow(self.group.title) + rename_window.connect( + 'rename', + lambda _window, name, ref: + EditableGroupRow._on_rename_confirmed(ref(), name), + weakref.ref(self), + ) + rename_window.present(self) diff --git a/source/monophony/ui/rows/editable_song_row.py b/source/monophony/ui/rows/editable_song_row.py new file mode 100644 index 0000000000000000000000000000000000000000..410e08cc36955f3f0b673286170c771b8906dfdf --- /dev/null +++ b/source/monophony/ui/rows/editable_song_row.py @@ -0,0 +1,59 @@ +import weakref + +from monophony import downloads +from monophony.data import Song +from monophony.ui.popovers.editable_song_row_popover import EditableSongRowPopover +from monophony.ui.rows.draggable_song_row import DraggableSongRow + +from gi.repository import GObject, Gtk + + +class EditableSongRow(DraggableSongRow): + __gtype_name__ = __qualname__ + + def __init__(self, song: Song): + super().__init__(song) + + self._more_button.set_create_popup_func( + lambda button, ref: EditableSongRow._on_show_more(ref(), button), + weakref.ref(self) + ) + + @GObject.Signal(name='remove-song', arg_types=(object,)) + def _remove_song(self, _song: Song): + return + + def _on_show_more(self, button: Gtk.MenuButton): + popover = EditableSongRowPopover( + downloads.is_downloaded(self.song), downloads.is_being_downloaded(self.song) + ) + popover.connect( + 'queue-song', + lambda _popover, row: row().emit('queue-song', row().song), + weakref.ref(self) + ) + popover.connect( + 'add-song-to', lambda _popover, row: row().emit('add-song-to', row().song), + weakref.ref(self) + ) + popover.connect( + 'view-artist', + lambda _popover, row: row().emit('view-artist', row().song.author), + weakref.ref(self) + ) + popover.connect( + 'undownload-song', + lambda _popover, row: row().emit('undownload-song', row().song), + weakref.ref(self) + ) + popover.connect( + 'download-song', + lambda _popover, row: row().emit('download-song', row().song), + weakref.ref(self) + ) + popover.connect( + 'remove-song', + lambda _popover, row: row().emit('remove-song', row().song), + weakref.ref(self) + ) + button.set_popover(popover) diff --git a/source/monophony/ui/rows/group_row.py b/source/monophony/ui/rows/group_row.py new file mode 100644 index 0000000000000000000000000000000000000000..e64113af4d11769db3ca4a04299a8bb4b3cf00a6 --- /dev/null +++ b/source/monophony/ui/rows/group_row.py @@ -0,0 +1,163 @@ +import weakref + +from monophony.data import Artist, Group, Song, TimeString +from monophony.debug import MemoryDebugger +from monophony.ui.popovers.group_row_popover import GroupRowPopover +from monophony.ui.rows.song_row import SongRow + +from gi.repository import Adw, GLib, GObject, Gtk + + +class GroupRow(MemoryDebugger, Adw.ExpanderRow): + __gtype_name__ = __qualname__ + _row_type = SongRow + + def __init__(self, group: Group): + super().__init__() + + self.group = group + self._rows = [] + + self._more_button = Gtk.MenuButton() + self._more_button.props.tooltip_text = _('More') + self._more_button.props.icon_name = 'view-more-symbolic' + self._more_button.props.has_frame = False + self._more_button.props.vexpand = False + self._more_button.props.valign = Gtk.Align.CENTER + self._more_button.set_create_popup_func( + lambda button, ref: GroupRow._on_show_more(ref(), button), + weakref.ref(self) + ) + + self.props.title = GLib.markup_escape_text(group.title, -1) + self.props.expanded = False + self.add_suffix(self._more_button) + self.connect('notify::expanded', GroupRow._on_expanded) + self.update_subtitle() + + @GObject.Signal(name='play', arg_types=(object, object)) + def _play(self, _song: Song, _group: Group): + return + + @GObject.Signal(name='queue-song', arg_types=(object,)) + def _queue_song(self, _song: Song): + return + + @GObject.Signal(name='add-song-to', arg_types=(object,)) + def _add_song_to(self, _song: Song): + return + + @GObject.Signal(name='view-artist', arg_types=(object,)) + def _view_artist(self, _artist: Artist): + return + + @GObject.Signal(name='undownload-song', arg_types=(object,)) + def _undownload_song(self, _song: Song): + return + + @GObject.Signal(name='download-song', arg_types=(object,)) + def _download_song(self, _song: Song): + return + + @GObject.Signal(name='queue-group', arg_types=(object,)) + def _queue_group(self, _group: Group): + return + + @GObject.Signal(name='add-group-to', arg_types=(object,)) + def _add_group_to(self, _group: Group): + return + + @GObject.Signal(name='download-group', arg_types=(object,)) + def _download_group(self, _group: Group): + return + + def _on_expanded(self, _param): + if self.props.expanded: + self.update_contents() + + def _on_show_more(self, button: Gtk.MenuButton): + self._popover = GroupRowPopover(bool(self.group.author.yt_id)) + self._popover.connect( + 'queue-group', + lambda _popover, row: row().emit('queue-group', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'add-group-to', + lambda _popover, row: row().emit('add-group-to', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'view-artist', + lambda _popover, row: row().emit('view-artist', row().group.author), + weakref.ref(self) + ) + self._popover.connect( + 'download-group', + lambda _popover, row: row().emit('download-group', row().group), + weakref.ref(self) + ) + button.set_popover(self._popover) + + def add_row(self, row: _row_type): + row.connect( + 'play', + lambda _row, song, _group, g_row: g_row().emit('play', song, g_row().group), + weakref.ref(self) + ) + row.connect( + 'queue-song', + lambda _row, song, g_row: g_row().emit('queue-song', song), + weakref.ref(self) + ) + row.connect( + 'add-song-to', + lambda _row, song, g_row: g_row().emit('add-song-to', song), + weakref.ref(self) + ) + row.connect( + 'view-artist', + lambda _row, artist, g_row: g_row().emit('view-artist', artist), + weakref.ref(self) + ) + row.connect( + 'download-song', + lambda _row, song, g_row: g_row().emit('download-song', song), + weakref.ref(self) + ) + row.connect( + 'undownload-song', + lambda _row, song, g_row: g_row().emit('undownload-song', song), + weakref.ref(self) + ) + self._rows.append(weakref.ref(row)) + super().add_row(row) + + def add_song(self, song: Song): + self.add_row(self._row_type(song)) + + def update_download_status(self): + for row_ref in self._rows: + row_ref().update_download_status() + + def update_contents(self): + for row_ref in self._rows: + self.remove(row_ref()) + + self._rows.clear() + + for song in self.group.songs: + self.add_song(song) + + self.update_subtitle() + + def update_subtitle(self): + total_seconds = 0 + for song in self.group.songs: + total_seconds += TimeString(string=song.length).as_seconds() + + self.props.subtitle = ( + TimeString(seconds=total_seconds).as_string() + + ' ' + + GLib.markup_escape_text(self.group.author.name, -1) + ) if total_seconds else '' diff --git a/source/monophony/ui/rows/importable_group_row.py b/source/monophony/ui/rows/importable_group_row.py new file mode 100644 index 0000000000000000000000000000000000000000..8e837d3d8afd9b632e0fbe78cef0e7ed9ba55119 --- /dev/null +++ b/source/monophony/ui/rows/importable_group_row.py @@ -0,0 +1,55 @@ +import weakref + +from monophony.data import Group +from monophony.ui.popovers.importable_group_row_popover import ImportableGroupRowPopover +from monophony.ui.rows.group_row import GroupRow +from monophony.ui.rows.song_row import SongRow + +from gi.repository import GObject, Gtk + + +class ImportableGroupRow(GroupRow): + __gtype_name__ = __qualname__ + _row_type = SongRow + + def __init__(self, group: Group): + super().__init__(group) + + self._more_button.set_create_popup_func( + lambda button, ref: ImportableGroupRow._on_show_more(ref(), button), + weakref.ref(self) + ) + + @GObject.Signal(name='import-group', arg_types=(object,)) + def _import_group(self, _group: Group): + return + + def _on_show_more(self, button: Gtk.MenuButton): + self._popover = ImportableGroupRowPopover(bool(self.group.author.yt_id)) + self._popover.connect( + 'queue-group', + lambda _popover, row: row().emit('queue-group', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'add-group-to', + lambda _popover, row: row().emit('add-group-to', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'view-artist', + lambda _popover, row: row().emit('view-artist', row().group.author), + weakref.ref(self) + ) + self._popover.connect( + 'download-group', + lambda _popover, row: row().emit('download-group', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'import-group', + lambda _popover, row: row().emit('import-group', row().group), + weakref.ref(self) + ) + button.set_popover(self._popover) + diff --git a/source/monophony/ui/rows/queue_song_row.py b/source/monophony/ui/rows/queue_song_row.py new file mode 100644 index 0000000000000000000000000000000000000000..0d431266bc911a97d01cfed2c9c6ba1238d8fab8 --- /dev/null +++ b/source/monophony/ui/rows/queue_song_row.py @@ -0,0 +1,54 @@ +import weakref + +from monophony import downloads +from monophony.data import Song +from monophony.ui.popovers.queue_song_row_popover import QueueSongRowPopover +from monophony.ui.rows.draggable_song_row import DraggableSongRow + +from gi.repository import GObject, Gtk + + +class QueueSongRow(DraggableSongRow): + __gtype_name__ = __qualname__ + + def __init__(self, song: Song): + super().__init__(song) + + self._more_button.set_create_popup_func( + lambda button, ref: QueueSongRow._on_show_more(ref(), button), + weakref.ref(self) + ) + + @GObject.Signal(name='unqueue-song', arg_types=(object,)) + def _unqueue_song(self, _song: Song): + return + + def _on_show_more(self, button: Gtk.MenuButton): + popover = QueueSongRowPopover( + downloads.is_downloaded(self.song), downloads.is_being_downloaded(self.song) + ) + popover.connect( + 'add-song-to', lambda _popover, row: row().emit('add-song-to', row().song), + weakref.ref(self) + ) + popover.connect( + 'view-artist', + lambda _popover, row: row().emit('view-artist', row().song.author), + weakref.ref(self) + ) + popover.connect( + 'undownload-song', + lambda _popover, row: row().emit('undownload-song', row().song), + weakref.ref(self) + ) + popover.connect( + 'download-song', + lambda _popover, row: row().emit('download-song', row().song), + weakref.ref(self) + ) + popover.connect( + 'unqueue-song', + lambda _popover, row: row().emit('unqueue-song', row().song), + weakref.ref(self) + ) + button.set_popover(popover) diff --git a/source/monophony/ui/rows/song_row.py b/source/monophony/ui/rows/song_row.py new file mode 100644 index 0000000000000000000000000000000000000000..c32ded2ed99135e4516b38dbabe72ec36830ad2d --- /dev/null +++ b/source/monophony/ui/rows/song_row.py @@ -0,0 +1,113 @@ +import weakref + +from monophony import downloads +from monophony.data import Artist, Group, Song, TimeString +from monophony.debug import MemoryDebugger +from monophony.ui.popovers.song_row_popover import SongRowPopover + +from gi.repository import Adw, GLib, GObject, Gtk + + +class SongRow(MemoryDebugger, Adw.ActionRow): + __gtype_name__ = __qualname__ + + def __init__(self, song: Song): + super().__init__() + + self.song = song + + title = GLib.markup_escape_text(song.title, -1) + length = GLib.markup_escape_text(song.length, -1) + author = GLib.markup_escape_text(song.author.name, -1) + subtitle = ( + f'{length} {author}' if TimeString(string=length).as_seconds() else author + ) + + self._downloaded_image = Gtk.Image.new_from_icon_name( + 'folder-download-symbolic' + ) + self._downloaded_image.props.tooltip_text = _('Downloaded') + + self._spinner = Adw.Spinner() + + self._more_button = Gtk.MenuButton() + self._more_button.props.tooltip_text = _('More') + self._more_button.props.icon_name = 'view-more-symbolic' + self._more_button.props.has_frame = False + self._more_button.props.vexpand = False + self._more_button.props.valign = Gtk.Align.CENTER + self._more_button.set_create_popup_func( + lambda button, ref: SongRow._on_show_more(ref(), button), + weakref.ref(self) + ) + + self.props.title = title + self.props.subtitle = subtitle + self.props.tooltip_text = _('Play') + self.props.activatable = True + self.add_suffix(self._downloaded_image) + self.add_suffix(self._spinner) + self.add_suffix(self._more_button) + self.connect( + 'activated', lambda row: row.emit('play', row.song, Group(songs=[row.song])) + ) + self.update_download_status() + + @GObject.Signal(name='play', arg_types=(object, object)) + def _play(self, _song: Song, _group: Group): + return + + @GObject.Signal(name='queue-song', arg_types=(object,)) + def _queue_song(self, _song: Song): + return + + @GObject.Signal(name='add-song-to', arg_types=(object,)) + def _add_song_to(self, _song: Song): + return + + @GObject.Signal(name='view-artist', arg_types=(object,)) + def _view_artist(self, _artist: Artist): + return + + @GObject.Signal(name='undownload-song', arg_types=(object,)) + def _undownload_song(self, _song: Song): + return + + @GObject.Signal(name='download-song', arg_types=(object,)) + def _download_song(self, _song: Song): + return + + def _on_show_more(self, button: Gtk.MenuButton): + popover = SongRowPopover( + downloads.is_downloaded(self.song), downloads.is_being_downloaded(self.song) + ) + popover.connect( + 'queue-song', + lambda _popover, row: row().emit('queue-song', row().song), + weakref.ref(self) + ) + popover.connect( + 'add-song-to', + lambda _popover, row: row().emit('add-song-to', row().song), + weakref.ref(self) + ) + popover.connect( + 'view-artist', + lambda _popover, row: row().emit('view-artist', row().song.author), + weakref.ref(self) + ) + popover.connect( + 'undownload-song', + lambda _popover, row: row().emit('undownload-song', row().song), + weakref.ref(self) + ) + popover.connect( + 'download-song', + lambda _popover, row: row().emit('download-song', row().song), + weakref.ref(self) + ) + button.props.popover = popover + + def update_download_status(self): + self._spinner.props.visible = downloads.is_being_downloaded(self.song) + self._downloaded_image.props.visible = downloads.is_downloaded(self.song) diff --git a/source/monophony/ui/rows/synchronized_group_row.py b/source/monophony/ui/rows/synchronized_group_row.py new file mode 100644 index 0000000000000000000000000000000000000000..7566a204dd4eed849b5a1381b39f4439066ee570 --- /dev/null +++ b/source/monophony/ui/rows/synchronized_group_row.py @@ -0,0 +1,73 @@ +import weakref + +from monophony import logging, playlists +from monophony.data import Group +from monophony.ui.popovers.synchronized_group_row_popover import ( + SynchronizedGroupRowPopover, +) +from monophony.ui.rows.group_row import GroupRow +from monophony.ui.rows.song_row import SongRow + +from gi.repository import GObject, Gtk + + +class SynchronizedGroupRow(GroupRow): + __gtype_name__ = __qualname__ + _row_type = SongRow + + def __init__(self, group: Group): + super().__init__(group) + + self._more_button.set_create_popup_func( + lambda button, ref: SynchronizedGroupRow._on_show_more(ref(), button), + weakref.ref(self) + ) + + @GObject.Signal(name='delete-playlist', arg_types=(object,)) + def _delete_playlist(self, _playlist: Group): + return + + def _on_show_more(self, button: Gtk.MenuButton): + self._popover = SynchronizedGroupRowPopover() + self._popover.connect( + 'queue-group', + lambda _popover, row: row().emit('queue-group', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'add-group-to', + lambda _popover, row: row().emit('add-group-to', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'download-group', + lambda _popover, row: row().emit('download-group', row().group), + weakref.ref(self) + ) + self._popover.connect( + 'delete-playlist', + lambda _popover, row: row().emit('delete-playlist', row().group), + weakref.ref(self) + ) + button.set_popover(self._popover) + + def update_contents(self): + logging.info( + __name__, + f'Updating row contents for external playlist "{self.group.title}"...' + ) + + for playlist in playlists.read_external(): + if playlist.title == self.group.title: + self.group = playlist + break + else: + logging.error( + __name__, + 'Failed to update row contents - ' + f'external playlist "{self.group.title}" does not exist' + ) + return + + super().update_contents() + logging.info(__name__, 'Updated row contents') diff --git a/source/monophony/frontend/tabs/__init__.py b/source/monophony/ui/windows/__init__.py similarity index 100% rename from source/monophony/frontend/tabs/__init__.py rename to source/monophony/ui/windows/__init__.py diff --git a/source/monophony/ui/windows/add_window.py b/source/monophony/ui/windows/add_window.py new file mode 100644 index 0000000000000000000000000000000000000000..0edb8abe63fbc07efd620d50aa4baccbfcebfd5a --- /dev/null +++ b/source/monophony/ui/windows/add_window.py @@ -0,0 +1,103 @@ +import weakref + +from monophony import MIN_WIDTH, playlists +from monophony.data import Group +from monophony.debug import MemoryDebugger + +from gi.repository import Adw, Gtk + + +class AddWindow(MemoryDebugger, Adw.Dialog): + def __init__(self, group: Group): + super().__init__() + + self._selected_lists = [] + self._group = group + self.did_anything = False + + self._playlists_group = Adw.PreferencesGroup() + self._playlists_group.props.visible = False + lists = playlists.read() + for playlist in lists: + self._add_playlist_row(playlist) + + new_playlist_row = Adw.EntryRow() + new_playlist_row.props.title = _('New Playlist') + new_playlist_row.props.show_apply_button = True + new_playlist_row.connect( + 'apply', + lambda row, ref: AddWindow._on_create_playlist(ref(), row), + weakref.ref(self) + ) + + new_playlist_group = Adw.PreferencesGroup() + new_playlist_group.add(new_playlist_row) + + playlists_page = Adw.PreferencesPage() + playlists_page.add(self._playlists_group) + playlists_page.add(new_playlist_group) + + self.add_button = Gtk.Button() + self.add_button.props.label = _('Add') + self.add_button.props.sensitive = False + self.add_button.add_css_class('suggested-action') + self.add_button.connect( + 'clicked', lambda _button, ref: AddWindow._on_add(ref()), weakref.ref(self) + ) + + add_bar = Gtk.ActionBar() + add_bar.pack_end(self.add_button) + + toolbar_view = Adw.ToolbarView() + toolbar_view.props.content = playlists_page + toolbar_view.add_top_bar(Adw.HeaderBar()) + toolbar_view.add_bottom_bar(add_bar) + + self.props.title = _('Add to Playlists...') + self.props.child = toolbar_view + self.props.follows_content_size = True + self.props.width_request = MIN_WIDTH + + def _add_playlist_row(self, playlist: Group, checked: bool=False): + check_button = Gtk.CheckButton() + check_button.connect( + 'toggled', + lambda button, ref, playlist: + AddWindow._on_check_playlist(ref(), button.props.active, playlist), + weakref.ref(self), + playlist + ) + check_button.props.active = checked + + check_row = Adw.ActionRow() + check_row.props.title = playlist.title + check_row.props.activatable_widget = check_button + check_row.add_prefix(check_button) + + self._playlists_group.props.visible = True + self._playlists_group.add(check_row) + + def _on_check_playlist(self, check: bool, playlist: Group): + if check: + self._selected_lists.append(playlist) + self.add_button.props.sensitive = True + else: + self._selected_lists.remove(playlist) + + def _on_create_playlist(self, entry_row: Adw.EntryRow): + if not entry_row.props.text: + return + + self._add_playlist_row( + Group(title=playlists.add(Group(title=entry_row.props.text))), True + ) + entry_row.props.text = '' + self.add_button.props.sensitive = True + self.did_anything = True + + def _on_add(self): + for playlist in self._selected_lists: + playlists.add_songs(self._group, playlist.title) + + self.did_anything = True + self.close() diff --git a/source/monophony/ui/windows/import_window.py b/source/monophony/ui/windows/import_window.py new file mode 100644 index 0000000000000000000000000000000000000000..07379079fffd2a9ae6903e5033b3b0de4a962831 --- /dev/null +++ b/source/monophony/ui/windows/import_window.py @@ -0,0 +1,110 @@ +import weakref + +from monophony import MIN_WIDTH +from monophony.data import Group +from monophony.debug import MemoryDebugger +from monophony.playlists import ImportTask + +from gi.repository import Adw, GObject, Gtk + + +class ImportWindow(MemoryDebugger, Adw.Dialog): + def __init__(self, group: Group | None=None): + super().__init__() + + self._group = Group() if group is None else group + + self._url_entry = Adw.EntryRow() + self._url_entry.props.title = _('Playlist URL') + self._url_entry.props.text = ( + 'https://www.youtube.com/playlist?list=' + self._group.yt_id + ) if self._group.yt_id else '' + self._url_entry.connect( + 'changed', + lambda _entry, ref: ImportWindow._on_url_changed(ref()), + weakref.ref(self) + ) + + self._name_entry = Adw.EntryRow() + self._name_entry.props.title = _('Playlist Name') + self._name_entry.props.text = self._group.title + + self._sync_switch = Adw.SwitchRow() + self._sync_switch.props.title = _('Synchronized') + self._sync_switch.props.subtitle = _( + "Synchronized playlists are updated automatically and can't be edited" + ) + self._sync_switch.connect( + 'notify::active', + lambda _switch, _param, ref: ImportWindow._on_sync_switched(ref()), + weakref.ref(self) + ) + + row_group = Adw.PreferencesGroup() + row_group.add(self._url_entry) + row_group.add(self._name_entry) + row_group.add(self._sync_switch) + + page = Adw.PreferencesPage() + page.add(row_group) + + self._import_button = Gtk.Button() + self._import_button.props.label = _('Import') + self._import_button.props.sensitive = bool(self._group.yt_id) + self._import_button.add_css_class('suggested-action') + self._import_button.connect( + 'clicked', + lambda _button, ref: ImportWindow._on_import(ref()), + weakref.ref(self) + ) + + action_bar = Gtk.ActionBar() + action_bar.pack_end(self._import_button) + + toolbar_view = Adw.ToolbarView() + toolbar_view.props.content = page + toolbar_view.add_top_bar(Adw.HeaderBar()) + toolbar_view.add_bottom_bar(action_bar) + + self.props.title = _('Import Playlist...') + self.props.child = toolbar_view + self.props.content_width = MIN_WIDTH + + @GObject.Signal(name='import') + def _import(self): + return + + @GObject.Signal(name='import-failed') + def _import_failed(self): + return + + def _on_import(self): + if not self._url_entry.props.text: + return + + self.props.sensitive = False + self.props.can_close = False + self._import_button.props.child = Adw.Spinner() + ImportTask( + callback=self._on_import_finished, + args=( + self._name_entry.props.text, + self._url_entry.props.text, + not self._sync_switch.props.active + ) + ).start() + + def _on_import_finished(self, task: ImportTask): + self.props.can_close = True + self.close() + self.emit('import' if task.result else 'import-failed') + + def _on_sync_switched(self): + if self._sync_switch.props.active: + self._name_entry.props.text = self._group.title + self._name_entry.props.sensitive = False + else: + self._name_entry.props.sensitive = True + + def _on_url_changed(self): + self._import_button.props.sensitive = bool(self._url_entry.props.text) diff --git a/source/monophony/ui/windows/main_window.py b/source/monophony/ui/windows/main_window.py new file mode 100644 index 0000000000000000000000000000000000000000..da95d1b478a2c4e33b456e6dba4343e79b4c8146 --- /dev/null +++ b/source/monophony/ui/windows/main_window.py @@ -0,0 +1,931 @@ +import time +import weakref + +from monophony import ( + DISPLAY_NAME, + GRESOURCES_PATH, + ID, + MIN_WIDTH, + __version__, + downloads, + logging, + recommendations, + settings, +) +from monophony.asynchronous import Task +from monophony.data import Artist, Group, Song +from monophony.downloads import DownloadTask +from monophony.player import Player +from monophony.playlists import UpdateExternalTask +from monophony.ui.bars.player_bar import PlayerBar +from monophony.ui.pages.artist_page import ArtistPage +from monophony.ui.pages.home_page import HomePage +from monophony.ui.pages.loading_page import LoadingPage +from monophony.ui.pages.results_page import ResultsPage +from monophony.ui.pages.status_page import StatusPage +from monophony.ui.queue_sidebar import QueueSidebar +from monophony.ui.windows.add_window import AddWindow +from monophony.ui.windows.import_window import ImportWindow +from monophony.ui.windows.message_window import MessageWindow +from monophony.yt import GetArtistTask, GetRecommendationsTask, SearchTask + +from gi.repository import Adw, Gio, GObject, Gtk + + +class PrepareHomePageTask(Task): + def _on_progress_update(self, task: Task, progress: float): + if isinstance(task, GetRecommendationsTask): + self._recommendations_progress = progress + else: + self._externals_update_progress = progress + + self._update_progress( + (self._recommendations_progress + self._externals_update_progress) / 2 + ) + + def _function(self): + self._recommendations_progress = 0 + self._externals_update_progress = 0 + recommendations_task = GetRecommendationsTask( + progress_callback=self._on_progress_update + ) + update_task = UpdateExternalTask( + progress_callback=self._on_progress_update + ) + recommendations_task.start() + update_task.start() + + while recommendations_task.is_running() or update_task.is_running(): + time.sleep(0.5) + + result = recommendations_task.result + if result: + recommendations.write(result) + + +class MainWindow(Adw.ApplicationWindow): + __gtype_name__ = __qualname__ + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self._downloader = downloads.downloader + self._inhibit_suspend_cookie = 0 + self._last_search_query = None + self._last_artist = None + self._current_browsing_task = SearchTask() + + self._application = self.props.application + self._application.hold() + + self._download_fail_window = MessageWindow( + _('Download Failed'), _('Some songs could not be downloaded') + ) + + self._player = Player() + self._player.connect( + 'recents-changed', + lambda _player, ref: + MainWindow._on_recents_changed(ref()), + weakref.ref(self) + ) + self._player.connect( + 'queue-changed', + lambda _player, queue, i, ref: + MainWindow._on_queue_changed(ref(), queue, i), + weakref.ref(self) + ) + self._player.connect( + 'progress-changed', + lambda _player, progress, ref: + MainWindow._on_progress_changed(ref(), progress), + weakref.ref(self) + ) + self._player.connect( + 'buffering-changed', + lambda _player, progress, ref: + MainWindow._on_buffering_changed(ref(), progress), + weakref.ref(self) + ) + self._player.connect( + 'state-changed', + lambda _player, state, ref: + MainWindow._on_state_changed(ref(), state), + weakref.ref(self) + ) + self._player.connect( + 'volume-changed', + lambda _player, volume, ref: + MainWindow._on_volume_changed_in_backend(ref(), volume), + weakref.ref(self) + ) + self._player.connect( + 'mode-changed', + lambda _player, mode, ref: + MainWindow._on_mode_changed_in_backend(ref(), mode), + weakref.ref(self) + ) + self._player.connect( + 'pause-changed', + lambda _player, pause, ref: + MainWindow._on_pause_changed_in_backend(ref(), pause), + weakref.ref(self) + ) + self._player.connect( + 'raise', lambda _player, ref: ref().present(), weakref.ref(self) + ) + + self._queue_sidebar = QueueSidebar() + self._queue_sidebar.connect( + 'play', + lambda _sidebar, song, _group, ref, player_ref: + MainWindow._on_play(ref(), song, player_ref().get_queue()), + weakref.ref(self), + weakref.ref(self._player) + ) + self._queue_sidebar.connect( + 'add-group-to', + lambda _sidebar, _group, ref: MainWindow._on_add_queue_to(ref()), + weakref.ref(self) + ) + self._queue_sidebar.connect( + 'add-song-to', + lambda _sidebar, song, ref: MainWindow._on_add_song_to(ref(), song), + weakref.ref(self) + ) + self._queue_sidebar.connect( + 'undownload-song', + lambda _sidebar, song, ref: + MainWindow._on_remove_song_from_downloads(ref(), song), + weakref.ref(self) + ) + self._queue_sidebar.connect( + 'download-song', + lambda _sidebar, song, ref: + MainWindow._on_download_songs(ref(), Group(songs=[song])), + weakref.ref(self) + ) + self._queue_sidebar.connect( + 'move-song', + lambda _sidebar, from_s, to_s, ref: + MainWindow._on_queue_move_song(ref(), from_s, to_s), + weakref.ref(self) + ) + self._queue_sidebar.connect( + 'unqueue-song', + lambda _sidebar, song, ref: MainWindow._on_remove_from_queue(ref(), song), + weakref.ref(self) + ) + self._queue_sidebar.connect( + 'view-artist', + lambda _sidebar, artist, ref: + MainWindow._on_view_artist(ref(), artist, True), + weakref.ref(self) + ) + self._queue_sidebar.connect( + 'clear-queue', + lambda _sidebar, ref: MainWindow._on_clear_queue(ref()), + weakref.ref(self) + ) + self._queue_sidebar.connect( + 'shuffle-queue', + lambda _sidebar, ref: MainWindow._on_shuffle_queue(ref()), + weakref.ref(self) + ) + self._queue_sidebar.hide_button.connect( + 'clicked', + lambda _b, ref: MainWindow._on_hide_sidebar(ref()), + weakref.ref(self) + ) + + self._home_page = HomePage() + self._home_page.connect( + 'play', + lambda _page, song, group, ref: MainWindow._on_play(ref(), song, group), + weakref.ref(self) + ) + self._home_page.connect( + 'queue-group', + lambda _page, group, ref: MainWindow._on_add_to_queue(ref(), group), + weakref.ref(self) + ) + self._home_page.connect( + 'queue-song', + lambda _page, song, ref: + MainWindow._on_add_to_queue(ref(), Group(songs=[song])), + weakref.ref(self) + ) + self._home_page.connect( + 'add-song-to', + lambda _page, song, ref: MainWindow._on_add_song_to(ref(), song), + weakref.ref(self) + ) + self._home_page.connect( + 'view-artist', + lambda _page, artist, ref: MainWindow._on_view_artist(ref(), artist, True), + weakref.ref(self) + ) + self._home_page.connect( + 'undownload-song', + lambda _page, song, ref: + MainWindow._on_remove_song_from_downloads(ref(), song), + weakref.ref(self) + ) + self._home_page.connect( + 'download-song', + lambda _page, song, ref: + MainWindow._on_download_songs(ref(), Group(songs=[song])), + weakref.ref(self) + ) + self._home_page.connect( + 'add-group-to', + lambda _page, group, ref: MainWindow._on_add_group_to(ref(), group), + weakref.ref(self) + ) + self._home_page.connect( + 'download-group', + lambda _page, group, ref: MainWindow._on_download_songs(ref(), group), + weakref.ref(self) + ) + self._home_page.connect( + 'import-group', + lambda _page, group, ref: MainWindow._on_import_group(ref(), group), + weakref.ref(self) + ) + self._home_page.connect( + 'search', + lambda _page, query, filter_, ref: + MainWindow._on_search(ref(), query, filter_), + weakref.ref(self) + ) + self._home_page.connect( + 'show-about', + lambda _page, ref: MainWindow._on_show_about(ref()), + weakref.ref(self) + ) + self._home_page.update_downloads(self._downloader.get_downloads()) + + self._navigation_view = Adw.NavigationView() + self._navigation_view.add(LoadingPage()) + self._navigation_view.connect( + 'popped', + lambda _view, page, ref: MainWindow._on_page_popped(ref(), page), + weakref.ref(self) + ) + + self._player_bar = PlayerBar() + self._player_bar.connect( + 'mode-changed', + lambda _bar, mode, ref: + MainWindow._on_mode_changed_in_frontend(ref(), mode), + weakref.ref(self) + ) + self._player_bar.connect( + 'next-song', + lambda _bar, ref: MainWindow._on_next_song(ref()), + weakref.ref(self) + ) + self._player_bar.connect( + 'previous-song', + lambda _bar, ref: MainWindow._on_previous_song(ref()), + weakref.ref(self) + ) + self._player_bar.connect( + 'seek', + lambda _bar, value, ref: + MainWindow._on_seek(ref(), value), + weakref.ref(self) + ) + self._player_bar.connect( + 'volume-changed', + lambda _bar, volume, ref: + MainWindow._on_volume_changed_in_frontend(ref(), volume), + weakref.ref(self) + ) + self._player_bar.connect( + 'pause', + lambda _bar, ref: MainWindow._on_pause_changed_in_frontend(ref()), + weakref.ref(self) + ) + + self._toolbar_view = Adw.ToolbarView() + self._toolbar_view.props.content = self._navigation_view + self._toolbar_view.props.reveal_bottom_bars = False + self._toolbar_view.add_bottom_bar(self._player_bar) + + self._split_view = Adw.OverlaySplitView() + self._split_view.props.content = self._toolbar_view + self._split_view.props.sidebar = self._queue_sidebar + self._split_view.props.min_sidebar_width = MIN_WIDTH + self._split_view.bind_property( + 'show-sidebar', + self._player_bar.queue_button, + 'active', + GObject.BindingFlags.BIDIRECTIONAL | + GObject.BindingFlags.SYNC_CREATE + ) + self._split_view.bind_property( + 'collapsed', + self._player_bar.queue_button, + 'visible', + GObject.BindingFlags.BIDIRECTIONAL | + GObject.BindingFlags.SYNC_CREATE + ) + self._split_view.bind_property( + 'collapsed', + self._queue_sidebar.hide_button, + 'visible', + GObject.BindingFlags.BIDIRECTIONAL | + GObject.BindingFlags.SYNC_CREATE + ) + + view_breakpoint = Adw.Breakpoint() + view_breakpoint.props.condition = Adw.BreakpointCondition.parse( + 'max-width: 700' + ) + view_breakpoint.add_setter(self._split_view, 'collapsed', True) + + focus_search_action = Gio.SimpleAction.new('focus-search', None) + focus_search_action.connect('activate', self._on_focus_search) + self.add_action(focus_search_action) + + self.props.title = DISPLAY_NAME + self.props.icon_name = ID + self.props.content = self._split_view + self.props.width_request = MIN_WIDTH + self.props.height_request = MIN_WIDTH + self.set_default_size( + int(settings.load('window-width', 720)), + int(settings.load('window-height', 600)) + ) + self.connect('close-request', MainWindow._on_close) + self.add_breakpoint(view_breakpoint) + + self._current_browsing_task = PrepareHomePageTask( + progress_callback=self._on_loading_progress, + callback=self._on_home_page_prepared + ) + self._current_browsing_task.start() + self._on_volume_changed_in_backend(self._player.get_volume()) + self._on_mode_changed_in_backend(self._player.mode) + + def _inhibit_suspend(self): + self._uninhibit_suspend() + self._inhibit_suspend_cookie = self._application.inhibit( + self, + Gtk.ApplicationInhibitFlags.SUSPEND, + _('Playing') + ) + + def _uninhibit_suspend(self): + if self._inhibit_suspend_cookie != 0: + self._application.uninhibit(self._inhibit_suspend_cookie) + self._inhibit_suspend_cookie = 0 + + def _update_external_playlists(self): + self._home_page.update_external_playlists() + + def _update_playlists(self): + self._home_page.update_playlists() + + def _update_downloads(self): + self._queue_sidebar.update_download_status() + for page in self._navigation_view.props.navigation_stack: + if not isinstance(page, LoadingPage): + page.update_download_status() + + self._home_page.update_downloads(self._downloader.get_downloads()) + + def _on_add_group_to(self, group: Group): + add_window = AddWindow(group) + add_window.connect( + 'closed', + lambda window, ref: MainWindow._on_add_window_closed(ref(), window), + weakref.ref(self) + ) + add_window.present(self) + + def _on_add_song_to(self, song: Song): + add_window = AddWindow(Group(songs=[song])) + add_window.connect( + 'closed', + lambda _window, ref: MainWindow._update_playlists(ref()), + weakref.ref(self) + ) + add_window.present(self) + + def _on_add_queue_to(self): + self._on_add_group_to(self._player.get_queue()) + + def _on_add_to_queue(self, group: Group): + self._player.add_to_queue(group) + + def _on_add_window_closed(self, window: AddWindow): + if window.did_anything: + self._update_playlists() + + def _on_buffering_changed(self, progress: float): + self._player_bar.update_buffering(progress) + + def _on_clear_queue(self): + self._player.stop() + + def _on_close(self) -> bool: + logging.info(__name__, 'Close requested') + if self._player.get_queue().songs: + logging.info(__name__, 'Still playing - hiding instead of closing') + self.props.visible = False + return True + + size = self.get_default_size() + settings.save({ + 'window-width': size.width, + 'window-height': size.height + }) + self._application.release() + return False + + def _on_download_songs(self, group: Group): + DownloadTask( + progress_callback=self._on_download_status_changed, + callback=self._on_download_finished, + args=( + self._downloader, + group + ) + ).start() + + def _on_download_finished(self, task: DownloadTask): + self._on_download_status_changed(task) + if not task.result: + self._download_fail_window.present(self) + + def _on_download_status_changed(self, _task: DownloadTask | None=None): + # Never canceled + self._update_downloads() + + def _on_home_page_prepared(self, _task: PrepareHomePageTask): + self._navigation_view.replace([self._home_page]) + self._update_external_playlists() + self._home_page.update_recommendations() + + def _on_filter_artist(self, filter_: str): + if isinstance( + self._navigation_view.get_previous_page( + self._navigation_view.get_visible_page() + ), + ArtistPage + ): + self._navigation_view.pop() + self._on_view_artist(self._last_artist, False, filter_) + + def _on_filter_results(self, filter_: str): + if isinstance( + self._navigation_view.get_previous_page( + self._navigation_view.get_visible_page() + ), + ResultsPage + ): + self._navigation_view.pop() + self._on_search(self._last_search_query, filter_) + + def _on_focus_search(self, _action, _param): + self._home_page.focus_search() + + def _on_hide_sidebar(self): + self._split_view.props.show_sidebar = False + + def _on_import_group(self, group: Group): + import_window = ImportWindow(group) + import_window.connect( + 'import', + lambda _window, ref: MainWindow._on_import_success(ref()), + weakref.ref(self) + ) + import_window.connect( + 'import-failed', + lambda _window, ref: + MessageWindow( + _('Failed to Import'), + _('Check your internet connection and try again') + ).present(ref()), + weakref.ref(self) + ) + import_window.present(self) + + def _on_import_success(self): + self._update_playlists() + self._update_external_playlists() + + def _on_loading_progress(self, task: Task, progress: float): + if task.is_canceled() or task is not self._current_browsing_task: + logging.info( + __name__, f'Ignoring progress update "{progress}" from canceled task' + ) + return + + page = self._navigation_view.props.visible_page + if not isinstance(page, LoadingPage): + logging.info( + __name__, f'Ignoring progress update "{progress}" as loading is done' + ) + return + + page.update_progress(progress) + + def _on_mode_changed_in_backend(self, mode: int): + self._player_bar.update_mode(mode) + + def _on_mode_changed_in_frontend(self, mode: int): + self._player.set_mode(mode) + + def _on_next_song(self): + self._player.next(from_user=True) + + def _on_page_popped(self, page: Adw.NavigationPage): + if isinstance(page, LoadingPage): + self._current_browsing_task.cancel() + + if isinstance(self._navigation_view.get_visible_page(), LoadingPage): + self._navigation_view.pop() + + def _on_pause_changed_in_backend(self, pause: bool): + if pause: + self._uninhibit_suspend() + else: + self._inhibit_suspend() + + self._player_bar.update_pause(pause) + + def _on_pause_changed_in_frontend(self): + self._player.set_pause(not self._player.paused) + + def _on_play(self, song: Song, group: Group): + self._player.play(song, group) + + def _on_previous_song(self): + self._player.previous() + + def _on_queue_move_song(self, song: Song, to_song: Song): + self._player.move_song(song, to_song) + + def _on_recents_changed(self): + self._home_page.update_history() + + def _on_remove_from_queue(self, song: Song): + self._player.remove_from_queue(song) + + def _on_remove_song_from_downloads(self, song: Song): + self._downloader.remove(song) + self._update_downloads() + + def _on_search(self, query: str, filter_: str=''): + query = query.strip() + if not query: + return + + self._last_search_query = query + + loading_page = LoadingPage() + loading_page.connect( + 'show-about', + lambda _page, ref: MainWindow._on_show_about(ref()), + weakref.ref(self) + ) + self._navigation_view.push(loading_page) + + logging.info(__name__, f'Searching for "{query}" with filter "{filter_}"...') + + self._current_browsing_task.cancel() + self._current_browsing_task = SearchTask( + progress_callback=self._on_loading_progress, + callback=self._on_search_finished, + args=( + query, filter_, None if filter_ else 4 + ) + ) + self._current_browsing_task.extra_data = filter_ + self._current_browsing_task.start() + + def _on_search_finished(self, task: SearchTask): + if task.is_canceled() or self._current_browsing_task is not task: + logging.info(__name__, 'Ignoring callback from canceled search task') + return + + filter_ = task.extra_data + results = task.result + if isinstance(self._navigation_view.get_visible_page(), LoadingPage): + self._navigation_view.pop() + + page = None + if results is None: + logging.error(__name__, 'Failed to search') + page = StatusPage( + _('Failed to Search'), + _('Check your internet connection and try again'), + 'dialog-error-symbolic' + ) + elif results == []: + logging.warning(__name__, 'Done searching, no results') + page = StatusPage( + _('No Results'), + _('Try searching for something else'), + 'dialog-information-symbolic' + ) + else: + logging.info(__name__, 'Done searching') + page = ResultsPage(results, filter_) + page.connect( + 'play', + lambda _page, song, group, ref: MainWindow._on_play(ref(), song, group), + weakref.ref(self) + ) + page.connect( + 'filter-results', + lambda _page, filter_, ref: + MainWindow._on_filter_results(ref(), filter_), + weakref.ref(self) + ) + page.connect( + 'queue-song', + lambda _page, song, ref: + MainWindow._on_add_to_queue(ref(), Group(songs=[song])), + weakref.ref(self) + ) + page.connect( + 'add-song-to', + lambda _page, song, ref: MainWindow._on_add_song_to(ref(), song), + weakref.ref(self) + ) + page.connect( + 'undownload-song', + lambda _page, song, ref: + MainWindow._on_remove_song_from_downloads(ref(), song), + weakref.ref(self) + ) + page.connect( + 'download-song', + lambda _page, song, ref: + MainWindow._on_download_songs(ref(), Group(songs=[song])), + weakref.ref(self) + ) + page.connect( + 'queue-group', + lambda _page, group, ref: MainWindow._on_add_to_queue(ref(), group), + weakref.ref(self) + ) + page.connect( + 'add-group-to', + lambda _page, group, ref: MainWindow._on_add_group_to(ref(), group), + weakref.ref(self) + ) + page.connect( + 'download-group', + lambda _page, group, ref: MainWindow._on_download_songs(ref(), group), + weakref.ref(self) + ) + page.connect( + 'import-group', + lambda _page, group, ref: MainWindow._on_import_group(ref(), group), + weakref.ref(self) + ) + page.connect( + 'view-artist', + lambda _page, artist, ref: + MainWindow._on_view_artist(ref(), artist, True), + weakref.ref(self) + ) + + page.connect( + 'show-about', + lambda _page, ref: MainWindow._on_show_about(ref()), + weakref.ref(self) + ) + self._navigation_view.push(page) + + def _on_seek(self, value: float): + self._player.seek(value) + + def _on_show_about(self): + about_dialog = Adw.AboutDialog.new_from_appdata( + GRESOURCES_PATH + '/metainfo.xml', __version__ + ) + about_dialog.props.debug_info = logging.get_log() + about_dialog.props.debug_info_filename = 'log.txt' + about_dialog.props.translator_credits = _('translator-credits') + about_dialog.props.copyright = 'Copyright © Zehkira and contributors' + about_dialog.add_link( + _('Donate'), 'https://zeh-kira.itch.io/monophony/purchase' + ) + about_dialog.add_legal_section( + 'mprisify', + 'Copyright © Zehkira and contributors', + Gtk.License.LGPL_3_0_ONLY + ) + about_dialog.add_legal_section('Mopidy-MPRIS', '', Gtk.License.APACHE_2_0) + about_dialog.add_legal_section( + 'StrEnum', 'Copyright © 2019 James C Sinclair', Gtk.License.MIT_X11 + ) + about_dialog.add_legal_section('pycairo', '', Gtk.License.LGPL_2_1_ONLY) + about_dialog.add_legal_section('cairo', '', Gtk.License.LGPL_2_1_ONLY) + about_dialog.add_legal_section( + 'pydbus', + 'Copyright © 2014, 2015, 2016 Linus Lewandowski', + Gtk.License.LGPL_2_1 + ) + about_dialog.add_legal_section('GLib', '', Gtk.License.LGPL_2_1) + about_dialog.add_legal_section('PyGObject', '', Gtk.License.LGPL_2_1) + about_dialog.add_legal_section( + 'ytmusicapi', 'Copyright © 2024 sigma67', Gtk.License.MIT_X11 + ) + about_dialog.add_legal_section('certifi', '', Gtk.License.MPL_2_0) + about_dialog.add_legal_section( + 'charset_normalizer', + 'Copyright © 2025 TAHRI Ahmed R.', + Gtk.License.MIT_X11 + ) + about_dialog.add_legal_section( + 'idna', + 'Copyright © 2013-2025, Kim Davies and contributors', + Gtk.License.BSD_3 + ) + about_dialog.add_legal_section( + 'requests', + 'Copyright © 2019 Kenneth Reitz', + Gtk.License.APACHE_2_0 + ) + about_dialog.add_legal_section( + 'urllib3', + 'Copyright © 2008-2020 Andrey Petrov and contributors', + Gtk.License.MIT_X11 + ) + about_dialog.add_legal_section('GTK', '', Gtk.License.LGPL_2_1) + about_dialog.add_legal_section('GTK/roaring', '', Gtk.License.APACHE_2_0) + about_dialog.add_legal_section('GTK/timsort', '', Gtk.License.APACHE_2_0) + about_dialog.add_legal_section('libadwaita', '', Gtk.License.LGPL_2_1) + about_dialog.add_legal_section( + 'Adwaita Icon Theme', '', Gtk.License.LGPL_3_0_ONLY + ) + about_dialog.add_legal_section('GStreamer', '', Gtk.License.LGPL_2_1_ONLY) + + about_dialog.present(self) + + def _on_shuffle_queue(self): + self._player.shuffle() + + def _on_progress_changed(self, progress: float): + self._player_bar.update_progress(progress) + + def _on_queue_changed(self, queue: Group, song_index: int): + self._queue_sidebar.update_contents(queue, song_index) + if not queue.songs: + self._toolbar_view.props.reveal_bottom_bars = False + self._uninhibit_suspend() + if not self.props.visible: + logging.info(__name__, 'Playback ended while window hidden') + self.close() + return + + self._toolbar_view.props.reveal_bottom_bars = True + self._inhibit_suspend() + self._player_bar.update_song(queue.songs[song_index]) + + def _on_state_changed(self, state: int): + self._player_bar.update_state(state) + + def _on_view_artist(self, artist: Artist, replace_page: bool, filter_: str=''): + self._last_artist = artist + + if ( + replace_page and + isinstance( + self._navigation_view.get_visible_page(), ArtistPage | StatusPage + ) + ): + self._navigation_view.pop() + + loading_page = LoadingPage() + loading_page.connect( + 'show-about', + lambda _page, ref: MainWindow._on_show_about(ref()), + weakref.ref(self) + ) + self._navigation_view.push(loading_page) + + logging.info(__name__, f'Showing artist "{artist.yt_id}"...') + self._current_browsing_task.cancel() + self._current_browsing_task = GetArtistTask( + progress_callback=self._on_loading_progress, + callback=self._on_view_artist_finished, + args=( + artist.yt_id, filter_, None if filter_ else 4 + ) + ) + self._current_browsing_task.extra_data = filter_ + self._current_browsing_task.start() + + def _on_view_artist_finished(self, task: GetArtistTask): + if task.is_canceled() or self._current_browsing_task is not task: + logging.info(__name__, 'Ignoring callback from canceled view artist task') + return + + results = task.result + filter_ = task.extra_data + if isinstance(self._navigation_view.get_visible_page(), LoadingPage): + self._navigation_view.pop() + + page = None + if results is None: + logging.error(__name__, 'Failed to load artist page') + page = StatusPage( + _('Failed to Load Artist Page'), + _('Check your internet connection and try again'), + 'dialog-error-symbolic' + ) + elif results == []: + logging.warning(__name__, 'Loaded empty artist page') + page = StatusPage( + _('Empty Artist Page'), + _('No content found from this artist'), + 'dialog-information-symbolic' + ) + else: + logging.info(__name__, 'Loaded artist page') + page = ArtistPage(results, filter_) + page.connect( + 'play', + lambda _page, song, group, ref: MainWindow._on_play(ref(), song, group), + weakref.ref(self) + ) + page.connect( + 'filter-results', + lambda _page, filter_, ref: + MainWindow._on_filter_artist(ref(), filter_), + weakref.ref(self) + ) + page.connect( + 'queue-song', + lambda _page, song, ref: + MainWindow._on_add_to_queue(ref(), Group(songs=[song])), + weakref.ref(self) + ) + page.connect( + 'add-song-to', + lambda _page, song, ref: MainWindow._on_add_song_to(ref(), song), + weakref.ref(self) + ) + page.connect( + 'undownload-song', + lambda _page, song, ref: + MainWindow._on_remove_song_from_downloads(ref(), song), + weakref.ref(self) + ) + page.connect( + 'download-song', + lambda _page, song, ref: + MainWindow._on_download_songs(ref(), Group(songs=[song])), + weakref.ref(self) + ) + page.connect( + 'queue-group', + lambda _page, group, ref: MainWindow._on_add_to_queue(ref(), group), + weakref.ref(self) + ) + page.connect( + 'add-group-to', + lambda _page, group, ref: MainWindow._on_add_group_to(ref(), group), + weakref.ref(self) + ) + page.connect( + 'download-group', + lambda _page, group, ref: MainWindow._on_download_songs(ref(), group), + weakref.ref(self) + ) + page.connect( + 'import-group', + lambda _page, group, ref: MainWindow._on_import_group(ref(), group), + weakref.ref(self) + ) + page.connect( + 'view-artist', + lambda _page, artist, ref: + MainWindow._on_view_artist(ref(), artist, True), + weakref.ref(self) + ) + + page.connect( + 'show-about', + lambda _page, ref: MainWindow._on_show_about(ref()), + weakref.ref(self) + ) + self._navigation_view.push(page) + + def _on_volume_changed_in_backend(self, volume: float): + self._player_bar.update_volume(volume) + + def _on_volume_changed_in_frontend(self, volume: float): + self._player.set_volume(volume, notify_frontend=False) + + def present(self): + logging.info(__name__, 'Presenting window') + super().present() diff --git a/source/monophony/ui/windows/message_window.py b/source/monophony/ui/windows/message_window.py new file mode 100644 index 0000000000000000000000000000000000000000..d8a2334e0e9187ead41dabf372b009ddc8a358b8 --- /dev/null +++ b/source/monophony/ui/windows/message_window.py @@ -0,0 +1,12 @@ +from monophony.debug import MemoryDebugger + +from gi.repository import Adw + + +class MessageWindow(MemoryDebugger, Adw.AlertDialog): + def __init__(self, title: str, details: str): + super().__init__() + + self.props.heading = title + self.props.body = details + self.add_response('dismiss', _('Ok')) diff --git a/source/monophony/ui/windows/rename_window.py b/source/monophony/ui/windows/rename_window.py new file mode 100644 index 0000000000000000000000000000000000000000..86efc992e7bad9ac5601e05f4ae837d5f8a9f03a --- /dev/null +++ b/source/monophony/ui/windows/rename_window.py @@ -0,0 +1,45 @@ +import weakref + +from monophony.debug import MemoryDebugger + +from gi.repository import Adw, GObject + + +class RenameWindow(MemoryDebugger, Adw.Dialog): + def __init__(self, original_name: str): + super().__init__() + + self.original_name = original_name + + entry_row = Adw.EntryRow() + entry_row.props.title = _('Playlist Name') + entry_row.props.show_apply_button = True + entry_row.props.text = original_name + entry_row.connect( + 'apply', + lambda entry, ref: + RenameWindow._on_apply(ref(), entry.props.text), + weakref.ref(self) + ) + + group = Adw.PreferencesGroup() + group.add(entry_row) + + page = Adw.PreferencesPage() + page.add(group) + + toolbar_view = Adw.ToolbarView() + toolbar_view.props.content = page + toolbar_view.add_top_bar(Adw.HeaderBar()) + + self.props.title = _('Rename Playlist...') + self.props.child = toolbar_view + + @GObject.Signal(name='rename', arg_types=(str,)) + def _rename(self, _new_name: str): + return + + def _on_apply(self, text: str): + if text and text != self.original_name: + self.emit('rename', text) + self.close() diff --git a/source/monophony/yt.py b/source/monophony/yt.py new file mode 100644 index 0000000000000000000000000000000000000000..ccd306f499bce1e6bb2903dc14f10fc77b609711 --- /dev/null +++ b/source/monophony/yt.py @@ -0,0 +1,553 @@ +import contextlib +import subprocess +import time +import traceback + +from monophony import logging +from monophony.asynchronous import Task +from monophony.data import Artist, Group, Song, TimeString, YTItem + +import requests +import ytmusicapi + + +# Exceptions raised by some (but not all) ytmusicapi functions in case of a data +# parsing error. They can be safely interpreted as a "not found" response from YTM. In +# the case of an internet connection error, requests.exceptions.ConnectionError is +# raised instead +YTMUSICAPI_PARSING_EXCEPTIONS = (AttributeError, KeyError, TypeError) + + +class SearchResult: + # Raises KeyError on unknown type + def __init__(self, type_: str, top: bool, item: YTItem | None=None): + if type_ == 'single': + type_ = 'album' + + self.item = item or { + 'album': Group, + 'artist': Artist, + 'playlist': Group, + 'song': Song, + 'video': Song + }[type_]() + self.top = top + self.type = type_ + + +def _get_artist_name(name_data: list[dict] | str) -> str: + if isinstance(name_data, str): + return name_data + if not isinstance(name_data, list): + logging.warning(__name__, 'Got unusual artist name data', name_data or None) + return '' + + return ', '.join( + [artist.get('name', '') for artist in name_data if 'id' in artist] + ) + + +def _get_artist_id(artists: list[dict] | str) -> str: + if isinstance(artists, str): + return '' + if not isinstance(artists, list): + logging.warning(__name__, 'Got unusual artist data', artists or None) + return '' + + a_id = '' + for artist in artists: + a_id = artist.get('id', '') + if a_id: + break + + return a_id + + +def _parse_single_result(yt: ytmusicapi.YTMusic, data: dict) -> SearchResult | None: + category = data.get('category', '') + type_ = data.get('resultType', '') + + if type_ in ('podcast', 'episode'): + logging.info(__name__, 'Discarded podcast result') + return None + + try: + result = SearchResult(type_, category == 'Top result') + except KeyError: + logging.error( + __name__, f'Failed to parse result of unexpected type "{type_}"', data + ) + return None + + if result.type == 'artist': + result.item.name = ( + _get_artist_name(data.get('artists', '')) or + data.get('artist', '') + ) + result.item.yt_id = ( + _get_artist_id(data.get('artists', '')) or + data.get('browseId', '') + ) + else: + result.item.yt_id = ( + data.get('videoId', '') or + data.get('browseId', '') or + data.get('playlistId', '') + ) + result.item.title = data.get('title', '') + result.item.author.yt_id = ( + _get_artist_id(data.get('artists', '')) or + _get_artist_id(data.get('author', '')) or + data.get('channelId', '') + ) + result.item.author.name = ( + _get_artist_name(data.get('artists', '')) or + _get_artist_name(data.get('author', '')) or + data.get('artist', '') + ) + result.item.length = ( + data.get('duration', '') or + TimeString(seconds=int(data.get('lengthSeconds', 0))).as_string() + ) + thumbnail_container = data.get('thumbnails') or data.get('thumbnail') + result.item.thumbnail = ( + thumbnail_container[0].get('url', '') + if isinstance(thumbnail_container, list) + else ( + thumbnail_container.get('thumbnails') or [{}] + )[0].get('url', '') + ) if thumbnail_container else '' + + if not result.item.yt_id: + result_name = ( + result.item.name if isinstance(result.item, Artist) else result.item.title + ) + logging.warning( + __name__, + f'Discarded id-less result "{result_name}" of type "{result.type}"' + ) + return None + + if result.type in ('album', 'playlist'): + try: + try: + playlist = yt.get_playlist(result.item.yt_id, limit=None) + except YTMUSICAPI_PARSING_EXCEPTIONS: + playlist = yt.get_album(result.item.yt_id) + except ( + *YTMUSICAPI_PARSING_EXCEPTIONS, + ytmusicapi.exceptions.YTMusicUserError, # Invalid ID + requests.exceptions.ConnectionError + ): + logging.error( + __name__, 'Failed to parse a result', traceback.format_exc() + ) + return None + + result.item.title = playlist.get('title', result.item.title) + for song_data in playlist.get('tracks', []): + song_data['resultType'] = 'song' + parsed_song_data = _parse_single_result(yt, song_data) + if parsed_song_data: + if result.item.thumbnail and not parsed_song_data.item.thumbnail: + parsed_song_data.item.thumbnail = result.item.thumbnail + result.item.songs.append(parsed_song_data.item) + + return result + + +def get_song_uri(song: Song) -> str | None: + logging.info(__name__, f'Getting URI for song "{song.yt_id}"...') + out, err = subprocess.Popen( + [ + 'yt-dlp', + '--get-url', + '--extract-audio', + '--quiet', + '--no-warnings', + f'https://music.youtube.com/watch?v={song.yt_id}' + ], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ).communicate() + if err: + logging.error(__name__, 'Failed to get song URI', err) + return None + + logging.info(__name__, 'Got song URI') + return out.split('\n')[0] + + +def get_similar_songs(song: Song, ignore: Group | None=None) -> Group | None: + logging.info( + __name__, + f'Getting similar song to "{song.yt_id}" ignoring ' + f'{len(ignore.songs) if ignore else 0} songs...' + ) + yt = ytmusicapi.YTMusic() + ignore = ignore if ignore else Group() + + try: + data = yt.get_watch_playlist(song.yt_id, radio=True)['tracks'] + except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError): + logging.error( + __name__, 'Failed to get similar song', traceback.format_exc() + ) + return None + + available_songs = [] + for item in data: + item['resultType'] = 'song' + parsed = _parse_single_result(yt, item) + if not parsed: + continue + + song = parsed.item + for ignore_song in ignore.songs: + if ignore_song.yt_id == song.yt_id: + break + else: + available_songs.append(song) + + if available_songs: + logging.info(__name__, f'Got {len(available_songs)} similar songs') + return Group(songs=available_songs) + + logging.error(__name__, 'Failed to get similar song - no songs available') + return Group() + + +def get_song(id_: str) -> Song | None: + logging.info(__name__, f'Getting song "{id_}"...') + yt = ytmusicapi.YTMusic() + + try: + result = yt.get_song(id_)['videoDetails'] + except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError): + logging.error( + __name__, 'Failed to get song', traceback.format_exc() + ) + return None + + result['resultType'] = 'song' + if parsed := _parse_single_result(yt, result): + logging.info(__name__, 'Got song') + return parsed.item + + logging.error(__name__, 'Failed to get song') + return None + + +def get_album_or_playlist(yt_id: str) -> Group | None: + logging.info(__name__, f'Getting album/playlist "{yt_id}"...') + + if result := _parse_single_result( + ytmusicapi.YTMusic(), + { + 'resultType': 'playlist', + 'playlistId': yt_id + } + ): + logging.info(__name__, 'Got album/playlist') + return result.item + + logging.error(__name__, 'Failed to get album/playlist') + return None + + +class ParseResultsTask(Task): + def _function( + self, yt: ytmusicapi.YTMusic, data: list[dict], limit: int | None=None, + ) -> list[SearchResult] | None: + count_per_type = {} + + logging.info( + __name__, f'Parsing {len(data)} results with limit of {limit} per type...' + ) + results = [] + got_top_result = False + for i, item in enumerate(data): + if self.is_canceled(): + logging.info(__name__, 'Parsing canceled') + return None + + with contextlib.suppress(KeyError): + temp_result = SearchResult(item.get('resultType'), False) + if limit and count_per_type.get(temp_result.type, 0) >= limit: + continue + + parsed = _parse_single_result(yt, item) + self._update_progress(i / len(data)) + if parsed: + if parsed.top: + if got_top_result: + logging.warning(__name__, 'Multiple top results') + parsed.top = False + else: + logging.info(__name__, f'Top result type is "{parsed.type}"') + got_top_result = True + + count_per_type[parsed.type] = count_per_type.get(parsed.type, 0) + 1 + results.append(parsed) + + # Internal results (in albums, playlists) not counted + logging.info(__name__, f'Done parsing results, kept {len(results)}/{len(data)}') + return results + + +class GetArtistTask(Task): + def _on_parse_progress_update(self, task: ParseResultsTask, progress: float): + if not task.is_canceled(): + self._update_progress(0.5 + progress / 2) + + def _function( + self, browse_id: str, filter_: str, limit: int | None=None + ) -> list[SearchResult] | None: + logging.info( + __name__, + f'Getting artist "{browse_id}" with filter "{filter_}" and limit of ' + f'{limit} per type...' + ) + yt = ytmusicapi.YTMusic() + + logging.info(__name__, 'Fetching artist...') + try: + try: + data = yt.get_artist(browse_id) + logging.info(__name__, 'Fetched artist') + except YTMUSICAPI_PARSING_EXCEPTIONS: + logging.info(__name__, 'No such artist, fetching as user instead...') + data = yt.get_user(browse_id) + logging.info(__name__, 'Fetched artist as user') + except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError): + logging.error( + __name__, + 'Failed to get artist - could not fetch', + traceback.format_exc() + ) + return None + + if self.is_canceled(): + logging.info(__name__, 'Canceled getting artist') + return None + + self._update_progress(0.1) + + to_parse = [] + for type_ in ('songs', 'videos'): + if filter_ and type_ != filter_: + continue + + if not (group := data.get(type_)): + logging.info(__name__, f'Artist has no {type_} list') + continue + + tracks = [] + logging.info( + __name__, f'Trying to get {type_} from artist with get_playlist...' + ) + try: + try: + tracks = yt.get_playlist(group.get('browseId', ''))['tracks'] + except YTMUSICAPI_PARSING_EXCEPTIONS: + logging.info( + __name__, + f'Got no {type_}, trying with get_user_videos instead...' + ) + # Does not raise YTMUSICAPI_PARSING_EXCEPTIONS, ever + tracks = yt.get_user_videos(browse_id, group.get('params', '')) + except requests.exceptions.ConnectionError: + logging.error(__name__, 'Failed to get artist', traceback.format_exc()) + return None + + if not tracks: + logging.info( + __name__, f'Got no {type_}, trying from "results" list instead...' + ) + if not (tracks := group.get('results')): + logging.info(__name__, f'Got no {type_} from artist') + continue + + logging.info(__name__, f'Got {len(tracks)} {type_} from artist') + for track in tracks: + track['resultType'] = type_[:-1] + to_parse.append(track) + + if self.is_canceled(): + logging.info(__name__, 'Canceled getting artist') + return None + + self._update_progress(0.2) + for type_ in ('albums', 'singles', 'playlists'): + if filter_ and ('albums' if type_ == 'singles' else type_) != filter_: + continue + + if not (group := data.get(type_)): + logging.info(__name__, f'Artist has no {type_} list') + continue + + lists = [] + logging.info( + __name__, f'Trying to get {type_} from artist with get_artist_albums...' + ) + try: + try: + lists = yt.get_artist_albums( + group.get('browseId', ''), group.get('params', '') + ) + except YTMUSICAPI_PARSING_EXCEPTIONS: + logging.info( + __name__, + f'Got no {type_}, trying with get_user_playlists instead...' + ) + # Does not raise YTMUSICAPI_PARSING_EXCEPTIONS, ever + lists = yt.get_user_playlists(browse_id, group.get('params', '')) + except requests.exceptions.ConnectionError: + logging.error(__name__, 'Failed to get artist', traceback.format_exc()) + return None + + if not lists: + logging.info( + __name__, f'Got no {type_}, trying from "results" list instead...' + ) + if not (lists := group.get('results')): + logging.info(__name__, f'Got no {type_} from artist') + continue + + logging.info(__name__, f'Got {len(lists)} {type_} from artist') + for list_ in lists: + list_['resultType'] = type_[:-1] + to_parse.append(list_) + + if self.is_canceled(): + logging.info(__name__, 'Canceled getting artist') + return None + + self._update_progress(0.5) + parse_task = ParseResultsTask( + progress_callback=self._on_parse_progress_update, + args=(yt, to_parse, limit) + ) + parse_task.start() + while parse_task.is_running(): + time.sleep(0.1) + if self.is_canceled(): + parse_task.cancel() + logging.info(__name__, 'Canceled getting artist') + return None + + if results := parse_task.result: + for result in results: + if not result.item.author.name: + result.item.author.name = data.get('name', '') + + time.sleep(0.1) + logging.info(__name__, 'Got artist') + return results + + logging.error(__name__, 'Failed to get artist') + return None + + +class GetRecommendationsTask(Task): + def _function(self) -> list[Group] | None: + logging.info(__name__, 'Getting recommendations...') + yt = ytmusicapi.YTMusic() + + try: + data = yt.get_home() + except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError): + logging.error( + __name__, 'Failed to get recommendations', traceback.format_exc() + ) + return None + + recommendations = [] + for i, grouping in enumerate(data): + playlist = Group(title=grouping.get('title', '')) + for item in grouping.get('contents', []): + if not isinstance(item, dict): + logging.warning(__name__, 'Unexpected recommendation item', item) + continue + + if 'videoId' not in item: + continue + + item['resultType'] = 'song' + if parsed := _parse_single_result(yt, item): + playlist.songs.append(parsed.item) + + if playlist.songs: + recommendations.append(playlist) + self._update_progress(i / len(data)) + + logging.info(__name__, f'Got {len(recommendations)} groups of recommendations') + return recommendations + + +class SearchTask(Task): + def _on_parse_progress_update(self, task: ParseResultsTask, progress: float): + if not task.is_canceled(): + self._update_progress(0.5 + progress / 2) + + def _function( + self, query: str, filter_: str='', limit: int | None=None + ) -> list[SearchResult] | None: + logging.info(__name__, f'Searching for "{query}" with filter "{filter_}"...') + yt = ytmusicapi.YTMusic() + + self._update_progress(0.1) + try: + if '?v=' in query and '/' in query: + song = get_song(query.split('?v=')[-1].split('&')[0]) + if song: + logging.info(__name__, 'Done searching - got song from URL') + return [SearchResult('song', True, song)] + logging.error( + __name__, 'Failed to search - failed to get song from URL' + ) + return None + if 'youtu.be/' in query: + song = get_song(query.split('youtu.be/')[-1].split('?')[0]) + if song: + logging.info(__name__, 'Done searching - got song from URL') + return [SearchResult('song', True, song)] + logging.error( + __name__, 'Failed to search - failed to get song from URL' + ) + return None + + self._update_progress(0.2) + data = ( + yt.search(query, filter=filter_, limit=100) if filter_ + else yt.search(query) + ) + except (*YTMUSICAPI_PARSING_EXCEPTIONS, requests.exceptions.ConnectionError): + logging.error(__name__, 'Failed to search', traceback.format_exc()) + return None + + if self.is_canceled(): + logging.info(__name__, 'Canceled search') + return None + + self._update_progress(0.5) + parse_task = ParseResultsTask( + progress_callback=self._on_parse_progress_update, + args=(yt, data, limit) + ) + parse_task.start() + while parse_task.is_running(): + time.sleep(0.1) + if self.is_canceled(): + parse_task.cancel() + logging.info(__name__, 'Canceled search') + return None + + if parse_task.result is not None: + time.sleep(0.1) + logging.info(__name__, 'Done searching') + return parse_task.result + + logging.error(__name__, 'Failed to search') + return None diff --git a/source/pyproject.toml b/source/pyproject.toml index 74f8c8ac4f458c2606608e61e4a4cf7b2564013a..1cc22159adca744e00825e32f196f5cca16aa0d9 100644 --- a/source/pyproject.toml +++ b/source/pyproject.toml @@ -1,6 +1,6 @@ [project] name = 'monophony' -dynamic = ['dependencies', 'optional-dependencies', 'version'] +dynamic = ['version'] requires-python = '>=3.12' [build-system] @@ -8,8 +8,6 @@ requires = ['setuptools'] build-backend = 'setuptools.build_meta' [tool.setuptools.dynamic] -dependencies = {file=['requirements.txt']} -optional-dependencies = {dev={file=['requirements-dev.txt']}} version = {attr='monophony.__version__'} [tool.setuptools.packages.find] @@ -17,19 +15,50 @@ include = ['monophony*'] [tool.ruff] builtins = ['_'] -lint.extend-ignore = ['E401', 'E402', 'E722', 'E74'] -lint.extend-select = [ - 'ARG', 'BLE', 'E1', 'E5', 'FA', 'INP', 'ISC', 'PERF', 'PIE', 'PLC', 'Q', 'RET', - 'RUF', 'SIM', 'TRY', 'UP' -] line-length = 88 indent-width = 4 -[tool.ruff.format] -quote-style = 'single' -indent-style = 'tab' +[tool.ruff.lint] +select = [ + 'A', 'ARG', 'ASYNC', + 'B', 'BLE', + 'C4', 'COM', + 'DTZ', + 'E', 'EM', 'EXE', + 'F', 'FA', 'FIX', 'FLY', 'FURB', + 'G', + 'I', 'ICN', 'INP', 'INT', 'ISC', + 'LOG', + 'N', + 'PERF', 'PGH', 'PIE', 'PLC', 'PLE', 'PLR', 'PLW', 'PT', + 'Q', + 'RET', 'RSE', 'RUF', + 'S', 'SIM', 'SLF', 'SLOT', + 'T20', 'TID', 'TRY', + 'UP', + 'W' +] +ignore = [ + 'COM812', # missing-trailing-comma + 'PLR09', # too-many-* + 'S108', # hardcoded-temp-file + 'S311', # suspicious-non-cryptographic-random-usage + 'S603', # subprocess-without-shell-equals-true + 'S607', # start-process-with-partial-path + 'W191' # tab-indentation +] [tool.ruff.lint.flake8-quotes] inline-quotes = 'single' docstring-quotes = 'single' multiline-quotes = 'single' + +[tool.ruff.lint.flake8-tidy-imports] +ban-relative-imports = 'all' + +[tool.ruff.lint.isort] +section-order = [ + 'future', 'standard-library', 'local-folder', 'first-party', 'third-party' +] +known-first-party = ['monophony'] +lines-after-imports = 2 diff --git a/source/requirements-dev.txt b/source/requirements-dev.txt deleted file mode 100644 index de628466695c933cb39eaac2efc6f3728f413471..0000000000000000000000000000000000000000 --- a/source/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ -ruff==0.12.8 diff --git a/source/requirements.txt b/source/requirements.txt deleted file mode 100644 index 3eaa62e831f861ea726ae9bcf83be6a51f9636f1..0000000000000000000000000000000000000000 --- a/source/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -mpris_server==0.9.0 -ytmusicapi==1.11.1 diff --git a/source/tests/tests.py b/source/tests/tests.py new file mode 100755 index 0000000000000000000000000000000000000000..04e9c8d82d4de0476b0a4cc0753975fb20872d6c --- /dev/null +++ b/source/tests/tests.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +# ruff: noqa: S101 - Asserts used in tests +# ruff: noqa: SLF001 - Private members used in tests + +import os +import shutil +import time +import unittest + +from monophony import ID, NAME, __version__, playlists, settings +from monophony.data import Group, Song +from monophony.playlists import ImportTask + + +class BaseTestCase(unittest.TestCase): + def tearDown(self): + shutil.rmtree( + os.getenv( + 'XDG_CONFIG_HOME', os.path.expanduser('~/.config') + ) + '/' + NAME, + ignore_errors=True + ) + shutil.rmtree( + os.getenv( + 'XDG_DATA_HOME', os.path.expanduser('~/.local/share') + ) + '/' + NAME, + ignore_errors=True + ) + + +class MetadataTestCase(BaseTestCase): + def test_version(self): + for path in os.getenv('XDG_DATA_DIRS', '/usr/share/').split(':'): + file_path = ( + path if path.endswith('/') else path + '/' + ) + f'metainfo/{ID}.metainfo.xml' + + if not os.path.isfile(file_path): + continue + + with open(file_path) as metainfo: + version = metainfo.read().split('