pax_global_header00006660000000000000000000000064151235373000014511gustar00rootroot0000000000000052 comment=87d2b0b12d277728284ac1d1e21d3539da30d198 turntable/000077500000000000000000000000001512353730000130555ustar00rootroot00000000000000turntable/.editorconfig000066400000000000000000000003371512353730000155350ustar00rootroot00000000000000root = true [*] end_of_line = lf insert_final_newline = true indent_style = tab trim_trailing_whitespace = true charset = utf-8 indent_size = 4 [*.{build,build.in,yml,yaml,xml,xml.in}] indent_style = space indent_size = 2turntable/.forgejo/000077500000000000000000000000001512353730000145665ustar00rootroot00000000000000turntable/.forgejo/ISSUE_TEMPLATE/000077500000000000000000000000001512353730000167515ustar00rootroot00000000000000turntable/.forgejo/ISSUE_TEMPLATE/bug-report.yaml000066400000000000000000000034721512353730000217310ustar00rootroot00000000000000name: Bug Report description: Report a problem you encountered while using the app labels: bug body: - type: textarea attributes: label: Describe the bug description: A clear and concise description of what the bug is. validations: required: true - type: textarea attributes: label: Steps To Reproduce description: Steps to reproduce the behavior. placeholder: | 1. Go to '...' 2. Click on '...' 3. Scroll down to '...' 4. See error validations: required: true - type: textarea attributes: label: Logs and/or Screenshots description: Terminal logs are often invaluable. If you can, launch the app from terminal and paste the output here. If there are no useful logs available, run `G_MESSAGES_DEBUG=Turntable flatpak run dev.geopjr.Turntable` for a verbose output. value: | ``` ``` validations: required: false - type: input attributes: label: Operating System description: Please include its version if available. validations: required: true - type: dropdown attributes: label: Package description: How did you install the app? multiple: false options: - Flatpak - Snap - OS repositories - Compiled manually - I'm not sure validations: required: true - type: textarea attributes: label: Troubleshooting information description: You can find this info under About > Troubleshooting > Debugging Information. If you can't access them, skip this field or grab them from the head of `G_MESSAGES_DEBUG=Turntable flatpak run dev.geopjr.Turntable`. validations: required: false - type: textarea attributes: label: Additional Context description: Add any other relevant information about the problem here. validations: required: false turntable/.forgejo/ISSUE_TEMPLATE/feature-request.yaml000066400000000000000000000006521512353730000227610ustar00rootroot00000000000000name: Feature Request description: Suggest an idea for this app labels: enhancement body: - type: textarea attributes: label: Describe the request description: A clear and concise description of what the request is. validations: required: true - type: checkboxes attributes: label: Checks options: - label: This follows the [GNOME HIG](https://developer.gnome.org/hig/). required: true turntable/.gitattributes000066400000000000000000000001571512353730000157530ustar00rootroot00000000000000data/screenshots/*.png filter=lfs diff=lfs merge=lfs -text build-aux/*.bmp filter=lfs diff=lfs merge=lfs -text turntable/.github/000077500000000000000000000000001512353730000144155ustar00rootroot00000000000000turntable/.github/workflows/000077500000000000000000000000001512353730000164525ustar00rootroot00000000000000turntable/.github/workflows/build.yml000066400000000000000000000023661512353730000203030ustar00rootroot00000000000000on: push: branches: [main] pull_request: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true name: CI jobs: lint: name: "Vala Lint" runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: elementary/actions/vala-lint@master with: conf: vala-lint.conf flatpak-builder: name: "Flatpak Builder" needs: [ lint ] container: image: ghcr.io/flathub-infra/flatpak-github-actions:gnome-49 options: --privileged strategy: matrix: variant: - arch: x86_64 runner: ubuntu-latest - arch: aarch64 runner: ubuntu-24.04-arm # Don't fail the whole workflow if one architecture fails fail-fast: false runs-on: ${{ matrix.variant.runner }} steps: - uses: actions/checkout@v4 - uses: flatpak/flatpak-github-actions/flatpak-builder@v6 with: bundle: "dev.geopjr.Turntable.Devel.flatpak" run-tests: true manifest-path: "build-aux/dev.geopjr.Turntable.json" cache-key: flatpak-builder-${{ github.sha }} arch: ${{ matrix.variant.arch }} repository-name: flathub repository-url: https://dl.flathub.org/repo/flathub.flatpakrepo turntable/.gitignore000066400000000000000000000003011512353730000150370ustar00rootroot00000000000000_ignore build build.sh subprojects/packagecache *~ builddir *.snap .flatpak-builder *.exe /turntable_windows_portable/ turntable_windows_portable.zip /nsis/ subprojects/ !subprojects/glib.wrap turntable/.valalintignore000066400000000000000000000001001512353730000160630ustar00rootroot00000000000000build *~ builddir .flatpak-builder /turntable_windows_portable/ turntable/.woodpecker.yml000066400000000000000000000007531512353730000160250ustar00rootroot00000000000000when: - event: pull_request - event: push branch: main steps: lint: image: valalang/lint:latest commands: - io.elementary.vala-lint -c vala-lint.conf || exit 1 build: depends_on: [lint] image: alpine:edge commands: - apk add --no-cache meson vala glib-dev gtk4.0-dev json-glib-dev libadwaita-dev libsoup3-dev desktop-file-utils gettext-dev make clang git libsecret-dev glycin-loaders-dev libportal-dev libportal-gtk4 - CC=clang make build turntable/CODE_OF_CONDUCT.md000066400000000000000000000301511512353730000156540ustar00rootroot00000000000000# Code of Conduct Thank you for being a part of the GNOME project. We value your participation and want everyone to have an enjoyable and fulfilling experience. Accordingly, all participants are expected to follow this Code of Conduct, and to show respect, understanding, and consideration to one another. Thank you for helping make this a welcoming, friendly community for everyone. ## Scope This Code of Conduct applies to all GNOME community spaces, including, but not limited to: * Issue tracking systems - bugzilla.gnome.org * Documentation and tutorials - developer.gnome.org * Code repositories - git.gnome.org and gitlab.gnome.org * Mailing lists - mail.gnome.org * Wikis - wiki.gnome.org * Chat and forums - irc.gnome.org, discourse.gnome.org, GNOME Telegram channels, and GNOME groups and channels on Matrix.org (including bridges to GNOME IRC channels) * Community spaces hosted on gnome.org infrastructure * Any other channels or groups which exist in order to discuss GNOME project activities * All event venues and associated spaces, including conferences, hackfests, release parties, workshops and other small events * All areas related to event venues: vendor exhibit halls, staff and meal areas, connecting infrastructure like walkways, hallways, elevators, and stairs * Sponsor events, either on-site or off-site * Private events off-site that involve one or more attendees * Social events around the main event * Private conversations taking place in official conference hotels Communication channels and private conversations that are normally out of scope may be considered in scope if a GNOME participant is being stalked or harassed. Social media conversations may be considered in-scope if the incident occurred under a GNOME event hashtag, or when an official GNOME account on social media is tagged, or within any other discussion about GNOME. The GNOME Foundation reserves the right to take actions against behaviors that happen in any context, if they are deemed to be relevant to the GNOME project and its participants. All participants in GNOME community spaces are subject to the Code of Conduct. This includes GNOME Foundation board members, corporate sponsors, and paid employees. This also includes volunteers, maintainers, leaders, contributors, contribution reviewers, issue reporters, GNOME users, and anyone participating in discussion in GNOME community spaces. For in-person events, this also includes all attendees, exhibitors, vendors, speakers, panelists, organizers, staff, and volunteers. ## Reporting an Incident If you believe that someone is violating the Code of Conduct, or have any other concerns, please [contact the Code of Conduct committee](https://wiki.gnome.org/Foundation/CodeOfConduct/ReporterGuide). ## Our Standards The GNOME community is dedicated to providing a positive experience for everyone, regardless of: * age * body size * caste * citizenship * disability * education * ethnicity * familial status * gender expression * gender identity * genetic information * immigration status * level of experience * nationality * personal appearance * pregnancy * race * religion * sex characteristics * sexual orientation * sexual identity * socio-economic status * tribe * veteran status ### Community Guidelines Examples of behavior that contributes to creating a positive environment include: * **Be friendly.** Use welcoming and inclusive language. * **Be empathetic.** Be respectful of differing viewpoints and experiences. * **Be respectful.** When we disagree, we do so in a polite and constructive manner. * **Be considerate.** Remember that decisions are often a difficult choice between competing priorities. Focus on what is best for the community. Keep discussions around technology choices constructive and respectful. * **Be patient and generous.** If someone asks for help it is because they need it. When documentation is available that answers the question, politely point them to it. If the question is off-topic, suggest a more appropriate online space to seek help. * **Try to be concise.** Read the discussion before commenting in order to not repeat a point that has been made. ### Inappropriate Behavior Community members asked to stop any inappropriate behavior are expected to comply immediately. We want all participants in the GNOME community have the best possible experience they can. In order to be clear what that means, we've provided a list of examples of behaviors that are inappropriate for GNOME community spaces: * **Deliberate intimidation, stalking, or following.** * **Sustained disruption of online discussion, talks, or other events.** Sustained disruption of events, online discussions, or meetings, including talks and presentations, will not be tolerated. This includes 'Talking over' or 'heckling' event speakers or influencing crowd actions that cause hostility in event sessions. Sustained disruption also includes drinking alcohol to excess or using recreational drugs to excess, or pushing others to do so. * **Harassment of people who don't drink alcohol or other legal substances.** We do not tolerate derogatory comments about those who abstain from alcohol or other legal substances. We do not tolerate pushing people to drink, talking about their abstinence or preferences to others, or pressuring them to drink - physically or through jeering. * **Sexist, racist, homophobic, transphobic, ableist language or otherwise exclusionary language.** This includes deliberately referring to someone by a gender that they do not identify with, and/or questioning the legitimacy of an individual's gender identity. If you're unsure if a word is derogatory, don't use it. This also includes repeated subtle and/or indirect discrimination. * **Unwelcome sexual attention or behavior that contributes to a sexualized environment.** This includes sexualized comments, jokes or imagery in interactions, communications or presentation materials, as well as inappropriate touching, groping, or sexual advances. Sponsors should not use sexualized images, activities, or other material. Meetup organizing staff and other volunteer organizers should not use sexualized clothing/uniforms/costumes, or otherwise create a sexualized environment. * **Unwelcome physical contact.** This includes touching a person without permission, including sensitive areas such as their hair, pregnant stomach, mobility device (wheelchair, scooter, etc) or tattoos. This also includes physically blocking or intimidating another person. Physical contact or simulated physical contact (such as emojis like "kiss") without affirmative consent is not acceptable. This includes sharing or distribution of sexualized images or text. * **Violence or threats of violence.** Violence and threats of violence are not acceptable - online or offline. This includes incitement of violence toward any individual, including encouraging a person to commit self-harm. This also includes posting or threatening to post other people's personally identifying information ("doxxing") online. * **Influencing or encouraging inappropriate behavior.** If you influence or encourage another person to violate the Code of Conduct, you may face the same consequences as if you had violated the Code of Conduct. * **Possession of an offensive weapon at a GNOME event.** This includes anything deemed to be a weapon by the event organizers. ### Safety versus Comfort The GNOME community prioritizes marginalized people's safety over privileged people's comfort, for example in situations involving: * "Reverse"-isms, including "reverse racism," "reverse sexism," and "cisphobia" * Reasonable communication of boundaries, such as "leave me alone," "go away," or "I'm not discussing this with you." * Criticizing racist, sexist, cissexist, or otherwise oppressive behavior or assumptions * Communicating boundaries or criticizing oppressive behavior in a "tone" you don't find congenial The examples listed above are not against the Code of Conduct. If you have questions about the above statements, please [read our document on Supporting Diversity](https://wiki.gnome.org/Foundation/CodeOfConduct/SupportingDiversity). Outreach and diversity efforts directed at under-represented groups are permitted under the code of conduct. For example, a social event for women would not be classified as being outside the Code of Conduct under this provision. Basic expectations for conduct are not covered by the "reverse-ism clause" and would be enforced irrespective of the demographics of those involved. For example, racial discrimination will not be tolerated, irrespective of the race of those involved. Nor would unwanted sexual attention be tolerated, whatever someone's gender or sexual orientation. Members of our community have the right to expect that participants in the project will uphold these standards. If a participant engages in behavior that violates this code of conduct, the GNOME Code of Conduct committee may take any action they deem appropriate. Examples of consequences are outlined in the [Committee Procedures Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/CommitteeProcedures). ## Procedure for Handling Incidents * [Reporter Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/ReporterGuide) * [Moderator Procedures](https://wiki.gnome.org/Foundation/CodeOfConduct/ModeratorProcedures) * [Committee Procedures Guide](https://wiki.gnome.org/Foundation/CodeOfConduct/CommitteeProcedures) ## License The GNOME Code of Conduct is licensed under a [Creative Commons Attribution Share-Alike 3.0 Unported License](http://creativecommons.org/licenses/by-sa/3.0/) [![Creative Commons License](http://i.creativecommons.org/l/by-sa/3.0/88x31.png)](http://creativecommons.org/licenses/by-sa/3.0/) ## Attribution The GNOME Code of Conduct was forked from the example policy from the [Geek Feminism wiki, created by the Ada Initiative and other volunteers](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy), which is under a Creative Commons Zero license. Additional language was incorporated and modified from the following Codes of Conduct: * [Citizen Code of Conduct](http://citizencodeofconduct.org/) is licensed [Creative Commons Attribution-ShareAlike 3.0 Unported License](https://creativecommons.org/licenses/by-sa/3.0/). * [Code of Conduct template](https://github.com/sagesharp/code-of-conduct-template/) is licensed [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/) by [Otter Tech](https://otter.technology/code-of-conduct-training) * [Contributor Covenant version 1.4](https://www.contributor-covenant.org/version/1/4/code-of-conduct) (licensed [CC BY 4.0](https://github.com/ContributorCovenant/contributor_covenant/blob/master/LICENSE.md)) * [Data Carpentry Code of Conduct](https://docs.carpentries.org/topic_folders/policies/index_coc.html) is licensed [Creative Commons Attribution 4.0 License](https://creativecommons.org/licenses/by/4.0/) * [Django Project Code of Conduct](https://www.djangoproject.com/conduct/) is licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/) * [Fedora Code of Conduct](http://fedoraproject.org/code-of-conduct) * [Geek Feminism Anti-harassment Policy](http://geekfeminism.wikia.com/wiki/Conference_anti-harassment/Policy) which is under a [Creative Commons Zero license](https://creativecommons.org/publicdomain/zero/1.0/) * [GNOME Foundation Code of Conduct](https://wiki.gnome.org/action/recall/Foundation/CodeOfConduct?action=recall&rev=48) * [LGBTQ in Technology Slack Code of Conduct](https://lgbtq.technology/coc.html) licensed [Creative Commons Zero](https://creativecommons.org/publicdomain/zero/1.0/) * [Mozilla Community Participation Guidelines](https://www.mozilla.org/en-US/about/governance/policies/participation/) is licensed [Creative Commons Attribution-ShareAlike 3.0 Unported License](https://creativecommons.org/licenses/by-sa/3.0/). * [Python Mentors Code of Conduct](http://pythonmentors.com/) * [Speak Up! Community Code of Conduct](http://web.archive.org/web/20141109123859/http://speakup.io/coc.html), licensed under a [Creative Commons Attribution 3.0 Unported License](http://creativecommons.org/licenses/by/3.0/) turntable/LICENSE000066400000000000000000001045131512353730000140660ustar00rootroot00000000000000 GNU GENERAL PUBLIC LICENSE Version 3, 29 June 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. 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 them 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 prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others. For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. 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. Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it. For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions. Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users. Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey 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; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If 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 convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Use with the GNU Affero General Public License. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU 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 that a certain numbered version of the GNU General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. 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. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 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. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. 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 state 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 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: Copyright (C) This program 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, your program's commands might be different; for a GUI interface, you would use an "about box". You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU 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. But first, please read . turntable/Makefile000066400000000000000000000015611512353730000145200ustar00rootroot00000000000000.PHONY: all install uninstall build test potfiles PREFIX ?= /usr scrobbling ?= 1 # Remove the devel headerbar style: # make release=1 release ?= ifeq ($(scrobbling),0) SCROBBLING = -Dscrobbling=false else SCROBBLING = -Dscrobbling=true endif all: build build: meson setup builddir --prefix=$(PREFIX) meson configure builddir -Ddevel=$(if $(release),false,true) $(SCROBBLING) meson compile -C builddir install: meson install -C builddir uninstall: sudo ninja uninstall -C builddir test: ninja test -C builddir potfiles: find ./ -not -path '*/.*' -type f -name "*.in" | sort > po/POTFILES echo "" >> po/POTFILES find ./ -not -path '*/.*' -type f -name "*.ui" -exec grep -l "translatable=\"yes\"" {} \; | sort >> po/POTFILES echo "" >> po/POTFILES find ./ -not -path '*/.*' -type f -name "*.vala" -exec grep -l "_(\"\|ngettext" {} \; | sort >> po/POTFILES turntable/README.md000066400000000000000000000103001512353730000143260ustar00rootroot00000000000000

A turntable in the style of GNOME icons

Turntable

Scrobble your music


GNOME Code of Conduct License GPL-3.0 Please do not theme this app

Screenshot of the Turntable app in light mobile. Heartache - BritPop by A. G. Cook is playing though GNOME Music.

Keep track of your listening habits by scrobbling them to last.fm, ListenBrainz, Libre.fm and Maloja at the same time using your favorite music app's, favorite music app! Turntable comes with a highly customizable and sleek design that displays information about the currently playing song and allows you to control your music player, allowlist it for scrobbling and manage your scrobbling accounts. All MPRIS-enabled apps are supported.

Not interested in the GUI but still want to scrobble your MPRIS-enabled players? Turntable comes with a CLI, allowing you to scrobble in the background! Try it out using flatpak run dev.geopjr.Turntable --help.

# Install ## Official ### Release Download on Flathub ## From Source
Dependencies Package Name | Required :--- | ---: meson | ✅ valac | ✅ libadwaita-1.0-dev | ✅ glycin-2-dev | ✅ libsecret-1-dev | ❌ libjson-glib-dev | ❌ libsoup3.0-dev | ❌
### Scrobbling Scrobbling will only be enabled if last.fm tokens are provided. The optional deps are only required then. Read [`meson_options.txt`](./meson_options.txt) on how to generate one. Please avoid using the official debug or release tokens. ### Makefile ``` $ make $ make install ``` ### GNOME Builder - Clone - Open in GNOME Builder # FaQ - **XYZ player is missing controls** - Most likely MPRIS doesn't give us enough info about whether the player supports it or is inaccurate. - **What's MBID?** - MusicBrainz is, among other things, a big database for song metadata. Enabling the option on Turntable, will check if the song to-be scrobbled exists and will fix its metadata. - **When does a scrobble get sent?** - A song will be scrobbled either when it hits 4 minutes of playtime or half the playtime, whichever one comes first. Playtime does not count while the song is not playing. - **What if I have multiple Turntable windows open?** - Turntable was built with multiple windows open in mind. The scrobbling manager will make sure to filter out requests so only 1 instance of an MPRIS client will be counted. - **CLI can't find any accounts** - Use the GUI to set them up. They require validation and callbacks which the GUI can handle. - **Flatpak can't access my player's cover** - Due to the way the sandbox works, Turntable has to get access to its files. Please open an issue or give the flatpak access to your host filesystem. # Sponsors
[![GeopJr Sponsors](https://cdn.jsdelivr.net/gh/GeopJr/GeopJr@main/sponsors.svg)](https://github.com/sponsors/GeopJr)
[![Translation status](https://translate.codeberg.org/widgets/turntable/-/turntable/287x66-white.png)](https://translate.codeberg.org/engage/turntable) # Contributing 1. Read the [Code of Conduct](./CODE_OF_CONDUCT.md) 2. Fork it ( https://codeberg.org/GeopJr/Turntable/fork ) 3. Create your feature branch (git checkout -b my-new-feature) 4. Commit your changes (git commit -am 'Add some feature') 5. Push to the branch (git push origin my-new-feature) 6. Create a new Pull Request turntable/Turntable.doap000066400000000000000000000017461512353730000156720ustar00rootroot00000000000000 Turntable Scrobble your music Vala GTK 4 Libadwaita Evangelos "GeopJr" Paterakis GeopJr turntable/build-aux/000077500000000000000000000000001512353730000147475ustar00rootroot00000000000000turntable/build-aux/dev.geopjr.Turntable.json000066400000000000000000000055061512353730000216520ustar00rootroot00000000000000{ "id": "dev.geopjr.Turntable", "runtime": "org.gnome.Platform", "runtime-version": "49", "sdk": "org.gnome.Sdk", "sdk-extensions": [ "org.freedesktop.Sdk.Extension.vala", "org.freedesktop.Sdk.Extension.llvm20" ], "command": "dev.geopjr.Turntable", "finish-args": [ "--share=network", "--share=ipc", "--socket=fallback-x11", "--device=dri", "--socket=wayland", "--talk-name=org.mpris.MediaPlayer2.*", "--talk-name=org.freedesktop.DBus", "--filesystem=~/.mozilla/firefox/firefox-mpris/:ro", "--filesystem=~/.var/app/org.mozilla.firefox/.mozilla/firefox/firefox-mpris/:ro", "--filesystem=~/.var/app/org.mozilla.firefox/data/firefox-mpris/:ro", "--filesystem=~/.librewolf/firefox/firefox-mpris/:ro", "--filesystem=~/.var/app/io.gitlab.librewolf-community/.librewolf/:ro", "--filesystem=~/.floorp/firefox/firefox-mpris/:ro", "--filesystem=~/.var/app/app.zen_browser.zen/data/firefox-mpris/:ro", "--filesystem=~/.zen/firefox/firefox-mpris/:ro", "--filesystem=~/.var/app/one.ablaze.floorp/data/firefox-mpris/:ro", "--filesystem=~/.var/app/io.bassi.Amberol/cache/amberol/covers/:ro", "--filesystem=~/.var/app/com.github.neithern.g4music/cache/com.github.neithern.g4music/:ro", "--filesystem=~/.var/app/ca.edestcroix.Recordbox/data/Recordbox/:ro", "--filesystem=~/.var/app/org.gnome.Rhythmbox3/cache/rhythmbox/album-art/:ro", "--filesystem=~/.var/app/org.gnome.Lollypop/cache/lollypop/:ro", "--filesystem=~/.var/app/io.github.nokse22.high-tide/cache/images:ro", "--filesystem=~/.cache/:ro", "--filesystem=/tmp", "--filesystem=xdg-data/applications", "--filesystem=~/.local/share/xdg-desktop-portal:ro", "--filesystem=/var/lib/snapd/:ro", "--filesystem=xdg-data/flatpak:ro" ], "build-options": { "append-path": "/usr/lib/sdk/vala/bin", "prepend-ld-library-path": "/usr/lib/sdk/vala/lib" }, "cleanup": [ "/include", "/lib/pkgconfig", "/man", "/share/doc", "/share/gtk-doc", "/share/man", "/share/pkgconfig", "/share/vala", "*.la", "*.a" ], "modules": [ { "name": "libportal", "buildsystem": "meson", "config-opts": [ "-Ddocs=false", "-Dtests=false", "-Dbackend-gtk4=enabled" ], "sources": [ { "type": "archive", "url": "https://github.com/flatpak/libportal/releases/download/0.9.1/libportal-0.9.1.tar.xz", "sha256": "de801ee349ed3c255a9af3c01b1a401fab5b3fc1c35eb2fd7dfb35d4b8194d7f" } ] }, { "name": "turntable", "builddir": true, "buildsystem": "meson", "config-opts": [ "-Ddevel=true", "-Dsandboxed=true" ], "build-options": { "arch": { "aarch64": { "append-path": "/usr/lib/sdk/llvm20/bin", "prepend-ld-library-path": "/usr/lib/sdk/llvm20/lib", "env": { "CC": "clang" } } } }, "sources": [ { "type": "dir", "path": "../" } ] } ] } turntable/data/000077500000000000000000000000001512353730000137665ustar00rootroot00000000000000turntable/data/dev.geopjr.Turntable.desktop.in000066400000000000000000000007511512353730000217730ustar00rootroot00000000000000[Desktop Entry] Type=Application Name=Turntable Exec=dev.geopjr.Turntable %u Icon=dev.geopjr.Turntable Terminal=false Categories=GNOME;GTK;Music;AudioVideo;Audio; Keywords=music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio; X-GNOME-Gettext-Domain=dev.geopjr.Turntable MimeType=x-scheme-handler/turntable; # Translators: Do NOT translate or transliterate this text (these are enum types)! X-Purism-FormFactor=Workstation;Mobile; StartupNotify=true turntable/data/dev.geopjr.Turntable.gresource.xml000066400000000000000000000122371512353730000225140ustar00rootroot00000000000000 ui/gtk/dropdown/client.ui ui/gtk/dropdown/client_display.ui style.css icons/hicolor/symbolic/actions/music-note-outline-symbolic.svg icons/hicolor/symbolic/actions/auth-fingerprint-symbolic.svg icons/hicolor/symbolic/actions/fingerprint2-symbolic.svg icons/hicolor/symbolic/actions/menu-large-symbolic.svg icons/hicolor/symbolic/actions/pause-large-symbolic.svg icons/hicolor/symbolic/actions/play-large-symbolic.svg icons/hicolor/symbolic/actions/skip-backward-large-symbolic.svg icons/hicolor/symbolic/actions/skip-forward-large-symbolic.svg icons/hicolor/symbolic/actions/application-x-executable-symbolic.svg icons/hicolor/symbolic/actions/left-large-symbolic.svg icons/hicolor/symbolic/actions/right-large-symbolic.svg icons/hicolor/symbolic/actions/user-trash-symbolic.svg icons/hicolor/symbolic/actions/playlist-shuffle-symbolic.svg icons/hicolor/symbolic/actions/playlist-repeat-symbolic.svg icons/hicolor/symbolic/actions/playlist-repeat-song-symbolic.svg icons/hicolor/symbolic/actions/playlist-consecutive-symbolic.svg icons/hicolor/symbolic/actions/background-app-ghost-symbolic.svg icons/hicolor/symbolic/actions/dock-right-symbolic.svg icons/hicolor/symbolic/actions/dock-left-symbolic.svg icons/hicolor/symbolic/actions/dock-bottom-symbolic.svg icons/hicolor/symbolic/actions/settings-symbolic.svg icons/hicolor/symbolic/actions/network-server-symbolic.svg icons/hicolor/symbolic/actions/funnel-symbolic.svg icons/hicolor/symbolic/actions/view-wrapped-symbolic.svg icons/hicolor/symbolic/actions/sad-computer-symbolic.svg icons/hicolor/symbolic/actions/folder-download-symbolic.svg icons/hicolor/scalable/actions/listenbrainz.svg icons/hicolor/scalable/actions/maloja.svg icons/hicolor/scalable/actions/librefm.svg icons/hicolor/symbolic/actions/lastfm-symbolic.svg icons/hicolor/scalable/apps/dev.geopjr.Archives.svg icons/hicolor/scalable/apps/dev.geopjr.Calligraphy.svg icons/hicolor/scalable/apps/dev.geopjr.Collision.svg icons/hicolor/scalable/apps/dev.geopjr.Tuba.svg turntable/data/dev.geopjr.Turntable.gschema.xml000066400000000000000000000050701512353730000221220ustar00rootroot00000000000000 true true 'card' 'overlay' 'regular' 'regular' true true false false '' 'linear' true true 'unset' true [] false false true false true [] false false false false 0 0 false turntable/data/dev.geopjr.Turntable.metainfo.xml.in000066400000000000000000000164061512353730000227270ustar00rootroot00000000000000 dev.geopjr.Turntable CC0-1.0 GPL-3.0-only Turntable Scrobble your music

Keep track of your listening habits by scrobbling them to last.fm, ListenBrainz, Libre.fm and Maloja at the same time using your favorite music app's, favorite music app! Turntable comes with a highly customizable and sleek design that displays information about the currently playing song and allows you to control your music player, allowlist it for scrobbling and manage your scrobbling accounts. All MPRIS-enabled apps are supported.

Not interested in the GUI but still want to scrobble your MPRIS-enabled players? Turntable comes with a CLI, allowing you to scrobble in the background! Try it out using flatpak run dev.geopjr.Turntable --help.

dev.geopjr.Turntable Evangelos "GeopJr" Paterakis Evangelos "GeopJr" Paterakis https://turntable.geopjr.dev https://codeberg.org/GeopJr/Turntable https://codeberg.org/GeopJr/Turntable/issues https://translate.codeberg.org/engage/turntable/ https://geopjr.dev/donate https://codeberg.org/GeopJr/Turntable#contributing dev.geopjr.Turntable dev.geopjr.Turntable.desktop #c5f1a1 #1a5fb4 https://codeberg.org/GeopJr/Turntable/media/branch/main/data/screenshots/screenshot-1.png https://codeberg.org/GeopJr/Turntable/media/branch/main/data/screenshots/screenshot-2.png https://codeberg.org/GeopJr/Turntable/media/branch/main/data/screenshots/screenshot-3.png https://codeberg.org/GeopJr/Turntable/media/branch/main/data/screenshots/screenshot-4.png
  • Fixed missing version bump, see previous changelog for 0.5
  • Added the ability to collapse the controls
  • Added background portal support and automatically starting on boot
  • Added a Wrapped year-in-review experiment
  • Added automatically picking the client that's actively playing on startup
  • Fixed progress bar being enabled by default even if set to disabled
  • Fixed bugs with the cover overlay when animations are disabled by replacing it with a more robust solution
  • Moved some options around to better organize them
  • Other minor fixes and design changes
  • Updated translations
  • Added the ability to submit now_playing
  • Added offline scrobbling allowing you to publish scrobbles the next time you are online
  • Added player seek bar
  • Added shuffle and loop controls
  • Image decoding now uses glycin for security and format support range
  • Added more flatpak permissions to access more music player covers
  • Enabled window resizing
  • Added cover blur to the window styles
  • Rewrote the maths around the tonearm and turntable mode to better handle unconventional window and art sizes
  • Redesigned the tonearm slightly
  • Redesigned some cover styles to better fit a non-static window size
  • Split scrobbling into services and settings
  • Fixed Maloja not scrobbling
  • Restructured the codebase to improve maintainability
  • Fixed memory leaks and optimized parts of the codebase
  • Other minor fixes and design changes
  • Updated translations
  • Fixed remote cover arts not being fetch on flatpak due to missing permission
  • Fixed cover art scaling when fit cover and turntable style are enabled
  • Fixed scrobbling on looping tracks
  • Fixed issues with namespaces that had more than 3 parts
  • Updated translations
  • Fixed CLI-mode typo in property name
  • Read the last changelog
  • Fixed some gapless players being unscrollable because their playing status does not change
  • Fixed scrobbling queue not getting cleared between client changes
  • Fixed scrobbling allowlist not being re-checked between client changes
  • Added center text option
  • Fixed incorrect positioning when scaling is anything but 1
  • Fixed a bug caused not properly clearing the last player when it's the last available one
  • Fixed client icon finding for sandboxed environments
  • Optimized CLI mode further by avoiding searching for icons and creating an application instance
  • Added the ability to decode base64 encoded artUrls
  • Added the ability to handle lengths as strings
  • Initial release
turntable/data/icons/000077500000000000000000000000001512353730000151015ustar00rootroot00000000000000turntable/data/icons/dev.geopjr.Turntable.Source.svg000066400000000000000000000775131512353730000231000ustar00rootroot00000000000000 Adwaita Icon Template Adwaita Icon Template Hicolor Symbolic turntable/data/icons/hicolor/000077500000000000000000000000001512353730000165405ustar00rootroot00000000000000turntable/data/icons/hicolor/scalable/000077500000000000000000000000001512353730000203065ustar00rootroot00000000000000turntable/data/icons/hicolor/scalable/actions/000077500000000000000000000000001512353730000217465ustar00rootroot00000000000000turntable/data/icons/hicolor/scalable/actions/librefm.svg000066400000000000000000000072261512353730000241160ustar00rootroot00000000000000turntable/data/icons/hicolor/scalable/actions/listenbrainz.svg000066400000000000000000000271221512353730000251770ustar00rootroot00000000000000turntable/data/icons/hicolor/scalable/actions/maloja.svg000066400000000000000000000033501512353730000237330ustar00rootroot00000000000000 turntable/data/icons/hicolor/scalable/apps/000077500000000000000000000000001512353730000212515ustar00rootroot00000000000000turntable/data/icons/hicolor/scalable/apps/dev.geopjr.Archives.svg000066400000000000000000000042351512353730000256040ustar00rootroot00000000000000 turntable/data/icons/hicolor/scalable/apps/dev.geopjr.Calligraphy.svg000066400000000000000000000111421512353730000262720ustar00rootroot00000000000000 turntable/data/icons/hicolor/scalable/apps/dev.geopjr.Collision.svg000066400000000000000000000156461512353730000260030ustar00rootroot00000000000000 turntable/data/icons/hicolor/scalable/apps/dev.geopjr.Tuba.svg000066400000000000000000001455121512353730000247370ustar00rootroot00000000000000 turntable/data/icons/hicolor/scalable/apps/dev.geopjr.Turntable.Devel.svg000066400000000000000000000161431512353730000270370ustar00rootroot00000000000000 turntable/data/icons/hicolor/scalable/apps/dev.geopjr.Turntable.svg000066400000000000000000000146071512353730000260040ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/000077500000000000000000000000001512353730000203615ustar00rootroot00000000000000turntable/data/icons/hicolor/symbolic/actions/000077500000000000000000000000001512353730000220215ustar00rootroot00000000000000turntable/data/icons/hicolor/symbolic/actions/application-x-executable-symbolic.svg000066400000000000000000000047121512353730000312540ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/auth-fingerprint-symbolic.svg000066400000000000000000000041661512353730000276560ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/background-app-ghost-symbolic.svg000066400000000000000000000025211512353730000304000ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/dock-bottom-symbolic.svg000066400000000000000000000011661512353730000266070ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/dock-left-symbolic.svg000066400000000000000000000011671512353730000262360ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/dock-right-symbolic.svg000066400000000000000000000011671512353730000264210ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/fingerprint2-symbolic.svg000066400000000000000000000047601512353730000270010ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/folder-download-symbolic.svg000066400000000000000000000011751512353730000274450ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/funnel-symbolic.svg000066400000000000000000000003111512353730000256430ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/lastfm-symbolic.svg000066400000000000000000000015241512353730000256510ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/left-large-symbolic.svg000066400000000000000000000007431512353730000264070ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/menu-large-symbolic.svg000066400000000000000000000004241512353730000264150ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/music-note-outline-symbolic.svg000066400000000000000000000017621512353730000301270ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/network-server-symbolic.svg000066400000000000000000000032201512353730000273530ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/pause-large-symbolic.svg000066400000000000000000000007521512353730000265720ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/play-large-symbolic.svg000066400000000000000000000010331512353730000264130ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/playlist-consecutive-symbolic.svg000066400000000000000000000012161512353730000305470ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/playlist-repeat-song-symbolic.svg000066400000000000000000000032641512353730000304510ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/playlist-repeat-symbolic.svg000066400000000000000000000024011512353730000274750ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/playlist-shuffle-symbolic.svg000066400000000000000000000032621512353730000276570ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/right-large-symbolic.svg000066400000000000000000000007441512353730000265730ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/sad-computer-symbolic.svg000066400000000000000000000043301512353730000267640ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/settings-symbolic.svg000066400000000000000000000035731512353730000262310ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/skip-backward-large-symbolic.svg000066400000000000000000000012271512353730000301750ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/skip-forward-large-symbolic.svg000066400000000000000000000012241512353730000300600ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/user-trash-symbolic.svg000066400000000000000000000020501512353730000264530ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/actions/view-wrapped-symbolic.svg000066400000000000000000000014441512353730000267760ustar00rootroot00000000000000 turntable/data/icons/hicolor/symbolic/apps/000077500000000000000000000000001512353730000213245ustar00rootroot00000000000000turntable/data/icons/hicolor/symbolic/apps/dev.geopjr.Turntable-symbolic.svg000066400000000000000000000014251512353730000276700ustar00rootroot00000000000000 turntable/data/icons/meson.build000066400000000000000000000010201512353730000172340ustar00rootroot00000000000000icon_name = meson.project_name() if get_option('devel') icon_name = ('@0@.Devel').format(icon_name) endif scalable_dir = 'hicolor' / 'scalable' / 'apps' install_data( scalable_dir / ('@0@.svg').format(icon_name), install_dir: get_option('datadir') / 'icons' / scalable_dir, rename: ('@0@.svg').format(meson.project_name()) ) symbolic_dir = 'hicolor' / 'symbolic' / 'apps' install_data( symbolic_dir / ('@0@-symbolic.svg').format(meson.project_name()), install_dir: get_option('datadir') / 'icons' / symbolic_dir ) turntable/data/meson.build000066400000000000000000000025011512353730000161260ustar00rootroot00000000000000install_data( meson.project_name() + '.gschema.xml', install_dir: join_paths( get_option('prefix'), get_option('datadir'), 'glib-2.0', 'schemas', ), ) desktop_file = i18n.merge_file( input: meson.project_name() + '.desktop.in', output: meson.project_name() + '.desktop', po_dir: join_paths(meson.project_source_root(), 'po'), type: 'desktop', install: true, install_dir: join_paths(get_option('datadir'), 'applications'), ) desktop_utils = find_program('desktop-file-validate', required: false) if desktop_utils.found() test('Validate desktop file', desktop_utils, args: [desktop_file]) endif if host_machine.system() != 'windows' and host_machine.system() != 'darwin' appstream_file = i18n.merge_file( input: meson.project_name() + '.metainfo.xml.in', output: meson.project_name() + '.metainfo.xml', po_dir: join_paths(meson.project_source_root(), 'po'), install: true, install_dir: join_paths(get_option('datadir'), 'metainfo'), ) appstream_util = find_program('appstream-util', required: false) if appstream_util.found() test( 'Validate appstream file', appstream_util, args: ['validate-relax', '--nonet', appstream_file], ) endif endif subdir('icons') turntable/data/screenshots/000077500000000000000000000000001512353730000163265ustar00rootroot00000000000000turntable/data/screenshots/screenshot-1.png000066400000000000000000000002021512353730000213410ustar00rootroot00000000000000version https://git-lfs.github.com/spec/v1 oid sha256:141f34d26011f4ae744b51141aa7afd2eded5036fdc20a63f15bb51fd75d8ea9 size 72418 turntable/data/screenshots/screenshot-2.png000066400000000000000000000002031512353730000213430ustar00rootroot00000000000000version https://git-lfs.github.com/spec/v1 oid sha256:aa8c8472c316cfc959fc80638a49931216b0f10045e51c05163fe5803565365c size 150311 turntable/data/screenshots/screenshot-3.png000066400000000000000000000002021512353730000213430ustar00rootroot00000000000000version https://git-lfs.github.com/spec/v1 oid sha256:0e5d36838322dc05f7046d518e97efbd63ebcaecc1586fe99b482fedc4263acb size 89081 turntable/data/screenshots/screenshot-4.png000066400000000000000000000002021512353730000213440ustar00rootroot00000000000000version https://git-lfs.github.com/spec/v1 oid sha256:7c65fd182ac7b255d3b0b56238a53c022127196ea5c1d1ad37dbb4a31bac68f2 size 49137 turntable/data/style.css000066400000000000000000000203441512353730000156430ustar00rootroot00000000000000.circular-art { border-radius: 9999px; } button.large { min-width: 48px; min-height: 48px; } .card-like { margin: 16px; } .card-like.vertical:not(.collapsed) { margin-bottom: 0; } .card-like.horizontal:dir(ltr):not(.circular-art):not(.collapsed) { margin-right: 0; } .card-like.horizontal:dir(rtl):not(.circular-art):not(.collapsed) { margin-left: 0; } .transparent .fade { border-radius: 0; } .fade.horizontal:dir(ltr) { border-top-left-radius: 15px; border-bottom-left-radius: 15px; } .fade.horizontal:dir(rtl) { border-top-right-radius: 15px; border-bottom-right-radius: 15px; } .fade.vertical { border-top-left-radius: 15px; border-top-right-radius: 15px; } .bigger-label { font-size: 120%; } .smaller-label { font-size: 80%; } .client-chooser>button { border-radius: 9999px; } .osd { min-height: unset; min-width: unset; } .transparent { background: transparent; } .min34px { min-height: 34px; min-width: 34px; } .clear-view { background: none; } .main-box.osd { border-radius: 15px; } .main-box.osd .fade { border-radius: 0; } .small-text { font-size: small; } .style-window .pagebox { background-color: var(--window-bg-color); } .style-accent .pagebox { background-color: var(--accent-bg-color); color: var(--accent-fg-color); } .style-pride .pagebox { background-image: linear-gradient(135deg, rgba(228,3,3,0.3) 0%, rgba(255,140,0,0.3) 20%, rgba(255,237,0,0.3) 40%, rgba(0,128,38,0.3) 60%, rgba(0,77,255,0.3) 80%, rgba(117,7,135,0.3) 100%); background-color: var(--window-bg-color); } .style-trans .pagebox { background-image: linear-gradient(135deg, rgba(92,206,250,0.5) 0%, rgba(246,168,183,0.5) 25%, rgba(255,255,255,0.5) 50%, rgba(246,168,183,0.5) 75%, rgba(92,206,250,0.5) 100%); background-color: var(--window-bg-color); } /* Ported from Amberol https://gitlab.gnome.org/World/amberol/-/blob/5d1d9bec9c0817759e154f2df7e17692d748f495/src/gtk/style.css */ .progressscale.overlay { --accent-bg-color: color-mix(in srgb, rgb(0 0 0 / 75%), currentColor); --accent-fg-color: white; --accent-color: oklab(from var(--accent-bg-color) var(--standalone-color-oklab)); } .progressscale.overlay scale trough highlight { background: color-mix(in srgb, var(--accent-bg-color) var(--dim-opacity), transparent); } .progressscale.overlay scale { color: inherit; } .progressscale.overlay scale trough highlight { min-height: 12px; min-width: 12px; } .progressscale.overlay scale trough slider { opacity: 0; } .progressscale scale { padding: 10px; } .about .app-version{color:#fff;text-shadow:-1px -1px 0#000,1px -1px 0#000,-1px 1px 0#000,1px 1px 0#000;background:linear-gradient(150deg,#fdd81755 0,#66338b55 8.3%,#fff5 16.6%,#f4aec855 24.9%,#7bcce555 33.2%,#94551655 41.5%,#0005 49.8%,#e2201655 58.1%,#f2891755 66.4%,#efe52455 74.7%,#78b82a55 83%,#2c58a4 91.3%,#6d238055 100%);} .about .app-version:hover{background:radial-gradient(circle at 9.75% 50%,#0000 6.66%,#7902aa 6.7%,#0000 8.4%),conic-gradient(at 26.66% 50%,#0000 222.75deg,#ffd800 0 317.25deg,#0000 0),conic-gradient(at 33% 50%,#0000 222.75deg,white 0 317.25deg,#0000 0),conic-gradient(at 39% 50%,#0000 222.75deg,#ffa6b9 0 317.25deg,#0000 0),conic-gradient(at 45.66% 50%,#0000 222.75deg,#00d2ff 0 317.25deg,#0000 0),conic-gradient(at 52% 50%,#0000 222.75deg,#753000 0 317.25deg,#0000 0),conic-gradient(at 58.33% 50%,#0000 222.75deg,#000 0 317.25deg,#0000 0),linear-gradient(to bottom,#e40303,#e40303 16.67%,#ff8c00 16.67%,#ff8c00 33.33%,#ffed00 33.33%,#ffed00 50%,#008026 50%,#008026 66.67%,#004dff 66.67%,#004dff 83.33%,#750787 83.33%,#750787);} .theme-disability.about .app-version{background:linear-gradient(45deg,#59595955 0,#59595955 37.5%,#cf728055 37.5%,#cf728055 42.5%,#eede7755 42.5%,#eede7755 47.5%,#e8e8e855 47.5%,#e8e8e855 52.5%,#7bc2e055 52.5%,#7bc2e055 57.5%,#3bb07d55 57.5%,#3bb07d55 62.5%,#59595955 62.5%,#59595955 100%);} .theme-disability.about .app-version:hover{background:linear-gradient(45deg,#595959 0,#595959 37.5%,#cf7280 37.5%,#cf7280 42.5%,#eede77 42.5%,#eede77 47.5%,#e8e8e8 47.5%,#e8e8e8 52.5%,#7bc2e0 52.5%,#7bc2e0 57.5%,#3bb07d 57.5%,#3bb07d 62.5%,#595959 62.5%,#595959 100%);} .theme-lesbian.about .app-version{background:linear-gradient(to bottom,#d1260155,#d1260155 20%,#ff8f4555 20%,#ff8f4555 40%,#fff5 40%,#fff5 60%,#cf559b55 60%,#cf559b55 80%,#9b1a5855 80%,#9b1a5855);} .theme-lesbian.about .app-version:hover{background:linear-gradient(to bottom,#d12601,#d12601 20%,#ff8f45 20%,#ff8f45 40%,#fff 40%,#fff 60%,#cf559b 60%,#cf559b 80%,#9b1a58 80%,#9b1a58);} .theme-pan.about .app-version{background:linear-gradient(to bottom,#ff218c55,#ff218c55 33.33%,#fed80055 33.33%,#fed80055 66.67%,#21b1ff55 66.67%,#21b1ff55);} .theme-pan.about .app-version:hover{background:linear-gradient(to bottom,#ff218c,#ff218c 33.33%,#fed800 33.33%,#fed800 66.67%,#21b1ff 66.67%,#21b1ff);} .theme-trans.about .app-version{background:linear-gradient(to bottom,#5ccefa55,#5ccefa55 20%,#f6a8b755 20%,#f6a8b755 40%,#fff5 40%,#fff5 60%,#f6a8b755 60%,#f6a8b755 80%,#5ccefa55 80%,#5ccefa55);} .theme-trans.about .app-version:hover{background:linear-gradient(to bottom,#5ccefa,#5ccefa 20%,#f6a8b7 20%,#f6a8b7 40%,#fff 40%,#fff 60%,#f6a8b7 60%,#f6a8b7 80%,#5ccefa 80%,#5ccefa);} .theme-bi.about .app-version{background:linear-gradient(to bottom,#d6027055,#d6027055 40%,#9a4f9655 40%,#9a4f9655 60%,#0039a955 60%,#0039a955);} .theme-bi.about .app-version:hover{background:linear-gradient(to bottom,#d60270,#d60270 40%,#9a4f96 40%,#9a4f96 60%,#0039a9 60%,#0039a9);} .theme-agender.about .app-version{background:linear-gradient(to bottom,#0005,#00000055 14.29%,#bcc4c755 14.29%,#bcc4c755 28.57%,#fff5 28.57%,#fff5 42.86%,#b7f58455 42.86%,#b7f58455 57.14%,#fff5 57.14%,#fff5 71.43%,#bcc4c755 71.43%,#bcc4c755 85.71%,#00000055 85.71%,#0005);} .theme-agender.about .app-version:hover{background:linear-gradient(to bottom,#000,#000 14.29%,#bcc4c7 14.29%,#bcc4c7 28.57%,#fff 28.57%,#fff 42.86%,#b7f584 42.86%,#b7f584 57.14%,#fff 57.14%,#fff 71.43%,#bcc4c7 71.43%,#bcc4c7 85.71%,#000 85.71%,#000);} .theme-aro.about .app-version{background:linear-gradient(to bottom,#309c3455,#309c3455 20%,#9dce6955 20%,#9dce6955 40%,#fff5 40%,#fff5 60%,#a0a0a055 60%,#a0a0a055 80%,#0005 80%,#0005);} .theme-aro.about .app-version:hover{background:linear-gradient(to bottom,#309c34,#309c34 20%,#9dce69 20%,#9dce69 40%,#fff 40%,#fff 60%,#a0a0a0 60%,#a0a0a0 80%,#000 80%,#000);} .theme-ace.about .app-version{background:linear-gradient(to bottom,#0005,#0005 25%,#a3a3a355 25%,#a3a3a355 50%,#fff5 50%,#fff5 75%,#82008055 75%,#82008055);} .theme-ace.about .app-version:hover{background:linear-gradient(to bottom,#000,#000 25%,#a3a3a3 25%,#a3a3a3 50%,#fff 50%,#fff 75%,#820080 75%,#820080);} .theme-non-binary.about .app-version{background:linear-gradient(to bottom,#fdf52f55,#fdf52f55 25%,#fff5 25%,#fff5 50%,#9d5ad255 50%,#9d5ad255 75%,#0005 75%,#0005);} .theme-non-binary.about .app-version:hover{background:linear-gradient(to bottom,#fdf52f,#fdf52f 25%,#fff 25%,#fff 50%,#9d5ad2 50%,#9d5ad2 75%,#000 75%,#000);} .theme-black-history.about .app-version{background:linear-gradient(to bottom,#e31b2355,#e31b2355 33%,#0005 33%,#0005 66%,#00853f55 66%,#00853f55);} .theme-black-history.about .app-version:hover{background:linear-gradient(to bottom,#e31b23,#e31b23 33%,#000 33%,#000 66%,#00853f 66%,#00853f);} .theme-aids.about .app-version{background:linear-gradient(to bottom,#ffffff55,#ffffff55 20%,#d1111355 20%,#d1111355 40%,#ffffff55 40%,#ffffff55 60%,#d1111355 60%,#d1111355 80%,#ffffff55 80%,#ffffff55);} .theme-aids.about .app-version:hover{background:linear-gradient(to bottom,#fff,#fff 20%,#d11113 20%,#d11113 40%,#fff 40%,#fff 60%,#d11113 60%,#d11113 80%,#fff 80%,#fff);} .theme-intersex.about .app-version{background:radial-gradient(closest-side circle at center,#ffd80055 44%,#7902aa55 44%,#7902aa55 56%,#ffd80055 56%);} .theme-intersex.about .app-version:hover{background:radial-gradient(closest-side circle at center,#ffd800 44%,#7902aa 44%,#7902aa 56%,#ffd800 56%);} .theme-autism.about .app-version{background:linear-gradient(to bottom,#e5304355,#e5304355 16.66%,#f6834755 16.66%,#f6834755 33.33%,#fac85755 33.33%,#fac85755 66.67%,#99d67155 66.67%,#99d67155 83.33%,#21ae7555 83.33%,#21ae7555);} .theme-autism.about .app-version:hover{background:linear-gradient(to bottom,#e53043,#e53043 16.66%,#f68347 16.66%,#f68347 33.33%,#fac857 33.33%,#fac857 66.67%,#99d671 66.67%,#99d671 83.33%,#21ae75 83.33%,#21ae75);} turntable/data/ui/000077500000000000000000000000001512353730000144035ustar00rootroot00000000000000turntable/data/ui/gtk/000077500000000000000000000000001512353730000151705ustar00rootroot00000000000000turntable/data/ui/gtk/dropdown/000077500000000000000000000000001512353730000170245ustar00rootroot00000000000000turntable/data/ui/gtk/dropdown/client.ui000066400000000000000000000021031512353730000206350ustar00rootroot00000000000000 turntable/data/ui/gtk/dropdown/client_display.ui000066400000000000000000000021571512353730000223730ustar00rootroot00000000000000 turntable/data/ui/gtk/help-overlay.ui000066400000000000000000000022001512353730000201300ustar00rootroot00000000000000 True shortcuts 10 General Show Shortcuts win.show-help-overlay Quit app.quit turntable/meson.build000066400000000000000000000101651512353730000152220ustar00rootroot00000000000000project('dev.geopjr.Turntable', ['c', 'vala'], version: '0.5.1', meson_version: '>= 1.0.0', default_options: [ 'warning_level=2', 'werror=false', ], ) # https://gitlab.gnome.org/GNOME/vala/-/issues/1413#note_1707480 if meson.get_compiler ('c').get_id () == 'clang' add_project_arguments('-Wno-incompatible-function-pointer-types', language: 'c') endif devel = get_option('devel') # Setup configuration file config = configuration_data() config.set('EXEC_NAME', meson.project_name()) config.set('GETTEXT_PACKAGE', meson.project_name()) config.set('LOCALEDIR', join_paths(get_option('prefix'), get_option('localedir'))) config.set('DOMAIN', meson.project_name ()) config.set('G_LOG_DOMAIN', 'Turntable') config.set('RESOURCES', '/' + '/'.join(meson.project_name().split('.')) + '/') config.set('VERSION', meson.project_version()) config.set('PREFIX', get_option('prefix')) config.set('NAME', 'Turntable') config.set('WEBSITE', 'https://turntable.geopjr.dev') config.set('ISSUES_WEBSITE', 'https://codeberg.org/GeopJr/Turntable/issues') config.set('DONATE_WEBSITE', 'https://geopjr.dev/donate') config.set('TRANSLATE_WEBSITE', 'https://translate.codeberg.org/engage/turntable/') config.set('PROFILE', devel ? 'development' : 'production') config.set('LIBREFM_KEY', '778aa5b9a1a796dce785f1416ba7265a') # can be anything, 'Turntable' | md5sum config.set('LIBREFM_SECRET', '7a4e4df2400597eb184eabe65dfc3310') # above | md5sum if devel config.set('LASTFM_KEY', '7c7320be36b02ba3b8639ab6867c9471') # Devel key, will be renewed often, do not use in prod. config.set('LASTFM_SECRET', 'bc183481df70a23d23f7f145af61a3a6') # Devel key, will be renewed often, do not use in prod. else config.set('LASTFM_KEY', get_option('lastfm_key')) config.set('LASTFM_SECRET', get_option('lastfm_secret')) endif if devel git = find_program('git') if git.found() branch = run_command('git', 'branch', '--show-current', check: true).stdout().strip() revision = run_command('git', 'rev-parse', '--short', 'HEAD', check: true).stdout().strip() version = '@0@-@1@'.format(branch, revision) config.set('VERSION', version) endif endif if host_machine.system() == 'windows' add_project_arguments(['--define=WINDOWS'], language: 'vala') elif host_machine.system() == 'darwin' add_project_arguments(['--define=DARWIN'], language: 'vala') endif add_project_arguments ( '-DGETTEXT_PACKAGE="@0@"'.format(meson.project_name()), '-DG_LOG_DOMAIN="Turntable"', '-w', language: 'c' ) i18n = import('i18n') gnome = import('gnome') asresources = gnome.compile_resources( 'as-resources', 'data/dev.geopjr.Turntable.gresource.xml', source_dir: 'data', c_name: 'as', ) if get_option('sandboxed') add_project_arguments(['--define=SANDBOXED'], language: 'vala') endif scrobbling = false libsoup_dep = dependency('libsoup-3.0', required: false) json_glib_dep = dependency('json-glib-1.0', version: '>=1.4.4', required: false) libsecret_dep = dependency('libsecret-1', required: false) gtk_dep = dependency('gtk4', version: '>=4.18.0', required: true) if get_option('scrobbling') and libsoup_dep.found () and json_glib_dep.found () and libsecret_dep.found () scrobbling = true add_project_arguments(['--define=SCROBBLING'], language: 'vala') endif if gtk_dep.version().version_compare('>=4.20.0') add_project_arguments(['--define=GTK_4_20'], language: 'vala') endif sources = files() subdir('src') final_deps = [ dependency('glib-2.0', version: '>=2.76.0'), gtk_dep, dependency('libadwaita-1', version: '>=1.7', required: true), dependency('gio-unix-2.0', required: true), dependency('glycin-2', required: true), dependency('glycin-gtk4-2', required: true), dependency('libportal', required: true), dependency('libportal-gtk4', required: true), libsoup_dep, json_glib_dep, libsecret_dep, meson.get_compiler('c').find_library('m', required: false) ] executable( meson.project_name(), asresources, sources, dependencies: final_deps, install: true, win_subsystem: 'windows' ) subdir('data') subdir('po') gnome.post_install( glib_compile_schemas: true, gtk_update_icon_cache: true, update_desktop_database: true, ) turntable/meson_options.txt000066400000000000000000000011351512353730000165120ustar00rootroot00000000000000option('devel', type: 'boolean', value: false) option('scrobbling', type: 'boolean', value: true) option('sandboxed', type: 'boolean', value: false) # Distros need to fill these for last.fm to work. # You can get a key on https://www.last.fm/api/account/create # Please mention that you are unofficially packaging Turntable # and your distro. Leave callback URL empty. Fill the homepage # with Turntable's website. # # This is needed to ensure that Turntable keeps working for # everyone in case of bans. option('lastfm_key', type: 'string', value: '') option('lastfm_secret', type: 'string', value: '') turntable/po/000077500000000000000000000000001512353730000134735ustar00rootroot00000000000000turntable/po/LINGUAS000066400000000000000000000001341512353730000145160ustar00rootroot00000000000000# Please keep this file sorted alphabetically. nl es zh_CN de fr et fi fa ru pt_BR it id vi turntable/po/POTFILES000066400000000000000000000010521512353730000146410ustar00rootroot00000000000000./data/dev.geopjr.Turntable.desktop.in ./data/dev.geopjr.Turntable.metainfo.xml.in ./src/Build.vala.in ./data/ui/gtk/help-overlay.ui ./src/Application.vala ./src/Scrobbling/Accounts.vala ./src/Scrobbling/Manager.vala ./src/Views/LibreFMPage.vala ./src/Views/ListenBrainzPage.vala ./src/Views/MalojaPage.vala ./src/Views/OfflineScrobbling.vala ./src/Views/Preferences.vala ./src/Views/ScrobblerSetup.vala ./src/Views/Window.vala ./src/Views/Wrapped.vala ./src/Widgets/ControlsOverlay.vala ./src/Widgets/MPRISControls.vala ./src/Widgets/ProgressBin.vala turntable/po/de.po000066400000000000000000000420601512353730000144250ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # jak2k , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-05-13 06:53+0000\n" "Last-Translator: jak2k \n" "Language-Team: German \n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 5.10.2\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Plattenspieler" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "musik;spieler;widget;mris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;lied;audio;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Scrobble deine Musik" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Allgemein" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Tastenkürzel Anzeigen" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Schließen" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "Übersetzer Danksagung" #: src/Application.vala:199 msgid "Donate" msgstr "Spenden" #: src/Application.vala:200 msgid "Translate" msgstr "Übersetzten" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Archive" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Abbrechen" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "_Mehr lesen" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Host" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "Du kannst deinen Zugangsschlüssel auf %s finden." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "Host-API" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "Zugangsschlüssel" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Ungültiger Zugangsschlüssel" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "Zugangsschlüssel konnte nicht verifiziert werden: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "%s Konto vergessen" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "Scobblers" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "Vor dem Scrobblen Metadaten von MusicBrainz abrufen" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" "Empfohlen für unkuratierte Clients oder ungetaggte Musikbibliotheken, weil " "es Metadaten korrigiert und vervollständigt. Allerdings werden Lieder, die " "nicht auf MusicBrainz gefunden wurden, auch nicht gescrobblet." #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "Willst du „%s“ entfernen?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "Bereits hochgeladene Scrobbles sind nicht betroffen." #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "_Vergessen" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "Ungültige Sitzung" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "Unbekannter Titel" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "Unbekannter Künstler" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "Unbekanntes Album" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "Scrobbling deaktivieren" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "Scrobbling aktivieren" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "Musikspieler auswählen" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "Neues Fenster" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "Scrobbling" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "Hintergrundfortschritt" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "Text zentrieren" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "Spieler-Symbol" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "Metadaten-Beschriftungen dimmen" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "Bild an Cover anpassen" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "Albumfarben extrahieren" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "Tonarm" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "Komponenten" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "Linear" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "Nächstes" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "Trilinear" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "Berechnungsmethode für die Cover-Höhe" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "Horizontal" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "Vertikal" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "Orientierung" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "Klein" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "Normal" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "Groß" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "Cover" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "Größe" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "Text" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "Symbolisch" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "Farbig" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "Karte" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "Schatten" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "Fenster" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "OSD" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "Durchsichtig" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "Stil" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "Über %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "Beenden" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "Menü" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "Pause" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "Abspielen" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "Vorheriges Lied" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "Nächstes Lied" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "Unbekannter Spieler" turntable/po/dev.geopjr.Turntable.pot000066400000000000000000000527631512353730000202360ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # FIRST AUTHOR , YEAR. # #, fuzzy msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-12-26 17:58+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:225 msgid "Turntable" msgstr "" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "" #. translators: Name or Name https://website.example #: src/Application.vala:200 msgid "translator-credits" msgstr "" #: src/Application.vala:203 msgid "Donate" msgstr "" #: src/Application.vala:204 msgid "Translate" msgstr "" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:207 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:211 msgid "Archives" msgstr "" #: src/Application.vala:211 msgid "Create and view web archives" msgstr "" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:213 msgid "Calligraphy" msgstr "" #: src/Application.vala:213 msgid "Turn text into ASCII banners" msgstr "" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:215 msgid "Collision" msgstr "" #: src/Application.vala:215 msgid "Check hashes for your files" msgstr "" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:217 msgid "Tuba" msgstr "" #: src/Application.vala:217 msgid "Browse the Fediverse" msgstr "" #. translators: message shown when requesting autostart or run in the background permissions #. the variable is the app name (Turntable) #: src/Application.vala:256 #, c-format msgid "Allow %s to start when you log in" msgstr "" #: src/Application.vala:256 #, c-format msgid "Allow %s to run in the background" msgstr "" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:260 msgid "_Cancel" msgstr "" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "" #. translators: background portal subtitle when scrobbling, #. the variable is a song name #: src/Scrobbling/Manager.vala:107 #, c-format msgid "Scrobbling %s" msgstr "" #. translators: background portal subtitle when publishing a scrobble, #. the variable is a song name #: src/Scrobbling/Manager.vala:123 #, c-format msgid "Scrobbled %s" msgstr "" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "" #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "" #. translators: switch description on "Start Hidden" notifying the user that they need to enable the other options first #: src/Views/Preferences.vala:11 msgid "Requires both running on startup and in background" msgstr "" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #: src/Views/Preferences.vala:17 src/Views/Preferences.vala:23 #: src/Widgets/ControlsOverlay.vala:262 msgid "Preferences" msgstr "" #. translators: switch title, enables autostart on boot #: src/Views/Preferences.vala:28 msgid "Run on Startup" msgstr "" #. translators: switch title, enables the background portal #: src/Views/Preferences.vala:33 msgid "Keep Running in Background" msgstr "" #. translators: switch title, starts the app hidden if it starts on boot #: src/Views/Preferences.vala:38 msgid "Start Hidden" msgstr "" #. translators: Running settings group title, like "Running in the background" #: src/Views/Preferences.vala:62 msgid "Running" msgstr "" #. translators: Running settings group description, the variable is the app name (Turntable), #. the purpose of this description is to push users towards the CLI version of #. Turntable if they want to scrobble in the background #: src/Views/Preferences.vala:66 #, c-format msgid "" "While it's possible to have %s run in the background for constant " "scrobbling, it's recommended to use the CLI instead as it's much more " "performant, skips initializing the GUI code entirely and can lock to " "specific MPRIS clients." msgstr "" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" #. translators: as in experimental features, page title #: src/Views/ScrobblerSetup.vala:184 msgid "Experiments" msgstr "" #. translators: as in Spotify Wrapped, leave it as is if possible as it's more recognizable #: src/Views/ScrobblerSetup.vala:189 src/Views/Wrapped.vala:75 msgid "Wrapped" msgstr "" #. translators: Wrapped feature, row subtitle explaining what it does briefly #: src/Views/ScrobblerSetup.vala:191 msgid "Your scrobbling year-in-review" msgstr "" #: src/Views/ScrobblerSetup.vala:254 #, c-format msgid "Forget %s Account?" msgstr "" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:256 msgid "This won't affect your submitted scrobbles." msgstr "" #: src/Views/ScrobblerSetup.vala:261 msgid "_Forget" msgstr "" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:350 src/Views/ScrobblerSetup.vala:353 #: src/Views/ScrobblerSetup.vala:354 src/Views/ScrobblerSetup.vala:357 msgid "Invalid Session" msgstr "" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:368 #, c-format msgid "Couldn't get session: %s" msgstr "" #. translators: default string when title is missing #: src/Views/Window.vala:239 msgid "Unknown Title" msgstr "" #. translators: default string when artist is missing #: src/Views/Window.vala:244 src/Views/Window.vala:249 msgid "Unknown Artist" msgstr "" #. translators: default string when album is missing #: src/Views/Window.vala:257 src/Views/Window.vala:262 msgid "Unknown Album" msgstr "" #: src/Views/Wrapped.vala:87 msgid "Default" msgstr "" #. translators: Accent color #: src/Views/Wrapped.vala:89 msgid "Accent" msgstr "" #. translators: button shown in error message that repeats the action that led to this #: src/Views/Wrapped.vala:94 msgid "Try Again" msgstr "" #. translators: error message with wrapped (as in Spotify wrapped see other comments on this) #: src/Views/Wrapped.vala:103 msgid "Couldn't generate Wrapped" msgstr "" #. translators: as in save file #: src/Views/Wrapped.vala:132 msgid "Save" msgstr "" #. translators: dropdown label for picking a window style in Wrapped experiment #. translators: menu entry that opens a submenu #: src/Views/Wrapped.vala:140 src/Widgets/ControlsOverlay.vala:255 msgid "Style" msgstr "" #. translators: error message shown when trying to generate a Wrapped #. but none of the accounts have the necessary functions #: src/Views/Wrapped.vala:155 msgid "Unfortunately, you don't have any eligible accounts" msgstr "" #. translators: services as in "Scrobbling Services", if it's easier, #. translate it into "Platforms" or "Providers" #: src/Views/Wrapped.vala:163 msgid "Service" msgstr "" #. translators: description of a group of rows, services as in "Scrobbling Services", #. if it's easier, translate it into "Platforms" or "Providers", wrapped #. as in Spotify Wrapped #: src/Views/Wrapped.vala:167 msgid "Choose which service to use for generating your Wrapped" msgstr "" #. translators: error description when the user is not connected to the internet #: src/Views/Wrapped.vala:202 msgid "You are currently offline" msgstr "" #. translators: error message shown on wrapped when theres not enough info to generate one #: src/Views/Wrapped.vala:481 msgid "Not enough info available, go and scrobble some more!" msgstr "" #. translators: wrapped page 1, fun title, feel free to change it something similar in your language #: src/Views/Wrapped.vala:502 msgid "What a year, huh?" msgstr "" #. translators: wrapped page 1 description #: src/Views/Wrapped.vala:504 msgid "Let's rewind the year!" msgstr "" #. translators: how many times you listened to **something** in wrapped, variable is a number #: src/Views/Wrapped.vala:522 src/Views/Wrapped.vala:543 #, c-format msgid "You listened to it %lld times!" msgstr "" #. translators: number 1 album shown in wrapped #: src/Views/Wrapped.vala:527 msgid "#1 Album" msgstr "" #. translators: number 1 song shown in wrapped #: src/Views/Wrapped.vala:552 msgid "#1 Track" msgstr "" #. how many times you listened to **someone** in wrapped, variable is a number #: src/Views/Wrapped.vala:570 #, c-format msgid "You listened to them %lld times!" msgstr "" #. translators: number 1 artist shown in wrapped #: src/Views/Wrapped.vala:575 msgid "#1 Artist" msgstr "" #: src/Views/Wrapped.vala:596 msgid "Top Tracks" msgstr "" #: src/Views/Wrapped.vala:608 msgid "Top Artists" msgstr "" #. translators: wrapped page 6, fun title, feel free to change it something similar in your language #: src/Views/Wrapped.vala:634 msgid "That's all folks!" msgstr "" #. translators: wrapped page 6 description #: src/Views/Wrapped.vala:636 msgid "Keep scrobbling!" msgstr "" #. translators: save dialog title, refer to the other Wrapped strings for more info #: src/Views/Wrapped.vala:699 msgid "Save Wrapped" msgstr "" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:69 msgid "Disable Scrobbling" msgstr "" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:73 src/Widgets/ControlsOverlay.vala:82 msgid "Enable Scrobbling" msgstr "" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:115 msgid "Select Player" msgstr "" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:141 msgid "New Window" msgstr "" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:144 msgid "Scrobbling" msgstr "" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:151 msgid "Background Progress" msgstr "" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:153 msgid "Center Text" msgstr "" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:155 msgid "Dim Metadata Labels" msgstr "" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:157 msgid "Fit Art on Cover" msgstr "" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:159 msgid "Extract Cover Colors" msgstr "" #. translators: whether to hide the client icon when the controls are collapsed #: src/Widgets/ControlsOverlay.vala:161 msgid "Hide Client Icon when Collapsed" msgstr "" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:163 msgid "More Player Controls" msgstr "" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:166 msgid "Tonearm" msgstr "" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:168 msgid "Components" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:172 msgid "Linear" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:174 msgid "Nearest" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:176 msgid "Trilinear" msgstr "" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:178 msgid "Cover Scaling" msgstr "" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:182 msgid "Horizontal" msgstr "" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:184 msgid "Vertical" msgstr "" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:186 msgid "Orientation" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:191 src/Widgets/ControlsOverlay.vala:203 msgid "Small" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:193 src/Widgets/ControlsOverlay.vala:205 msgid "Regular" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:195 src/Widgets/ControlsOverlay.vala:207 msgid "Big" msgstr "" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:197 src/Widgets/ControlsOverlay.vala:229 msgid "Cover" msgstr "" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:199 msgid "Size" msgstr "" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:209 msgid "Text" msgstr "" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:213 src/Widgets/ControlsOverlay.vala:233 msgid "None" msgstr "" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:215 msgid "Symbolic" msgstr "" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:217 msgid "Full Color" msgstr "" #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:219 msgid "Client Icon" msgstr "" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:223 msgid "Card" msgstr "" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:227 msgid "Shadow" msgstr "" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:235 msgid "Knob" msgstr "" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:237 msgid "Overlay" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:239 msgid "Progress Bar" msgstr "" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:243 src/Widgets/ControlsOverlay.vala:252 msgid "Window" msgstr "" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:246 msgid "OSD" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:248 msgid "Transparent" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:250 msgid "Blur" msgstr "" #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:264 #, c-format msgid "About %s" msgstr "" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:266 msgid "Quit" msgstr "" #. translators: tooltip for button that hides/shows all the media controls and labels #: src/Widgets/ControlsOverlay.vala:286 msgid "Toggle Controls" msgstr "" #: src/Widgets/ControlsOverlay.vala:296 msgid "Menu" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:114 src/Widgets/ProgressBin.vala:237 msgid "Unknown Client" msgstr "" turntable/po/es.po000066400000000000000000000441461512353730000144530ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # Sergio Varela , 2025. # jeheda , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-06-25 16:58+0000\n" "Last-Translator: jeheda \n" "Language-Team: Spanish \n" "Language: es\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: Weblate 5.11.4\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Giradiscos" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "música;reproductor;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;canción;audio;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Haz «scroblling» de tu música" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "Mantén un registro de tus hábitos de escucha haciendo scrobbling en last.fm, " "ListenBrainz, Libre.fm y Maloja al mismo tiempo utilizando tus aplicaciones " "de música favoritas. Giradiscos viene con un diseño elegante y altamente " "personalizable que muestra información sobre la canción que se está " "reproduciendo en ese momento y te permite controlar tu reproductor de " "música, permitirle hacer scrobbling y gestionar tus cuentas de scrobbling. " "Todas las aplicaciones compatibles con MPRIS son compatibles." #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "¿No te interesa la interfaz gráfica de usuario, pero quieres hacer " "«scrobbling» con tus reproductores compatibles con MPRIS? Giradiscos trae " "consigo una interfaz de línea de comandos, ¡permitiéndote hacer scrobbling " "en segundo plano! Pruébalo usando flatpak run dev.geopjr.Turntable --" "help." #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "General" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Mostrar atajos" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Salir" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "Sergio Varela https://ingrownmink4.codeberg.page/" #: src/Application.vala:199 msgid "Donate" msgstr "Donar" #: src/Application.vala:200 msgid "Translate" msgstr "Traducir" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "Buenas prácticas para el «scrobbling»\n" "• Evitar la inclusión en la lista de clientes MPRIS no seleccionados, como " "navegadores web y reproductores de vídeo\n" "• Etiqueta tu música con los nombres adecuados de pista, álbum y artista\n" "• Comprueba, arregla y empareja tus scrobbles con regularidad" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Archives" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "Crea y visualiza archivos web" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "Calligraphy" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "Convierte texto en banners ASCII" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "Collision" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "Comprueba los hashes de tus archivos" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "Navega por el Fediverso" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Cancelar" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "_Más información" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Servidor" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "Puedes obtener tu token de usuario desde %s." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "Servidor API" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "Token de Usuario" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Token Invalido" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "No se pudo validar el token: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "Olvidar Cuenta %s" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "¿Olvidar Cuenta %s?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "" #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "Sesión Invalida" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "No se pudo obtener la sesión: %s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "Título Desconocido" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "Artista Desconocido" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "Álbum Desconocido" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "Desactivar Scrobbling" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "Activar Scrobbling" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "Seleccionar Reproductor" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "Nueva Ventana" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "Scrobbling" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "Proceso en Segundo Plano" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "Centrar Texto" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "Ícono del Cliente" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "Atenuar Texto de Metadatos" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "Ajustar Imagen a la Cubierta" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "Extraer Colores de la Cubierta" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "Elementos" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "Lineal" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "Más cercano" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "Trilineal" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "Escalado de la Cubierta" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "Horizontal" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "Vertical" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "Orientación" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "Pequeño" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "Normal" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "Grande" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "Cubierta" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "Tamaño" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "Texto" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "Simplificado" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "A Color" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "Tarjeta" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "Sombreada" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "Ventana" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "Transparente" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "Estilo" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "Acerca de %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "Salir" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "Menú" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "Pausar" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "Reproducir" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "Canción Anterior" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "Canción Siguiente" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "Cliente Desconocido" turntable/po/et.po000066400000000000000000000463021512353730000144500ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # Priit Jõerüüt , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-09-16 22:09+0000\n" "Last-Translator: Priit Jõerüüt \n" "Language-Team: Estonian \n" "Language: et\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: Weblate 5.13.2\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Grammofon" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;kraasimine;kraasi;muusika;lemmik;esitamine;esitus;lugu;laul;pala;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Kraasi oma muusikat ehk märgi kuulatud muusika üles" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "Kuulates muusikat oma lemmikrakendustes ja kraasides kuulatud lugusid " "last.fm, ListenBrainz, Libre.fm ja Maloja teenustesse saad sa pidada arvet " "oma muusikakuulamisharjumuste üle. Grammofon (Turntable) on suurepärase ning " "kohendatava välimusega, mis näitab hetkel kuulatava muusika teavet ning " "lubab sul samast meediaesitajat juhtida. Samast saad hallata oma " "kraasimiskontosid ja kraasimist ennast. Toetatud on kõik rakendused, mis " "oskavad kasutada MPRIS-liidestust. Kraasimine on vana termini uuskasutus " "ingliskeelse „scrobbling“ tõlkimiseks eesti keelde." #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "Sa ei taha pruukida graafilist kasutajaliidest, kuid siiski tahad kraasida " "muusikat oma MPRIS-suutlikust meediaesitajast? Grammofonil on olemas ka " "käsurea liidestus ning selle abil saad kraasida kuulatavat muusikat taustal! " "Abiteavet leiad nii: flatpak run dev.geopjr.Turntable --help." #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Üldised" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Näita kiirklahve" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Välju" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "Priit Jõerüüt 2025" #: src/Application.vala:199 msgid "Donate" msgstr "Toeta rahaliselt" #: src/Application.vala:200 msgid "Translate" msgstr "Tõlgi" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "Soovitused mõistlikuks kraasimiseks:\n" "• Väldi kureerimata/haldamata MPRIS-kliente, nagu veebibrauserid ja " "videoesitajad\n" "• Palun vaata, et lugudel oleks sildid korralikult täidetud: loo nimi, album " "ja esitaja\n" "• Kontrolli, paranda ja klapita kraasitud andmeid mingi aja tagant" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Arhiivid" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "Koosta ja vaata arhiveeritud veebisaite" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "Kalligraafia" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "Muuda tekst ASCII plakatiteks" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "Collision" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "Kontrolli oma failide räsisid" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "Sirvi födiversumit" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Katkesta" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "_Loe lisaks" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Server" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "Siit saad vajaliku tunnusloa: %s." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "Serveri API" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "Kasutaja tunnusluba" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Vigane tunnusluba" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "Tunnusloa õigsuse kontrollimine ei õnnestunud: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "Eemalda kraasimine" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "Kraasimine vallasrežiimis" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "Pole ühtegi vallasrežiimis kraasimist" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "Unusta kasutajakonto: %s" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "Kraasijad" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "Teenused" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" "Pea arvet oma muusika üle kraasides MPRIS-suutlikus kliendis kuulatud " "muusikat. Kui oled lisanud oma kasutajakonto, siis minimaalse kuulamisaja " "järel saadetakse MPRIS-liidestusest tuvastatud andmed sellele teenusele. " "Tagamaks sinu privaatsuset eeldab %s iga MPRIS-kliendi puhul eraldi " "nõustumist andmete saatmisega." #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "Seadistused" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" "Kraasi ka siis, kui võrguühendus puudub ja edasta andmed ühenduse " "taastumisel." #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "Edasta hetkel esitamisel loo andmed" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "Märgi, et oled alustanud loo kuulamist." #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "Enne kraasimist otsi MusicBrainzi teenusest loo metateavet" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" "Kuna selle valikuga lisatakse puuduv metateave, siis on see soovitatav " "haldamata muusikamängijate haldamaata muusikakogu või jaoks. Sel juhul palun " "arvesta, et kui MusicBrainzi kogus andmeid ei leidu, siis lugu kraasida ei " "saa." #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "Kas unustad kasutajakonto: %s?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "See ei muuda juba edastatud kraasmeid." #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "_Unusta" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "Mittekehtiv sessioon" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "Sessiooni tuvastamine ei õnnestunud: %s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "Teadmata pealkiri" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "Tundmatu esitaja" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "Tundmatu album" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "Lülita kraasimine välja" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "Lülita kraasimine sisse" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "Vali meediaesitaja" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "Uus aken" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "Kraasimine" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "Edenemine taustal" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "Joonda tekst keskele" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "Kliendi ikoon" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "Hägusta metateabe silte" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "Sobita kaanepildid" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "Kasuta kaanepildi värve" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "Täiendavad juhtnupud" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "Nõelahoidja" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "Komponendid" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "Lineaarne" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "Lähim" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "Trilineaarne" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "Kaanepildi skaleerimine" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "Rõhtloodis" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "Püstloodis" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "Suund" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "Väike" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "Tavaline" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "Suur" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "Kaanepilt" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "Suurus" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "Tekst" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "Mustvalge" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "Värviline" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "Kaart" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "vari" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "Määramata" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "Nupp" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "Ülekate" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "Edenemisriba" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "Aken" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "Ülekatteaken" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "Läbipaistev" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "Hägu" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "Stiil" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "Rakenduse teave: %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "Välju" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "Menüü" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "Ära korda" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "Korda esitusloendit" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "Korda lugu" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "Peata" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "Esita" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "Sega lood" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "Eelmine lugu" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "Järgmine lugu" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "Tundmatu klient" turntable/po/fa.po000066400000000000000000000510231512353730000144220ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # danialbehzadi , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-09-15 20:09+0000\n" "Last-Translator: danialbehzadi \n" "Language-Team: Persian \n" "Language: fa\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: Weblate 5.13.2\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "ثبت صوت" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;turntable;آهنگ;موسیقی;ابزارک\"اسکروبل;پخش;ضبط;صدا;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "ثبت آهنگ‌هایتان" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "نگه داری آمار شنیدن‌هایتان با ثبت‌کردن هم‌زمانشان در لست.اف‌ام، لیسن‌برینز، " "لیبره.اف‌ام و مولایا به وسیلهٔ کارهٔ آهنگ محبوبِ کارهٔ آهنگ محبوبتان! ثبت صوت با " "طرّاحی به شدّت سفارشی‌پذیر اطّلاعاتی را دربارهٔ آهنگ در حال پخش نشان می‌دهد؛ " "می‌گذارد پخش‌کنندهٔ آهنگتان را واپاییده، سیاههٔ مجاز برای ثبت ایجاد کرده و " "حساب‌های ثبتتان را مدیریت کنید. همهٔ کاره‌هایی که از MPRIS استفاده می‌کنند " "پشتیبانی می‌شوند." #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "به میانای گرافیکی علاقه‌ای ندارید؛ ولی می‌خواهید آهنگ‌هایتان را ثبت کنید؟ ثبت " "صوت میانای خط فرمانی داشته که می‌گذارد در پس‌زمینه ثبت کنید! با استفاده از " "flatpak run dev.geopjr.Turntable --help بیازماییدش." #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "عمومی" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "نمایش میان‌برها" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "خروج" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "دانیال بهزادی " #: src/Application.vala:199 msgid "Donate" msgstr "اعانه" #: src/Application.vala:200 msgid "Translate" msgstr "ترجمه" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "پیشنهاداتی برای ثبت بهتر\n" "• مجاز نکردن کارخواه‌های MPRIS بدون سرپرستی چون مرورگرهای وب و پخش‌کننده‌های " "ویدیو\n" "• برچسب زدن به آهنگ‌هایتان با فطعه، آلبوم و نام هنرمند\n" "• بررسی، تعمیر و تطابق منظّم ثبت‌هایتان" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "بایگانی‌ها" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "ایجاد و نمایش بایگانی‌های وب" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "خوشنویسی" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "تبدیل متن به بیرق‌های اسکی" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "تصادم" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "بررسی درهم ریزی‌های پرونده‌هایتان" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "توبا" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "کاوش وب اجتماعی" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_لغو" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "_بیش‌تر بخوانید" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "میزبان" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "می‌توانید ژتون کاربریتان را از %s بگیرید." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "میانای برنامه‌نویسی میزبان" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "ژتون کاربر" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "ژتون نامعتبر" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "نمی‌توان ژتون را تأیید کرد: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "برداشتن ثبت" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "ثبت کردن برون‌خط" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "بدون ثبت برون‌خط" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "فراموش کردن حساب %s" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "ثبت‌کننده‌ها" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "خدمت‌ها" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" "نگه داشتن آمار آهنک‌هایتان با ثبت‌کردن کارخواه‌های MPRIS. با وصل کردن حسابتان " "هنگام رسیدن به کمینهٔ زمان گوش کردن، اطّلاعات MPRIS به آن خدمت فرستاده خواهد " "شد. برای محافظت از محرمانگیتان %s نیازمند دادن اجازه به هر کارخواه MPRIS است." #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "تنظیمات" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "ثبت هنگام برون‌خط بودن و فرستادن خودکار هنگام برخط شدن." #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "فرستادن پخش شدن کنونی" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "نشان می‌دهد آغاز به شنیدن قطعه کرده‌اید." #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "گشتن به دنبال فراداده روی میوزیک‌برنز پیش از ثبت کردن" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" "توصیه شده برای کارخواه‌های سرپرستی نشده یا کتابخانه‌های آهنگ برچسب نخورده؛ چرا " "که فراداده‌های را تعمیر و کامل می‌کند. ولی همچنین جلوی ثبت قطعه‌هایی که در " "کتابخانهٔ میوزیک‌برینز پیدا نشوند را خواهد گرفت." #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "فراموش کردن حساب %s؟" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "روی ثبت‌های پیشنیتان تأثیری ندارد." #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "_فراموشی" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "نشست نامعتبر" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "نتوانست نشست را بگیرد: %s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "عنوان ناشناخته" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "هنرمند ناشناخته" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "آلبوم ناشناخته" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "از کار انداختن ثبت" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "به کار انداختن ثبت" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "گزینش پخش‌کننده" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "پنجرهٔ جدید" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "ثبت کردن" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "پیشرفت پس‌زمینه" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "وسط قرار دادن متن" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "نقشک کارخواه" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "تاریک کردن برچسب‌های فراداده" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "برازش اثر هنری روی جلد" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "استخراج رنگ‌های طرح جلد" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "واپایش‌های پخش کنندهٔ بیش‌تر" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "سوزن" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "اجزا" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "خطی" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "نزدیک‌ترین" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "سه‌خطی" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "مقیاس کردن طرح جلد" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "افقی" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "عمودی" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "جهت" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "کوچک" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "معمولی" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "بزرگ" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "طرح جلد" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "اندازه" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "متن" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "نمادین" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "تمام رنگی" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "کارت" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "سایه" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "هیچ‌کدام" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "دستگیره" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "روکش" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "نوار پیشرفت" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "پنجره‌" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "نمایش روی صفحه" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "شفّاف" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "ماتی" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "سبک" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "دربارهٔ %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "خروج" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "فهرست" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "بدون تکرار" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "سیاههٔ پخش اخیر" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "تکرار قطعه" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "مکث" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "پخش" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "بر زدن" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "آهنگ پیشین" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "آهنگ بعدی" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "کارخواه ناشناخته" turntable/po/fi.po000066400000000000000000000416001512353730000144320ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # artnay , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-10-26 06:09+0000\n" "Last-Translator: artnay \n" "Language-Team: Finnish \n" "Language: fi\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: Weblate 5.13.3\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Turntable" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;musiikki;skrobblaaminen;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Kirjaa kuuntelemasi musiikki" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Yleiset" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Näytä pikanäppäimet" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Lopeta" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "Jiri Grönroos" #: src/Application.vala:199 msgid "Donate" msgstr "Lahjoita" #: src/Application.vala:200 msgid "Translate" msgstr "Käännä" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "Selaa fediverseä" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Peru" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "_Lue lisää" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Palvelin" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "Saat käyttäjäpoletin linkin %s kautta." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "Palvelimen API" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "Käyttäjäpoletti" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Virheellinen poletti" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "Polettia ei voitu validoida: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "Unohda %s-tili" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "Kirjaamispalvelut" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "Palvelut" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "Asetukset" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "Unohdetaanko %s-tili?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "Tämä ei vaikuta lähettämiisi kuuntelutietoihin." #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "_Unohda" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "Virheellinen istunto" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "Istuntoa ei saatu: %s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "Tuntematon kappale" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "Tuntematon esittäjä" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "Tuntematon albumi" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "Poista kuuntelutietojen kirjaus käytöstä" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "Käytä kuuntelutietojen kirjausta" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "Valitse soitin" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "Uusi ikkuna" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "Kirjaaminen" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "Taustaedistyminen" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "Keskitä teksti" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "Asiakkaan kuvake" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "Himmennä metatietonimikkeet" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "Sovita kuvitus kanteen" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "Pura kannen värit" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "Komponentit" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "Lineaarinen" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "Lähin" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "Trilineaarinen" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "Kannen skaalaus" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "Vaakasuunta" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "Pystysuunta" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "Suunta" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "Pieni" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "Tavallinen" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "Suuri" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "Kansi" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "Koko" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "Teksti" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "Symbolinen" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "Täysväri" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "Kortti" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "Varjo" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "Ikkuna" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "Läpinäkyvä" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "Tyyli" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "Tietoja - %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "Lopeta" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "Valikko" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "Ei kertausta" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "Kertaa soittolista" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "Kertaa kappale" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "Keskeytä" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "Toista" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "Sekoita" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "Edellinen kappale" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "Seuraava kappale" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "Tuntematon asiakasohjelmisto" turntable/po/fr.po000066400000000000000000000454661512353730000144610ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # Mrdaft , 2025. # milimarg , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-05-29 11:58+0000\n" "Last-Translator: milimarg \n" "Language-Team: French \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: Weblate 5.11.4\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Turntable" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "musique;lecteur;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;chanson;audio;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Scrobblez votre musique" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "Gardez une trace de vos habitudes d'écoute en les scrobblant sur last.fm, " "ListenBrainz, Libre.fm et Maloja en même temps que vous utilisez votre " "application de musique préférée, votre application de musique préférée ! " "Turntable est doté d'un design élégant et hautement personnalisable qui " "affiche des informations sur la chanson en cours de lecture et vous permet " "de contrôler votre lecteur de musique, de l'autoriser à scrobbler et de " "gérer vos comptes de scrobbling. Toutes les applications compatibles MPRIS " "sont prises en charge." #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "Le GUI ne vous intéresse pas, mais vous voulez quand même scrobbler vos " "lecteurs MPRIS ? Turntable est livré avec un CLI, vous permettant de " "scrobbler en arrière-plan ! Essayez-le en utilisant flatpak run " "dev.geopjr.Turntable --help." #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Général" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Afficher les raccourcis" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Quitter" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "crédits-traducteur" #: src/Application.vala:199 msgid "Donate" msgstr "Faire un don" #: src/Application.vala:200 msgid "Translate" msgstr "Traduire" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "Bonnes pratiques pour le Scrobbling\n" "• Évitez d'inscrire sur la liste d'autorisation des clients MPRIS non " "classés, tels que les navigateurs Web et les lecteurs vidéo\n" "• Marquez votre musique avec les noms de piste, d'album et d'artiste " "appropriés\n" "• Vérifiez, corrigez et faites correspondre vos scrobbles régulièrement" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Archives" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "Créer et consulter des archives web" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "Calligraphy" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "Transformer un texte en bannière ASCII" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "Collision" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "Vérifier les hachages de vos fichiers" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "Parcourez le Fediverse" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Annuler" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "_En savoir plus" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Hôte" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "Vous pouvez obtenir votre jeton d'utilisateur auprès de %s." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "Host API" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "User Token" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Token invalide" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "Impossible de valider le token : %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "Oublier le compte %s" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "Scrobblers" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" "Suivez votre musique en scrobblingant vos clients MPRIS. En connectant votre " "compte, les informations MPRIS seront envoyées à ce service lorsque vous " "atteindrez la durée d'écoute minimale. Pour protéger votre vie privée, %s " "vous demande d'accepter le scrobbling pour chaque client MPRIS." #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "Consulter les métadonnées sur MusicBrainz avant le scrobbling" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" "Recommandé pour les clients non référencés ou les bibliothèques musicales " "non étiquetées, car il corrigera et complétera les métadonnées, mais il " "empêchera également le scrobbling de pistes non trouvées dans la " "bibliothèque MusicBrainz." #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "Compte %s oublié ?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "Cela n'affectera pas les scrobbles que vous avez soumis." #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "_Oublier" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "Session invalide" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "Impossible d'obtenir la session : %s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "Titre inconnu" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "Artiste inconnu" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "Album inconnu" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "Désactive le scrobbling" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "Active le scrobbling" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "Sélectionne le lecteur" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "Nouvelle fenêtre" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "Scrobbler" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "Progrès en arrière-plan" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "Centrer le texte" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "Icône du client" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "Étiquettes de métadonnées discrètes" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "Adapter la couverture" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "Extraire les couleurs de la couverture" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "Bras de lecture" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "Composants" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "Linéaire" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "Au plus proche" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "Trilinéaire" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "Mise à l'échelle" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "Horizontale" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "Verticale" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "Orientation" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "Petit" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "Normal" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "Grand" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "Couverture" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "Taille" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "Texte" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "Symbolique" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "Coloré" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "Carte" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "Ombré" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "Fenêtré" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "OSD" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "Transparent" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "Style" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "À propos de %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "Quitter" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "Menu" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "Pause" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "Jouer" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "Morceau précédent" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "Morceau suivant" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "Client inconnu" turntable/po/id.po000066400000000000000000000371711512353730000144400ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # KiKaraage , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: Automatically generated\n" "Language-Team: none\n" "Language: id\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "" #: src/Application.vala:199 msgid "Donate" msgstr "" #: src/Application.vala:200 msgid "Translate" msgstr "" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "" #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "" #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "" turntable/po/it.po000066400000000000000000000454341512353730000144610ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # legacychimera247 , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-10-03 14:09+0000\n" "Last-Translator: legacychimera247 \n" "Language-Team: Italian \n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" "X-Generator: Weblate 5.13.2\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Turntable" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "" "musica;lettore;widget;mpris;invio;lastfm;librefm;listenbrainz;musicbrainz;maloja;traccia;audio;" "" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Crea statistiche della tua musica" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "Tieni traccia delle tue abitudini musicali, inviandole a last.fm, " "ListenBrainz, Libre.fm e Maloja contemporaneamente usando il tuo lettore " "musicale preferito! Turntable viene fornito di un design pulito e " "personalizzabile che mostra le informazioni sulla traccia corrente e ti " "permette di controllare il lettore musicale, consentendoti l'invio delle " "statistiche dei brani ai vari servizi. Ogni lettore con funzionalità MPRIS è " "supportato." #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "Non sei interessato alla GUI ma vuoi comunque inviare le tue statistiche? " "Turnable ha anche la funzione da riga di comando per inviare dati in " "background! Provalo con flatpak run dev.geopjr.Turntable --help." #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Generale" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Mostra Scorciatoie" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Esci" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "crediti-traduttori" #: src/Application.vala:199 msgid "Donate" msgstr "Dona" #: src/Application.vala:200 msgid "Translate" msgstr "Traduci" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "Suggerimenti per l'invio\n" "• Evita di usare lettori non specifici quali Browser e lettori Video\n" "• Compila i metadati con i dati corretti di artista, album e traccia\n" "• Controlla e sistema i tuoi invii regolarmente" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Archives" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "Crea e vedi archivi web" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "Calligraphy" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "Converti testo in banner ASCII" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "Collision" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "Controlla gli hash dei tuoi file" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "Naviga nel Fediverso" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Cancella" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "_Leggi di più" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Host" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "Puoi prendere il tuo token utente da %s." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "API host" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "Token utente" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Token non valido" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "Non ho potuto validare il token: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "Rimuovi invio" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "Invio non in linea" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "Nessun invio non in linea" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "Elimina Account %s" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "Servizi invii" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "Servizi" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" "Tieni traccia della tua musica inviando statistiche dal lettore MPRIS. " "Connettendo il tuo account, le informazioni MPRIS saranno inviate al " "servizio scelto quando raggiungerai il tempo minimo richiesto d'ascolto. Per " "proteggere la tua privacy, %s richiede l'attivazione separata di ogni " "servizio per client MPRIS." #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "Impostazioni" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" "Salva i tuoi ascolti quando non sei in linea e inviali automaticamente " "quando torni online." #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "Invia traccia In ascolto ora" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "Indica che hai iniziato l'ascolto di una traccia." #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "Controlla i Metadati su MusicBrainz prima di inviarli" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" "Raccomandato per lettori non specifici o musica senza informazioni dato che " "sistemerà i metadati ma eviterà anche l'invio di tracce non trovate nella " "libreria di MusicBrainz." #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "Elimina Account %s?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "Qusto non riguarderà le tue statistiche già inviate." #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "_Elimina" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "Sessione non valida" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "Non posso ritrovare la sessione: %s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "Titolo sconosciuto" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "Artista sconosciuto" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "Album sconosciuto" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "Invio statistiche disabilitato" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "Invio statistiche abilitato" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "Scegli lettore" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "Nuova finestra" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "Invio statistiche" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "Progresso in background" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "Centra Testo" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "Icona applicazione" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "Sfoca Etichette Metadati" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "Ridimensiona Copertina sulla cover" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "Estrai Colori Copertina" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "Più controlli del lettore" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "Braccio" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "Componenti" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "Lineare" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "Più vicino" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "Trilineare" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "Scala copertina" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "Orizzontale" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "Verticale" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "Orientamento" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "Piccolo" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "Medio" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "Grande" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "Copertina" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "Dimensione" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "Testo" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "Simbolico" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "Normale" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "Scheda" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "Ombra" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "Nessuno" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "Manopola" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "Overlay" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "Barra del progresso" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "Finestra" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "OSD" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "Trasparente" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "Sfocatura" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "Stile" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "Info su %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "Esci" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "Menu" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "Disattiva Ripeti" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "Ripeti Scaletta" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "Ripeti Traccia" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "Pausa" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "Play" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "Mischia" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "Traccia precedente" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "Traccia successiva" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "Applicazione sconosciuta" turntable/po/meson.build000066400000000000000000000000631512353730000156340ustar00rootroot00000000000000i18n.gettext(meson.project_name(), preset: 'glib') turntable/po/nl.po000066400000000000000000000455171512353730000144600ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # Vistaus , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-09-15 20:09+0000\n" "Last-Translator: Vistaus \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: Weblate 5.13.2\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Draaitafel" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "muziek;speler;widget;mpris;scrobble;lastfm;librefm;listenbrainz;maloja;nummer;lied;song;audio;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Scrobble je muziek" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "Houd je luistergedrag nauwkeurig bij door te scrobbelen naar Last.fm, " "ListenBrainz, Libre.fm en Maloja, door middel van je favoriete muziekspeler! " "Draaitafel is voorzien van een gelikt doch zeer aanpasbaar ontwerp, en toont " "alle informatie over het nummer waar je naar luistert, bedienknoppen van je " "mediaspeler, een lijst met spelers die je wilt gebruiken en een venster met " "al je accounts. Alle toepassingen die over MPRIS-functionaliteit beschikken " "worden ondersteund." #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "Wel interesse in scrobbelen, maar niet in het grafische venster? Dan heb je " "geluk: Draaitafel beschikt namelijk ook over een cli, zodat je dat op de " "achtergrond kunt doen! Probeer het maar eens: flatpak run " "dev.geopjr.Turntable --help." #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Algemeen" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Sneltoetsen tonen" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Afsluiten" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "Heimen Stoffels " #: src/Application.vala:199 msgid "Donate" msgstr "Doneren" #: src/Application.vala:200 msgid "Translate" msgstr "Vertalen" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "Gouden tips omtrent scrobbelen:\n" "• Voeg geen ongecertificeerde MPRIS-clients toe, zoals webbrowsers en " "videospelers;\n" "• Tag je muziek met de juiste (album)titels en artiestennamen;\n" "• Controleer je scrobbles regelmatig en voer reparaties uit waar nodig." #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Archieven" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "Maak en bekijk webarchieven" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "Kalligrafie" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "Zet tekst om naar ascii" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "Botsing" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "Loop de controlesommen van bestanden na" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "Verken het fediverse" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Annuleren" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "Mee_r informatie" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Host" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "De toegangssleutel is te vinden op %s." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "Host-api" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "Toegangssleutel" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Ongeldige toegangssleutel" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "De sleutel kan niet worden geverifieerd: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "Scrobble verwijderen" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "Offline-scrobblen" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "Geen offline-scrobbles" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "‘%s’ verwijderen" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "Scrobblers" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "Diensten" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" "Houd je muziek nauwkeurig bij via MPRIS-spelers. Door verbinding te maken " "met je account, wordt er MPRIS-informatie verstuurd naar de dienst in " "kwestie zodra je enige tijd naar een nummer luister. Om je privacy te " "beschermen kun je per MPRIS-speler aangeven of je %s toegang wilt geven." #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "Instellingen" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "Scrobble als je offline bent en verstuur indien je weer online bent." #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "Nu aan het luisteren scrobbelen" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "Geef aan dat je begonnen bent met luisteren." #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "Metagegevens van MusicBrainz ophalen alvorens te scrobbelen" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" "Aanbevolen voor ongecertificeerde clients of ongetagde muziek, zodat de " "metagegevens automatisch aangevuld, maar let op: muziek die niet op " "MusicBrainz staat, wordt niet gescrobbeled." #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "Wil je ‘%s’ verwijderen?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "Dit is niet van invloed op reeds ingediende scrobbles." #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "_Verwijderen" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "Ongeldige sessie" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "De sessie kan niet worden opgehaald: %s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "Onbekende titel" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "Onbekende artiest" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "Onbekend album" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "Scrobbelen uitschakelen" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "Scrobbelen inschakelen" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "Kies een muziekspeler" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "Nieuw venster" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "Scrobbelen" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "Achtergrondproces" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "Tekst centreren" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "Spelerpictogram" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "Metagegevenslabels dimmen" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "Albumhoezen automatisch inpassen" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "Albumhoeskleuren extraheren" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "Meer afspeelbedienknoppen tonen" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "Toonarm" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "Onderdelen" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "Lineair" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "Dichtstbijzijnd" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "Trilineair" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "Afmetingen van albumhoezen" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "Horizontaal" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "Verticaal" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "Oriëntatie" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "Klein" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "Normaal" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "Groot" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "Albumhoes" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "Grootte" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "Tekst" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "Symbolisch" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "Gekleurd" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "Kaart" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "Schaduw" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "Geen" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "Knop" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "Zwevend" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "Voortgangsbalk" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "Venster" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "OSD" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "Doorzichtig" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "Vervagen" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "Stijl" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "Over %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "Afsluiten" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "Menu" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "Niet herhalen" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "Afspeellijst herhalen" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "Nummer herhalen" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "Pauzeren" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "Beluisteren" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "Willekeurig" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "Vorig nummer" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "Volgend nummer" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "Onbekende speler" turntable/po/pt_BR.po000066400000000000000000000426251512353730000150520ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # pyuurin , 2025. # pirespro , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-09-16 22:09+0000\n" "Last-Translator: pirespro \n" "Language-Team: Portuguese (Brazil) \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: Weblate 5.13.2\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Turntable" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "" "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" "" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Rabisque sua música" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "Acompanhe seus hábitos de escuta, fazendo scrobling para last.fm, " "ListenBrainz, Libre.fm e Maloja simultaneamente, usando o aplicativo de " "música favorito do seu aplicativo! O Turntable possui um design elegante e " "altamente personalizável que exibe informações sobre a música que está " "tocando e permite que você controle seu reprodutor de música, adicione-o à " "lista de permissões para scrobling e gerencie suas contas de scrobling. " "Todos os aplicativos compatíveis com MPRIS são suportados." #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "Não está interessado na interface gráfica, mas ainda quer fazer scrobble dos " "players compatíveis com MPRIS? O Turntable inclui uma CLI, permitindo fazer " "scrobble em segundo plano! Experimente usando: flatpak run " "dev.geopjr.Turntable –help." #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Geral" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Mostrar atalhos" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Sair" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "créditos dos tradutores" #: src/Application.vala:199 msgid "Donate" msgstr "Doar" #: src/Application.vala:200 msgid "Translate" msgstr "Traduzir" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "Boas práticas de egistro das músicas escutadas\n" "•Evite colocar na lista de permissões clientes MPRIS não curados, como " "navegadores web e reprodutores de vídeo\n" "•Marque suas músicas com os nomes corretos de faixa, álbum e artista\n" "•Verifique, corrija e concilie seus registros de reprodução regularmente" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Arquivos" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "Criar e visualizar arquivos web" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "Caligrafia" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "Converter texto em banners ASCII" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "Colisão" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "Verificar hashes dos seus arquivos" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "Navegar pelo Fediverso" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Cancelar" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "_Leia Mais" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Host" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "Você pode obter seu token de usuário em %s." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "Host API" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "Token do usuário" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Token inválido" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "Não foi possível validar o token: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "Remover scrobble" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "" #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "" turntable/po/ru.po000066400000000000000000000400371512353730000144650ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # Katy248 , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-08-31 13:09+0000\n" "Last-Translator: Katy248 \n" "Language-Team: Russian \n" "Language: 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<10 || n%100>=20) ? 1 : 2;\n" "X-Generator: Weblate 5.12.2\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Turntable" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Общие" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Показать комбинации клавиш" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Закрыть" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "Перевод" #: src/Application.vala:199 #, fuzzy msgid "Donate" msgstr "Пожертвования" #: src/Application.vala:200 msgid "Translate" msgstr "" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "" #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "" #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "" turntable/po/vi.po000066400000000000000000000475211512353730000144620ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # hthienloc , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-12-25 17:21+0000\n" "Last-Translator: hthienloc \n" "Language-Team: Vietnamese \n" "Language: vi\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "X-Generator: Weblate 5.15.1\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Turntable" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "" "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "Scrobble âm nhạc của bạn" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "Theo dõi thói quen nghe nhạc của bạn bằng cách chuyển chúng sang last.fm, " "ListenBrainz, Libre.fm và Maloja cùng lúc bằng ứng dụng âm nhạc yêu thích " "của bạn, ứng dụng âm nhạc yêu thích! Bàn xoay có thiết kế đẹp mắt và có khả " "năng tùy chỉnh cao, hiển thị giới thiệu về bài hát hiện đang phát và cho " "phép bạn điều khiển trình phát nhạc của mình, đưa nó vào danh sách cho phép " "để ghi chép và quản lý các tài khoản ghi chép của bạn. Tất cả các ứng dụng " "hỗ trợ MPRIS đều được hỗ trợ." #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "Bạn không quan tâm đến GUI nhưng vẫn muốn tìm kiếm scrobble trình phát trợ " "giúp MPRIS của mình? Bàn xoay đi kèm với CLI, cho phép bạn tìm kiếm ở chế độ " "nền! Hãy dùng thử bằng cách sử dụng flatpak run dev.geopjr.Turntable --" "help." #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "Chung" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "Hiển thị phím tắt" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "Thoát" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "" "Vietnam Linux L10n \n" "Loc Huynh " #: src/Application.vala:199 msgid "Donate" msgstr "Quyên góp" #: src/Application.vala:200 msgid "Translate" msgstr "Dịch" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "Các phương pháp hay nhất để viết chữ \n" "• Tránh đưa các ứng dụng MPRIS không được quản lý vào danh sách cho phép như " "Trình duyệt web và Trình phát video\n" "• Gắn thẻ nhạc của bạn với tên bài hát, album và nghệ sĩ thích hợp\n" "• Thường xuyên kiểm tra, sửa chữa và ghép các scrobble của bạn" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Lưu trữ" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "Tạo và xem lưu trữ web" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "Thư pháp" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "Biến văn bản thành banner ASCII" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "Va chạm" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "Kiểm tra hàm băm cho các tệp của bạn" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "Duyệt qua Fediverse" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "_Hủy bỏ" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "Đọ_c thêm" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "Máy chủ" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "Bạn có thể nhận mã thông báo người dùng của mình từ %s." #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "API máy chủ" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "Mã thông báo người dùng" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "Mã thông báo không hợp lệ" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "Không thể xác thực mã thông báo: %s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "Xóa Scrobble" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "Quét ngoại tuyến" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "Không Scrobble ngoại tuyến" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "Quên tài khoản %s" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "Scrobblers" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "Dịch vụ" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" "Theo dõi âm nhạc của bạn bằng cách tìm kiếm khách hàng MPRIS của bạn. Bằng " "cách kết nối tài khoản của bạn, thông tin MPRIS sẽ được gửi đến dịch vụ đó " "khi bạn đạt đến thời gian nghe tối thiểu. Để bảo vệ quyền riêng tư của bạn, " "%s yêu cầu bạn chọn tham gia thu thập dữ liệu cho mỗi khách hàng MPRIS." #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "Cài đặt" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "" "Scrobble ngay cả khi bạn ngoại tuyến và tự động gửi chúng khi bạn trực tuyến." #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "Gửi ngay Đang phát" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "Cho biết bạn đã bắt đầu nghe một bản nhạc." #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "Tra cứu siêu dữ liệu trên MusicBrainz trước khi Scrobbling" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" "Được đề xuất cho các ứng dụng khách không được quản lý hoặc thư viện nhạc " "không được gắn thẻ vì nó sẽ sửa và hoàn thiện siêu dữ liệu nhưng cũng sẽ " "ngăn các bản nhạc thu thập dữ liệu không tìm thấy trong thư viện MusicBrainz." #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "Quên tài khoản %s?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "Điều này sẽ không ảnh hưởng đến các scrobble đã gửi của bạn." #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "_Quên" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "Phiên không hợp lệ" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "Không thể nhận phiên: %s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "Tiêu đề không xác định" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "Nghệ sĩ vô danh" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "Album không xác định" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "Tắt tính năng Scrobble" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "Bật tính năng ghi nhanh" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "Chọn người chơi" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "Cửa sổ mới" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "Viết nguệch ngoạc" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "Tiến trình nền" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "Văn bản ở giữa" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "Biểu tượng khách hàng" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "Nhãn siêu dữ liệu mờ" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "Phù hợp với nghệ thuật trên bìa" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "Trích xuất màu bìa" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "Thêm điều khiển người chơi" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "Cần tay" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "Linh kiện" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "Tuyến tính" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "Gần nhất" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "Tam tuyến" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "Chia tỷ lệ bìa" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "Nằm ngang" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "Thẳng đứng" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "Hướng" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "Bé nhỏ" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "Thường xuyên" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "To lớn" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "Che phủ" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "Kích thước" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "Văn bản" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "Tượng trưng" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "Đầy đủ màu sắc" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "Thẻ" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "Bóng tối" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "Không có" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "Nhô lên" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "Lớp phủ" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "Thanh tiến trình" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "Cửa sổ" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "OSD" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "Minh bạch" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "Làm mờ" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "Kiểu" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "Giới thiệu về %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "Thoát" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "Trình đơn" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "Không lặp lại" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "Danh sách phát lặp lại" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "Lặp lại bài hát" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "Tạm dừng" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "Chơi" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "Trộn bài" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "Bài hát trước đó" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "Bài hát tiếp theo" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "Khách hàng không xác định" turntable/po/zh_CN.po000066400000000000000000000447231512353730000150460ustar00rootroot00000000000000# SOME DESCRIPTIVE TITLE. # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER # This file is distributed under the same license as the PACKAGE package. # Outbreak2096 , 2025. msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" "POT-Creation-Date: 2025-09-14 08:34+0300\n" "PO-Revision-Date: 2025-09-15 20:09+0000\n" "Last-Translator: Outbreak2096 \n" "Language-Team: Chinese (Simplified Han script) \n" "Language: zh_CN\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=1; plural=0;\n" "X-Generator: Weblate 5.13.2\n" #. translators: cover image style; it's a rotating record like on a turntable #: data/dev.geopjr.Turntable.desktop.in:3 #: data/dev.geopjr.Turntable.metainfo.xml.in:7 #: src/Widgets/ControlsOverlay.vala:210 msgid "Turntable" msgstr "Turntable" #: data/dev.geopjr.Turntable.desktop.in:8 msgid "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" msgstr "" "music;player;widget;mpris;scrobble;lastfm;librefm;listenbrainz;musicbrainz;maloja;song;audio;" "音乐;播放器;组件;歌曲;音频;" #: data/dev.geopjr.Turntable.metainfo.xml.in:8 msgid "Scrobble your music" msgstr "记录您的音乐" #: data/dev.geopjr.Turntable.metainfo.xml.in:10 msgid "" "Keep track of your listening habits by scrobbling them to last.fm, " "ListenBrainz, Libre.fm and Maloja at the same time using your favorite music " "app's, favorite music app! Turntable comes with a highly customizable and " "sleek design that displays information about the currently playing song and " "allows you to control your music player, allowlist it for scrobbling and " "manage your scrobbling accounts. All MPRIS-enabled apps are supported." msgstr "" "使用您最喜爱的音乐应用,同时将音乐记录到 last.fm、ListenBrainz、Libre.fm 和 " "Maloja,追踪您的听歌习惯!Turntable 采用高度可定制的简洁设计,可显示当前播放" "歌曲的信息,并允许您控制音乐播放器、将其添加到音乐记录列表以及管理音乐记录账" "号。所有支持 MPRIS 的应用均受支持。" #: data/dev.geopjr.Turntable.metainfo.xml.in:11 msgid "" "Not interested in the GUI but still want to scrobble your MPRIS-enabled " "players? Turntable comes with a CLI, allowing you to scrobble in the " "background! Try it out using flatpak run dev.geopjr.Turntable --help." msgstr "" "对 GUI 不感兴趣,但仍想记录音乐到支持 MPRIS 的播放器?Turntable 还附带 CLI," "让您可以在后台记录音乐!使用 flatpak run dev.geopjr.Turntable --help 试试吧。" #: data/ui/gtk/help-overlay.ui:11 msgctxt "shortcut window" msgid "General" msgstr "常规" #: data/ui/gtk/help-overlay.ui:14 msgctxt "shortcut window" msgid "Show Shortcuts" msgstr "显示快捷键" #: data/ui/gtk/help-overlay.ui:20 msgctxt "shortcut window" msgid "Quit" msgstr "退出" #. translators: Name or Name https://website.example #: src/Application.vala:196 msgid "translator-credits" msgstr "Outbreak2096 https://translate.codeberg.org/user/Outbreak2096/" #: src/Application.vala:199 msgid "Donate" msgstr "捐赠" #: src/Application.vala:200 msgid "Translate" msgstr "翻译" #. translators: Shown in the about dialog as a tip. Leave markup as is (, \n) #: src/Application.vala:203 msgid "" "Best Practices for Scrobbling\n" "• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video " "Players\n" "• Tag your music with the proper track, album and artist names\n" "• Check, fix and match your scrobbles regularly" msgstr "" "记录音乐最佳实践\n" "• 避免将未分类的 MPRIS 客户端列入允许名单,例如 Web 浏览器和视频播放器\n" "• 用正确的曲目、专辑和音乐人名称标记您的音乐\n" "• 定期检查、修复和匹配您的音乐记录" #. translators: Application metainfo for the app "Archives". #: src/Application.vala:207 msgid "Archives" msgstr "Archives" #: src/Application.vala:207 msgid "Create and view web archives" msgstr "创建和查看 Web 归档" #. translators: Application metainfo for the app "Calligraphy". #: src/Application.vala:209 msgid "Calligraphy" msgstr "Calligraphy" #: src/Application.vala:209 msgid "Turn text into ASCII banners" msgstr "将文本转换为 ASCII 横幅" #. translators: Application metainfo for the app "Collision". #: src/Application.vala:211 msgid "Collision" msgstr "Collision" #: src/Application.vala:211 msgid "Check hashes for your files" msgstr "检查文件的散列值" #. translators: Application metainfo for the app "Tuba". #: src/Application.vala:213 msgid "Tuba" msgstr "Tuba" #: src/Application.vala:213 msgid "Browse the Fediverse" msgstr "浏览联邦宇宙" #: src/Scrobbling/Accounts.vala:83 src/Views/ScrobblerSetup.vala:236 msgid "_Cancel" msgstr "取消(_C)" #: src/Scrobbling/Accounts.vala:84 msgid "_Read More" msgstr "阅读更多(_R)" #. translators: host as in a web server; entry title #: src/Views/LibreFMPage.vala:66 src/Views/MalojaPage.vala:52 msgid "Host" msgstr "主机" #. translators: variable is a link #: src/Views/ListenBrainzPage.vala:88 #, c-format msgid "You can get your user token from %s." msgstr "您可以从 %s 获取用户令牌。" #. translators: host as in a web server; entry title #: src/Views/ListenBrainzPage.vala:108 msgid "Host API" msgstr "主机 API" #. translators: can also be translated as Authentication Token #: src/Views/ListenBrainzPage.vala:118 msgid "User Token" msgstr "用户令牌" #: src/Views/ListenBrainzPage.vala:170 src/Views/ListenBrainzPage.vala:172 #: src/Views/ListenBrainzPage.vala:174 src/Views/ListenBrainzPage.vala:175 msgid "Invalid Token" msgstr "令牌无效" #. translators: the variable is an error message #: src/Views/ListenBrainzPage.vala:183 src/Views/ListenBrainzPage.vala:188 #, c-format msgid "Couldn't validate token: %s" msgstr "无法验证令牌:%s" #. translators: tooltip on offline scrobbles row button #: src/Views/OfflineScrobbling.vala:43 msgid "Remove Scrobble" msgstr "移除听歌记录" #. translators: row title #: src/Views/OfflineScrobbling.vala:71 src/Views/ScrobblerSetup.vala:138 msgid "Offline Scrobbling" msgstr "离线听歌记录" #. translators: shown when there's 0 offline scrobbles in the queue #: src/Views/OfflineScrobbling.vala:124 msgid "No Offline Scrobbles" msgstr "无离线听歌记录" #. translators: variable is a scrobbler e.g. ListenBrainz #: src/Views/ScrobblerSetup.vala:60 #, c-format msgid "Forget %s Account" msgstr "忘记 %s 账号" #. vala-lint=line-length #. translators: probably leave it as is unless there's a way to describe it accurately #: src/Views/ScrobblerSetup.vala:108 msgid "Scrobblers" msgstr "Scrobblers" #. translators: scrobbling dialog tab title of the page that allows you to setup your accounts #. services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" #: src/Views/ScrobblerSetup.vala:114 msgid "Services" msgstr "服务" #. translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) #: src/Views/ScrobblerSetup.vala:116 #, c-format msgid "" "Track your music by scrobbling your MPRIS clients. By connecting your " "account, MPRIS information will be sent to that service when you reach the " "minimum listening time. To protect your privacy, %s requires you to opt-in " "scrobbling per MPRIS client." msgstr "" "通过记录您的 MPRIS 客户端来追踪您的音乐。连接您的账号后,当您达到最短听歌时间" "时,MPRIS 信息将会发送到该服务。为了保护您的隐私,%s 要求您为每个 MPRIS 客户" "端选择加入音乐记录功能。" #: src/Views/ScrobblerSetup.vala:132 msgid "Settings" msgstr "设置" #. translators: row description #: src/Views/ScrobblerSetup.vala:140 msgid "" "Scrobble even when you are offline and submit them automatically when you " "get online." msgstr "即使离线也能记录听歌,并在您上线时自动提交。" #. translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. #: src/Views/ScrobblerSetup.vala:157 msgid "Submit Now Playing" msgstr "提交当前播放" #. translators: switch description #: src/Views/ScrobblerSetup.vala:159 msgid "Indicate that you started listening to a track." msgstr "指示您已开始收听曲目。" #. translators: switch title; lookup = search, fetch, request #: src/Views/ScrobblerSetup.vala:167 msgid "Lookup Metadata on MusicBrainz before Scrobbling" msgstr "在记录音乐之前在 MusicBrainz 上查找元数据" #. translators: switch description; untagged as in music files missing metadata like artist, album etc #: src/Views/ScrobblerSetup.vala:169 msgid "" "Recommended for non-curated clients or untagged music libraries as it will " "fix and complete metadata but it will also prevent scrobbling tracks not " "found in the MusicBrainz library." msgstr "" "推荐用于未分类的客户端或未标记的音乐库,因为它将修复和完成元数据,但它也会阻" "止在 MusicBrainz 库中未收录曲目的音乐记录。" #: src/Views/ScrobblerSetup.vala:230 #, c-format msgid "Forget %s Account?" msgstr "忘记 %s 账号?" #. translators: dialog description #: src/Views/ScrobblerSetup.vala:232 msgid "This won't affect your submitted scrobbles." msgstr "这不会影响您已提交的听歌记录。" #: src/Views/ScrobblerSetup.vala:237 msgid "_Forget" msgstr "忘记(_F)" #. translators: error message when lastfm/librefm token validation fails #: src/Views/ScrobblerSetup.vala:326 src/Views/ScrobblerSetup.vala:329 #: src/Views/ScrobblerSetup.vala:330 src/Views/ScrobblerSetup.vala:333 msgid "Invalid Session" msgstr "会话无效" #. translators: the variable is an error message #: src/Views/ScrobblerSetup.vala:344 #, c-format msgid "Couldn't get session: %s" msgstr "无法获取会话:%s" #. translators: default string when title is missing #: src/Views/Window.vala:187 msgid "Unknown Title" msgstr "未知标题" #. translators: default string when artist is missing #: src/Views/Window.vala:192 src/Views/Window.vala:197 msgid "Unknown Artist" msgstr "未知音乐人" #. translators: default string when album is missing #: src/Views/Window.vala:205 src/Views/Window.vala:210 msgid "Unknown Album" msgstr "未知专辑" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:53 msgid "Disable Scrobbling" msgstr "禁用记录音乐" #. translators: button tooltip text #: src/Widgets/ControlsOverlay.vala:57 src/Widgets/ControlsOverlay.vala:66 msgid "Enable Scrobbling" msgstr "启用记录音乐" #. translators: dropdown tooltip text #: src/Widgets/ControlsOverlay.vala:101 msgid "Select Player" msgstr "选择播放器" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:127 msgid "New Window" msgstr "新建窗口" #. translators: menu entry that opens a dialog #: src/Widgets/ControlsOverlay.vala:130 msgid "Scrobbling" msgstr "记录音乐" #. translators: whether to show the (window) background progress bar #: src/Widgets/ControlsOverlay.vala:137 msgid "Background Progress" msgstr "后台进度" #. translators: whether to show center the title, album and artist labels #: src/Widgets/ControlsOverlay.vala:139 msgid "Center Text" msgstr "居中文本" #. translators: whether to show a client icon in the bottom right; client = music playing app #. translators: menu entry that opens a submenu; client = music playing app #: src/Widgets/ControlsOverlay.vala:141 src/Widgets/ControlsOverlay.vala:204 msgid "Client Icon" msgstr "客户端图标" #. translators: whether to make the artist and album labels slightly transparent / less prominent #: src/Widgets/ControlsOverlay.vala:143 msgid "Dim Metadata Labels" msgstr "调暗元数据标签" #. translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square #: src/Widgets/ControlsOverlay.vala:145 msgid "Fit Art on Cover" msgstr "封面图片适配" #. translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) #: src/Widgets/ControlsOverlay.vala:147 msgid "Extract Cover Colors" msgstr "提取封面颜色" #. translators: whether to show the shuffle and loop buttons #: src/Widgets/ControlsOverlay.vala:149 msgid "More Player Controls" msgstr "更多播放器控件" #. translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, #. you may translate it as 'arm' (mechanical part) #: src/Widgets/ControlsOverlay.vala:152 msgid "Tonearm" msgstr "唱臂" #. translators: menu entry that opens a submenu; components = toggleable parts of the UI #: src/Widgets/ControlsOverlay.vala:154 msgid "Components" msgstr "组件" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:158 msgid "Linear" msgstr "线性" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:160 msgid "Nearest" msgstr "邻近" #. translators: cover scaling algorithm name, probably leave as is #: src/Widgets/ControlsOverlay.vala:162 msgid "Trilinear" msgstr "三线性" #. translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art #: src/Widgets/ControlsOverlay.vala:164 msgid "Cover Scaling" msgstr "封面缩放" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:168 msgid "Horizontal" msgstr "水平" #. translators: orientation name #: src/Widgets/ControlsOverlay.vala:170 msgid "Vertical" msgstr "垂直" #. translators: menu entry that opens a submenu; as in whether it's horizontal or vertical #: src/Widgets/ControlsOverlay.vala:172 msgid "Orientation" msgstr "方向" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:177 src/Widgets/ControlsOverlay.vala:189 msgid "Small" msgstr "小" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:179 src/Widgets/ControlsOverlay.vala:191 msgid "Regular" msgstr "常规" #. translators: cover size #. translators: text style (size) #: src/Widgets/ControlsOverlay.vala:181 src/Widgets/ControlsOverlay.vala:193 msgid "Big" msgstr "大" #. translators: menu entry that opens a submenu; cover = the song cover art #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:183 src/Widgets/ControlsOverlay.vala:214 msgid "Cover" msgstr "封面" #. https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:185 msgid "Size" msgstr "大小" #. translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts #: src/Widgets/ControlsOverlay.vala:195 msgid "Text" msgstr "文本" #. translators: cover icon style; symbolic = the monochrome simplified version #: src/Widgets/ControlsOverlay.vala:200 msgid "Symbolic" msgstr "符号" #. translators: cover icon style #: src/Widgets/ControlsOverlay.vala:202 msgid "Full Color" msgstr "全彩" #. translators: cover image style; it's a square with rounded corners #: src/Widgets/ControlsOverlay.vala:208 msgid "Card" msgstr "卡片" #. translators: cover image style; it's a fading out effect; may be translated to 'Fade' #: src/Widgets/ControlsOverlay.vala:212 msgid "Shadow" msgstr "阴影" #. translators: progressbar style; disable it #: src/Widgets/ControlsOverlay.vala:218 msgid "None" msgstr "无" #. translators: progressbar style; default gtk style, has a big knob / circle as the position marker #: src/Widgets/ControlsOverlay.vala:220 msgid "Knob" msgstr "旋钮" #. translators: progressbar style; hard to explain, looks like amberol's volume bar #: src/Widgets/ControlsOverlay.vala:222 msgid "Overlay" msgstr "叠加" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:224 msgid "Progress Bar" msgstr "进度条" #. translators: window style name #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:228 src/Widgets/ControlsOverlay.vala:237 msgid "Window" msgstr "窗口" #. translators: window style name, probably leave it as is; OSD = on screen display, #. it's the dark semi-trasparent background and white text style #: src/Widgets/ControlsOverlay.vala:231 msgid "OSD" msgstr "OSD" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:233 msgid "Transparent" msgstr "透明" #. translators: window style name #: src/Widgets/ControlsOverlay.vala:235 msgid "Blur" msgstr "模糊" #. translators: menu entry that opens a submenu #: src/Widgets/ControlsOverlay.vala:240 msgid "Style" msgstr "样式" #. misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); #. translators: menu entry, variable is the app name (Turntable) #: src/Widgets/ControlsOverlay.vala:248 #, c-format msgid "About %s" msgstr "关于 %s" #. translators: menu entry #: src/Widgets/ControlsOverlay.vala:250 msgid "Quit" msgstr "退出" #: src/Widgets/ControlsOverlay.vala:271 msgid "Menu" msgstr "菜单" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:22 msgid "No Repeat" msgstr "不重复播放" #. translators: repeat = loop, tooltip text #: src/Widgets/MPRISControls.vala:28 msgid "Repeat Playlist" msgstr "重复播放列表" #. translators: repeat = loop, track = song, tooltip text #: src/Widgets/MPRISControls.vala:34 msgid "Repeat Track" msgstr "重复播放曲目" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:53 msgid "Pause" msgstr "暂停" #: src/Widgets/MPRISControls.vala:53 src/Widgets/MPRISControls.vala:185 msgid "Play" msgstr "播放" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:167 msgid "Shuffle" msgstr "随机播放" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:177 msgid "Previous Song" msgstr "上一首歌" #. translators: button tooltip text #: src/Widgets/MPRISControls.vala:194 msgid "Next Song" msgstr "下一首歌" #. translators: default string when MPRIS Client (aka Music playing app) doesn't have a name #: src/Widgets/ProgressBin.vala:97 src/Widgets/ProgressBin.vala:220 msgid "Unknown Client" msgstr "未知客户端" turntable/src/000077500000000000000000000000001512353730000136445ustar00rootroot00000000000000turntable/src/Application.vala000066400000000000000000000307621512353730000167640ustar00rootroot00000000000000namespace Turntable { public static Mpris.Manager mpris_manager; public const int PROGRESS_UPDATE_TIME = 1000; // it was 250ms, turns out it updates every second? public static Application application; public static bool debug_enabled = false; public static bool is_rtl = false; #if SANDBOXED public static string[]? desktop_file_dirs = null; #endif // public static Utils.Cache custom_color_cache; public static Utils.Settings settings; public static bool headless = false; #if SCROBBLING public static Scrobbling.Manager scrobbling_manager; public static Scrobbling.AccountManager account_manager; public static bool cli_list_clients = false; public static string cli_client_id_scrobble; public static bool cli_mode = false; public static NetworkMonitor network_monitor; #endif public class Application : Adw.Application { #if SCROBBLING [Signal (detailed = true)] public signal void token_received (Scrobbling.Manager.Provider provider, string token); public bool batch_in_progress { get; set; default = false; } public const GLib.OptionEntry[] APP_OPTIONS = { { "list-clients", 'l', 0, GLib.OptionArg.NONE, ref cli_list_clients, "List all currently available MPRIS clients (ID - Name)", null }, { "client", 'c', 0, GLib.OptionArg.STRING, ref cli_client_id_scrobble, "MPRIS client ID to scrobble", "CLIENT-ID" }, { "headless", 0, 0, GLib.OptionArg.NONE, ref headless, "Do not show main window on start", null }, { null } }; #endif private const GLib.ActionEntry[] APP_ENTRIES = { { "about", on_about_action }, { "new-window", on_new_window }, { "preferences", on_preferences }, { "quit", quit } }; string troubleshooting = "os: %s %s\nprefix: %s\nsandboxed: %s\nversion: %s (%s)\ngtk: %u.%u.%u (%d.%d.%d)\nlibadwaita: %u.%u.%u (%d.%d.%d)\ndebug-logs: %s\nscrobbling: %s".printf ( GLib.Environment.get_os_info ("NAME"), GLib.Environment.get_os_info ("VERSION"), Build.PREFIX, #if SANDBOXED "true", #else "false", #endif Build.VERSION, Build.PROFILE, Gtk.get_major_version (), Gtk.get_minor_version (), Gtk.get_micro_version (), Gtk.MAJOR_VERSION, Gtk.MINOR_VERSION, Gtk.MICRO_VERSION, Adw.get_major_version (), Adw.get_minor_version (), Adw.get_micro_version (), Adw.MAJOR_VERSION, Adw.MINOR_VERSION, Adw.MICRO_VERSION, debug_enabled.to_string (), #if SCROBBLING "true\nlibsoup: %u.%u.%u (%d.%d.%d)\njson-glib: %d.%d.%d\nlibsecret: %d.%d.%d\nCLI: %s".printf ( Soup.get_major_version (), Soup.get_minor_version (), Soup.get_micro_version (), Soup.MAJOR_VERSION, Soup.MINOR_VERSION, Soup.MICRO_VERSION, Json.MAJOR_VERSION, Json.MINOR_VERSION, Json.MICRO_VERSION, Secret.MAJOR_VERSION, Secret.MINOR_VERSION, Secret.MICRO_VERSION, cli_mode.to_string () ) #else "false" #endif ); construct { application_id = Build.DOMAIN; flags = ApplicationFlags.HANDLES_OPEN; } protected override void startup () { base.startup (); var lines = troubleshooting.split ("\n"); foreach (unowned string line in lines) { debug (line); } Gtk.Window.set_default_icon_name (Build.DOMAIN); Adw.init (); is_rtl = Gtk.Widget.get_default_direction () == Gtk.TextDirection.RTL; // custom_color_cache = Utils.Cache (); settings = new Utils.Settings (); #if SCROBBLING account_manager = new Scrobbling.AccountManager (); scrobbling_manager = new Scrobbling.Manager (); #endif this.add_action_entries (APP_ENTRIES, this); this.set_accels_for_action ("app.preferences", {"comma"}); this.set_accels_for_action ("app.quit", {"q"}); this.set_accels_for_action ("app.new-window", {"n"}); #if SANDBOXED var display = Gdk.Display.get_default (); if (display != null) { string home = GLib.Environment.get_home_dir (); var theme = Gtk.IconTheme.get_for_display (display); theme.add_search_path (@"$home/.local/share/icons"); theme.add_search_path (@"$home/.local/share/flatpak/exports/share/icons"); theme.add_search_path ("/var/lib/snapd/desktop/icons"); theme.add_search_path ("/var/lib/flatpak/exports/share/icons"); } #endif } public static int main (string[] args) { #if SCROBBLING try { var opt_context = new OptionContext ("- Options"); opt_context.set_summary (@"CLI MPRIS Scrobbler. Accounts can be configured in GUI mode. --list-clients exists for convenience; the --client doesn't need to exist before running. $(Build.NAME) will keep track of MPRIS client changes and automatically connect to it when it appears. GUI mode settings, like the MBID one, affect CLI mode."); opt_context.set_description (@"Send bug reports to $(Build.ISSUES_WEBSITE), including logs generated by running $(Build.NAME) with G_MESSAGES_DEBUG=Turntable"); opt_context.add_main_entries (APP_OPTIONS, null); opt_context.parse (ref args); } catch (GLib.OptionError e) { warning (e.message); } #endif Intl.setlocale (LocaleCategory.ALL, ""); Intl.bindtextdomain (Build.GETTEXT_PACKAGE, Build.LOCALEDIR); Intl.bind_textdomain_codeset (Build.GETTEXT_PACKAGE, "UTF-8"); Intl.textdomain (Build.GETTEXT_PACKAGE); debug_enabled = !GLib.Log.writer_default_would_drop (GLib.LogLevelFlags.LEVEL_DEBUG, "Turntable"); GLib.Environment.unset_variable ("GTK_THEME"); #if SCROBBLING cli_mode = cli_list_clients || cli_client_id_scrobble != null; #endif #if SANDBOXED #if SCROBBLING if (!cli_mode) #endif // vala-lint=block-opening-brace-space-before { string[] temp_dirs = {}; string home = GLib.Environment.get_home_dir (); string[] def_dirs = { "/usr/share/applications", @"$home/.local/share/applications", "/var/lib/flatpak/exports/share/applications", @"$home/.local/share/flatpak/exports/share/applications", "/var/lib/snapd/desktop/applications" }; foreach (string path in def_dirs) { if (GLib.FileUtils.test (path, GLib.FileTest.IS_DIR)) { temp_dirs += path; } } desktop_file_dirs = temp_dirs; } #endif mpris_manager = new Mpris.Manager (); #if SCROBBLING network_monitor = NetworkMonitor.get_default (); if (cli_mode) return (new Utils.CLI ()).run (); #endif application = new Application (); return application.run (args); } bool activated = false; public override void activate () { base.activate (); var win = this.active_window ?? new Turntable.Views.Window (this); if (!headless || (headless && activated)) win.present (); #if SCROBBLING if (!activated) account_manager.load (); #endif activated = true; } const string[] DEVELOPERS = { "Evangelos “GeopJr” Paterakis" }; const string[] DESIGNERS = { "Evangelos “GeopJr” Paterakis" }; const string[] ARTISTS = { "Evangelos “GeopJr” Paterakis" }; private void on_about_action () { var about = new Adw.AboutDialog () { application_name = Build.NAME, application_icon = Build.DOMAIN, developer_name = DEVELOPERS[0], version = Build.VERSION, developers = DEVELOPERS, artists = ARTISTS, designers = DESIGNERS, debug_info = troubleshooting, debug_info_filename = @"$(Build.NAME).txt", copyright = @"© 2025 $(DEVELOPERS[0])", // translators: Name or Name https://website.example translator_credits = _("translator-credits") }; about.add_link (_("Donate"), Build.DONATE_WEBSITE); about.add_link (_("Translate"), Build.TRANSLATE_WEBSITE); #if SCROBBLING // translators: Shown in the about dialog as a tip. Leave markup as is (, \n) about.comments = _("Best Practices for Scrobbling\n• Avoid allowlisting non-curated MPRIS clients like Web Browsers and Video Players\n• Tag your music with the proper track, album and artist names\n• Check, fix and match your scrobbles regularly"); #endif // translators: Application metainfo for the app "Archives". about.add_other_app ("dev.geopjr.Archives", _("Archives"), _("Create and view web archives")); // translators: Application metainfo for the app "Calligraphy". about.add_other_app ("dev.geopjr.Calligraphy", _("Calligraphy"), _("Turn text into ASCII banners")); // translators: Application metainfo for the app "Collision". about.add_other_app ("dev.geopjr.Collision", _("Collision"), _("Check hashes for your files")); // translators: Application metainfo for the app "Tuba". about.add_other_app ("dev.geopjr.Tuba", _("Tuba"), _("Browse the Fediverse")); this.active_window.resizable = false; about.present (this.active_window); about.closed.connect (((Views.Window) this.active_window).make_resizable); GLib.Idle.add (() => { var style = Utils.Celebrate.get_celebration_css_class (new GLib.DateTime.now ()); if (style != "") about.add_css_class (style); return GLib.Source.REMOVE; }); } private void on_new_window () { debug ("New Window"); (new Turntable.Views.Window (this)).present (); } private void on_preferences () { if (this.active_window == null) return; this.active_window.resizable = false; var perfs = new Turntable.Views.Preferences (); perfs.present (this.active_window); perfs.closed.connect (((Views.Window) this.active_window).make_resizable); } private Xdp.Portal? portal = null; public async bool request_autostart (bool autostart, bool hidden) throws GLib.Error { if (cli_mode || this.active_window == null) return false; GLib.GenericArray cmd = new GLib.GenericArray (); cmd.add (Build.DOMAIN); if (autostart && hidden) cmd.add ("--headless"); if (portal == null) portal = new Xdp.Portal (); return yield portal.request_background ( Xdp.parent_new_gtk (this.active_window), // translators: message shown when requesting autostart or run in the background permissions // the variable is the app name (Turntable) (autostart ? _("Allow %s to start when you log in") : _("Allow %s to run in the background")).printf (Build.NAME), cmd, autostart ? Xdp.BackgroundFlags.AUTOSTART : Xdp.BackgroundFlags.NONE, null ); } public async void background_portal_message (string? message) { if (cli_mode || !Xdp.Portal.running_under_sandbox ()) return; if (portal == null) portal = new Xdp.Portal (); try { yield portal.set_background_status (message, null); } catch (GLib.Error e) { warning (@"Couldn't change the background portal message: $(e.message) $(e.code)"); } } public override void open (File[] files, string hint) { if (!activated) activate (); debug ("Received open signal"); foreach (File file in files) { string unparsed_uri = file.get_uri (); try { Uri uri = Uri.parse (unparsed_uri, UriFlags.ENCODED); string scheme = uri.get_scheme (); switch (scheme) { case "turntable": string? host = uri.get_host (); if (host == null) host = ""; string down_host = host.down (); switch (down_host) { #if SCROBBLING case "lastfm": case "librefm": string? path = uri.get_path (); if (path == null || path == "") throw new Error.literal (-1, 3, @"$unparsed_uri doesn't have win id"); if (!path.has_prefix ("/")) path = @"/$path"; string[] path_parts = path.split ("/"); if (path_parts.length < 2 || path_parts[1].length < 4) throw new Error.literal (-1, 3, @"$unparsed_uri doesn't have win id"); string win_id = path_parts[1]; string? query = uri.get_query (); if (query == null || query == "") throw new Error.literal (-1, 3, @"$unparsed_uri doesn't have query params"); var uri_params = Uri.parse_params (query); if (!uri_params.contains ("token")) throw new Error.literal (-1, 3, @"$unparsed_uri doesn't have a 'token' query param"); token_received[win_id] ( down_host == "librefm" ? Scrobbling.Manager.Provider.LIBREFM : Scrobbling.Manager.Provider.LASTFM, uri_params.get ("token") ); break; #endif default: throw new Error.literal (-1, 3, @"$(Build.NAME) does not handle '$host'"); } #if SCROBBLING break; #endif default: throw new Error.literal (-1, 3, @"$(Build.NAME) does not accept '$scheme://'"); } } catch (GLib.Error e) { critical (@"Couldn't open $unparsed_uri: $(e.message)"); } } } } } turntable/src/Build.vala.in000066400000000000000000000015761512353730000161660ustar00rootroot00000000000000namespace Build { public const string NAME = "@NAME@"; public const string VERSION = "@VERSION@"; public const string DOMAIN = "@DOMAIN@"; public const string RESOURCES = "@RESOURCES@"; public const string PROFILE = "@PROFILE@"; public const string GETTEXT_PACKAGE = "@GETTEXT_PACKAGE@"; public const string LOCALEDIR = "@LOCALEDIR@"; public const string WEBSITE = "@WEBSITE@"; public const string ISSUES_WEBSITE = "@ISSUES_WEBSITE@"; // public const string SUPPORT_WEBSITE = "@SUPPORT_WEBSITE@"; public const string DONATE_WEBSITE = "@DONATE_WEBSITE@"; public const string TRANSLATE_WEBSITE = "@TRANSLATE_WEBSITE@"; public const string PREFIX = "@PREFIX@"; public const string LASTFM_KEY = "@LASTFM_KEY@"; public const string LASTFM_SECRET = "@LASTFM_SECRET@"; public const string LIBREFM_KEY = "@LIBREFM_KEY@"; public const string LIBREFM_SECRET = "@LIBREFM_SECRET@"; } turntable/src/Mpris/000077500000000000000000000000001512353730000147365ustar00rootroot00000000000000turntable/src/Mpris/DBus.vala000066400000000000000000000101701512353730000164370ustar00rootroot00000000000000// vala-dbus-binding-tool --api-path=. --directory=. --strip-namespace=org --rename-namespace=mpris:Mpris --no-synced namespace Turntable.DesktopBus { namespace Mpris { [DBus (name = "org.mpris.MediaPlayer2.Player", timeout = 120000)] public interface MediaPlayer2Player : GLib.Object { [DBus (name = "Next")] public abstract void next () throws DBusError, IOError; [DBus (name = "Previous")] public abstract void previous () throws DBusError, IOError; [DBus (name = "Pause")] public abstract void pause () throws DBusError, IOError; [DBus (name = "PlayPause")] public abstract void play_pause () throws DBusError, IOError; [DBus (name = "Stop")] public abstract void stop () throws DBusError, IOError; [DBus (name = "Play")] public abstract void play () throws DBusError, IOError; [DBus (name = "Seek")] public abstract void seek (int64 Offset) throws DBusError, IOError; // vala-lint=naming-convention [DBus (name = "SetPosition")] public abstract void set_position (GLib.ObjectPath TrackId, int64 Position) throws DBusError, IOError; // vala-lint=naming-convention [DBus (name = "OpenUri")] public abstract void open_uri (string Uri) throws DBusError, IOError; // vala-lint=naming-convention [DBus (name = "PlaybackStatus")] public abstract string playback_status { owned get; } [DBus (name = "LoopStatus")] public abstract string loop_status { owned get; set; } [DBus (name = "Rate")] public abstract double rate { get; set; } [DBus (name = "Shuffle")] public abstract bool shuffle { get; set; } [DBus (name = "Metadata")] public abstract GLib.HashTable metadata { owned get; } [DBus (name = "Volume")] public abstract double volume { get; set; } [DBus (name = "Position")] public abstract int64 position { get; } [DBus (name = "MinimumRate")] public abstract double minimum_rate { get; } [DBus (name = "MaximumRate")] public abstract double maximum_rate { get; } [DBus (name = "CanGoNext")] public abstract bool can_go_next { get; } [DBus (name = "CanGoPrevious")] public abstract bool can_go_previous { get; } [DBus (name = "CanPlay")] public abstract bool can_play { get; } [DBus (name = "CanPause")] public abstract bool can_pause { get; } [DBus (name = "CanSeek")] public abstract bool can_seek { get; } [DBus (name = "CanControl")] public abstract bool can_control { get; } [DBus (name = "Seeked")] public signal void seeked (int64 Position); // vala-lint=naming-convention } [DBus (name = "org.mpris.MediaPlayer2", timeout = 120000)] public interface MediaPlayer2 : GLib.Object { [DBus (name = "Raise")] public abstract void raise () throws DBusError, IOError; [DBus (name = "Quit")] public abstract void quit () throws DBusError, IOError; [DBus (name = "CanQuit")] public abstract bool can_quit { get; } [DBus (name = "Fullscreen")] public abstract bool fullscreen { get; set; } [DBus (name = "CanSetFullscreen")] public abstract bool can_set_fullscreen { get; } [DBus (name = "CanRaise")] public abstract bool can_raise { get; } [DBus (name = "HasTrackList")] public abstract bool has_track_list { get; } [DBus (name = "Identity")] public abstract string identity { owned get; } [DBus (name = "DesktopEntry")] public abstract string desktop_entry { owned get; } [DBus (name = "SupportedUriSchemes")] public abstract string[] supported_uri_schemes { owned get; } [DBus (name = "SupportedMimeTypes")] public abstract string[] supported_mime_types { owned get; } } } [DBus (name="org.freedesktop.DBus")] public interface Base : Object { public abstract string[] list_names () throws GLib.Error; public signal void name_owner_changed (string name, string old_owner, string new_owner); public signal void name_acquired (string name); } [DBus (name="org.freedesktop.DBus.Properties")] public interface Props : Object { public abstract Variant get (string iface, string property) throws Error; public signal void properties_changed (string iface, HashTable changed, string[] invalid); } } turntable/src/Mpris/Entry.vala000066400000000000000000000216131512353730000167070ustar00rootroot00000000000000public class Turntable.Mpris.Entry : GLib.Object { ~Entry () { debug ("Destroying: %s (%s)", this.client_info_name, this.bus_namespace); } public struct ClientInfo { public string identity; public string desktop_entry; public string icon; } public string bus_namespace { get; private set; } public ClientInfo client_info { get; private set; } public DesktopBus.Mpris.MediaPlayer2Player? player { get; private set; default = null; } private DesktopBus.Props? props { get; set; default = null; } // for expressions public string client_info_name { get { return this.client_info.identity; } } public string client_info_icon { get { return this.client_info.icon; } } public enum LoopStatus { NONE, TRACK, PLAYLIST; } public LoopStatus loop_status { get; private set; default = LoopStatus.NONE; } public bool can_go_next { get; private set; default = false; } public bool can_go_back { get; private set; default = false; } public bool can_control { get; private set; default = false; } public bool can_seek { get; private set; default = false; } public bool playing { get; private set; default = false; } public bool shuffle { get; private set; default = false; } public string? art { get; private set; default = null; } public string? title { get; private set; default = null; } public string? artist { get; private set; default = null; } public string? album { get; private set; default = null; } public int64 length { get; private set; default = -1; } public int64 position { get; private set; default = -1; } public void seek (int64 offset) { try { this.player.seek (offset); } catch (Error e) { debug ("Couldn't Seek %lld: %s", offset, e.message); } } public void play_pause () { try { this.player.play_pause (); } catch (Error e) { debug ("Couldn't PlayPause: %s", e.message); } } public void next () { try { this.player.next (); } catch (Error e) { debug ("Couldn't Next: %s", e.message); } } public void back () { try { this.player.previous (); } catch (Error e) { debug ("Couldn't Previous: %s", e.message); } } public void toggle_shuffle () { this.player.shuffle = !this.player.shuffle; } public void loop_none () { this.player.loop_status = "None"; } public void loop_track () { this.player.loop_status = "Track"; } public void loop_playlist () { this.player.loop_status = "Playlist"; } #if SANDBOXED GLib.HashTable cached_desktop_icons = new GLib.HashTable (str_hash, str_equal); private string get_sandboxed_icon_for_id (string id) { if (cached_desktop_icons.contains (id)) return cached_desktop_icons.get (id); string icon = "application-x-executable-symbolic"; try { var key_file = new GLib.KeyFile (); if (key_file.load_from_dirs (@"$id.desktop", desktop_file_dirs, null, GLib.KeyFileFlags.NONE)) { var icon_key = key_file.get_string ("Desktop Entry", "Icon"); if (icon_key != null) icon = icon_key; } } catch {} cached_desktop_icons.set (id, icon); return icon; } #endif public Entry (string name, DesktopBus.Mpris.MediaPlayer2 media_player) { this.bus_namespace = name; string icon = "application-x-executable-symbolic"; #if SCROBBLING if (!cli_mode) { #endif #if SANDBOXED if (media_player.desktop_entry != null) icon = get_sandboxed_icon_for_id (media_player.desktop_entry); #else var app_info = media_player.desktop_entry == null ? null : new GLib.DesktopAppInfo (@"$(media_player.desktop_entry).desktop"); if (app_info != null) { var app_icon = app_info.get_icon (); if (app_icon != null) icon = app_icon.to_string (); } else if (media_player.desktop_entry == "spotify") { icon = "com.spotify.Client"; } #endif #if SCROBBLING } #endif this.client_info = { media_player.identity, media_player.desktop_entry, icon }; } int users = 0; public void initialize_player () { users += 1; if (player != null) return; try { this.player = Bus.get_proxy_sync ( BusType.SESSION, this.bus_namespace, "/org/mpris/MediaPlayer2" ); this.props = Bus.get_proxy_sync ( BusType.SESSION, this.bus_namespace, "/org/mpris/MediaPlayer2" ); this.props.properties_changed.connect (on_props_changed); update_metadata (); update_position (); update_playback_status (); update_controls (); update_loop_status (); update_shuffle_status (); GLib.Timeout.add (PROGRESS_UPDATE_TIME, update_position); } catch (Error e) { debug ("Couldn't setup Proxies for %s: %s", this.bus_namespace, e.message); } } public void terminate_player () { if (player == null) return; users -= 1; if (users > 0) return; this.props.properties_changed.disconnect (on_props_changed); this.props = null; this.player = null; } private void on_props_changed (string name, GLib.HashTable changed, string[] invalid) { if (name != "org.mpris.MediaPlayer2.Player") return; changed.foreach ((key, value) => { switch (key) { case "Metadata": update_metadata (); break; case "PlaybackStatus": update_playback_status (); break; case "LoopStatus": update_loop_status (); break; case "Shuffle": update_shuffle_status (); break; case "CanGoNext": case "CanGoPrevious": case "CanPlay": case "CanPause": case "CanControl": case "CanSeek": update_controls (); break; default: break; } }); } private void update_loop_status () { switch (this.player.loop_status) { case "Track": this.loop_status = LoopStatus.TRACK; break; case "Playlist": this.loop_status = LoopStatus.PLAYLIST; break; default: this.loop_status = LoopStatus.NONE; break; } } private bool update_position () { if (this.props == null) return GLib.Source.REMOVE; try { int64 new_pos = (int64) this.props.get ("org.mpris.MediaPlayer2.Player", "Position"); if (this.position != new_pos) this.position = new_pos; } catch (Error e) { debug ("Couldn't get Position: %s", e.message); } return GLib.Source.CONTINUE; } private void update_shuffle_status () { this.shuffle = this.player.shuffle; } private void update_controls () { if (!this.player.can_control) { this.can_go_back = this.can_go_next = this.can_seek = this.can_control = false; return; } this.can_seek = this.player.can_seek; this.can_go_back = this.player.can_go_previous; this.can_go_next = this.player.can_go_next; this.can_control = true; } private void update_playback_status () { this.playing = this.player.playback_status == "Playing"; } private void update_metadata () { if (this.player.metadata.contains ("mpris:artUrl")) { string new_art = this.player.metadata["mpris:artUrl"].get_string (); if (new_art != this.art) this.art = new_art; } else { this.art = null; } if (this.player.metadata.contains ("xesam:title")) { string new_title = this.player.metadata["xesam:title"].get_string ().strip (); if (new_title == "") { this.title = null; } else if (new_title != this.title) { this.title = new_title; } } else { this.title = null; } if (this.player.metadata.contains ("xesam:artist")) { (unowned string)[] artists = this.player.metadata["xesam:artist"].get_strv (); string new_artist = string.joinv (", ", artists).strip (); if (new_artist == "") { this.artist = null; } else if (this.artist != new_artist) { this.artist = new_artist; } } else { this.artist = null; } if (this.player.metadata.contains ("xesam:album")) { string new_album = this.player.metadata["xesam:album"].get_string ().strip (); if (new_album == "") { this.album = null; } else if (new_album != this.album) { this.album = new_album; } } else { this.album = null; } if (this.player.metadata.contains ("mpris:length")) { var variant_length = this.player.metadata["mpris:length"]; // Spec: "If the length of the track is known, it should be provided in the metadata property with the 'mpris:length' key. // The length must be given in microseconds, and be represented as a signed 64-bit integer." // Spotify: if (variant_length.is_of_type (GLib.VariantType.UINT64)) { this.length = (int64) variant_length.get_uint64 (); } else if (variant_length.is_of_type (GLib.VariantType.STRING)) { // whatever at this point, nobody reads the spec this.length = int64.parse (variant_length.get_string ()); } else if (variant_length.is_of_type (GLib.VariantType.INT32)) { this.length = (int64) variant_length.get_int32 (); } else { this.length = variant_length.get_int64 (); } this.notify_property ("length"); } } public async bool is_active () { try { DesktopBus.Mpris.MediaPlayer2Player bus = yield Bus.get_proxy ( BusType.SESSION, this.bus_namespace, "/org/mpris/MediaPlayer2" ); return bus.playback_status == "Playing"; } catch { return false; } } } turntable/src/Mpris/Manager.vala000066400000000000000000000034131512353730000171560ustar00rootroot00000000000000public class Turntable.Mpris.Manager : GLib.Object { GLib.Array entries = new GLib.Array (); public signal void players_changed (); public unowned Mpris.Entry[] get_players () { return entries.data; } DesktopBus.Base dbus_base; construct { try { dbus_base = Bus.get_proxy_sync (BusType.SESSION, "org.freedesktop.DBus", "/org/freedesktop/DBus"); foreach (var name in dbus_base.list_names ()) { if (!name.has_prefix ("org.mpris.MediaPlayer2.")) continue; add_player (name); } dbus_base.name_owner_changed.connect (on_name_owner_changed); } catch (Error e) { critical ("Couldn't get all MPRIS Clients: %s", e.message); } } void add_player (string name) { try { DesktopBus.Mpris.MediaPlayer2 mpris = Bus.get_proxy_sync ( BusType.SESSION, name, "/org/mpris/MediaPlayer2" ); entries.append_val (new Mpris.Entry (name, mpris)); players_changed (); } catch (Error e) { debug ("Couldn't add Player for %s: %s", name, e.message); } } void remove_player (string name) { bool removed = false; for (int i = 0; i < entries.length ; i++) { var entry = entries.index (i); string name_for_removal = entry.bus_namespace; if (name_for_removal != name) continue; entries.remove_index_fast (i); removed = true; } if (removed) players_changed (); } public void on_name_owner_changed (string name, string old_owner, string new_owner) { if (!name.has_prefix ("org.mpris.MediaPlayer2.")) return; if (old_owner == "") { add_player (name); } else { remove_player (name); } } public async Entry? active_player () { for (int i = 0; i < entries.length ; i++) { var entry = entries.index (i); if (yield entry.is_active ()) { return entry; } } return null; } } turntable/src/Mpris/meson.build000066400000000000000000000001121512353730000170720ustar00rootroot00000000000000sources += files( 'DBus.vala', 'Entry.vala', 'Manager.vala' ) turntable/src/Scrobbling/000077500000000000000000000000001512353730000157305ustar00rootroot00000000000000turntable/src/Scrobbling/Accounts.vala000066400000000000000000000147061512353730000203640ustar00rootroot00000000000000public class Turntable.Scrobbling.AccountManager : GLib.Object { const string VERSION = "1"; Secret.Schema schema; GLib.HashTable schema_attributes; public signal void accounts_changed (); public GLib.HashTable accounts { get; private set; } public class ScrobblerAccount : GLib.Object { public string username { get; set; } public string token { get; set; } public string? custom_url { get; set; } public ScrobblerAccount (string username, string token, string? custom_url = null) { this.username = username; this.token = token; this.custom_url = custom_url; } } construct { accounts = new GLib.HashTable (str_hash, str_equal); schema_attributes = new GLib.HashTable (str_hash, str_equal); schema_attributes["version"] = Secret.SchemaAttributeType.STRING; schema = new Secret.Schema.newv ( Build.DOMAIN, Secret.SchemaFlags.NONE, schema_attributes ); } public void add (Scrobbling.Manager.Provider provider, string username, string token, string? custom_url = null) { debug ("[%s] Adding %s for %s", provider.to_string (), username, custom_url == null ? "default" : custom_url); accounts.set (provider.to_string (), new ScrobblerAccount (username, token, custom_url)); accounts_changed (); } public void remove (Scrobbling.Manager.Provider provider) { debug ("[%s] Removing", provider.to_string ()); accounts.remove (provider.to_string ()); accounts_changed (); } bool loaded = false; public void load () { if (loaded) return; loaded = true; var attrs = new GLib.HashTable (str_hash, str_equal); attrs["version"] = VERSION; debug ("Begin search"); Secret.password_searchv.begin ( schema, attrs, Secret.SearchFlags.UNLOCK, null, (obj, res) => { try { List secrets = Secret.password_searchv.end (res); secrets.foreach (item => { load_to_store (item); }); } catch (GLib.Error e) { string wiki_page = "https://github.com/GeopJr/Tuba/wiki/keyring-issues"; // Let's leave this untranslated for now string help_msg = "If you didn’t manually cancel it, try creating a password keyring named \"login\" using Passwords and Keys (seahorse) or KWalletManager"; if (e.message == "org.freedesktop.DBus.Error.ServiceUnknown") { wiki_page = "https://github.com/GeopJr/Tuba/wiki/libsecret-issues"; help_msg = @"$(e.message), $(Build.NAME) might be missing some permissions"; } critical (@"Error while searching for items in the secret service: $(e.message)"); warning (@"$help_msg\nread more: $wiki_page"); var dlg = new Adw.AlertDialog ( "Error while searching for user accounts", @"$help_msg." ); dlg.add_responses ( "cancel", _("_Cancel"), "read", _("_Read More") ); dlg.set_default_response ("read"); dlg.set_response_appearance ("read", Adw.ResponseAppearance.SUGGESTED); dlg.choose.begin (application.active_window, null, (obj, res) => { if (dlg.choose.end (res) == "read") { Utils.Host.open_in_default_app.begin (wiki_page, application.active_window, (obj, res) => { Utils.Host.open_in_default_app.end (res); Process.exit (1); }); } else { Process.exit (1); } }); } } ); } public void load_cli_sync () throws Error { if (loaded) return; loaded = true; var attrs = new GLib.HashTable (str_hash, str_equal); attrs["version"] = VERSION; debug ("Begin sync search"); List secrets = Secret.password_searchv_sync ( schema, attrs, Secret.SearchFlags.UNLOCK, null ); secrets.foreach (item => { try { load_to_store_sync (item); } catch (Error e) { critical ("Couldn't load account: %s", e.message); } }); } public void save () { debug ("Begin secret generation"); var attrs = new GLib.HashTable (str_hash, str_equal); attrs["version"] = VERSION; StringBuilder providers = new StringBuilder (); var generator = new Json.Generator (); var builder = new Json.Builder (); builder.begin_array (); accounts.foreach ((k, v) => { providers.append (k); providers.append_c (' '); builder.begin_object (); builder.set_member_name ("provider"); builder.add_string_value (k); builder.set_member_name ("username"); builder.add_string_value (v.username); builder.set_member_name ("token"); builder.add_string_value (v.token); builder.set_member_name ("custom_url"); builder.add_string_value (v.custom_url); builder.end_object (); }); builder.end_array (); generator.set_root (builder.get_root ()); var secret = generator.to_data (null); debug ("Begin secret saving"); Secret.password_storev.begin ( schema, attrs, Secret.COLLECTION_DEFAULT, "Scrobbler Accounts", secret, null, (obj, async_res) => { try { Secret.password_store.end (async_res); debug (@"Saved scrobbler accounts: $(providers.str)"); } catch (GLib.Error e) { critical (@"Couldn't save accounts: $(e.message)"); } } ); } private void load_to_store (Secret.Retrievable item) { item.retrieve_secret.begin (null, (obj, res) => { try { var secret = item.retrieve_secret.end (res); load_to_store_actual (secret); } catch (Error e) { critical (@"Couldn't load accounts to store: $(e.message)"); } }); } private void load_to_store_sync (Secret.Retrievable item) throws Error { load_to_store_actual (item.retrieve_secret_sync (null)); } private inline void load_to_store_actual (Secret.Value secret) throws Error { debug ("Begin loading secret"); var contents = secret.get_text (); var parser = new Json.Parser (); parser.load_from_data (contents, -1); var root = parser.get_root (); if (root == null) throw new Error.literal (-1, 3, "Malformed json"); var root_arr = root.get_array (); if (root_arr == null) throw new Error.literal (-1, 3, "Malformed json"); accounts.remove_all (); root_arr.foreach_element ((arr, i, node) => { var arr_obj = node.get_object (); string provider = arr_obj.get_string_member ("provider"); accounts.set ( provider, new ScrobblerAccount ( arr_obj.get_string_member ("username"), arr_obj.get_string_member ("token"), arr_obj.has_member ("custom_url") ? arr_obj.get_string_member ("custom_url") : null ) ); }); accounts_changed (); } } turntable/src/Scrobbling/LastFM.vala000066400000000000000000000132271512353730000177300ustar00rootroot00000000000000public class Turntable.Scrobbling.LastFM : GLib.Object, Scrobbler { public virtual Manager.Provider SERVICE { get { return Manager.Provider.LASTFM; } } public virtual string api_key { get { return Build.LASTFM_KEY; } } public virtual string api_secret { get { return Build.LASTFM_SECRET; } } public virtual string token { get; set; default = ""; } public override Experiments experiments { get { return WRAPPED; } } public virtual string url { get { return "http://ws.audioscrobbler.com/2.0/"; } set {} } protected void scrobble_actual (ScrobbleEntry[] scrobble_entries, ScrobbleType scrobble_type) { var scrobble_params = new GLib.HashTable (str_hash, str_equal); scrobble_params.set ("api_key", api_key); scrobble_params.set ("sk", token); scrobble_params.set ("method", scrobble_type.to_last_fm ()); if (scrobble_entries.length == 1 || scrobble_type == NOW_PLAYING) { var entry = scrobble_entries[0]; if (entry.payload.album != null) scrobble_params.set ("album", entry.payload.album); scrobble_params.set ("artist", entry.payload.artist); scrobble_params.set ("timestamp", entry.datetime.to_unix ().to_string ()); scrobble_params.set ("track", entry.payload.track); } else { for (int i = 0; i < scrobble_entries.length; i++) { var entry = scrobble_entries[i]; if (entry.payload.album != null) scrobble_params.set (@"album[$i]", entry.payload.album); scrobble_params.set (@"artist[$i]", entry.payload.artist); scrobble_params.set (@"timestamp[$i]", entry.datetime.to_unix ().to_string ()); scrobble_params.set (@"track[$i]", entry.payload.track); } } string signature = Utils.Host.lfm_signature (scrobble_params, this.api_secret); scrobble_params.set ("api_sig", signature); scrobble_params.set ("format", "json"); var msg = new Soup.Message.from_encoded_form ("POST", this.url, Soup.Form.encode_hash (scrobble_params)); scrobbling_manager.send_scrobble (msg, SERVICE, scrobble_type); } protected override async Wrapped? wrapped_actual (Soup.Session session, string username, int max = 5) throws GLib.Error { var tracks = yield get_stats_entities (session, username, max, "gettoptracks", "toptracks", "track"); var artists = yield get_stats_entities (session, username, max, "gettopartists", "topartists", "artist"); // lastfm doesn't provide these (star placeholder) // let's fallback to mbid search for (int i = 0; i < tracks.length; i++) { tracks[i].image = null; } for (int i = 0; i < artists.length; i++) { artists[i].image = null; } return { artists, tracks, yield get_stats_entities (session, username, max, "gettopalbums", "topalbums", "album") }; } private inline string lastfm_stats_url (string method, string username, int max) { return @"$(this.url)/?method=user.$method&user=$username&api_key=$api_key&format=json&limit=$max&range=12months"; } protected inline async MBIDable[] get_stats_entities (Soup.Session session, string username, int max, string method, string toplevel_key, string stat_key) throws Error { var msg = new Soup.Message ("GET", lastfm_stats_url (method, username, max)); GLib.InputStream in_stream = yield session.send_async (msg, 0, null); if (msg.status_code != Soup.Status.OK) throw new Error.literal (-1, 2, @"Server returned $(msg.status_code)"); MBIDable[] res = {}; var parser = new Json.Parser (); yield parser.load_from_stream_async (in_stream); var root = parser.get_root (); if (root == null) throw new Error.literal (-1, 3, "Malformed JSON"); var obj = root.get_object (); if (obj == null) throw new Error.literal (-1, 3, "Malformed JSON"); if (!obj.has_member (toplevel_key)) throw new Error.literal (-1, 3, @"$toplevel_key is missing"); var payload = obj.get_object_member (toplevel_key); if (!payload.has_member (stat_key)) throw new Error.literal (-1, 3, @"$stat_key is missing"); var stats_member = payload.get_member (stat_key); switch (stats_member.get_node_type ()) { case Json.NodeType.OBJECT: res += parse_stats_entity (stats_member.get_object (), stat_key == "track"); break; case Json.NodeType.ARRAY: Json.Array stats_arr = stats_member.get_array (); if (stats_arr.get_length () == 0) throw new Error.literal (-1, 4, "Not enough data"); stats_arr.foreach_element ((arr, i, node) => { var arr_obj = node.get_object (); res += parse_stats_entity (arr_obj, stat_key == "track"); }); break; default: throw new Error.literal (-1, 3, "Malformed JSON"); } return res; } private inline MBIDable parse_stats_entity (Json.Object stat, bool artist_mbid = false) { int64 count = 0; string count_s = stat.get_string_member_with_default ("playcount", ""); if (count_s == "") { count = stat.get_int_member_with_default ("playcount", 0); } else { count = int64.parse (count_s); } string? mbid = null; if (stat.has_member ("mbid")) { mbid = stat.get_string_member ("mbid"); if (mbid == "") mbid = null; } // hack: last.fm doesn't have images for tracks // and doesn't include the album mbid // therefore fallback to artist mbid if (artist_mbid && stat.has_member ("artist")) { var artist_obj = stat.get_object_member ("artist"); if (artist_obj.has_member ("mbid")) { var ar_mbid = artist_obj.get_string_member ("mbid"); if (ar_mbid != "") mbid = ar_mbid; } } string? image = null; if (stat.has_member ("image")) { var arr = stat.get_array_member ("image"); var len = arr.get_length (); if (len > 0) { var obj = arr.get_object_element (len - 1); if (obj.has_member ("#text")) { image = obj.get_string_member ("#text"); } } } return { stat.get_string_member ("name"), mbid, count, image }; } } turntable/src/Scrobbling/LibreFM.vala000066400000000000000000000024351512353730000200610ustar00rootroot00000000000000public class Turntable.Scrobbling.LibreFM : LastFM, Scrobbler { public override Manager.Provider SERVICE { get { return Manager.Provider.LIBREFM; } } public override string api_key { get { return Build.LIBREFM_KEY; } } public override string api_secret { get { return Build.LIBREFM_SECRET; } } public override string token { get; set; default = ""; } private string _url = "https://libre.fm/2.0/"; public override string url { get { return _url; } set { if (value == null) value = "https://libre.fm/2.0/"; if (value == "") { _url = value; } else if (_url != value) { try { var uri = GLib.Uri.parse (value, GLib.UriFlags.NONE); _url = GLib.Uri.build ( GLib.UriFlags.NONE, "https", uri.get_userinfo (), uri.get_host (), uri.get_port (), "/2.0/", null, null ).to_string (); } catch (Error e) { critical (@"Can't parse $value: $(e.message)"); } } } } protected override async Wrapped? wrapped_actual (Soup.Session session, string username, int max = 5) throws GLib.Error { return { yield get_stats_entities (session, username, max, "gettopartists", "topartists", "artist"), yield get_stats_entities (session, username, max, "gettoptracks", "toptracks", "track"), {} // no albums }; } } turntable/src/Scrobbling/ListenBrainz.vala000066400000000000000000000222661512353730000212110ustar00rootroot00000000000000public class Turntable.Scrobbling.ListenBrainz : GLib.Object, Scrobbler { public virtual Manager.Provider SERVICE { get { return Manager.Provider.LISTENBRAINZ; } } public virtual string token { get; set; default = ""; } protected virtual string auth_token_key { get; set; default = "Bearer"; } public override Experiments experiments { get { return WRAPPED; } } private string _url = "https://api.listenbrainz.org"; public virtual string url { get { return _url; } set { if (value == null) value = "https://api.listenbrainz.org"; if (value == "") { _url = value; } else if (_url != value) { try { var uri = GLib.Uri.parse (value, GLib.UriFlags.NONE); _url = GLib.Uri.build ( GLib.UriFlags.NONE, "https", uri.get_userinfo (), uri.get_host (), uri.get_port (), "", null, null ).to_string (); } catch (Error e) { critical (@"Can't parse $value: $(e.message)"); } } } } protected void scrobble_actual (ScrobbleEntry[] scrobble_entries, ScrobbleType scrobble_type) { var builder = new Json.Builder (); builder.begin_object (); builder.set_member_name ("listen_type"); builder.add_string_value (scrobble_type.to_listenbrainz ()); builder.set_member_name ("payload"); builder.begin_array (); foreach (ScrobbleEntry entry in scrobble_entries) { builder.begin_object (); if (scrobble_type != NOW_PLAYING) { builder.set_member_name ("listened_at"); builder.add_int_value (entry.datetime.to_unix ()); } builder.set_member_name ("track_metadata"); builder.begin_object (); builder.set_member_name ("artist_name"); builder.add_string_value (entry.payload.artist); builder.set_member_name ("track_name"); builder.add_string_value (entry.payload.track); if (entry.payload.album != null) { builder.set_member_name ("release_name"); builder.add_string_value (entry.payload.album); } builder.end_object (); builder.end_object (); } builder.end_array (); builder.end_object (); var msg = new Soup.Message ("POST", @"$(this.url)/1/submit-listens"); var generator = new Json.Generator (); generator.set_root (builder.get_root ()); msg.set_request_body_from_bytes ("application/json", new Bytes.take (generator.to_data (null).data)); msg.request_headers.append ("Authorization", @"$(auth_token_key) $(this.token)"); scrobbling_manager.send_scrobble (msg, SERVICE, scrobble_type); } protected override async Wrapped? wrapped_actual (Soup.Session session, string username, int max = 5) throws GLib.Error { bool has_year = true; var tracks = parse_stats_entities (yield get_stats_entities (session, username, max, "recordings", has_year, out has_year), "track_name", "recording_mbid"); var artists = parse_stats_entities (yield get_stats_entities (session, username, max, "artists", has_year, null), "artist_name", "artist_mbid"); var albums = parse_stats_entities (yield get_stats_entities (session, username, max, "release_groups", has_year, null), "release_group_name", "release_group_mbid"); return { artists, tracks, albums }; } private inline string listenbrainz_stats_url (string stat, string username, int max, bool all_time) { return @"$(this.url)/1/stats/user/$username/$(stat.replace ("_", "-"))?offset=0&range=$(all_time ? "all_time" : "year")&count=$max"; } private inline async Json.Array get_stats_entities (Soup.Session session, string username, int max, string stat, bool has_year, out bool has_year_res) throws Error { has_year_res = has_year; var msg = new Soup.Message ("GET", listenbrainz_stats_url (stat, username, max, !has_year_res)); msg.request_headers.append ("Authorization", @"$(auth_token_key) $(this.token)"); GLib.InputStream in_stream = yield session.send_async (msg, GLib.Priority.HIGH, null); if (msg.status_code < 200 || msg.status_code >= 300) throw new Error.literal (-1, 2, @"Server returned $(msg.status_code)"); var parser = new Json.Parser (); yield parser.load_from_stream_async (in_stream); var root = parser.get_root (); if (has_year_res && root == null) { has_year_res = false; msg = new Soup.Message ("GET", listenbrainz_stats_url (stat, username, max, !has_year_res)); msg.request_headers.append ("Authorization", @"$(auth_token_key) $(this.token)"); in_stream = yield session.send_async (msg, GLib.Priority.HIGH, null); if (msg.status_code < 200 || msg.status_code >= 300) throw new Error.literal (-1, 2, @"Server returned $(msg.status_code)"); parser = new Json.Parser (); yield parser.load_from_stream_async (in_stream); root = parser.get_root (); } if (root == null) throw new Error.literal (-1, 3, "Malformed JSON"); var obj = root.get_object (); if (obj == null) throw new Error.literal (-1, 3, "Malformed JSON"); if (!obj.has_member ("payload")) throw new Error.literal (-1, 3, "Payload is missing"); var payload = obj.get_object_member ("payload"); if (!payload.has_member ("count") || payload.get_int_member_with_default ("count", 0) == 0 || !payload.has_member (stat)) throw new Error.literal (-1, 4, "Not enough data"); var stats = payload.get_array_member (stat); if (stats.get_length () == 0) throw new Error.literal (-1, 4, "Not enough data"); return stats; } private inline MBIDable[] parse_stats_entities (Json.Array stats, string name_key, string mbid_key) { MBIDable[] entities = {}; stats.foreach_element ((arr, i, node) => { var arr_obj = node.get_object (); string? mbid = arr_obj.has_member ("release_mbid") ? arr_obj.get_string_member ("release_mbid") : null; if (mbid == null) mbid = arr_obj.has_member (mbid_key) ? arr_obj.get_string_member (mbid_key) : null; entities += MBIDable () { text = arr_obj.get_string_member (name_key), id = mbid, count = arr_obj.get_int_member_with_default ("listen_count", 0), image = null }; }); return entities; } public static inline async string? fetch_cover_from_wiki (Soup.Session session, string mbid, string kind) { if (mbid == null) return null; string? res = null; try { string? wikidata_id = null; { var msg = new Soup.Message ("GET", @"https://musicbrainz.org/ws/2/$kind/$mbid?inc=url-rels&fmt=json"); GLib.InputStream in_stream = yield session.send_async (msg, GLib.Priority.HIGH, null); var parser = new Json.Parser (); yield parser.load_from_stream_async (in_stream); var root = parser.get_root (); if (root == null) throw new Error.literal (-1, 3, "Malformed JSON"); var obj = root.get_object (); if (obj == null) throw new Error.literal (-1, 3, "Malformed JSON"); if (!obj.has_member ("relations")) throw new Error.literal (-1, 3, "Relations is missing"); var relations = obj.get_array_member ("relations"); for (uint i = 0; i < relations.get_length (); i++) { var arr_obj = relations.get_object_element (i); if ( arr_obj.has_member ("type") && arr_obj.get_string_member ("type") == "wikidata" && arr_obj.has_member ("url") ) { var url_obj = arr_obj.get_object_member ("url"); if (url_obj.has_member ("resource")) { string wikidata_url = url_obj.get_string_member ("resource"); if (wikidata_url.has_prefix ("http") && "wikidata.org" in wikidata_url) { GLib.Regex regex = new Regex ("wiki/([^/]+)$"); GLib.MatchInfo match; if (regex.match (wikidata_url, 0, out match)) { wikidata_id = match.fetch (1); break; } } } } } } string? img_val = null; if (wikidata_id != null) { var msg = new Soup.Message ("GET", @"https://www.wikidata.org/wiki/Special:EntityData/$wikidata_id.json"); GLib.InputStream in_stream = yield session.send_async (msg, GLib.Priority.HIGH, null); var parser = new Json.Parser (); yield parser.load_from_stream_async (in_stream); var root = parser.get_root (); if (root == null) throw new Error.literal (-1, 3, "Malformed JSON"); var obj = root.get_object (); if (obj == null) throw new Error.literal (-1, 3, "Malformed JSON"); if (!obj.has_member ("entities")) throw new Error.literal (-1, 3, "Entities is missing"); var entities = obj.get_object_member ("entities"); entities.foreach_member ((t_obj, key, val) => { var val_obj = val.get_object (); if (val_obj != null && val_obj.has_member ("claims")) { var claims = val_obj.get_object_member ("claims"); if (claims.has_member ("P18")) { var p_18 = claims.get_array_member ("P18"); if (p_18.get_length () > 0) { var p_18_f = p_18.get_object_element (0); if (p_18_f.has_member ("mainsnak")) { var mainsnak = p_18_f.get_object_member ("mainsnak"); if (mainsnak.has_member ("datavalue")) { var datavalue = mainsnak.get_object_member ("datavalue"); if (datavalue.has_member ("value")) { img_val = datavalue.get_string_member ("value"); } } } } } } return; }); } if (img_val != null) res = @"https://commons.wikimedia.org/wiki/Special:FilePath/$(GLib.Uri.escape_string (img_val))"; } catch (Error e) { warning (@"Couldn't fetch cover for $mbid from wiki: $(e.message) $(e.code)"); } return res; } } turntable/src/Scrobbling/Maloja.vala000066400000000000000000000017551512353730000200100ustar00rootroot00000000000000public class Turntable.Scrobbling.Maloja : ListenBrainz, Scrobbler { public override Manager.Provider SERVICE { get { return Manager.Provider.MALOJA; } } public override string token { get; set; default = ""; } public override Experiments experiments { get { return NONE; } } // https://github.com/krateng/maloja/blob/9e44cc3ce6d4259c32026ba50ee934e024b43a7a/maloja/apis/listenbrainz.py#L103-L108 protected override string auth_token_key { get; set; default = "Token"; } private string _url = ""; public override string url { get { return _url; } set { if (value == null) value = ""; if (_url != value) { try { var uri = GLib.Uri.parse (value, GLib.UriFlags.NONE); _url = GLib.Uri.build ( GLib.UriFlags.NONE, "https", uri.get_userinfo (), uri.get_host (), uri.get_port (), "/apis/listenbrainz", null, null ).to_string (); } catch (Error e) { critical (@"Can't parse $value: $(e.message)"); } } } } } turntable/src/Scrobbling/Manager.vala000066400000000000000000000356261512353730000201630ustar00rootroot00000000000000public class Turntable.Scrobbling.Manager : GLib.Object { Soup.Session session; GLib.HashTable queue_squared = new GLib.HashTable (str_hash, str_equal); GLib.HashTable reserved_clients = new GLib.HashTable (str_hash, str_equal); public enum Provider { LISTENBRAINZ, LIBREFM, LASTFM, MALOJA; public string to_string () { switch (this) { case LISTENBRAINZ: return "ListenBrainz"; case LIBREFM: return "Libre.fm"; case LASTFM: return "last.fm"; case MALOJA: return "Maloja"; default: assert_not_reached (); } } public string to_icon_name () { switch (this) { case LISTENBRAINZ: return "listenbrainz"; case LIBREFM: return "librefm"; case LASTFM: return "lastfm-symbolic"; case MALOJA: return "maloja"; default: assert_not_reached (); } } } public const Provider[] ALL_PROVIDERS = { LISTENBRAINZ, LIBREFM, LASTFM, MALOJA }; public bool cli_mode { get; set; default = false; } Scrobbler[] services = { new Scrobbling.ListenBrainz (), new Scrobbling.LibreFM (), new Scrobbling.LastFM (), new Scrobbling.Maloja () }; public struct Payload { public string? track; public string? artist; public string? album; } public class Pushable : GLib.Object { private Payload payload { get; set; } private uint scrobble_timeout { get; set; default = 0; } private int scrobble_in_seconds { get; set; } private int total_playtime { get; set; default = 0; } public signal void scrobbled (Payload payload, bool now_playing = false); bool cleared = false; ~Pushable () { debug ("[Pushable] Destroying: %s", payload.track); } private bool _playing = false; public bool playing { get { return _playing; } set { if (_playing != value) { _playing = value; if (this.scrobble_timeout != 0) GLib.Source.remove (this.scrobble_timeout); if (value && !cleared) { this.scrobble_timeout = GLib.Timeout.add_seconds (1, on_add_second); } else { this.scrobble_timeout = 0; } debug ("[Pushable] Changed play status for %s", payload.track); } } } public Pushable (Payload payload, int scrobble_in_seconds) { this.payload = payload; this.scrobble_in_seconds = scrobble_in_seconds; } private bool on_add_second () { if (cleared) return GLib.Source.REMOVE; total_playtime += 1; if (total_playtime == 3) on_started_playing (); else if (total_playtime >= scrobble_in_seconds) on_scrobble (); return GLib.Source.CONTINUE; } private inline string to_background_portal_message (Payload payload) { string res = payload.track; if (payload.artist != null) { res += @" - $(payload.artist)"; } return res; } bool now_played = false; private void on_started_playing () { // translators: background portal subtitle when scrobbling, // the variable is a song name application.background_portal_message.begin (_("Scrobbling %s").printf (to_background_portal_message (payload))); if (!settings.now_playing || now_played) return; now_played = true; debug ("[Pushable] Now Playing %s", payload.track); scrobbled (payload, true); } bool lock_scrobbled = false; private void on_scrobble () { if (lock_scrobbled) return; lock_scrobbled = true; // translators: background portal subtitle when publishing a scrobble, // the variable is a song name application.background_portal_message.begin (_("Scrobbled %s").printf (to_background_portal_message (payload))); debug ("[Pushable] Scrobbling %s", payload.track); scrobbled (payload); clear (); } public void clear () { if (cleared) return; if (this.scrobble_timeout != 0) GLib.Source.remove (this.scrobble_timeout); this.scrobble_timeout = 0; this.total_playtime = 0; cleared = true; debug ("[Pushable] Cleared %s", payload.track); } } private class BatchSplitter : GLib.Object { const int BATCH_SIZE = 50; public signal void done (Scrobbler.ScrobbleEntry[] batch); public signal void done_all (); string[] batches = {}; public BatchSplitter (string[] batches) { this.batches = batches; } public void split () { int batches_length = this.batches.length; if (batches_length == 0) { done_all (); this.batches = {}; return; } int total_batches = (batches_length + BATCH_SIZE - 1) / BATCH_SIZE; for (int b = 0; b < total_batches; b++) { int start = b * BATCH_SIZE; int end = int.min (start + BATCH_SIZE, batches_length); Scrobbler.ScrobbleEntry[] batch_entries = {}; foreach (string payload in this.batches[start:end]) { var parser = new Json.Parser (); try { parser.load_from_data (payload, -1); var root = parser.get_root (); if (root == null) assert_not_reached (); var obj = root.get_object (); if (obj == null) assert_not_reached (); if (!obj.has_member ("track") || !obj.has_member ("artist") || !obj.has_member ("date")) assert_not_reached (); batch_entries += Scrobbler.ScrobbleEntry () { payload = Payload () { track = obj.get_string_member ("track"), artist = obj.get_string_member ("artist"), album = obj.has_member ("album") ? obj.get_string_member ("album") : null }, datetime = new GLib.DateTime.from_iso8601 (obj.get_string_member ("date"), null) }; } catch { assert_not_reached (); } } done (batch_entries); GLib.Thread.usleep (250000); } done_all (); this.batches = {}; } } construct { session = new Soup.Session.with_options ("max-conns", 64, "max-conns-per-host", 64) { user_agent = @"$(Build.NAME)/$(Build.VERSION) libsoup/$(Soup.get_major_version()).$(Soup.get_minor_version()).$(Soup.get_micro_version()) ($(Soup.MAJOR_VERSION).$(Soup.MINOR_VERSION).$(Soup.MICRO_VERSION))" // vala-lint=line-length }; settings.notify["scrobbler-allowlist"].connect (on_allowlist_changed); account_manager.accounts_changed.connect (update_services); update_services (); GLib.Timeout.add_once (5000, submit_offline_scrobbles); } public void queue_payload (string win_id, string client, Payload payload, int64 length) { int add_in_seconds = int.min (4 * 60, (int) (length / 1000000 / 2)); clear_queue (win_id); if (add_in_seconds < 25 || (reserved_clients.find ((k, v) => { return v == client; }) != null)) return; reserved_clients.set (win_id, client); var pushable = new Pushable (payload, add_in_seconds); pushable.scrobbled.connect (scrobble_all); queue_squared.set (win_id, pushable); } public void clear_queue (string win_id) { debug ("Clearing %s", win_id); if (queue_squared.contains (win_id)) { application.background_portal_message.begin (null); queue_squared.get (win_id).clear (); queue_squared.remove (win_id); } if (reserved_clients.contains (win_id)) { reserved_clients.remove (win_id); } } public void set_playing_for_id (string win_id, bool playing) { if (!queue_squared.contains (win_id)) return; queue_squared.get (win_id).playing = playing; } private void update_services () { debug ("Updating service tokens"); foreach (var service in services) { service.update_tokens (); } } private void on_allowlist_changed () { if (reserved_clients.length == 0 || cli_mode) return; debug ("Allowlist changed"); string[] win_ids_to_clear = {}; reserved_clients.foreach ((k, v) => { if (!(v in settings.scrobbler_allowlist)) win_ids_to_clear += k; }); // let's not modify reserved_clients while foreaching it foreach (var win_id in win_ids_to_clear) { clear_queue (win_id); } } private void add_to_offline_scrobbling (Payload payload) { debug ("[OFFLINE] Adding %s to the offline scrobbling list", payload.track); var builder = new Json.Builder (); builder.begin_object (); builder.set_member_name ("date"); builder.add_string_value ((new DateTime.now_local ()).format_iso8601 ()); builder.set_member_name ("track"); builder.add_string_value (payload.track); builder.set_member_name ("artist"); builder.add_string_value (payload.artist); if (payload.album != null) { builder.set_member_name ("album"); builder.add_string_value (payload.album); } builder.end_object (); var generator = new Json.Generator (); generator.set_root (builder.get_root ()); // this can be long, let's not keep it in memory string[] offline_scrobbles = settings.get_strv ("offline-scrobbles"); offline_scrobbles += generator.to_data (null); settings.set_strv ("offline-scrobbles", offline_scrobbles); } public void scrobble_all (Payload payload, bool now_playing) { if (payload.track == null || payload.artist == null) return; debug (now_playing ? "Now Playing %s" : "Scrobbling %s", payload.track); if (settings.offline_scrobbling && !network_monitor.network_available) { if (!now_playing) add_to_offline_scrobbling (payload); return; } if (settings.mbid_required) { scrobbling_manager.fetch_mb_data.begin (payload, (obj, res) => { var new_payload = scrobbling_manager.fetch_mb_data.end (res); if (new_payload != null) { scrobble_all_actual (new_payload, now_playing); } }); return; } scrobble_all_actual (payload, now_playing); } private inline void scrobble_all_actual (Payload payload, bool now_playing) { var now = new GLib.DateTime.now (); foreach (var scrobbler in services) { scrobbler.scrobble ({Scrobbler.ScrobbleEntry () {payload = payload, datetime = now}}, now_playing ? Scrobbler.ScrobbleType.NOW_PLAYING : Scrobbler.ScrobbleType.TRACK); } } public void submit_offline_scrobbles () { if (!network_monitor.network_available || !settings.offline_scrobbling || application.batch_in_progress) return; application.batch_in_progress = true; BatchSplitter splitter; { string[] offline_scrobbles = settings.get_strv ("offline-scrobbles"); int total = offline_scrobbles.length; if (total == 0) { application.batch_in_progress = false; return; } debug ("Splitting %d offline scrobbles", total); splitter = new BatchSplitter (offline_scrobbles); } try { splitter.done.connect (on_split_done); splitter.done_all.connect (on_split_done_all); debug ("Spawining BatchSplitter thread"); new GLib.Thread.try ("BatchSplitter", splitter.split); settings.set_strv ("offline-scrobbles", {}); } catch (Error e) { critical (@"Couldn't spawn BatchSplitter thread: $(e.code) $(e.message)"); } } private void on_split_done (Scrobbler.ScrobbleEntry[] batch) { debug ("Submitting BatchSplitter batch"); foreach (var scrobbler in services) { scrobbler.scrobble (batch, Scrobbler.ScrobbleType.IMPORT); } } private void on_split_done_all () { debug ("Finished BatchSplitter"); application.batch_in_progress = false; } public void send_scrobble (owned Soup.Message msg, Provider provider, Scrobbler.ScrobbleType scrobble_type) { debug ("Sending %s to %s", scrobble_type.to_action (), provider.to_string ()); session.send_async.begin (msg, 0, null, (obj, res) => { try { var in_stream = session.send_async.end (res); switch (msg.status_code) { case Soup.Status.OK: debug ("Successfully %s %s", scrobble_type.to_past_action (), provider.to_string ()); break; case GLib.IOError.CANCELLED: debug ("Cancelled %s for %s", scrobble_type.to_present_action (), provider.to_string ()); return; // ! default: critical ("Request \"%s\" failed: %zu %s", msg.uri.to_string (), msg.status_code, msg.reason_phrase); break; } if (debug_enabled) { DataInputStream dis = new DataInputStream (in_stream); StringBuilder builder = new StringBuilder (); string? line; while ((line = dis.read_line (null)) != null) { builder.append (line); builder.append ("\n"); } debug ("Response: %s", builder.str); } } catch (GLib.Error e) { warning (e.message); } }); } struct MbCached { string mb_url; Payload? payload; } MbCached? mb_cached = null; private async Payload? fetch_mb_data (Payload payload) { string album_string_param = payload.album == null ? "" : @"+AND+release:$(GLib.Uri.escape_string (payload.album))"; string mb_url = @"https://musicbrainz.org/ws/2/recording?query=recording:$(GLib.Uri.escape_string (payload.track))+AND+artist:$(GLib.Uri.escape_string (payload.artist))$album_string_param&fmt=json&limit=1"; debug ("MBID Request %s", mb_url); if (mb_cached != null) { if (mb_cached.mb_url == mb_url) { debug ("MBID Found in Cache"); return mb_cached.payload; } else { mb_cached = null; } } var msg = new Soup.Message ("GET", mb_url); try { var in_stream = yield session.send_async (msg, 0, null); if (msg.status_code != Soup.Status.OK) throw new Error.literal (-1, 2, @"Server returned $(msg.status_code)"); var parser = new Json.Parser (); yield parser.load_from_stream_async (in_stream); var root = parser.get_root (); if (root == null) throw new Error.literal (-1, 3, "Malformed JSON"); var obj = root.get_object (); if (obj == null) throw new Error.literal (-1, 3, "Malformed JSON"); if (!obj.has_member ("count") || obj.get_int_member_with_default ("count", 0) == 0) throw new Error.literal (-1, 2, "Count doesn't exist or 0"); if (!obj.has_member ("recordings")) throw new Error.literal (-1, 2, "Recordings is missing"); var recordings = obj.get_array_member ("recordings"); if (recordings.get_length () == 0) throw new Error.literal (-1, 2, "Recordings is empty"); var recording = recordings.get_object_element (0); if (recording.has_member ("title")) { payload.track = recording.get_string_member ("title"); } if (recording.has_member ("artist-credit")) { var artists = recording.get_array_member ("artist-credit"); if (artists.get_length () > 0) { var artist = artists.get_object_element (0); if (artist.has_member ("name")) { payload.artist = artist.get_string_member ("name"); } } } if (recording.has_member ("releases")) { var releases = recording.get_array_member ("releases"); if (releases.get_length () > 0) { var release = releases.get_object_element (0); if (release.has_member ("title")) { payload.album = release.get_string_member ("title"); } } } mb_cached = {mb_url, payload}; return payload; } catch (Error e) { if (e.code == 2) { debug ("Couldn't complete MBID: %s", e.message); } else { warning ("Couldn't complete MBID: %s", e.message); } } mb_cached = {mb_url, null}; return null; } public Provider[] get_providers_with_experiment (Scrobbler.Experiments experiment) { Provider[] res = {}; foreach (var serv in services) { if (experiment in serv.experiments && serv.token != "") { res += serv.SERVICE; } } return res; } public async Scrobbler.Wrapped? wrapped (Provider provider, string username, int max = 5) throws Error { foreach (var serv in services) { if (serv.SERVICE == provider) { return yield serv.wrapped (username, max); } } return null; } } turntable/src/Scrobbling/Scrobbler.vala000066400000000000000000000054041512353730000205150ustar00rootroot00000000000000public interface Turntable.Scrobbling.Scrobbler : GLib.Object { public abstract Manager.Provider SERVICE { get; } public abstract string url { get; set; } public abstract string token { get; set; default = ""; } public virtual Experiments experiments { get { return NONE; } } [Flags] public enum Experiments { NONE, WRAPPED; } public enum ScrobbleType { TRACK, NOW_PLAYING, IMPORT; public string to_last_fm () { switch (this) { case NOW_PLAYING: return "track.updateNowPlaying"; default: return "track.scrobble"; } } public string to_listenbrainz () { switch (this) { case IMPORT: return "import"; case NOW_PLAYING: return "playing_now"; default: return "single"; } } public string to_past_action () { switch (this) { case IMPORT: return "imported"; case NOW_PLAYING: return "submitted"; default: return "scrobbled"; } } public string to_action () { switch (this) { case IMPORT: return "import"; case NOW_PLAYING: return "now playing"; default: return "scrobble"; } } public string to_present_action () { switch (this) { case IMPORT: return "importing"; case NOW_PLAYING: return "submitting"; default: return "scrobbling"; } } } public struct ScrobbleEntry { Scrobbling.Manager.Payload payload; GLib.DateTime datetime; } public struct MBIDable { string text; string? id; int64 count; string? image; } public struct Wrapped { MBIDable[] artists; MBIDable[] tracks; MBIDable[] albums; } protected abstract void scrobble_actual (ScrobbleEntry[] scrobble_entries, ScrobbleType scrobble_type); protected virtual async Wrapped? wrapped_actual (Soup.Session session, string username, int max = 5) throws GLib.Error { return null; } public virtual async Wrapped? wrapped (string username, int max = 5) throws GLib.Error { if (!(Experiments.WRAPPED in experiments) || token == "" || url == null || url == "") return null; Soup.Session session = new Soup.Session () { user_agent = @"$(Build.NAME)/$(Build.VERSION) libsoup/$(Soup.get_major_version()).$(Soup.get_minor_version()).$(Soup.get_micro_version()) ($(Soup.MAJOR_VERSION).$(Soup.MINOR_VERSION).$(Soup.MICRO_VERSION))" // vala-lint=line-length }; return yield wrapped_actual (session, username, max); } public virtual void scrobble (ScrobbleEntry[] scrobble_entries, ScrobbleType scrobble_type) { if (token == "" || url == null || url == "") return; scrobble_actual (scrobble_entries, scrobble_type); } public virtual void update_tokens () { if (!account_manager.accounts.contains (SERVICE.to_string ())) { this.token = ""; this.url = ""; return; } var acc = account_manager.accounts.get (SERVICE.to_string ()); this.token = acc.token; this.url = acc.custom_url; } } turntable/src/Scrobbling/meson.build000066400000000000000000000002451512353730000200730ustar00rootroot00000000000000sources += files( 'Accounts.vala', 'LastFM.vala', 'LibreFM.vala', 'ListenBrainz.vala', 'Maloja.vala', 'Manager.vala', 'Scrobbler.vala' ) turntable/src/Utils/000077500000000000000000000000001512353730000147445ustar00rootroot00000000000000turntable/src/Utils/Cache.vala000066400000000000000000000023171512353730000166170ustar00rootroot00000000000000// While there have been ideas of caching both covers and colors, // it seems unrealistic as we cannot guarantee that clients wont // re-use file paths. // Ideas like using metadata were also on the table but were // discarded for similar reasons. public class Turntable.Utils.Cache : GLib.Object { private GLib.HashTable custom_color_cache; private GLib.Array key_queue; construct { custom_color_cache = new GLib.HashTable (str_hash, str_equal); key_queue = new GLib.Array (); } public void add (string key, Utils.Color.ExtractedColors? value) { uint match = 0; if (key_queue.binary_search (key, GLib.strcmp, out match)) { custom_color_cache.set (key, value); if (match < key_queue.length - 1) { key_queue.append_val (key_queue.index (match)); key_queue.remove_index (match); } } else { key_queue.append_val (key); custom_color_cache.set (key, value); if (key_queue.length > 10) { key_queue.remove_index (0); } } } public Utils.Color.ExtractedColors? get_val (string key) { if (custom_color_cache.contains (key)) { return custom_color_cache.get (key); } return null; } } turntable/src/Utils/Celebrate.vala000066400000000000000000000127121512353730000175020ustar00rootroot00000000000000// Celebrate is inspired by Warp's pride.rs // https://gitlab.gnome.org/World/warp/-/blob/main/src/ui/pride.rs public class Turntable.Utils.Celebrate { enum CelebrateStyle { INTERSEX, LESBIAN, AIDS, PAN, TRANS, BI, AGENDER, DISABILITY, BLACK_HISTORY, ARO, ACE, NON_BINARY, AUTISM; public string to_string () { switch (this) { case INTERSEX: return "intersex"; case LESBIAN: return "lesbian"; case AIDS: return "aids"; case PAN: return "pan"; case TRANS: return "trans"; case BI: return "bi"; case AGENDER: return "agender"; case DISABILITY: return "disability"; case BLACK_HISTORY: return "black-history"; case ARO: return "aro"; case ACE: return "ace"; case NON_BINARY: return "non-binary"; case AUTISM: return "autism"; default: assert_not_reached (); } } } struct Celebration { int day; int month; CelebrateStyle css_class; } const Celebration[] CELEBRATIONS_DAYS = { // Intersex Awareness Day { 26, 10, CelebrateStyle.INTERSEX }, // Intersex Day Of Remembrance { 8, 11, CelebrateStyle.INTERSEX }, // International Lesbian Day { 8, 10, CelebrateStyle.LESBIAN }, // World AIDS Day { 1, 12, CelebrateStyle.AIDS }, // Autistic Pride Day { 18, 6, CelebrateStyle.AUTISM }, // Pansexual and Panromantic Awareness and Visibility Day { 24, 5, CelebrateStyle.PAN }, // TDOR { 20, 11, CelebrateStyle.TRANS }, // TDOV { 31, 3, CelebrateStyle.TRANS }, // Bi Visibility Day { 23, 9, CelebrateStyle.BI }, // Agender Pride Day { 19, 5, CelebrateStyle.AGENDER }, }; const Celebration[] CELEBRATIONS_WEEKS = { // Lesbian Visibility Week { 26, 4, CelebrateStyle.LESBIAN }, // Trans Awareness Week { 13, 11, CelebrateStyle.TRANS }, // Bisexual Awareness Week { 16, 9, CelebrateStyle.BI }, }; const Celebration[] CELEBRATIONS_MONTHS = { // Disability Pride Month { 0, 7, CelebrateStyle.DISABILITY }, // Black History Month { 0, 2, CelebrateStyle.BLACK_HISTORY }, { 0, 10, CelebrateStyle.BLACK_HISTORY } }; private static Celebration[] get_dynamic_weeks (GLib.DateTime date) { return { // Aromantic Spectrum Awareness Week get_arospec_week (date), // Ace Week get_ace_week (date), // Non-Binary Awareness Week get_enby_week (date), }; } private static Celebration get_arospec_week (GLib.DateTime date) { // The week following 14th February (Sunday-Saturday) var february_14 = new GLib.DateTime.local (date.get_year (), 2, 14, 0, 0, 0); var weekday_offset = february_14.get_day_of_week () % 7; var start = 14 + 7 - weekday_offset; return { start, 2, CelebrateStyle.ARO }; } private static Celebration get_ace_week (GLib.DateTime date) { // Last week of October, starting on Sunday var last_day_october = new GLib.DateTime.local (date.get_year (), 10, 31, 0, 0, 0); var weekday_offset_last_day_october = last_day_october.get_day_of_week () % 7; var start = weekday_offset_last_day_october == 6 ? 31 - 7 : 31 - weekday_offset_last_day_october - 7; return { start, 10, CelebrateStyle.ACE }; } private static Celebration get_enby_week (GLib.DateTime date) { // The week, starting Sunday/Monday, surrounding 14 July // We will just start on Sunday and end on Monday, so 8 days var july_14 = new GLib.DateTime.local (date.get_year (), 7, 14, 0, 0, 0); var weekday_july_14_offset = july_14.get_day_of_week () - 1; var start = 14 - weekday_july_14_offset; return { start, 7, CelebrateStyle.NON_BINARY }; } public static string get_celebration_css_class (GLib.DateTime date) { var celebration = get_celebration (date); return celebration == null ? "" : @"theme-$(celebration.css_class)"; } private static Celebration? get_celebration (GLib.DateTime date) { var celebration = get_celebration_day (date); if (celebration == null) celebration = get_celebration_week (date); if (celebration == null) celebration = get_celebration_month (date); return celebration; } private static Celebration? get_celebration_day (GLib.DateTime date) { Celebration[] res = {}; var month = date.get_month (); var day = date.get_day_of_month (); foreach (var celebration in CELEBRATIONS_DAYS) { if (celebration.month == month && celebration.day == day) res += celebration; } return get_random_item (res); } private static Celebration? get_celebration_week (GLib.DateTime date) { Celebration[] res = {}; var year = date.get_year (); var day = date.get_day_of_year (); var weeks = get_dynamic_weeks (date); foreach (var celebration in CELEBRATIONS_WEEKS) { weeks += celebration; } foreach (var celebration in weeks) { var celebration_pre_week = new GLib.DateTime.local (year, celebration.month, celebration.day, 0, 0, 0); var week = celebration_pre_week.add_weeks (1); if ( day >= celebration_pre_week.get_day_of_year () && day <= week.get_day_of_year () - 1 ) res += celebration; } return get_random_item (res); } private static Celebration? get_celebration_month (GLib.DateTime date) { Celebration[] res = {}; var month = date.get_month (); foreach (var celebration in CELEBRATIONS_MONTHS) { if (celebration.month == month) res += celebration; } if (res.length == 0) return null; return get_random_item (res); } private static Celebration? get_random_item (Celebration[] res) { if (res.length == 0) return null; if (res.length == 1) return res[0]; return res[Random.int_range (0, res.length)]; } } turntable/src/Utils/Cli.vala000066400000000000000000000066351512353730000163320ustar00rootroot00000000000000public class Turntable.Utils.CLI : GLib.Object { private Mpris.Entry? cli_last_player = null; private int64 _cli_length = 0; public int64 cli_length { get { return _cli_length; } set { _cli_length = value; if (value > 0) { cli_add_to_scrobbler (); if (this.cli_playing) cli_update_scrobbler_playing (); } } } private bool _cli_playing = false; public bool cli_playing { get { return _cli_playing; } set { _cli_playing = value; cli_update_scrobbler_playing (); } } construct { } public int run () { if (cli_list_clients) { stdout.printf ("Available MPRIS Clients: (ID - Name)\n"); foreach (var client in mpris_manager.get_players ()) { stdout.printf (@"$(client.bus_namespace) - $(client.client_info_name)\n"); } return 0; } else if (cli_client_id_scrobble != null) { if (cli_client_id_scrobble == "") { stderr.printf ("Please provide a Client ID\n"); return 1; } else if (cli_client_id_scrobble.split (".").length < 4) { stderr.printf ("Please provide a valid Client ID\n"); return 1; } settings = new Utils.Settings (); mpris_manager.players_changed.connect (cli_update_players); GLib.MainLoop loop = new GLib.MainLoop (); account_manager = new Scrobbling.AccountManager (); scrobbling_manager = new Scrobbling.Manager (); scrobbling_manager.cli_mode = true; try { account_manager.load_cli_sync (); } catch (Error e) { stderr.printf (@"Error while loading accounts: $(e.message)\n"); return 1; } if (account_manager.accounts.length == 0) { stderr.printf (@"No Scrobbling accounts found, please set them up through $(Build.NAME)\n"); return 1; } cli_update_players (); loop.run (); } return 0; } private void cli_update_players () { debug ("Updating players"); Mpris.Entry? new_cli_player = null; foreach (var client in mpris_manager.get_players ()) { if (client.bus_namespace.down () == cli_client_id_scrobble.down ()) { new_cli_player = client; break; } } if (new_cli_player == null) { if (cli_last_player != null) { cli_last_player.terminate_player (); cli_last_player = null; } cli_player_changed (); return; } if (cli_last_player == null) { cli_last_player = new_cli_player; cli_last_player.initialize_player (); cli_player_changed (); } } GLib.Binding[] cli_player_bindings = {}; private void cli_player_changed () { debug ("Player changed"); scrobbling_manager.clear_queue ("1"); foreach (var binding in cli_player_bindings) { binding.unbind (); binding.unref (); } cli_player_bindings = {}; if (cli_last_player == null) return; cli_player_bindings += this.cli_last_player.bind_property ("length", this, "cli-length", GLib.BindingFlags.SYNC_CREATE); cli_player_bindings += this.cli_last_player.bind_property ("playing", this, "cli-playing", GLib.BindingFlags.SYNC_CREATE); } private void cli_add_to_scrobbler () { if (this.cli_last_player == null || this.cli_last_player.length == 0) return; scrobbling_manager.queue_payload ( "1", this.cli_last_player.bus_namespace, { this.cli_last_player.title, this.cli_last_player.artist, this.cli_last_player.album }, this.cli_last_player.length ); } private void cli_update_scrobbler_playing () { if (this.cli_last_player == null || this.cli_last_player.length == 0) return; scrobbling_manager.set_playing_for_id ( "1", this.cli_playing ); } } turntable/src/Utils/Color.vala000066400000000000000000000042511512353730000166710ustar00rootroot00000000000000public class Turntable.Utils.Color { public struct ExtractedColors { public Gdk.RGBA? light; public Gdk.RGBA? dark; } public static Gdk.RGBA get_prominent_color (Gly.Frame frame, GLib.Cancellable cancellable) { Gdk.RGBA? prominent_color = Utils.Thief.quantize (frame, 4, cancellable); if (prominent_color != null) return prominent_color; uint32 width = frame.get_width (); uint32 height = frame.get_height (); uint32 rowstride = frame.get_stride (); int n_channels = frame.get_memory_format ().has_alpha () ? 4 : 3; unowned uint8[] pixels = frame.get_buf_bytes ().get_data (); ulong sum_r = 0, sum_g = 0, sum_b = 0; uint32 total_pixels = width * height; for (int y = 0; y < height; y++) { for (int x = 0; x < width; x++) { uint32 offset = y * rowstride + x * n_channels; sum_r += pixels[offset]; sum_g += pixels[offset + 1]; sum_b += pixels[offset + 2]; } } Gdk.RGBA avg_color = Gdk.RGBA () { red = (float) (sum_r / total_pixels / 255.0), green = (float) (sum_g / total_pixels / 255.0), blue = (float) (sum_b / total_pixels / 255.0), alpha = 1.0f }; return avg_color; } public static ExtractedColors get_contrasting_colors (Gdk.RGBA color) { ExtractedColors result = { color, color }; double luminance = 0.299 * color.red + 0.587 * color.green + 0.114 * color.blue; if (luminance > 0.85) { result.light = Gdk.RGBA () { red = color.red * 0.7f, green = color.green * 0.7f, blue = color.blue * 0.7f, alpha = 1f }; if (luminance > 0.9) { result.dark = Gdk.RGBA () { red = color.red * 0.5f, green = color.green * 0.5f, blue = color.blue * 0.5f, alpha = 1f }; } } else if (luminance < 0.25) { result.dark = Gdk.RGBA () { red = color.red + (1.0f - color.red) * 0.7f, green = color.green + (1.0f - color.green) * 0.7f, blue = color.blue + (1.0f - color.blue) * 0.7f, alpha = 1f }; if (luminance < 0.15) { result.light = Gdk.RGBA () { red = color.red + (1.0f - color.red) * 0.25f, green = color.green + (1.0f - color.green) * 0.25f, blue = color.blue + (1.0f - color.blue) * 0.25f, alpha = 1f }; } } return result; } } turntable/src/Utils/Host.vala000066400000000000000000000021451512353730000165300ustar00rootroot00000000000000public class Turntable.Utils.Host { public async static bool open_in_default_app (string uri, Gtk.Window window) { debug (@"Opening URI: $uri"); try { yield (new Gtk.UriLauncher (uri)).launch (window, null); } catch (Error e) { warning (@"Error opening uri \"$uri\": $(e.message)"); return yield open_in_default_app_using_dbus (uri); } return true; } private async static bool open_in_default_app_using_dbus (string uri) { try { yield AppInfo.launch_default_for_uri_async (uri, null, null); } catch (Error e) { warning (@"Error opening using launch_default_for_uri \"$uri\": $(e.message)"); return false; } return true; } public static string lfm_signature (GLib.HashTable params_to_hash, string secret) { GLib.StringBuilder signature = new GLib.StringBuilder (); var sorted_keys = params_to_hash.get_keys (); sorted_keys.sort (strcmp); foreach (string key in sorted_keys) { signature.append (@"$key$(params_to_hash.get (key))"); } signature.append (secret); return GLib.Checksum.compute_for_string (GLib.ChecksumType.MD5, signature.str); } } turntable/src/Utils/Settings.vala000066400000000000000000000050771512353730000174220ustar00rootroot00000000000000public class Turntable.Utils.Settings : GLib.Settings { public bool orientation_horizontal { get; set; } public string cover_style { get; set; } public string progressscale_style { get; set; } public bool component_progressbin { get; set; } public bool component_extract_colors { get; set; } public string window_style { get; set; } public bool component_cover_fit { get; set; } public bool component_tonearm { get; set; } public bool component_center_text { get; set; } public bool component_more_controls { get; set; } public bool meta_dim { get; set; } public bool mbid_required { get; set; } public bool now_playing { get; set; } public bool offline_scrobbling { get; set; } public bool collapsed_controls { get; set; } public bool hide_client_icon_collapsed { get; set; } public string cover_size { get; set; } public string text_size { get; set; } public string cover_scaling { get; set; } public string client_icon_style { get; set; } public string[] scrobbler_allowlist { get; set; default = {}; } public bool autostart { get; set; } public bool run_in_background { get; set; } public bool start_hidden { get; set; } private const string[] KEYS_TO_INIT = { "orientation-horizontal", "cover-style", "progressscale-style", "component-progressbin", "component-extract-colors", "window-style", "component-cover-fit", "scrobbler-allowlist", "meta-dim", "cover-size", "text-size", "cover-scaling", "mbid-required", "component-tonearm", "component-center-text", "now-playing", "hide-client-icon-collapsed", "component-more-controls", "offline-scrobbling", "collapsed-controls", "client-icon-style", "autostart", "run-in-background", "start-hidden" }; public Settings () { Object (schema_id: Build.DOMAIN); foreach (var key in KEYS_TO_INIT) { init (key); } } public void remove_from_allowlist (string client_name) { debug ("Removing %s from the allowlist", client_name); if (client_name in this.scrobbler_allowlist) { string[] new_allowlist = {}; foreach (var allowed_client in this.scrobbler_allowlist) { if (allowed_client != client_name) new_allowlist += allowed_client; } this.scrobbler_allowlist = new_allowlist; } } public void add_to_allowlist (string client_name) { debug ("Adding %s to the allowlist", client_name); if (client_name in this.scrobbler_allowlist) return; string[] new_allowlist = this.scrobbler_allowlist; new_allowlist += client_name; this.scrobbler_allowlist = new_allowlist; } inline void init (string key) { bind (key, this, key, SettingsBindFlags.DEFAULT); } } turntable/src/Utils/Thief.vala000066400000000000000000000252721512353730000166600ustar00rootroot00000000000000// Port of https://github.com/RazrFalcon/color-thief-rs // Main reason being matching Amberol and generally having better colors. // Since we only need one color, the sorting is simplified and uses a formula // for the best count / volume ratio. public class Turntable.Utils.Thief : GLib.Object { const int SIGNAL_BITS = 5; const int RIGHT_SHIFT = 8 - SIGNAL_BITS; const int MULTIPLIER = 1 << RIGHT_SHIFT; const double MULTIPLIER_64 = (double)MULTIPLIER; const int HISTOGRAM_SIZE = 1 << (3 * SIGNAL_BITS); const int VBOX_LENGTH = 1 << SIGNAL_BITS; const double FRACTION_BY_POPULATION = 0.75; const int MAX_ITERATIONS = 1000; const int STEP = 5; public static size_t make_color_index_of (uint8 red, uint8 green, uint8 blue) { return (size_t)( ((int) red << (2 * SIGNAL_BITS)) + ((int) green << SIGNAL_BITS) + (int) blue ); } protected enum ColorChannel { RED, GREEN, BLUE; } private class VBox : GLib.Object { public uint8 r_min { get; set; } public uint8 r_max { get; set; } public uint8 g_min { get; set; } public uint8 g_max { get; set; } public uint8 b_min { get; set; } public uint8 b_max { get; set; } public Gdk.RGBA average = { 0, 0, 0, 1 }; public int volume { get; set; default = 0; } public int count { get; set; default = 0; } public float score { get; set; default = 0; } public VBox (uint8 r_min, uint8 r_max, uint8 g_min, uint8 g_max, uint8 b_min, uint8 b_max) { this.r_min = r_min; this.r_max = r_max; this.g_min = g_min; this.g_max = g_max; this.b_min = b_min; this.b_max = b_max; } public void recalc (int32[] histogram) { this.average = calc_average (histogram); this.count = calc_count (histogram); this.volume = calc_volume (); } public int calc_volume () { return (this.r_max - this.r_min + 1) * (this.g_max - this.g_min + 1) * (this.b_max - this.b_min + 1); } public int calc_count (int32[] histogram) { int count = 0; for (uint8 i = this.r_min; i <= this.r_max; i++) { for (uint8 j = this.g_min; j <= this.g_max; j++) { for (uint8 k = this.b_min; k <= this.b_max; k++) { var index = make_color_index_of (i, j, k); count += histogram[index]; } } } return count; } public Gdk.RGBA calc_average (int32[] histogram) { int ntot = 0; int r_sum = 0; int g_sum = 0; int b_sum = 0; for (uint8 i = this.r_min; i <= this.r_max; i++) { for (uint8 j = this.g_min; j <= this.g_max; j++) { for (uint8 k = this.b_min; k <= this.b_max; k++) { size_t index = make_color_index_of (i, j, k); double hval = (double) histogram[index]; ntot += (int) hval; r_sum += (int) (hval * ((double) i + 0.5) * MULTIPLIER_64); g_sum += (int) (hval * ((double) j + 0.5) * MULTIPLIER_64); b_sum += (int) (hval * ((double) k + 0.5) * MULTIPLIER_64); } } } if (ntot > 0) { int r = r_sum / ntot; int g = g_sum / ntot; int b = b_sum / ntot; return { r / 255f, g / 255f, b / 255f, 1 }; } else { int r = MULTIPLIER * ((int) this.r_min + (int) this.r_max + 1) / 2; int g = MULTIPLIER * ((int) this.g_min + (int) this.g_max + 1) / 2; int b = MULTIPLIER * ((int) this.b_min + (int) this.b_max + 1) / 2; return { int.min (r, 255) / 255f, int.min (g, 255) / 255f, int.min (b, 255) / 255f, 1 }; } } public ColorChannel widest_color_channel () { uint8 r_width = this.r_max - this.r_min; uint8 g_width = this.g_max - this.g_min; uint8 b_width = this.b_max - this.b_min; uint8 max = uint8.max (uint8.max (r_width, g_width), b_width); if (max == r_width) { return ColorChannel.RED; } else if (max == g_width) { return ColorChannel.GREEN; } else { return ColorChannel.BLUE; } } } private static inline void make_histogram_and_vbox (Gly.Frame frame, out int[] histogram, out VBox vbox) { histogram = new int[HISTOGRAM_SIZE]; uint8 r_min = uint8.MAX; uint8 r_max = uint8.MIN; uint8 g_min = uint8.MAX; uint8 g_max = uint8.MIN; uint8 b_min = uint8.MAX; uint8 b_max = uint8.MIN; uint32 width = frame.get_width (); uint32 height = frame.get_height (); uint32 rowstride = frame.get_stride (); int n_channels = frame.get_memory_format ().has_alpha () ? 4 : 3; unowned uint8[] pixels = frame.get_buf_bytes ().get_data (); for (int y = 0; y < height; y++) { for (int x = 0; x < width; x += STEP) { uint32 offset = y * rowstride + x * n_channels; uint8 r = pixels[offset]; uint8 g = pixels[offset + 1]; uint8 b = pixels[offset + 2]; uint8 a = n_channels == 4 ? pixels[offset + 3] : 255; if (a < 125 || (r > 250 && g > 250 && b > 250)) continue; r = (uint8) (r >> RIGHT_SHIFT); g = (uint8) (g >> RIGHT_SHIFT); b = (uint8) (b >> RIGHT_SHIFT); r_min = uint8.min (r_min, r); r_max = uint8.max (r_max, r); g_min = uint8.min (g_min, g); g_max = uint8.max (g_max, g); b_min = uint8.min (b_min, b); b_max = uint8.max (b_max, b); var index = make_color_index_of (r, g, b); histogram[index] += 1; } } vbox = new VBox (r_min, r_max, g_min, g_max, b_min, b_max); vbox.recalc (histogram); } private static bool apply_median_cut (int[] histogram, ref VBox vbox, out VBox? vbox1, out VBox? vbox2, GLib.Cancellable cancellable) { vbox1 = null; vbox2 = null; if (vbox.count == 0) { return false; } if (vbox.count == 1) { vbox1 = vbox; return true; } int total = 0; int[] partial_sum = new int[VBOX_LENGTH]; for (int i = 0; i < VBOX_LENGTH; i++) { partial_sum[i] = -1; } var axis = vbox.widest_color_channel (); switch (axis) { case ColorChannel.RED: for (uint8 i = vbox.r_min; i <= vbox.r_max; i++) { int sum = 0; for (uint8 j = vbox.g_min; j <= vbox.g_max; j++) { for (uint8 k = vbox.b_min; k <= vbox.b_max; k++) { size_t index = make_color_index_of (i, j, k); sum += histogram[index]; } } total += sum; partial_sum[i] = total; } break; case ColorChannel.GREEN: for (uint8 i = vbox.g_min; i <= vbox.g_max; i++) { int sum = 0; for (uint8 j = vbox.r_min; j <= vbox.r_max; j++) { for (uint8 k = vbox.b_min; k <= vbox.b_max; k++) { size_t index = make_color_index_of (j, i, k); sum += histogram[index]; } } total += sum; partial_sum[i] = total; } break; case ColorChannel.BLUE: for (uint8 i = vbox.b_min; i <= vbox.b_max; i++) { int sum = 0; for (uint8 j = vbox.r_min; j <= vbox.r_max; j++) { for (uint8 k = vbox.g_min; k <= vbox.g_max; k++) { size_t index = make_color_index_of (j, k, i); sum += histogram[index]; } } total += sum; partial_sum[i] = total; } break; } int[] look_ahead_sum = new int[VBOX_LENGTH]; for (int i = 0; i < VBOX_LENGTH; i++) { look_ahead_sum[i] = -1; } for (int i = 0; i < partial_sum.length; i++) { if (partial_sum[i] != -1) { look_ahead_sum[i] = total - partial_sum[i]; } } if (cancellable.is_cancelled ()) return false; return cut (axis, ref vbox, histogram, partial_sum, look_ahead_sum, total, out vbox1, out vbox2); } private static inline bool cut (ColorChannel axis, ref VBox vbox, int[] histogram, int[] partial_sum, int[] look_ahead_sum, int total, out VBox? vbox1, out VBox? vbox2) { vbox1 = null; vbox2 = null; int vbox_min, vbox_max; switch (axis) { case ColorChannel.RED: vbox_min = vbox.r_min; vbox_max = vbox.r_max; break; case ColorChannel.GREEN: vbox_min = vbox.g_min; vbox_max = vbox.g_max; break; case ColorChannel.BLUE: vbox_min = vbox.b_min; vbox_max = vbox.b_max; break; default: assert_not_reached (); } for (int i = vbox_min; i <= vbox_max; i++) { if (partial_sum[i] <= total / 2) { continue; } vbox1 = new VBox (vbox.r_min, vbox.r_max, vbox.g_min, vbox.g_max, vbox.b_min, vbox.b_max); vbox2 = new VBox (vbox.r_min, vbox.r_max, vbox.g_min, vbox.g_max, vbox.b_min, vbox.b_max); int left = i - vbox_min; int right = vbox_max - i; int d2; if (left <= right) { d2 = int.min (vbox_max - 1, i + right / 2); } else { d2 = int.max (vbox_min, (int)((i - 1) - left / 2f)); } while (d2 < 0 || partial_sum[d2] <= 0) { d2 += 1; } int count2 = look_ahead_sum[d2]; while (count2 == 0 && d2 > 0 && partial_sum[d2 - 1] > 0) { d2 -= 1; count2 = look_ahead_sum[d2]; } switch (axis) { case ColorChannel.RED: vbox1.r_max = (uint8)d2; vbox2.r_min = (uint8)(d2 + 1); break; case ColorChannel.GREEN: vbox1.g_max = (uint8)d2; vbox2.g_min = (uint8)(d2 + 1); break; case ColorChannel.BLUE: vbox1.b_max = (uint8)d2; vbox2.b_min = (uint8)(d2 + 1); break; } vbox1.recalc (histogram); vbox2.recalc (histogram); return true; } return false; } public static Gdk.RGBA? quantize (Gly.Frame frame, int max_colors, GLib.Cancellable cancellable) { int[] histogram; VBox vbox; make_histogram_and_vbox (frame, out histogram, out vbox); if (cancellable.is_cancelled ()) return null; var pq = new List (); pq.append (vbox); int target = (int) Math.ceil (FRACTION_BY_POPULATION * max_colors); iterate (ref pq, (CompareFunc) compare_by_count, target, histogram, cancellable); if (cancellable.is_cancelled ()) return null; int max_count = 0; int max_volume = 0; foreach (var p_vbox in pq) { max_count = int.max (max_count, p_vbox.count); max_volume = int.max (max_volume, p_vbox.volume); } foreach (var p_vbox in pq) { p_vbox.score = 0.3f * p_vbox.count / max_count + 0.7f * p_vbox.volume / max_volume; } pq.sort (((CompareFunc) compare_by_score)); if (pq.length () > 0) { return pq.first ().data.average; } return null; } private static void iterate (ref List queue, CompareFunc comparator, int target, int[] histogram, GLib.Cancellable cancellable) { int color = 1; for (int i = 0; i < MAX_ITERATIONS; i++) { if (queue.is_empty ()) { continue; } var vbox = queue.last ().data; if (vbox.count == 0) { queue.sort (comparator); continue; } queue.remove (queue.last ().data); VBox? vbox1 = null; VBox? vbox2 = null; if (cancellable.is_cancelled ()) break; if (!apply_median_cut (histogram, ref vbox, out vbox1, out vbox2, cancellable)) { continue; } queue.append (vbox1); if (vbox2 != null) { queue.append (vbox2); color++; } queue.sort (comparator); if (color >= target) { break; } } } private static int compare_by_count (VBox a, VBox b) { return a.count - b.count; } private static int compare_by_score (VBox a, VBox b) { if (a.score == b.score) { return 0; } else if (a.score > b.score) { return -1; } else { return 1; } } } turntable/src/Utils/meson.build000066400000000000000000000002721512353730000171070ustar00rootroot00000000000000sources += files( #'Cache.vala', 'Celebrate.vala', 'Color.vala', 'Host.vala', 'Settings.vala', 'Thief.vala' ) if scrobbling sources += files('Cli.vala') endif turntable/src/Views/000077500000000000000000000000001512353730000147415ustar00rootroot00000000000000turntable/src/Views/LibreFMPage.vala000066400000000000000000000040421512353730000176630ustar00rootroot00000000000000public class Turntable.Views.LibreFMPage : Views.ProviderPage { public signal void chose_url (string chosen_url); Adw.EntryRow url_row; private bool _url_entry_valid = true; private bool url_entry_valid { get { return _url_entry_valid; } set { _url_entry_valid = value; bool has_error_class = url_row.has_css_class ("error"); if (value && has_error_class) { url_row.remove_css_class ("error"); } else if (!value && !has_error_class) { url_row.add_css_class ("error"); } update_validity (); } } protected override void update_validity () { add_button.sensitive = this.url_entry_valid; } private string _url = ""; public string url { get { return _url; } set { bool error = false; string normalized_value = value.contains ("://") ? value : @"https://$value"; if (!normalized_value.contains (".")) { error = true; } else if (_url != normalized_value) { try { if (GLib.Uri.is_valid (normalized_value, GLib.UriFlags.NONE)) { var uri = GLib.Uri.parse (normalized_value, GLib.UriFlags.NONE); _url = GLib.Uri.build ( GLib.UriFlags.NONE, "https", uri.get_userinfo (), uri.get_host (), uri.get_port (), "", null, null ).to_string (); } else { error = true; } } catch { error = true; } } url_entry_valid = !error; } } construct { this.title = Scrobbling.Manager.Provider.LIBREFM.to_string (); var main_group = new Adw.PreferencesGroup (); url_row = new Adw.EntryRow () { title = _("Host") }; this.url = "https://libre.fm"; url_row.text = this.url; url_row.changed.connect (on_url_row_changed); main_group.add (url_row); page.add (main_group); } protected override void on_continue () { url_row.sensitive = add_button.sensitive = false; chose_url (this.url); } private void on_url_row_changed (Gtk.Editable url_row_editable) { string clean_uri = url_row_editable.text.strip (); if (clean_uri == "") clean_uri = "https://libre.fm"; this.url = clean_uri; } } turntable/src/Views/ListenBrainzPage.vala000066400000000000000000000126461512353730000210200ustar00rootroot00000000000000public class Turntable.Views.ListenBrainzPage : Views.ProviderPage { protected Adw.EntryRow url_row; Adw.EntryRow token_row; protected string token { get; set; default = ""; } public signal void done (); private bool _url_entry_valid = true; protected bool url_entry_valid { get { return _url_entry_valid; } set { _url_entry_valid = value; bool has_error_class = url_row.has_css_class ("error"); if (value && has_error_class) { url_row.remove_css_class ("error"); } else if (!value && !has_error_class) { url_row.add_css_class ("error"); } update_validity (); } } private bool _token_row_valid = false; protected bool token_row_valid { get { return _token_row_valid; } set { _token_row_valid = value; bool has_error_class = token_row.has_css_class ("error"); if (value && has_error_class) { token_row.remove_css_class ("error"); } else if (!value && !has_error_class) { token_row.add_css_class ("error"); } update_validity (); } } protected override void update_validity () { add_button.sensitive = this.token_row_valid && this.url_entry_valid; } private string _url = ""; public virtual string url { get { return _url; } set { bool error = false; string normalized_value = value.contains ("://") ? value : @"https://$value"; if (!normalized_value.contains (".")) { error = true; } else if (_url != normalized_value) { try { if (GLib.Uri.is_valid (normalized_value, GLib.UriFlags.NONE)) { var uri = GLib.Uri.parse (normalized_value, GLib.UriFlags.NONE); string host = uri.get_host (); string path = uri.get_path (); if (path.has_suffix ("/")) path = path.slice (0, path.length - 1); _url = GLib.Uri.build ( GLib.UriFlags.NONE, "https", uri.get_userinfo (), host, uri.get_port (), path, null, null ).to_string (); string regular_url = _url; int first_dot = host.index_of_char ('.'); if (host.index_of_char ('.', first_dot) != -1) { regular_url = GLib.Uri.build ( GLib.UriFlags.NONE, "https", uri.get_userinfo (), host.splice (0, first_dot + 1), uri.get_port (), "", null, null ).to_string (); } string settings_page = @"$(GLib.Markup.escape_text (regular_url))/settings/"; // translators: variable is a link page.description = _("You can get your user token from %s.").printf (@"$settings_page"); } else { error = true; } } catch { error = true; } } url_entry_valid = !error; } } protected virtual Scrobbling.Manager.Provider scrobbler_provider { get { return LISTENBRAINZ; }} construct { this.title = Scrobbling.Manager.Provider.LISTENBRAINZ.to_string (); var main_group = new Adw.PreferencesGroup (); url_row = new Adw.EntryRow () { // translators: host as in a web server; entry title title = _("Host API") }; this.url = "https://api.listenbrainz.org"; url_row.text = this.url; url_row.changed.connect (on_url_row_changed); main_group.add (url_row); token_row = new Adw.EntryRow () { // translators: can also be translated as Authentication Token title = _("User Token") }; token_row.changed.connect (on_token_changed); main_group.add (token_row); page.add (main_group); } protected virtual void on_url_row_changed (Gtk.Editable url_row_editable) { string clean_uri = url_row_editable.text.strip (); if (clean_uri == "") clean_uri = "https://api.listenbrainz.org"; this.url = clean_uri; } protected virtual void on_token_changed (Gtk.Editable token_row_editable) { this.token = token_row_editable.text.strip (); token_row_valid = GLib.Uuid.string_is_valid (this.token); } protected override void on_continue () { this.can_pop = url_row.sensitive = token_row.sensitive = add_button.sensitive = false; validate_token.begin (this.token, (obj, res) => { string? error = validate_token.end (res); this.can_pop = url_row.sensitive = token_row.sensitive = add_button.sensitive = true; if (error == null) { done (); return; } errored (error); }); } private async string? validate_token (string user_token) { var msg = new Soup.Message ("GET", @"$(this.url)/1/validate-token"); msg.request_headers.append ("Authorization", @"Token $user_token"); try { var in_stream = yield session.send_async (msg, 0, null); switch (msg.status_code) { case Soup.Status.OK: var parser = new Json.Parser (); yield parser.load_from_stream_async (in_stream); var root = parser.get_root (); if (root == null) return _("Invalid Token"); var obj = root.get_object (); if (obj == null) return _("Invalid Token"); if (!obj.has_member ("valid") || !obj.get_boolean_member ("valid")) return _("Invalid Token"); if (!obj.has_member ("user_name")) return _("Invalid Token"); var user_name = obj.get_string_member ("user_name"); account_manager.add (this.scrobbler_provider, user_name, user_token, this.url); break; default: // translators: the variable is an error message string message = _("Couldn't validate token: %s").printf (@"$(msg.status_code) $(msg.reason_phrase)"); critical (message); return message; } } catch (Error e) { string message = _("Couldn't validate token: %s").printf (e.message); critical (message); return message; } return null; } } turntable/src/Views/MalojaPage.vala000066400000000000000000000026541512353730000176150ustar00rootroot00000000000000public class Turntable.Views.MalojaPage : Views.ListenBrainzPage { protected override Scrobbling.Manager.Provider scrobbler_provider { get { return MALOJA; }} private string _url = ""; public override string url { get { return _url; } set { bool error = false; string normalized_value = value.contains ("://") ? value : @"https://$value"; if (!normalized_value.contains (".")) { error = true; } else if (_url != normalized_value) { try { if (GLib.Uri.is_valid (normalized_value, GLib.UriFlags.NONE)) { var uri = GLib.Uri.parse (normalized_value, GLib.UriFlags.NONE); _url = GLib.Uri.build ( GLib.UriFlags.NONE, "https", uri.get_userinfo (), uri.get_host (), uri.get_port (), "/apis/listenbrainz", null, null ).to_string (); } else { error = true; } } catch { error = true; } } url_entry_valid = !error; } } protected override void on_url_row_changed (Gtk.Editable url_row_editable) { this.url = url_row_editable.text.strip (); } protected override void on_token_changed (Gtk.Editable token_row_editable) { this.token = token_row_editable.text.strip (); token_row_valid = this.token != ""; } construct { this.title = Scrobbling.Manager.Provider.MALOJA.to_string (); // translators: host as in a web server; entry title url_row.title = _("Host"); url_row.text = this.url = ""; } } turntable/src/Views/OfflineScrobbling.vala000066400000000000000000000144051512353730000212010ustar00rootroot00000000000000public class Turntable.Views.OfflineScrobbling : Adw.NavigationPage { public class PayloadObject : GLib.Object { public string track { get; private set; } public string artist { get; private set; } public string? album { get; private set; default = null; } public GLib.DateTime date { get; private set; } public string payload_string { get; private set; } public PayloadObject (string payload_string) { this.payload_string = payload_string; var parser = new Json.Parser (); try { parser.load_from_data (payload_string, -1); var root = parser.get_root (); if (root == null) assert_not_reached (); var obj = root.get_object (); if (obj == null) assert_not_reached (); if (!obj.has_member ("track") || !obj.has_member ("artist") || !obj.has_member ("date")) assert_not_reached (); this.track = obj.get_string_member ("track"); this.artist = obj.get_string_member ("artist"); if (obj.has_member ("album")) this.album = obj.get_string_member ("album"); this.date = new GLib.DateTime.from_iso8601 (obj.get_string_member ("date"), null); } catch { assert_not_reached (); } } } public class PayloadRow : Adw.ActionRow { public signal void removed (PayloadObject payload); public unowned PayloadObject payload { get; private set; } construct { this.add_css_class ("card"); Gtk.Button delete_button = new Gtk.Button.from_icon_name ("user-trash-symbolic") { valign = Gtk.Align.CENTER, halign = Gtk.Align.CENTER, // translators: tooltip on offline scrobbles row button tooltip_text = _("Remove Scrobble"), css_classes = { "flat", "error" } }; delete_button.clicked.connect (on_delete); this.add_suffix (delete_button); } public void populate (PayloadObject payload_object) { this.payload = payload_object; this.title = payload_object.track; this.subtitle = "%s%s\n%s".printf ( payload_object.artist, payload_object.album == null ? "" : @"- $(payload_object.album)", payload_object.date.format ("%B %e, %Y · %R").replace (" ", "") ); } private void on_delete () { removed (this.payload); } } Gtk.Button sync_now; Gtk.ListView listview; Gtk.NoSelection selection; GLib.ListStore store; Gtk.Stack stack; construct { this.title = _("Offline Scrobbling"); Gtk.SignalListItemFactory signallistitemfactory = new Gtk.SignalListItemFactory (); signallistitemfactory.setup.connect (setup_listitem_cb); signallistitemfactory.bind.connect (bind_listitem_cb); store = new GLib.ListStore (typeof (PayloadObject)); selection = new Gtk.NoSelection (store); listview = new Gtk.ListView (selection, signallistitemfactory) { single_click_activate = false, overflow = VISIBLE }; // grid.activate.connect (on_item_activated); listview.remove_css_class ("view"); listview.add_css_class ("clear-view"); // translators: button label that submits offline scrobbles sync_now = new Gtk.Button.with_label ("Submit") { css_classes = { "suggested-action" } }; sync_now.clicked.connect (sync_now_clicked); stack = new Gtk.Stack () { vexpand = true, hexpand = true }; stack.add_named ( new Gtk.ScrolledWindow () { child = new Adw.ClampScrollable () { child = listview, maximum_size = 568, tightening_threshold = 200, overflow = HIDDEN, vexpand = true } }, "content" ); stack.add_named ( new Adw.Spinner () { halign = CENTER, valign = CENTER, width_request = 48, height_request = 48 }, "loading" ); stack.add_named ( new Adw.StatusPage () { icon_name = "background-app-ghost-symbolic", // translators: shown when there's 0 offline scrobbles in the queue title = _("No Offline Scrobbles") }, "empty" ); update_batch_in_progress (); var toolbar_view = new Adw.ToolbarView () { content = stack }; var header = new Adw.HeaderBar (); header.pack_end (sync_now); toolbar_view.add_top_bar (header); this.child = toolbar_view; settings.notify["offline-scrobbling"].connect (update_sync_now_sensitivity); application.notify["batch-in-progress"].connect (update_batch_in_progress); store.items_changed.connect (on_store_changed); populate_list (); } private void update_batch_in_progress () { update_sync_now_sensitivity (); listview.sensitive = !application.batch_in_progress; if (application.batch_in_progress) { stack.visible_child_name = "loading"; } else if (store.n_items > 0) { populate_list (); on_store_changed (); } else { stack.visible_child_name = "empty"; } } private void sync_now_clicked () { scrobbling_manager.submit_offline_scrobbles (); } private void update_sync_now_sensitivity () { sync_now.sensitive = settings.offline_scrobbling && network_monitor.network_available && !application.batch_in_progress && store.n_items > 0; } private void setup_listitem_cb (GLib.Object obj) { Gtk.ListItem list_item = (Gtk.ListItem) obj; var row = new PayloadRow (); list_item.set_child (row); } private void bind_listitem_cb (GLib.Object item) { var payload_object = (PayloadObject) ((Gtk.ListItem) item).item; var widget = (PayloadRow) ((Gtk.ListItem) item).child; widget.populate (payload_object); widget.removed.connect (remove_scrobble); var gtklistitemwidget = widget.get_parent (); if (gtklistitemwidget != null) { gtklistitemwidget.remove_css_class ("activatable"); gtklistitemwidget.margin_top = gtklistitemwidget.margin_bottom = 3; } } private void populate_list () { PayloadObject[] objects = {}; string[] offline_scrobbles = settings.get_strv ("offline-scrobbles"); foreach (string scrobble in offline_scrobbles) { objects += new PayloadObject (scrobble); } store.splice (0, store.n_items, objects); update_sync_now_sensitivity (); } private void remove_scrobble (PayloadObject payload) { uint pos; if (store.find (payload, out pos)) { store.remove (pos); } string[] offline_scrobbles = {}; foreach (string offline_scrobble in settings.get_strv ("offline-scrobbles")) { if (offline_scrobble != payload.payload_string) offline_scrobbles += offline_scrobble; } settings.set_strv ("offline-scrobbles", offline_scrobbles); update_sync_now_sensitivity (); } private void on_store_changed () { stack.visible_child_name = store.n_items > 0 ? "content" : "empty"; } } turntable/src/Views/Preferences.vala000066400000000000000000000103371512353730000200530ustar00rootroot00000000000000public class Turntable.Views.Preferences : Adw.PreferencesDialog { Adw.SwitchRow autostart; Adw.SwitchRow background; Adw.SwitchRow hidden; private void update_hidden (bool active_val = hidden.active) { bool enabled = autostart.active && background.active; bool active = enabled && active_val; // translators: switch description on "Start Hidden" notifying the user that they need to enable the other options first hidden.subtitle = enabled ? null : _("Requires both running on startup and in background"); hidden.sensitive = enabled; if (active != hidden.active) hidden.active = active; // needed so it doesn't trigger out running_changed } construct { this.title = _("Preferences"); this.can_close = false; this.close_attempt.connect (on_close); var main_page = new Adw.PreferencesPage () { icon_name = "settings-symbolic", title = _("Preferences") }; autostart = new Adw.SwitchRow () { // translators: switch title, enables autostart on boot title = _("Run on Startup") }; background = new Adw.SwitchRow () { // translators: switch title, enables the background portal title = _("Keep Running in Background") }; hidden = new Adw.SwitchRow () { // translators: switch title, starts the app hidden if it starts on boot title = _("Start Hidden") }; reset_all_toggles (); autostart.notify["active"].connect (changed_running); hidden.notify["active"].connect (changed_running); background.notify["active"].connect (changed_running); // var autoselect_row = new Adw.ActionRow () { // activatable = false, // title = _("Client Auto-select Mode"), // description = _("Selection criteria for when there are multiple MRPIS clients available and %s has to choose one automatically.") // }; // var toggle_group = new Adw.ToggelGroup (); // toggle_group.add (new Adw.Toggle () { name = "playing", label = _() }); // toggle_group.add (new Adw.Toggle () { name = "allowlist", label = _("Allowlist") }); // var prefix_group = new Adw.PreferencesGroup (); // prefix_group.add (autostart); // main_page.add (prefix_group); var main_group = new Adw.PreferencesGroup () { // translators: Running settings group title, like "Running in the background" title = _("Running"), // translators: Running settings group description, the variable is the app name (Turntable), // the purpose of this description is to push users towards the CLI version of // Turntable if they want to scrobble in the background description = _("While it's possible to have %s run in the background for constant scrobbling, it's recommended to use the CLI instead as it's much more performant, skips initializing the GUI code entirely and can lock to specific MPRIS clients.").printf (Build.NAME) }; main_group.add (background); main_group.add (autostart); main_group.add (hidden); main_page.add (main_group); this.add (main_page); } private bool running_changed = false; private void changed_running () { update_hidden (); running_changed = true; } // let's just do it in one go so we only send 1 request private async bool save () { // while it might be tempting to check if they actually changed, // we need to remember that people often backup and restore their gsettings // and this would cause issues here as it would never request the portal. // Instead we will check if anything was switched at all (even if switched back). if (!running_changed) return true; try { if (yield application.request_autostart (autostart.active, hidden.active)) { settings.autostart = autostart.active; settings.start_hidden = hidden.active; settings.run_in_background = background.active; } } catch (Error e) { reset_all_toggles (); warning (@"Couldn't set background portal: $(e.message) $(e.code)"); on_error (e.message); return false; } return true; } private void reset_all_toggles () { background.active = settings.run_in_background; autostart.active = settings.autostart; update_hidden (settings.start_hidden); running_changed = false; } private void on_error (string error) { this.add_toast (new Adw.Toast (error)); } private void on_close () { save.begin ((obj, res) => { if (save.end (res)) this.force_close (); }); } } turntable/src/Views/ProviderPage.vala000066400000000000000000000015501512353730000201760ustar00rootroot00000000000000public class Turntable.Views.ProviderPage : Adw.NavigationPage { public signal void errored (string error_message); protected Gtk.Button add_button { get; set; } protected Adw.PreferencesPage page { get; set; } public Soup.Session session { get; set; } construct { page = new Adw.PreferencesPage (); add_button = new Gtk.Button.with_label ("Continue") { sensitive = false, css_classes = {"pill", "suggested-action" }, valign = Gtk.Align.CENTER, halign = Gtk.Align.CENTER, margin_top = 8, margin_bottom = 8 }; add_button.clicked.connect (on_continue); var toolbar_view = new Adw.ToolbarView () { content = page }; toolbar_view.add_top_bar (new Adw.HeaderBar ()); toolbar_view.add_bottom_bar (add_button); this.child = toolbar_view; } protected virtual void update_validity () {} protected virtual void on_continue () {} } turntable/src/Views/ScrobblerSetup.vala000066400000000000000000000336261512353730000205560ustar00rootroot00000000000000public class Turntable.Views.ScrobblerSetup : Adw.PreferencesDialog { protected Soup.Session session { get; set; } public class ScrobblerRow : Adw.ActionRow { public signal void added (Scrobbling.Manager.Provider provider); public signal void trashed (Scrobbling.Manager.Provider provider); public Scrobbling.Manager.Provider provider { get; set; } public enum State { NEW, EXISTS, LOADING; } private State _state = NEW; public State state { get { return _state; } set { switch (value) { case NEW: trash_button.visible = false; spinner.visible = false; next_icon.visible = true; this.activatable = true; break; case EXISTS: trash_button.visible = true; spinner.visible = false; next_icon.visible = false; this.activatable = false; break; default: trash_button.visible = false; spinner.visible = true; next_icon.visible = false; this.activatable = false; break; } _state = value; } } Gtk.Button trash_button; Gtk.Image next_icon; Adw.Spinner spinner; public ScrobblerRow (Scrobbling.Manager.Provider provider, bool exists) { this.provider = provider; this.title = provider.to_string (); this.add_prefix (new Gtk.Image.from_icon_name (provider.to_icon_name ()) { icon_size = Gtk.IconSize.LARGE }); spinner = new Adw.Spinner (); next_icon = new Gtk.Image.from_icon_name (Gtk.Widget.get_default_direction () == Gtk.TextDirection.RTL ? "left-large-symbolic" : "right-large-symbolic"); trash_button = new Gtk.Button.from_icon_name ("user-trash-symbolic") { valign = Gtk.Align.CENTER, halign = Gtk.Align.CENTER, // translators: variable is a scrobbler e.g. ListenBrainz tooltip_text = _("Forget %s Account").printf (this.title), css_classes = { "flat", "error" } }; trash_button.clicked.connect (on_trash); this.state = exists ? State.EXISTS : State.NEW; this.add_suffix (trash_button); this.add_suffix (next_icon); this.add_suffix (spinner); this.activated.connect (on_activate); } private void on_trash () { trashed (this.provider); } private void on_activate () { added (this.provider); } } // private string generate_all_scrobblers_list () { // GLib.StringBuilder all_scrobblers = new GLib.StringBuilder (); // int total_providers = Scrobbling.Manager.ALL_PROVIDERS.length; // for (int i = 0; i < total_providers; i++) { // all_scrobblers.append (Scrobbling.Manager.ALL_PROVIDERS[i].to_string ()); // if (i < total_providers - 2) { // all_scrobblers.append (", "); // } else if (i < total_providers - 1) { // all_scrobblers.append (" & "); // } // } // return all_scrobblers.str; // } Adw.SwitchRow mbid_row; Adw.SwitchRow now_playing_row; Gtk.Switch offline_scrobbling_switch; string win_id; GLib.HashTable provider_rows = new GLib.HashTable (str_hash, str_equal); construct { session = new Soup.Session () { user_agent = @"$(Build.NAME)/$(Build.VERSION) libsoup/$(Soup.get_major_version()).$(Soup.get_minor_version()).$(Soup.get_micro_version()) ($(Soup.MAJOR_VERSION).$(Soup.MINOR_VERSION).$(Soup.MICRO_VERSION))" // vala-lint=line-length }; // translators: probably leave it as is unless there's a way to describe it accurately this.title = _("Scrobblers"); var main_page = new Adw.PreferencesPage () { icon_name = "network-server-symbolic", // translators: scrobbling dialog tab title of the page that allows you to setup your accounts // services as in "Scrobbling Services", if it's easier, translate it into "Platforms" or "Providers" title = _("Services"), // translators: warning shown in the scrobbler setup window. Leave MPRIS as is. The variable is the app name (Turntable) description = _("Track your music by scrobbling your MPRIS clients. By connecting your account, MPRIS information will be sent to that service when you reach the minimum listening time. To protect your privacy, %s requires you to opt-in scrobbling per MPRIS client.").printf (Build.NAME) }; var main_group = new Adw.PreferencesGroup (); foreach (var provider in Scrobbling.Manager.ALL_PROVIDERS) { var row = new ScrobblerRow (provider, false); row.added.connect (on_add); row.trashed.connect (on_trash); main_group.add (row); provider_rows.set (provider.to_string (), row); } main_page.add (main_group); this.add (main_page); var settings_page = new Adw.PreferencesPage () { icon_name = "settings-symbolic", title = _("Settings") }; var settings_group = new Adw.PreferencesGroup (); var offline_scrobbling_row = new Adw.ActionRow () { activatable = true, // translators: row title title = _("Offline Scrobbling"), // translators: row description subtitle = _("Scrobble even when you are offline and submit them automatically when you get online.") }; offline_scrobbling_row.activated.connect (open_offline_scrobbling_page); offline_scrobbling_switch = new Gtk.Switch () { active = settings.offline_scrobbling, valign = CENTER, halign = CENTER }; offline_scrobbling_switch.notify["active"].connect (offline_changed); offline_scrobbling_row.add_suffix (offline_scrobbling_switch); offline_scrobbling_row.add_suffix (new Gtk.Image.from_icon_name (Gtk.Widget.get_default_direction () == Gtk.TextDirection.RTL ? "left-large-symbolic" : "right-large-symbolic")); settings_group.add (offline_scrobbling_row); now_playing_row = new Adw.SwitchRow () { active = settings.now_playing, // translators: as explained later, "Now Playing" means the currently playing song even if it hasn't hit the scrobbling mark, please make sure they are not confused with each other, this doesn't mean scrobbling. title = _("Submit Now Playing"), // translators: switch description subtitle = _("Indicate that you started listening to a track.") }; now_playing_row.notify["active"].connect (np_changed); settings_group.add (now_playing_row); mbid_row = new Adw.SwitchRow () { active = settings.mbid_required, // translators: switch title; lookup = search, fetch, request title = _("Lookup Metadata on MusicBrainz before Scrobbling"), // translators: switch description; untagged as in music files missing metadata like artist, album etc subtitle = _("Recommended for non-curated clients or untagged music libraries as it will fix and complete metadata but it will also prevent scrobbling tracks not found in the MusicBrainz library.") }; mbid_row.notify["active"].connect (mbid_required_changed); settings_group.add (mbid_row); settings_page.add (settings_group); this.add (settings_page); win_id = ((Views.Window) application.active_window).uuid; application.token_received[win_id].connect (on_token_received); account_manager.accounts_changed.connect (update_row_states); update_row_states (); var experiments_page = new Adw.PreferencesPage () { icon_name = "funnel-symbolic", // translators: as in experimental features, page title title = _("Experiments") }; var experiments_group = new Adw.PreferencesGroup (); var wrapped_row = new Adw.ActionRow () { // translators: as in Spotify Wrapped, leave it as is if possible as it's more recognizable title = _("Wrapped"), // translators: Wrapped feature, row subtitle explaining what it does briefly subtitle = _("Your scrobbling year-in-review"), activatable = true }; wrapped_row.add_prefix (new Gtk.Image.from_icon_name ("view-wrapped-symbolic") {icon_size = Gtk.IconSize.LARGE}); wrapped_row.add_suffix (new Gtk.Image.from_icon_name (Gtk.Widget.get_default_direction () == Gtk.TextDirection.RTL ? "left-large-symbolic" : "right-large-symbolic")); wrapped_row.activated.connect (open_wrapped); experiments_group.add (wrapped_row); experiments_page.add (experiments_group); this.add (experiments_page); } private void open_wrapped () { this.push_subpage (new Views.Wrapped ()); } private void open_offline_scrobbling_page () { this.push_subpage (new Views.OfflineScrobbling ()); } private void offline_changed () { settings.offline_scrobbling = offline_scrobbling_switch.active; } private void np_changed () { settings.now_playing = now_playing_row.active; } private void mbid_required_changed () { settings.mbid_required = mbid_row.active; } private void update_row_states () { debug ("Updating Row States"); provider_rows.foreach ((provider, row) => { if (row.state != ScrobblerRow.State.LOADING) { bool exists = account_manager.accounts.contains (provider); row.state = exists ? ScrobblerRow.State.EXISTS : ScrobblerRow.State.NEW; string subtitle = ""; if (exists) { var acc = account_manager.accounts.get (provider); subtitle = acc.username; if (acc.custom_url != null) { string custom_url = acc.custom_url; try { custom_url = GLib.Uri.parse (custom_url, GLib.UriFlags.NONE).get_host (); } catch { custom_url = custom_url.replace ("https://", ""); } subtitle = @"$subtitle - $custom_url"; } } row.subtitle = subtitle; } }); } private void on_trash (Scrobbling.Manager.Provider provider) { debug ("Forgetting %s", provider.to_string ()); var dlg = new Adw.AlertDialog ( _("Forget %s Account?").printf (provider.to_string ()), // translators: dialog description _("This won't affect your submitted scrobbles.") ); dlg.add_responses ( "cancel", _("_Cancel"), "forget", _("_Forget") ); dlg.set_default_response ("forget"); dlg.set_response_appearance ("forget", Adw.ResponseAppearance.DESTRUCTIVE); dlg.choose.begin (this, null, (obj, res) => { if (dlg.choose.end (res) == "forget") { account_manager.remove (provider); } }); } bool last_fm_awaiting_token = false; bool libre_fm_awaiting_token = false; private void on_add (ScrobblerRow row, Scrobbling.Manager.Provider provider) { debug ("Selected %s", provider.to_string ()); switch (provider) { case LISTENBRAINZ: var page = new Views.ListenBrainzPage () { session = session }; page.done.connect (on_page_done); page.errored.connect (on_error); this.push_subpage (page); break; case MALOJA: var page = new Views.MalojaPage () { session = session }; page.done.connect (on_page_done); page.errored.connect (on_error); this.push_subpage (page); break; case LASTFM: row.state = LOADING; last_fm_awaiting_token = true; Utils.Host.open_in_default_app.begin (@"https://www.last.fm/api/auth/?api_key=$(Build.LASTFM_KEY)&cb=turntable://lastfm/$win_id", application.active_window); break; case LIBREFM: var page = new Views.LibreFMPage () { session = session }; page.chose_url.connect (on_librefm_chose); this.push_subpage (page); break; default: assert_not_reached (); } } private void on_page_done () { this.pop_subpage (); } string librefm_url = "https://libre.fm"; private void on_librefm_chose (string page_librefm_url) { this.pop_subpage (); librefm_url = page_librefm_url; provider_rows.get (Scrobbling.Manager.Provider.LIBREFM.to_string ()).state = LOADING; libre_fm_awaiting_token = true; Utils.Host.open_in_default_app.begin (@"$librefm_url/api/auth/?api_key=$(Build.LIBREFM_KEY)&cb=turntable://librefm/$win_id", application.active_window); } private async string? do_last_fm_step_2 (string token, bool libre) { var provider = libre ? Scrobbling.Manager.Provider.LIBREFM : Scrobbling.Manager.Provider.LASTFM; debug ("%s Begin Step 2", provider.to_string ()); var sk_params = new GLib.HashTable (str_hash, str_equal); sk_params.set ("api_key", libre ? Build.LIBREFM_KEY : Build.LASTFM_KEY); sk_params.set ("method", "auth.getSession"); sk_params.set ("token", token); string signature = Utils.Host.lfm_signature (sk_params, libre ? Build.LIBREFM_SECRET : Build.LASTFM_SECRET); GLib.StringBuilder query = new GLib.StringBuilder (); sk_params.foreach ((k, v) => { query.append (@"$k=$v&"); }); string final_url = libre ? librefm_url : "https://ws.audioscrobbler.com"; var msg = new Soup.Message ("POST", @"$final_url/2.0/?$(query.str)api_sig=$signature&format=json"); try { var in_stream = yield session.send_async (msg, 0, null); var parser = new Json.Parser (); yield parser.load_from_stream_async (in_stream); var root = parser.get_root (); // translators: error message when lastfm/librefm token validation fails if (root == null) return _("Invalid Session"); var obj = root.get_object (); if (obj == null) return _("Invalid Session"); if (!obj.has_member ("session")) return _("Invalid Session"); var session_obj = obj.get_object_member ("session"); if (!session_obj.has_member ("name") || !session_obj.has_member ("key")) return _("Invalid Session"); var name = session_obj.get_string_member ("name"); var sk = session_obj.get_string_member ("key"); provider_rows.get (provider.to_string ()).state = EXISTS; account_manager.add (provider, name, sk, libre ? librefm_url : null); return null; } catch (Error e) { provider_rows.get (provider.to_string ()).state = NEW; // translators: the variable is an error message return _("Couldn't get session: %s").printf (e.message); } } private void on_token_received (Scrobbling.Manager.Provider provider, string token) { debug ("Received token for %s", provider.to_string ()); switch (provider) { case LASTFM: if (!last_fm_awaiting_token) return; last_fm_awaiting_token = false; break; case LIBREFM: if (!libre_fm_awaiting_token) return; libre_fm_awaiting_token = false; break; default: return; } do_last_fm_step_2.begin (token, provider == LIBREFM, (obj, res) => { string? error = do_last_fm_step_2.end (res); if (error == null) return; on_error (error); }); } private void on_error (string error) { this.add_toast (new Adw.Toast (error)); } public override void closed () { base.closed (); account_manager.save (); } } turntable/src/Views/Window.vala000066400000000000000000001003501512353730000170540ustar00rootroot00000000000000public class Turntable.Views.Window : Adw.ApplicationWindow { GLib.SimpleAction toggle_orientation_action; GLib.SimpleAction cover_style_action; GLib.SimpleAction progressscale_style_action; GLib.SimpleAction component_progressbin_action; GLib.SimpleAction component_extract_colors_action; GLib.SimpleAction hide_client_icon_collapsed_action; GLib.SimpleAction window_style_action; GLib.SimpleAction client_icon_style_action; GLib.SimpleAction component_cover_fit_action; GLib.SimpleAction meta_dim_action; GLib.SimpleAction text_size_action; GLib.SimpleAction cover_size_action; GLib.SimpleAction cover_scaling_action; GLib.SimpleAction component_tonearm_action; GLib.SimpleAction component_center_text_action; GLib.SimpleAction component_more_controls_action; public string uuid { get; private set; } private bool _collapsed = false; public bool collapsed { get { return _collapsed; } set { if (_collapsed != value) { settings.collapsed_controls = _collapsed = value; non_art_revealer.reveal_child = !value; // only shrink the window when its slightly over // the main content size but not too much as to // not disturb users who set their windows to // specific sizes if ( value && this.default_width - main_box.get_width () < 100 && this.default_height - main_box.get_height () < 100 ) { window_animation.play (); } controls_overlay.collapsed = value; this.focus_widget = null; controls_overlay.hide_overlay (); update_client_icon_revealed (); } } } // dummy property for the animation // target to avoid memory leaks public double shrink_window_size { get { return window_animation.value; } set { switch (this.orientation) { case HORIZONTAL: int w = main_box.get_width (); if (w != this.default_width) this.default_width = w; break; default: int h = main_box.get_height (); if (h != this.default_height) this.default_height = h; break; } } } private void update_client_icon_revealed () { this.prog.client_icon_revealed = !settings.hide_client_icon_collapsed || !this.collapsed; } ~Window () { update_player (null); debug ("Destroying: %s", uuid); } public enum Style { WINDOW, OSD, TRANSPARENT, BLUR; public string to_string () { switch (this) { case OSD: return "osd"; case TRANSPARENT: return "transparent"; case BLUR: return "blur"; default: return "window"; } } public static Style from_string (string string_style) { switch (string_style.down ()) { case "osd": return OSD; case "transparent": return TRANSPARENT; case "blur": return BLUR; default: return WINDOW; } } } public enum Size { SMALL, REGULAR, BIG; public string to_string () { switch (this) { case SMALL: return "small"; case BIG: return "big"; default: return "regular"; } } public static Size from_string (string string_size) { switch (string_size.down ()) { case "small": return SMALL; case "big": return BIG; default: return REGULAR; } } } private Size _cover_size = Size.REGULAR; public Size cover_size { get { return _cover_size; } set { if (value != _cover_size) { _cover_size = value; switch (_cover_size) { case SMALL: art_pic.size = 124; prog.client_icon_large = false; break; case BIG: art_pic.size = 256; prog.client_icon_large = true; break; default: art_pic.size = 192; prog.client_icon_large = true; break; } update_orientation (); // update_offset (); } } } private Size _text_size = Size.REGULAR; public Size text_size { get { return _text_size; } set { if (value != _text_size) { switch (_text_size) { case SMALL: title_label.remove_css_class ("title-3"); album_label.remove_css_class ("smaller-label"); artist_label.remove_css_class ("smaller-label"); break; case BIG: title_label.remove_css_class ("title-1"); album_label.remove_css_class ("bigger-label"); artist_label.remove_css_class ("bigger-label"); break; default: title_label.remove_css_class ("title-2"); break; } _text_size = value; switch (_text_size) { case SMALL: title_label.add_css_class ("title-3"); album_label.add_css_class ("smaller-label"); artist_label.add_css_class ("smaller-label"); break; case BIG: title_label.add_css_class ("title-1"); album_label.add_css_class ("bigger-label"); artist_label.add_css_class ("bigger-label"); break; default: title_label.add_css_class ("title-2"); break; } } } } private Style _window_style = Style.WINDOW; public Style window_style { get { return _window_style; } set { if (value != _window_style) { // reset to initial state switch (_window_style) { case Style.BLUR: prog.cover = null; main_box.remove_css_class ("osd"); break; case Style.WINDOW: break; case Style.TRANSPARENT: this.add_css_class ("csd"); this.remove_css_class (Style.TRANSPARENT.to_string ()); break; default: string old_css_class = _window_style.to_string (); if (this.has_css_class (old_css_class)) this.remove_css_class (old_css_class); break; } _window_style = value; string window_style_string = value.to_string (); switch (_window_style) { case Style.BLUR: on_cover_changed (); main_box.add_css_class ("osd"); return; case Style.WINDOW: return; case Style.TRANSPARENT: this.remove_css_class ("csd"); break; default: break; } this.add_css_class (window_style_string); } } } public string? song_title { set { // translators: default string when title is missing title_label.content = value == null ? _("Unknown Title") : value; } } // translators: default string when artist is missing private string _artist = _("Unknown Artist"); public string? artist { get { return _artist; } set { string old_val = _artist; _artist = value == null ? _("Unknown Artist") : value; if (old_val != _artist) update_album_artist_title (); } } // translators: default string when album is missing private string _album = _("Unknown Album"); public string? album { get { return _album; } set { string old_val = _album; _album = value == null ? _("Unknown Album") : value; if (old_val != _album) update_album_artist_title (); } } public string? art { set { if (value == null) { art_pic.file_path = null; } else { art_pic.file_path = value; } } } private int64 _position = 0; public int64 position { get { return _position; } set { if (this.length == 0) { progress_scale.playtime = _position = 0; progress_scale.progress = tonearm.progress = prog.progress = 0; } else { #if SCROBBLING if (value == 0 && this.player != null && this.player.loop_status == Mpris.Entry.LoopStatus.TRACK && _position > 0) { this.length = this.length; // re-trigger it } #endif progress_scale.playtime = _position = value; progress_scale.progress = tonearm.progress = prog.progress = (double)value / (double)this.length; } } } private int64 _length = 0; public int64 length { get { return _length; } set { progress_scale.length = _length = value; progress_scale.progress = tonearm.progress = prog.progress = value == 0 ? 0 : (double)this.position / (double)value; #if SCROBBLING if (value > 0) { add_to_scrobbler (); if (this.playing) update_scrobbler_playing (); } #endif } } private bool _playing = false; public bool playing { get { return _playing; } set { _playing = mpris_controls.playing = art_pic.turntable_playing = value; #if SCROBBLING update_scrobbler_playing (); #endif } } private Gtk.Orientation _orientation = Gtk.Orientation.HORIZONTAL; public Gtk.Orientation orientation { get { return _orientation; } set { if (value != _orientation) { _orientation = value; update_orientation (); } } } private void update_orientation () { art_pic.orientation = main_box.orientation = prog.orientation = this.orientation; mpris_controls.newline = this.orientation == VERTICAL; album_label.force_width = artist_label.force_width = title_label.force_width = this.orientation == HORIZONTAL; non_art_revealer.transition_type = this.orientation == HORIZONTAL ? (Gtk.Widget.get_default_direction () == Gtk.TextDirection.RTL ? Gtk.RevealerTransitionType.SLIDE_LEFT : Gtk.RevealerTransitionType.SLIDE_RIGHT) : Gtk.RevealerTransitionType.SLIDE_DOWN; controls_overlay.update_toggle_controls_icon (); update_album_artist_title (); } private void update_album_artist_title () { if (this.orientation == Gtk.Orientation.VERTICAL || this.cover_size == Size.SMALL) { album_label.visible = false; artist_label.content = @"$(this.artist) - $(this.album)"; } else { artist_label.content = this.artist; album_label.content = this.album; album_label.visible = true; } } public Window (Adw.Application app) { this.application = app; } public Widgets.Cover.Style cover_style { get { return art_pic.style; } set { if (value != art_pic.style ) { art_pic.style = value; art_pic.turntable_playing = this.playing; // update_offset (); } } } public Widgets.ProgressScale.Style progressscale_style { get { return progress_scale.style; } set { if (value != progress_scale.style ) { progress_scale.style = value; } } } // private void update_offset () { // switch (this.cover_style) { // case Widgets.Cover.Style.SHADOW: // prog.offset = art_pic.size - (int32) (Widgets.Cover.FADE_WIDTH / 2); // break; // default: // prog.offset = 0; // break; // } // } private void update_extracted_colors () { prog.extracted_colors = art_pic.extracted_colors; } private inline void setup_window_size () { this.default_width = settings.get_int ("window-w"); this.default_height = settings.get_int ("window-h"); this.maximized = settings.get_boolean ("window-maximized"); this.notify["default-width"].connect (on_window_size_changed); this.notify["default-height"].connect (on_window_size_changed); this.notify["maximized"].connect (on_window_size_changed); } uint window_settings_timeout = 0; private void on_window_size_changed () { // non_art_box.visible = settings.orientation_horizontal ? this.default_width > 452 : this.default_height > 423; if (window_settings_timeout > 0) GLib.Source.remove (window_settings_timeout); window_settings_timeout = GLib.Timeout.add (2 * 1000, update_window_size_settings, Priority.LOW); } private bool update_window_size_settings () { settings.set_int ("window-h", this.default_height); settings.set_int ("window-w", this.default_width); settings.set_boolean ("window-maximized", this.maximized); window_settings_timeout = 0; return GLib.Source.REMOVE; } weak Mpris.Entry? player = null; Widgets.Marquee artist_label; Widgets.Marquee title_label; Widgets.Marquee album_label; Widgets.Cover art_pic; Widgets.Tonearm tonearm; // Gtk.Box non_art_box; Gtk.Revealer non_art_revealer; Widgets.ProgressBin prog; Gtk.Box main_box; Widgets.ControlsOverlay controls_overlay; Widgets.MPRISControls mpris_controls; Widgets.ProgressScale progress_scale; Adw.TimedAnimation window_animation; construct { this.uuid = GLib.Uuid.string_random (); this.icon_name = Build.DOMAIN; this.title = Build.NAME; this.close_request.connect (on_window_closed); #if GTK_4_20 // Somehow this worked for a bit but then // vala realized this is not in the vapi? // this.gravity = Gtk.WindowGravity.CENTER; this.set_property ("gravity", 4); #endif setup_window_size (); this.height_request = -1; this.width_request = -1; main_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 0) { valign = CENTER, halign = CENTER, css_classes = {"main-box"}, overflow = HIDDEN }; art_pic = new Widgets.Cover () { valign = Gtk.Align.START, halign = Gtk.Align.START }; art_pic.notify["extracted-colors"].connect (update_extracted_colors); controls_overlay = new Widgets.ControlsOverlay (art_pic); controls_overlay.toggle_controls.connect (toggle_controls); tonearm = new Widgets.Tonearm () { halign = CENTER, child = controls_overlay }; main_box.append (tonearm); Gtk.Box non_art_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 0) { hexpand = true, margin_top = 16, margin_bottom = 16, margin_end = 16, margin_start = 16 }; non_art_revealer = new Gtk.Revealer () { child = non_art_box, reveal_child = !settings.collapsed_controls }; main_box.append (non_art_revealer); title_label = new Widgets.Marquee () { css_classes = {"title-2"}, xalign = 0.0f }; artist_label = new Widgets.Marquee () { xalign = 0.0f }; album_label = new Widgets.Marquee () { xalign = 0.0f }; non_art_box.append (title_label); non_art_box.append (artist_label); non_art_box.append (album_label); prog = new Widgets.ProgressBin () { content = main_box }; progress_scale = new Widgets.ProgressScale () { vexpand = true }; progress_scale.progress_changed.connect (on_progress_changed); non_art_box.append (progress_scale); mpris_controls = new Widgets.MPRISControls () { hexpand = true, vexpand = true, halign = Gtk.Align.CENTER, valign = Gtk.Align.CENTER, }; mpris_controls.commanded.connect (mpris_command_received); non_art_box.append (mpris_controls); #if SCROBBLING var scrobbling_action = new GLib.SimpleAction ("open-scrobbling-setup", null); scrobbling_action.activate.connect (open_scrobbling_setup); this.add_action (scrobbling_action); #endif text_size_action = new GLib.SimpleAction.stateful ("text-size", GLib.VariantType.STRING, this.text_size.to_string ()); text_size_action.change_state.connect (on_change_text_size); this.add_action (text_size_action); cover_size_action = new GLib.SimpleAction.stateful ("cover-size", GLib.VariantType.STRING, this.cover_size.to_string ()); cover_size_action.change_state.connect (on_change_cover_size); this.add_action (cover_size_action); cover_scaling_action = new GLib.SimpleAction.stateful ("cover-scaling", GLib.VariantType.STRING, settings.cover_scaling.to_string ()); cover_scaling_action.change_state.connect (on_change_cover_scaling); this.add_action (cover_scaling_action); toggle_orientation_action = new GLib.SimpleAction.stateful ("toggle-orientation", GLib.VariantType.BOOLEAN, settings.orientation_horizontal); toggle_orientation_action.change_state.connect (on_toggle_orientation); this.add_action (toggle_orientation_action); cover_style_action = new GLib.SimpleAction.stateful ("cover-style", GLib.VariantType.STRING, this.cover_style.to_string ()); cover_style_action.change_state.connect (on_change_cover_style); this.add_action (cover_style_action); progressscale_style_action = new GLib.SimpleAction.stateful ("progressscale-style", GLib.VariantType.STRING, this.progressscale_style.to_string ()); progressscale_style_action.change_state.connect (on_change_progressscale_style); this.add_action (progressscale_style_action); component_extract_colors_action = new GLib.SimpleAction.stateful ("component-extract-colors", null, settings.component_extract_colors); component_extract_colors_action.change_state.connect (on_change_component_extract_colors); this.add_action (component_extract_colors_action); hide_client_icon_collapsed_action = new GLib.SimpleAction.stateful ("hide-client-icon-collapsed", null, settings.hide_client_icon_collapsed); hide_client_icon_collapsed_action.change_state.connect (on_change_hide_client_icon_collapsed); this.add_action (hide_client_icon_collapsed_action); meta_dim_action = new GLib.SimpleAction.stateful ("meta-dim", null, settings.meta_dim); meta_dim_action.change_state.connect (on_change_meta_dim); this.add_action (meta_dim_action); component_progressbin_action = new GLib.SimpleAction.stateful ("component-progressbin", null, settings.component_progressbin); component_progressbin_action.change_state.connect (on_change_component_progressbin); this.add_action (component_progressbin_action); window_style_action = new GLib.SimpleAction.stateful ("window-style", GLib.VariantType.STRING, this.window_style.to_string ()); window_style_action.change_state.connect (on_change_window_style); this.add_action (window_style_action); client_icon_style_action = new GLib.SimpleAction.stateful ("client-icon-style", GLib.VariantType.STRING, settings.client_icon_style); client_icon_style_action.change_state.connect (on_change_client_icon_style); this.add_action (client_icon_style_action); component_tonearm_action = new GLib.SimpleAction.stateful ("component-tonearm", null, settings.component_tonearm); component_tonearm_action.change_state.connect (on_change_component_tonearm); this.add_action (component_tonearm_action); component_center_text_action = new GLib.SimpleAction.stateful ("component-center-text", null, settings.component_center_text); component_center_text_action.change_state.connect (on_change_component_center_text); this.add_action (component_center_text_action); component_more_controls_action = new GLib.SimpleAction.stateful ("component-more-controls", null, settings.component_more_controls); component_more_controls_action.change_state.connect (on_change_component_more_controls); this.add_action (component_more_controls_action); component_cover_fit_action = new GLib.SimpleAction.stateful ("component-cover-fit", null, settings.component_cover_fit); component_cover_fit_action.change_state.connect (on_change_component_cover_fit); this.add_action (component_cover_fit_action); update_orientation (); update_from_settings (); this.content = new Gtk.WindowHandle () { child = prog }; controls_overlay.player_changed.connect (update_player); update_player (controls_overlay.last_player); // always ensure settings.notify["cover-style"].connect (update_cover_from_settings); settings.notify["progressscale-style"].connect (update_progressscale_from_settings); settings.notify["orientation-horizontal"].connect (update_orientation_from_settings); settings.notify["component-progressbin"].connect (update_progressbin_from_settings); settings.notify["component-extract-colors"].connect (update_extract_colors_from_settings); settings.notify["hide-client-icon-collapsed"].connect (update_hide_client_icon_collapsed_from_settings); settings.notify["window-style"].connect (update_window_from_settings); settings.notify["client-icon-style"].connect (update_client_icon_style_from_settings); settings.notify["component-tonearm"].connect (update_component_tonearm_from_settings); settings.notify["component-center-text"].connect (update_component_center_text_from_settings); settings.notify["component-more-controls"].connect (update_component_more_controls_from_settings); settings.notify["component-cover-fit"].connect (update_component_cover_fit_from_settings); settings.notify["meta-dim"].connect (update_meta_dim_from_settings); settings.notify["text-size"].connect (update_text_size_from_settings); settings.notify["cover-size"].connect (update_cover_size_from_settings); settings.notify["cover-scaling"].connect (update_cover_scaling_from_settings); #if SCROBBLING settings.notify["scrobbler-allowlist"].connect (update_scrobble_status); account_manager.accounts_changed.connect (update_scrobble_status); #endif art_pic.map.connect (on_mapped); Gtk.GestureClick click_gesture = new Gtk.GestureClick () { button = Gdk.BUTTON_PRIMARY }; click_gesture.pressed.connect (on_click); prog.add_controller (click_gesture); // art_pic.bind_property ("cover", prog, "cover", SYNC_CREATE); art_pic.notify["cover"].connect (on_cover_changed); window_animation = new Adw.TimedAnimation (this, 0.0, 1.0, 500, new Adw.PropertyAnimationTarget (this, "shrink-window-size")) { easing = Adw.Easing.EASE_IN_OUT }; } private bool on_window_closed () { this.hide_on_close = settings.run_in_background && application.get_windows ().length () == 1; return false; } private void on_cover_changed () { if (this.window_style != Style.BLUR) return; prog.cover = art_pic.cover; } private void on_click () { if (controls_overlay.hide_overlay ()) this.focus_widget = null; } private void on_progress_changed (double new_progress) { this.player.seek ((int64) ((new_progress * this.length) - this.position)); } private void mpris_command_received (Widgets.MPRISControls.Command command) { if (this.player == null || !this.player.can_control) return; switch (command) { case PLAY_PAUSE: this.player.play_pause (); break; case NEXT: this.player.next (); break; case PREVIOUS: this.player.back (); break; case SHUFFLE: this.player.toggle_shuffle (); break; case LOOP_NONE: this.player.loop_none (); break; case LOOP_PLAYLIST: this.player.loop_playlist (); break; case LOOP_TRACK: this.player.loop_track (); break; default: assert_not_reached (); } } private void on_mapped () { // update_offset (); art_pic.turntable_playing = this.playing; } #if SCROBBLING bool scrobble_enabled = false; private void update_scrobble_status () { bool new_val = account_manager.accounts.length > 0 && this.player != null && this.player.bus_namespace in settings.scrobbler_allowlist; if (scrobble_enabled != new_val) { scrobble_enabled = new_val; if (this.length > 0) { add_to_scrobbler (); update_scrobbler_playing (); } } } #endif private void update_from_settings () { update_orientation_from_settings (); update_cover_from_settings (); update_progressscale_from_settings (); update_progressbin_from_settings (); update_extract_colors_from_settings (); update_hide_client_icon_collapsed_from_settings (); update_window_from_settings (); update_client_icon_style_from_settings (); update_component_tonearm_from_settings (); update_component_cover_fit_from_settings (); update_meta_dim_from_settings (); update_text_size_from_settings (); update_cover_size_from_settings (); update_cover_scaling_from_settings (); update_component_center_text_from_settings (); update_component_more_controls_from_settings (); this.collapsed = settings.collapsed_controls; } private void update_cover_scaling_from_settings () { Widgets.Cover.Scaling new_size = Widgets.Cover.Scaling.from_string (settings.cover_scaling); art_pic.scaling_filter = new_size.to_filter (); cover_scaling_action.set_state (new_size.to_string ()); } private void update_cover_size_from_settings () { this.cover_size = Size.from_string (settings.cover_size); cover_size_action.set_state (this.cover_size.to_string ()); } private void update_text_size_from_settings () { this.text_size = Size.from_string (settings.text_size); text_size_action.set_state (this.text_size.to_string ()); } private void update_cover_from_settings () { this.cover_style = Widgets.Cover.Style.from_string (settings.cover_style); cover_style_action.set_state (this.cover_style.to_string ()); } private void update_progressscale_from_settings () { this.progressscale_style = Widgets.ProgressScale.Style.from_string (settings.progressscale_style); progressscale_style_action.set_state (this.progressscale_style.to_string ()); } private void update_window_from_settings () { this.window_style = Style.from_string (settings.window_style); window_style_action.set_state (this.window_style.to_string ()); } private void update_meta_dim_from_settings () { if (settings.meta_dim) { if (!artist_label.has_css_class ("dim-label")) artist_label.add_css_class ("dim-label"); if (!album_label.has_css_class ("dim-label")) album_label.add_css_class ("dim-label"); } else { if (artist_label.has_css_class ("dim-label")) artist_label.remove_css_class ("dim-label"); if (album_label.has_css_class ("dim-label")) album_label.remove_css_class ("dim-label"); } meta_dim_action.set_state (settings.meta_dim); } private void update_progressbin_from_settings () { this.prog.enabled = settings.component_progressbin; component_progressbin_action.set_state (this.prog.enabled); } private void update_extract_colors_from_settings () { this.prog.extract_colors_enabled = settings.component_extract_colors; component_extract_colors_action.set_state (this.prog.extract_colors_enabled); } private void update_hide_client_icon_collapsed_from_settings () { update_client_icon_revealed (); hide_client_icon_collapsed_action.set_state (settings.hide_client_icon_collapsed); } private void update_orientation_from_settings () { this.orientation = settings.orientation_horizontal ? Gtk.Orientation.HORIZONTAL : Gtk.Orientation.VERTICAL; toggle_orientation_action.set_state (settings.orientation_horizontal); } private void update_client_icon_style_from_settings () { // TODO: deprecated, remove in next major Widgets.ProgressBin.ClientIconStyle cis; if (settings.client_icon_style == "unset") { if (settings.get_boolean ("component-client-icon")) { cis = settings.get_boolean ("client-icon-style-symbolic") ? Widgets.ProgressBin.ClientIconStyle.SYMBOLIC : Widgets.ProgressBin.ClientIconStyle.FULL_COLOR; } else { cis = NONE; } settings.client_icon_style = cis.to_string (); } else { cis = Widgets.ProgressBin.ClientIconStyle.from_string (settings.client_icon_style); } this.prog.client_icon_style = cis; client_icon_style_action.set_state (cis.to_string ()); } private void update_component_tonearm_from_settings () { this.tonearm.enabled = settings.component_tonearm; component_tonearm_action.set_state (settings.component_tonearm); } private void update_component_center_text_from_settings () { album_label.xalign = artist_label.xalign = title_label.xalign = settings.component_center_text ? 0.5f : 0f; component_center_text_action.set_state (settings.component_center_text); } private void update_component_more_controls_from_settings () { mpris_controls.more_controls = settings.component_more_controls; component_more_controls_action.set_state (settings.component_more_controls); } private void update_component_cover_fit_from_settings () { this.art_pic.fit_cover = settings.component_cover_fit; component_cover_fit_action.set_state (settings.component_cover_fit); } #if SCROBBLING private void open_scrobbling_setup () { this.resizable = false; var dlg = new Views.ScrobblerSetup (); dlg.present (this); dlg.closed.connect (make_resizable); } #endif // hack to force present dialogs as windows public void make_resizable () { this.resizable = true; } GLib.Binding[] player_bindings = {}; private void update_player (Mpris.Entry? new_player) { debug ("[%s] Player Changed", uuid); #if SCROBBLING scrobbling_manager.clear_queue (uuid); scrobble_enabled = false; #endif this.player = new_player; foreach (var binding in player_bindings) { binding.unbind (); binding.unref (); } player_bindings = {}; if (new_player == null) { this.song_title = this.artist = this.album = this.art = prog.client_icon = prog.client_name = null; this.position = this.length = 0; this.playing = mpris_controls.can_control = false; return; } player_bindings += this.player.bind_property ("title", this, "song-title", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("artist", this, "artist", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("album", this, "album", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("art", this, "art", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("position", this, "position", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("length", this, "length", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("playing", this, "playing", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("can-go-next", mpris_controls, "can-go-next", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("can-go-back", mpris_controls, "can-go-back", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("can-control", mpris_controls, "can-control", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("shuffle", mpris_controls, "shuffle", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("loop-status", mpris_controls, "loop-status", GLib.BindingFlags.SYNC_CREATE); player_bindings += this.player.bind_property ("can-seek", progress_scale, "sensitive", GLib.BindingFlags.SYNC_CREATE); prog.client_icon = this.player.client_info_icon; prog.client_name = this.player.client_info_name; mpris_controls.grab_play_focus (); #if SCROBBLING update_scrobble_status (); #endif } #if SCROBBLING private void add_to_scrobbler () { if (this.player == null || this.player.length == 0 || !scrobble_enabled) return; scrobbling_manager.queue_payload ( uuid, this.player.bus_namespace, { this.player.title, this.player.artist, this.player.album }, this.player.length ); } private void update_scrobbler_playing () { if (this.player == null || this.player.length == 0 || !scrobble_enabled) return; scrobbling_manager.set_playing_for_id ( uuid, this.playing ); } #endif private void on_toggle_orientation (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.orientation_horizontal = value.get_boolean (); } private void on_change_text_size (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.text_size = value.get_string (); } private void on_change_cover_size (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.cover_size = value.get_string (); } private void on_change_cover_scaling (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.cover_scaling = value.get_string (); } private void on_change_cover_style (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.cover_style = value.get_string (); } private void on_change_progressscale_style (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.progressscale_style = value.get_string (); } private void on_change_meta_dim (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.meta_dim = value.get_boolean (); } private void on_change_component_progressbin (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.component_progressbin = value.get_boolean (); } private void on_change_component_extract_colors (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.component_extract_colors = value.get_boolean (); } private void on_change_hide_client_icon_collapsed (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.hide_client_icon_collapsed = value.get_boolean (); } private void on_change_window_style (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.window_style = value.get_string (); } private void on_change_client_icon_style (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.client_icon_style = value.get_string (); } private void on_change_component_tonearm (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.component_tonearm = value.get_boolean (); } private void on_change_component_center_text (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.component_center_text = value.get_boolean (); } private void on_change_component_more_controls (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.component_more_controls = value.get_boolean (); } private void on_change_component_cover_fit (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; settings.component_cover_fit = value.get_boolean (); } private void toggle_controls () { this.collapsed = !this.collapsed; } } turntable/src/Views/Wrapped.vala000066400000000000000000000504211512353730000172120ustar00rootroot00000000000000public class Turntable.Views.Wrapped : Adw.NavigationPage { ~Wrapped () { debug ("Destroying"); } enum Background { WINDOW, ACCENT, PRIDE, TRANS; public static Background from_string (string name) { switch (name.down ()) { case "accent": return ACCENT; case "pride": return PRIDE; case "trans": return TRANS; default: return WINDOW; } } public string to_string () { switch (this) { case ACCENT: return "style-accent"; case PRIDE: return "style-pride"; case TRANS: return "style-trans"; default: return "style-window"; } } } Background current_style = Background.WINDOW; private void on_change_style (GLib.SimpleAction action, GLib.Variant? value) { if (value == null) return; string current_style_class = current_style.to_string (); if (current_style_class != "" && this.has_css_class (current_style_class)) this.remove_css_class (current_style_class); current_style = Background.from_string (value.get_string ()); this.add_css_class (current_style.to_string ()); } public class ProviderRow : Adw.ActionRow { public signal void selected (Scrobbling.Manager.Provider provider); Scrobbling.Manager.Provider provider; public ProviderRow (Scrobbling.Manager.Provider provider) { this.provider = provider; this.add_prefix (new Gtk.Image.from_icon_name (provider.to_icon_name ()) { icon_size = Gtk.IconSize.LARGE }); this.add_suffix (new Gtk.Image.from_icon_name (Gtk.Widget.get_default_direction () == Gtk.TextDirection.RTL ? "left-large-symbolic" : "right-large-symbolic")); this.title = provider.to_string (); this.activatable = true; this.activated.connect (on_activate); } private void on_activate () { selected (this.provider); } ~ProviderRow () { debug ("Destroying"); } } Scrobbling.Manager.Provider? picked_provider = null; Gtk.Stack stack; Adw.StatusPage error_page; Gtk.Button try_again_button; Gtk.Button save_btn; Adw.Carousel carousel; GLib.Menu style_menu; Gtk.MenuButton style_button; construct { this.title = _("Wrapped"); var actions = new SimpleActionGroup (); actions.add_action_entries ( { {"change-style", on_change_style, "s"}, }, this ); this.insert_action_group ("wrapped", actions); style_menu = new GLib.Menu (); style_menu.append (_("Default"), "wrapped.change-style('window')"); // translators: Accent color style_menu.append (_("Accent"), "wrapped.change-style('accent')"); style_menu.append ("Pride", "wrapped.change-style('pride')"); style_menu.append ("Trans", "wrapped.change-style('trans')"); // translators: button shown in error message that repeats the action that led to this try_again_button = new Gtk.Button.with_label (_("Try Again")) { css_classes = { "suggested-action", "pill" }, halign = CENTER }; try_again_button.clicked.connect (do_wrap); error_page = new Adw.StatusPage () { icon_name = "sad-computer-symbolic", // translators: error message with wrapped (as in Spotify wrapped see other comments on this) title = _("Couldn't generate Wrapped"), child = try_again_button }; stack = new Gtk.Stack () { vexpand = true, hexpand = true }; stack.add_named ( new Adw.Spinner () { halign = CENTER, valign = CENTER, width_request = 48, height_request = 48 }, "loading" ); stack.add_named ( error_page, "error" ); var toolbar_view = new Adw.ToolbarView () { content = stack }; save_btn = new Gtk.Button.from_icon_name ("folder-download-symbolic") { // translators: as in save file tooltip_text = _("Save"), css_classes = {"suggested-action"}, visible = false }; save_btn.clicked.connect (on_save); style_button = new Gtk.MenuButton () { // translators: dropdown label for picking a window style in Wrapped experiment label = _("Style"), menu_model = style_menu, visible = false }; var header = new Adw.HeaderBar (); header.pack_end (save_btn); header.pack_start (style_button); toolbar_view.add_top_bar (header); this.child = toolbar_view; var eligible_providers = scrobbling_manager.get_providers_with_experiment (WRAPPED); if (eligible_providers.length == 0) { // translators: error message shown when trying to generate a Wrapped // but none of the accounts have the necessary functions show_error_page (_("Unfortunately, you don't have any eligible accounts"), false); } else if (eligible_providers.length == 1) { picked_provider = eligible_providers[0]; do_wrap (); } else { var chooser_group = new Adw.PreferencesGroup () { // translators: services as in "Scrobbling Services", if it's easier, // translate it into "Platforms" or "Providers" title = _("Service"), // translators: description of a group of rows, services as in "Scrobbling Services", // if it's easier, translate it into "Platforms" or "Providers", wrapped // as in Spotify Wrapped description = _("Choose which service to use for generating your Wrapped") }; foreach (var provider in eligible_providers) { var row = new ProviderRow (provider); row.selected.connect (on_row_activated); chooser_group.add (row); } stack.add_named ( new Gtk.ScrolledWindow () { child = new Adw.Clamp () { child = chooser_group, maximum_size = 568, tightening_threshold = 200, overflow = HIDDEN, vexpand = true } }, "chooser" ); stack.visible_child_name = "chooser"; } } private void on_row_activated (Scrobbling.Manager.Provider provider) { this.picked_provider = provider; do_wrap (); } private void do_wrap () { stack.visible_child_name = "loading"; if (!network_monitor.network_available) { // translators: error description when the user is not connected to the internet show_error_page (_("You are currently offline")); return; } else if (picked_provider == null) { show_error_page ("You shouldn't be seeing this, open an issue"); return; } scrobbling_manager.wrapped.begin (picked_provider, account_manager.accounts.get (picked_provider.to_string ()).username, 5, (obj, res) => { try { generate_wrapped (scrobbling_manager.wrapped.end (res)); } catch (Error e) { show_error_page (e.message); } }); } // https://github.com/GeopJr/Tuba/blob/main/src/Widgets/FocusPicture.vala public class SizedCoverPicture : Gtk.Widget, Gtk.Buildable, Gtk.Accessible { ulong paintable_invalidate_contents_signal = 0; ulong paintable_invalidate_size_signal = 0; Gdk.Paintable? _paintable = null; public Gdk.Paintable? paintable { get { return _paintable; } set { if (_paintable == value) return; bool size_changed = paintable_size_equal (value); clear_paintable (); _paintable = value; if (_paintable != null) { Gdk.PaintableFlags flags = _paintable.get_flags (); if (!(Gdk.PaintableFlags.STATIC_CONTENTS in flags)) paintable_invalidate_contents_signal = _paintable.invalidate_contents.connect (paintable_invalidate_contents); if (!(Gdk.PaintableFlags.STATIC_SIZE in flags)) paintable_invalidate_size_signal = _paintable.invalidate_size.connect (paintable_invalidate_size); } if (size_changed) { this.queue_resize (); } else { this.queue_draw (); } } } static construct { set_css_name ("picture"); set_accessible_role (Gtk.AccessibleRole.IMG); } construct { this.overflow = Gtk.Overflow.HIDDEN; } public override Gtk.SizeRequestMode get_request_mode () { return Gtk.SizeRequestMode.CONSTANT_SIZE; } public override void snapshot (Gtk.Snapshot snapshot) { if (_paintable == null) return; int width = this.get_width (); int height = this.get_height (); double ratio = _paintable.get_intrinsic_aspect_ratio (); if (ratio == 0) { _paintable.snapshot (snapshot, width, height); } else { double w = 0.0; double h = 0.0; double picture_ratio = (double) width / height; if (ratio > picture_ratio) { w = height * ratio; h = height; } else { w = width; h = width / ratio; } w = Math.ceil (w); h = Math.ceil (h); double x = (width - w) / 2; double y = Math.floor (height - h) / 2; snapshot.save (); snapshot.translate (Graphene.Point () { x = (float) x, y = (float) y }); _paintable.snapshot (snapshot, w, h); snapshot.restore (); } } public override void measure ( Gtk.Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline ) { minimum_baseline = -1; natural_baseline = -1; if (_paintable == null || for_size == 0) { minimum = 0; natural = 0; return; } minimum = natural = 168; } private void paintable_invalidate_contents () { this.queue_draw (); } private void paintable_invalidate_size () { this.queue_resize (); } private void clear_paintable () { if (_paintable == null) return; if (paintable_invalidate_contents_signal != 0) _paintable.disconnect (paintable_invalidate_contents_signal); if (paintable_invalidate_size_signal != 0) _paintable.disconnect (paintable_invalidate_size_signal); paintable_invalidate_contents_signal = 0; paintable_invalidate_size_signal = 0; _paintable = null; } private bool paintable_size_equal (Gdk.Paintable? new_paintable) { if (_paintable == null) { return new_paintable == null; } else if (new_paintable == null) { return false; } return _paintable.get_intrinsic_width () == new_paintable.get_intrinsic_width () && _paintable.get_intrinsic_height () == new_paintable.get_intrinsic_height () && _paintable.get_intrinsic_aspect_ratio () == new_paintable.get_intrinsic_aspect_ratio (); } ~SizedCoverPicture () { debug ("Destroying"); clear_paintable (); } } public class PageBox : Adw.Bin { public string file_suffix { get; set; default = ""; } public string? mbid { get; set; default = null; } public string kind { get; set; default = "release"; } ~PageBox () { debug ("Destroying"); } Gtk.Label title_label; Gtk.Label desc_label; Gtk.Box box; SizedCoverPicture pic; construct { this.add_css_class ("pagebox"); box = new Gtk.Box (VERTICAL, 0) { vexpand = true, hexpand = true, valign = CENTER, margin_top = margin_bottom = margin_start = margin_end = 16 }; title_label = new Gtk.Label ("") { css_classes = { "title-1" }, wrap_mode = WORD_CHAR, wrap = true, lines = 3, ellipsize = END, justify = CENTER, use_markup = false }; desc_label = new Gtk.Label ("") { css_classes = { "title-4", "body" }, wrap_mode = WORD_CHAR, wrap = true, lines = 4, ellipsize = END, justify = CENTER, use_markup = true }; pic = new SizedCoverPicture () { visible = false, width_request = 168, height_request = 168, halign = CENTER, valign = CENTER, overflow = HIDDEN, margin_bottom = 16, css_classes = {"card", "clear-view"} }; box.append (pic); box.append (title_label); box.append (desc_label); this.child = box; } public PageBox (string title, string description, Gtk.Widget? child = null, string? cover = null) { if (title == "") { title_label.visible = false; } else { title_label.label = title; } if (description == "") { desc_label.visible = false; } else { desc_label.label = description; } if (child != null) box.append (child); if (cover != null) fetch_cover (cover); } private Widgets.Cover.CoverLoader? working_loader = null; private void fetch_cover (string cover) { working_loader = new Widgets.Cover.CoverLoader (cover); working_loader.done.connect (load_paintable); working_loader.done_completely.connect (done_completely_cb); try { new GLib.Thread.try (@"CoverLoader $cover", working_loader.fetch); } catch (Error e) { warning (@"Couldn't fetch cover ($cover) for wrapped: $(e.message) $(e.code)"); }; } private bool tried = false; private void load_paintable (owned Gdk.Texture? texture) { pic.paintable = (owned) texture; if (pic.paintable != null) { pic.visible = true; } else if (!tried && mbid != null) { force_fetch_mbid (); } } private void done_completely_cb (owned Utils.Color.ExtractedColors? extracted_colors) { working_loader = null; } private async void fetch_from_mbid (string mbid, string kind) { Soup.Session session = new Soup.Session () { user_agent = @"$(Build.NAME)/$(Build.VERSION) libsoup/$(Soup.get_major_version()).$(Soup.get_minor_version()).$(Soup.get_micro_version()) ($(Soup.MAJOR_VERSION).$(Soup.MINOR_VERSION).$(Soup.MICRO_VERSION))" // vala-lint=line-length }; var cover_url = yield Scrobbling.ListenBrainz.fetch_cover_from_wiki (session, mbid, kind); if (cover_url != null) fetch_cover (cover_url); } public void force_fetch_mbid () { tried = true; if (mbid == null) return; fetch_from_mbid.begin (mbid, kind); } } private void generate_wrapped (Scrobbling.Scrobbler.Wrapped wrapped) { if ( wrapped.tracks == null || wrapped.artists == null || wrapped.tracks.length == 0 || wrapped.artists.length == 0 ) { // translators: error message shown on wrapped when theres not enough info to generate one show_error_page (_("Not enough info available, go and scrobble some more!")); return; } else { stack.visible_child_name = "loading"; } var box = new Gtk.Box (VERTICAL, 0); carousel = new Adw.Carousel () { vexpand = true, hexpand = true, allow_long_swipes = false, allow_scroll_wheel = false }; var dots = new Adw.CarouselIndicatorLines () { carousel = carousel }; box.append (dots); box.append (carousel); var page_1 = new PageBox ( // translators: wrapped page 1, fun title, feel free to change it something similar in your language _("What a year, huh?"), // translators: wrapped page 1 description _("Let's rewind the year!") ) { vexpand = true, hexpand = true, file_suffix = "welcome" }; carousel.append (page_1); string? stat_art = null; string? stat_mbid = null; string? stat_kind = null; if (wrapped.albums != null && wrapped.albums.length > 0) { var top_album = wrapped.albums[0]; string? cover = top_album.image; if (cover == null) cover = top_album.id == null ? null : @"https://coverartarchive.org/release-group/$(top_album.id)/front-250.jpg"; stat_art = cover; stat_mbid = top_album.id; // translators: how many times you listened to **something** in wrapped, variable is a number string desc = top_album.count == 0 ? "" : _("You listened to it %lld times!").printf (top_album.count); var page_2 = new PageBox ( top_album.text, "%s\n%s".printf ( // translators: number 1 album shown in wrapped _("#1 Album"), desc ), null, stat_art ) { vexpand = true, hexpand = true, file_suffix = "album", mbid = top_album.id }; carousel.append (page_2); } if (wrapped.tracks != null && wrapped.tracks.length > 0) { var top_track = wrapped.tracks[0]; string desc = top_track.count == 0 ? "" : _("You listened to it %lld times!").printf (top_track.count); string? cover = top_track.image; if (cover == null) cover = top_track.id == null ? null : @"https://coverartarchive.org/release/$(top_track.id)/front-250.jpg"; stat_art = cover; stat_mbid = top_track.id; var page_3 = new PageBox ( top_track.text, "%s\n%s".printf ( // translators: number 1 song shown in wrapped _("#1 Track"), desc ), null, cover ) { vexpand = true, hexpand = true, file_suffix = "track", mbid = top_track.id }; if (picked_provider == LASTFM) stat_kind = page_3.kind = "artist"; carousel.append (page_3); } if (wrapped.artists != null && wrapped.artists.length > 0) { var top_artist = wrapped.artists[0]; // how many times you listened to **someone** in wrapped, variable is a number string desc = top_artist.count == 0 ? "" : _("You listened to them %lld times!").printf (top_artist.count); var page_4 = new PageBox ( top_artist.text, "%s\n%s".printf ( // translators: number 1 artist shown in wrapped _("#1 Artist"), desc ), null, top_artist.image ) { vexpand = true, hexpand = true, file_suffix = "artist", mbid = top_artist.id, kind = "artist" }; page_4.force_fetch_mbid (); carousel.append (page_4); } var columns = new Gtk.Box (HORIZONTAL, 12) { halign = CENTER }; if (wrapped.tracks != null && wrapped.tracks.length > 0) { var col_1 = new Gtk.Box (VERTICAL, 0); col_1.append (new Gtk.Label (_("Top Tracks")) { wrap = true, wrap_mode = WORD_CHAR, css_classes = {"title-4"}, margin_bottom = 8 }); int i = 0; foreach (var track in wrapped.tracks) { i++; col_1.append (new Gtk.Label (@"$i. $(track.text)") { wrap = true, wrap_mode = WORD_CHAR, ellipsize = END, halign = START }); } columns.append (col_1); } if (wrapped.artists != null && wrapped.artists.length > 0) { var col_2 = new Gtk.Box (VERTICAL, 0); col_2.append (new Gtk.Label (_("Top Artists")) { wrap = true, wrap_mode = WORD_CHAR, css_classes = {"title-4"}, margin_bottom = 8 }); int i = 0; foreach (var artist in wrapped.artists) { i++; col_2.append (new Gtk.Label (@"$i. $(artist.text)") { wrap = true, wrap_mode = WORD_CHAR, ellipsize = END, halign = START }); } columns.append (col_2); } var page_5 = new PageBox ( "", "", columns, stat_art ) { vexpand = true, hexpand = true, file_suffix = "overall", mbid = stat_mbid }; if (stat_kind != null) page_5.kind = stat_kind; carousel.append (page_5); var page_6 = new PageBox ( // translators: wrapped page 6, fun title, feel free to change it something similar in your language _("That's all folks!"), // translators: wrapped page 6 description _("Keep scrobbling!") ) { vexpand = true, hexpand = true, file_suffix = "goodbye" }; carousel.append (page_6); stack.add_named (box, "wrapped"); stack.visible_child_name = "wrapped"; save_btn.visible = true; style_button.visible = true; } private Gdk.Texture? snap () { Gtk.Widget wdgt = carousel.get_nth_page ((uint) carousel.position); Gtk.WidgetPaintable screenshot_paintable = new Gtk.WidgetPaintable (wdgt); int width = wdgt.get_width (); int height = wdgt.get_height (); if (int.min (width, height) < 512) { if (width < height) { height = (int) (((float) height / (float) width) * 512); width = 512; } else { width = (int) (((float) width / (float) height) * 512); height = 512; } } Graphene.Rect rect = Graphene.Rect.zero (); rect.init (0, 0, (float) width, (float) height); Gtk.Snapshot snapshot = new Gtk.Snapshot (); screenshot_paintable.snapshot (snapshot, width, height); Gsk.RenderNode? node = snapshot.to_node (); if (node == null) { critical (@"Could not get node snapshot, width: $width, height: $height"); return null; } Gsk.Renderer renderer = wdgt.get_native ().get_renderer (); return renderer.render_texture (node, rect); } private void on_save () { save_as_async.begin (); } private async void save_as_async () { string suff = ""; { PageBox page = carousel.get_nth_page ((uint) carousel.position) as PageBox; if (page != null) { suff = page.file_suffix; if (suff != "") suff = @"-$suff"; } } var chooser = new Gtk.FileDialog () { // translators: save dialog title, refer to the other Wrapped strings for more info title = _("Save Wrapped"), modal = true, initial_name = @"wrapped$suff.png" }; try { var file = yield chooser.save (application.active_window, null); if (file != null) { var texture = snap (); if (texture != null) { FileOutputStream stream = file.replace (null, false, FileCreateFlags.PRIVATE); try { yield stream.write_bytes_async (texture.save_to_png_bytes ()); } catch (GLib.IOError e) { warning (e.message); } } } } catch (Error e) { // User dismissing the dialog also ends here so don't make it sound like // it's an error warning (@"Couldn't get the result of FileDialog for wrapped: $(e.message)"); } } private void show_error_page (string error, bool with_try_again = true) { error_page.description = error; try_again_button.visible = with_try_again; stack.visible_child_name = "error"; debug (error); } } turntable/src/Views/meson.build000066400000000000000000000004341512353730000171040ustar00rootroot00000000000000if scrobbling sources += files( 'LibreFMPage.vala', 'ListenBrainzPage.vala', 'MalojaPage.vala', 'OfflineScrobbling.vala', 'Preferences.vala', 'ProviderPage.vala', 'ScrobblerSetup.vala', 'Wrapped.vala' ) endif sources += files( 'Window.vala' ) turntable/src/Widgets/000077500000000000000000000000001512353730000152525ustar00rootroot00000000000000turntable/src/Widgets/ControlsOverlay.vala000066400000000000000000000425771512353730000213030ustar00rootroot00000000000000public class Turntable.Widgets.ControlsOverlay : Adw.Bin { public signal void toggle_controls (); public signal void player_changed (Mpris.Entry? new_player); public Mpris.Entry? last_player { get; set; default = null; } ~ControlsOverlay () { debug ("Destroying"); } private bool _collapsed = false; public bool collapsed { get { return _collapsed; } set { _collapsed = value; if (value) { this.add_css_class ("collapsed"); } else { this.remove_css_class ("collapsed"); } } } private void update_style (Widgets.Cover.Style style, Gtk.Orientation orientation) { this.overlay.child.valign = this.overlay.child.halign = this.valign = this.halign = Gtk.Align.CENTER; switch (style) { case CARD: this.css_classes = { "card", "card-like" }; break; case TURNTABLE: this.css_classes = { "card", "circular-art", "card-like", "clear-view" }; break; case SHADOW: this.css_classes = { "fade" }; this.overlay.child.valign = this.overlay.child.halign = this.valign = this.halign = Gtk.Align.FILL; break; default: assert_not_reached (); } switch (orientation) { case Gtk.Orientation.HORIZONTAL: this.add_css_class ("horizontal"); break; default: this.add_css_class ("vertical"); break; } if (this.collapsed) this.add_css_class ("collapsed"); } #if SCROBBLING public class ScrobbleButton : Gtk.Button { private bool _enabled = false; public bool enabled { get { return _enabled; } set { if (_enabled != value) { _enabled = value; if (value) { // translators: button tooltip text this.tooltip_text = _("Disable Scrobbling"); this.icon_name = "fingerprint2-symbolic"; } else { // translators: button tooltip text this.tooltip_text = _("Enable Scrobbling"); this.icon_name = "auth-fingerprint-symbolic"; } } } } construct { this.icon_name = "auth-fingerprint-symbolic"; this.tooltip_text = _("Enable Scrobbling"); } } #endif Adw.TimedAnimation animation; Gtk.EventControllerMotion pointer_controller; Gtk.EventControllerFocus focus_controller; Gtk.Overlay overlay; GLib.ListStore players_store; Gtk.DropDown client_dropdown; Gtk.MenuButton menu_button; Gtk.Button toggle_controls_button; #if SCROBBLING ScrobbleButton scrobble_button; #endif construct { this.overflow = Gtk.Overflow.HIDDEN; this.valign = Gtk.Align.CENTER; this.halign = Gtk.Align.CENTER; overlay = new Gtk.Overlay () { focusable = true }; players_store = new GLib.ListStore (typeof (Mpris.Entry)); mpris_manager.players_changed.connect (update_store); client_dropdown = new Gtk.DropDown (players_store, new Gtk.PropertyExpression (typeof (Mpris.Entry), null, "client-info-name")) { enable_search = false, factory = new Gtk.BuilderListItemFactory.from_resource (null, @"$(Build.RESOURCES)gtk/dropdown/client_display.ui"), list_factory = new Gtk.BuilderListItemFactory.from_resource (null, @"$(Build.RESOURCES)gtk/dropdown/client.ui"), // translators: dropdown tooltip text tooltip_text = _("Select Player"), css_classes = { "client-chooser" }, margin_start = 8, margin_end = 8 }; client_dropdown.notify["selected"].connect (selection_changed); { var toggle_btn = client_dropdown.get_first_child () as Gtk.ToggleButton; if (toggle_btn != null) toggle_btn.add_css_class ("osd"); } var main_box = new Gtk.Box (Gtk.Orientation.VERTICAL, 6) { vexpand = true, hexpand = true, valign = Gtk.Align.CENTER, halign = Gtk.Align.CENTER }; main_box.append (client_dropdown); var sub_box = new Gtk.Box (Gtk.Orientation.HORIZONTAL, 6) { halign = Gtk.Align.CENTER }; var menu_model = new GLib.Menu (); var main_section_model = new GLib.Menu (); // translators: menu entry main_section_model.append (_("New Window"), "app.new-window"); #if SCROBBLING // translators: menu entry that opens a dialog main_section_model.append (_("Scrobbling"), "win.open-scrobbling-setup"); #endif menu_model.append_section (null, main_section_model); var style_section_model = new GLib.Menu (); var component_submenu_model = new GLib.Menu (); // translators: whether to show the (window) background progress bar component_submenu_model.append (_("Background Progress"), "win.component-progressbin"); // translators: whether to show center the title, album and artist labels component_submenu_model.append (_("Center Text"), "win.component-center-text"); // translators: whether to make the artist and album labels slightly transparent / less prominent component_submenu_model.append (_("Dim Metadata Labels"), "win.meta-dim"); // translators: whether to fit the cover art in the cover; this will stretch or crop art that is not square component_submenu_model.append (_("Fit Art on Cover"), "win.component-cover-fit"); // translators: whether to extract the colors of the cover and use them in UI elements (like Amberol or Material You) component_submenu_model.append (_("Extract Cover Colors"), "win.component-extract-colors"); // translators: whether to hide the client icon when the controls are collapsed component_submenu_model.append (_("Hide Client Icon when Collapsed"), "win.hide-client-icon-collapsed"); // translators: whether to show the shuffle and loop buttons component_submenu_model.append (_("More Player Controls"), "win.component-more-controls"); // translators: whether to show a turntable tonearm in turntable styled cover art; tonearm is the 'arm' part of the turntable, // you may translate it as 'arm' (mechanical part) component_submenu_model.append (_("Tonearm"), "win.component-tonearm"); // translators: menu entry that opens a submenu; components = toggleable parts of the UI style_section_model.append_submenu (_("Components"), component_submenu_model); var cover_scaling_submenu_model = new GLib.Menu (); // translators: cover scaling algorithm name, probably leave as is cover_scaling_submenu_model.append (_("Linear"), "win.cover-scaling('linear')"); // translators: cover scaling algorithm name, probably leave as is cover_scaling_submenu_model.append (_("Nearest"), "win.cover-scaling('nearest')"); // translators: cover scaling algorithm name, probably leave as is cover_scaling_submenu_model.append (_("Trilinear"), "win.cover-scaling('trilinear')"); // translators: menu entry that opens a submenu; cover scaling = algorithm used for down/upscaling cover art style_section_model.append_submenu (_("Cover Scaling"), cover_scaling_submenu_model); var orientation_submenu_model = new GLib.Menu (); // translators: orientation name orientation_submenu_model.append (_("Horizontal"), "win.toggle-orientation(true)"); // translators: orientation name orientation_submenu_model.append (_("Vertical"), "win.toggle-orientation(false)"); // translators: menu entry that opens a submenu; as in whether it's horizontal or vertical style_section_model.append_submenu (_("Orientation"), orientation_submenu_model); var size_submenu_model = new GLib.Menu (); var cover_size_submenu_model = new GLib.Menu (); // translators: cover size cover_size_submenu_model.append (_("Small"), "win.cover-size('small')"); // translators: cover size cover_size_submenu_model.append (_("Regular"), "win.cover-size('regular')"); // translators: cover size cover_size_submenu_model.append (_("Big"), "win.cover-size('big')"); // translators: menu entry that opens a submenu; cover = the song cover art size_submenu_model.append_submenu ("%s ".printf (_("Cover")), cover_size_submenu_model); // https://gitlab.gnome.org/GNOME/gtk/-/issues/7064 // translators: menu entry that opens a submenu style_section_model.append_submenu (_("Size"), size_submenu_model); var text_size_submenu_model = new GLib.Menu (); // translators: text style (size) text_size_submenu_model.append (_("Small"), "win.text-size('small')"); // translators: text style (size) text_size_submenu_model.append (_("Regular"), "win.text-size('regular')"); // translators: text style (size) text_size_submenu_model.append (_("Big"), "win.text-size('big')"); // translators: menu entry that opens a submenu; text = all the app text, may be translated to fonts size_submenu_model.append_submenu (_("Text"), text_size_submenu_model); var style_submenu_model = new GLib.Menu (); var client_style_submenu_model = new GLib.Menu (); client_style_submenu_model.append (_("None"), "win.client-icon-style('none')"); // translators: cover icon style; symbolic = the monochrome simplified version client_style_submenu_model.append (_("Symbolic"), "win.client-icon-style('symbolic')"); // translators: cover icon style client_style_submenu_model.append (_("Full Color"), "win.client-icon-style('full-color')"); // translators: menu entry that opens a submenu; client = music playing app style_submenu_model.append_submenu (_("Client Icon"), client_style_submenu_model); var cover_style_submenu_model = new GLib.Menu (); // translators: cover image style; it's a square with rounded corners cover_style_submenu_model.append (_("Card"), "win.cover-style('card')"); // translators: cover image style; it's a rotating record like on a turntable cover_style_submenu_model.append (_("Turntable"), "win.cover-style('turntable')"); // translators: cover image style; it's a fading out effect; may be translated to 'Fade' cover_style_submenu_model.append (_("Shadow"), "win.cover-style('shadow')"); // translators: menu entry that opens a submenu style_submenu_model.append_submenu (_("Cover"), cover_style_submenu_model); var scale_style_submenu_model = new GLib.Menu (); // translators: progressbar style; disable it scale_style_submenu_model.append (_("None"), "win.progressscale-style('none')"); // translators: progressbar style; default gtk style, has a big knob / circle as the position marker scale_style_submenu_model.append (_("Knob"), "win.progressscale-style('knob')"); // translators: progressbar style; hard to explain, looks like amberol's volume bar scale_style_submenu_model.append (_("Overlay"), "win.progressscale-style('overlay')"); // translators: menu entry that opens a submenu style_submenu_model.append_submenu (_("Progress Bar"), scale_style_submenu_model); var window_style_submenu_model = new GLib.Menu (); // translators: window style name window_style_submenu_model.append (_("Window"), "win.window-style('window')"); // translators: window style name, probably leave it as is; OSD = on screen display, // it's the dark semi-trasparent background and white text style window_style_submenu_model.append (_("OSD"), "win.window-style('osd')"); // translators: window style name window_style_submenu_model.append (_("Transparent"), "win.window-style('transparent')"); // translators: window style name window_style_submenu_model.append (_("Blur"), "win.window-style('blur')"); // translators: menu entry that opens a submenu style_submenu_model.append_submenu (_("Window"), window_style_submenu_model); // translators: menu entry that opens a submenu style_section_model.append_submenu (_("Style"), style_submenu_model); menu_model.append_section (null, style_section_model); var misc_section_model = new GLib.Menu (); misc_section_model = new GLib.Menu (); // misc_section_model.append (_("Keyboard Shortcuts"), "win.show-help-overlay"); misc_section_model.append (_("Preferences"), "app.preferences"); // translators: menu entry, variable is the app name (Turntable) misc_section_model.append (_("About %s").printf (Build.NAME), "app.about"); // translators: menu entry misc_section_model.append (_("Quit"), "app.quit"); menu_model.append_section (null, misc_section_model); #if SCROBBLING scrobble_button = new ScrobbleButton () { css_classes = {"circular", "osd", "min34px"}, sensitive = false, enabled = false }; scrobble_button.clicked.connect (on_scrobble_client_toggle); sub_box.append (scrobble_button); account_manager.accounts_changed.connect (on_accounts_changed); on_accounts_changed (); #endif toggle_controls_button = new Gtk.Button () { css_classes = {"circular", "osd", "min34px"}, icon_name = "dock-right-symbolic", // translators: tooltip for button that hides/shows all the media controls and labels tooltip_text = _("Toggle Controls") }; toggle_controls_button.clicked.connect (on_toggle_controls_button); sub_box.append (toggle_controls_button); menu_button = new Gtk.MenuButton () { icon_name = "menu-large-symbolic", primary = true, menu_model = menu_model, css_classes = {"circular", "osd"}, tooltip_text = _("Menu") }; sub_box.append (menu_button); main_box.append (sub_box); var main_bin = new Adw.Bin () { css_classes = {"osd"}, child = main_box, opacity = 0 }; animation = new Adw.TimedAnimation (this, 0.0, 1.0, 250, new Adw.PropertyAnimationTarget (main_bin, "opacity")) { easing = Adw.Easing.EASE_IN_OUT }; overlay.add_overlay (main_bin); this.child = overlay; update_store (); focus_controller = new Gtk.EventControllerFocus (); focus_controller.enter.connect (on_main_bin_enter); focus_controller.leave.connect (on_main_bin_leave_focus); main_bin.add_controller (focus_controller); pointer_controller = new Gtk.EventControllerMotion (); pointer_controller.enter.connect (on_main_bin_enter); pointer_controller.leave.connect (on_main_bin_leave_pointer); main_bin.add_controller (pointer_controller); } private void on_main_bin_enter () { animation.value_from = animation.value; animation.value_to = 1; animation.play (); } // properties updated AFTER the signals // so we have to assume here without using them private void on_main_bin_leave_focus () { if (pointer_controller.is_pointer || pointer_controller.contains_pointer) return; hide_main_bin_overlay (); } private void on_main_bin_leave_pointer () { if (focus_controller.is_focus || focus_controller.contains_focus) return; hide_main_bin_overlay (); } private void hide_main_bin_overlay () { animation.value_from = animation.value; animation.value_to = 0; animation.play (); } public ControlsOverlay (Widgets.Cover cover) { this.overlay.child = cover; cover.style_changed.connect (update_style); update_style (cover.style, cover.orientation); } public void update_toggle_controls_icon () { toggle_controls_button.icon_name = settings.orientation_horizontal ? (Gtk.Widget.get_default_direction () == Gtk.TextDirection.RTL ? "dock-left-symbolic" : "dock-right-symbolic") : "dock-bottom-symbolic"; } public bool hide_overlay () { if ( pointer_controller.is_pointer || pointer_controller.contains_pointer || menu_button.active || ((Gtk.ToggleButton) client_dropdown.get_first_child ()).active ) return false; hide_main_bin_overlay (); return true; } private void on_toggle_controls_button () { toggle_controls (); } private bool done_initial_active_check = false; private void update_store () { players_store.splice (0, players_store.n_items, mpris_manager.get_players ()); players_store.sort ((GLib.CompareDataFunc) compare_players); client_dropdown.enable_search = players_store.n_items > 10; if (!done_initial_active_check) { done_initial_active_check = true; if (players_store.n_items > 1) { mpris_manager.active_player.begin ((obj, res) => { Mpris.Entry? active_player = mpris_manager.active_player.end (res); if (active_player != null) { uint pos; if (players_store.find (active_player, out pos)) { client_dropdown.selected = pos; } } }); } } if (this.last_player == null) selection_changed (); // if always ensure player } private static int compare_players (Mpris.Entry a, Mpris.Entry b) { return a.client_info.identity.collate (b.client_info.identity); } private void selection_changed () { debug ("Changed player"); bool was_null = this.last_player == null; if (client_dropdown.selected == Gtk.INVALID_LIST_POSITION) { if (!was_null) { this.last_player.terminate_player (); this.last_player = null; trigger_player_changed (null); } return; } var new_last_player = (Mpris.Entry?) players_store.get_item (client_dropdown.selected); if (!was_null && new_last_player != null && new_last_player.bus_namespace == this.last_player.bus_namespace) return; if (!was_null) this.last_player.terminate_player (); this.last_player = new_last_player; if (this.last_player == null) { if (!was_null) trigger_player_changed (null); return; } this.last_player.initialize_player (); trigger_player_changed (this.last_player); } private inline void trigger_player_changed (Mpris.Entry? new_entry) { player_changed (new_entry); #if SCROBBLING update_scrobble_button (); #endif } #if SCROBBLING private void update_scrobble_button () { if (this.last_player == null) { scrobble_button.sensitive = false; scrobble_button.enabled = false; return; } scrobble_button.sensitive = true; scrobble_button.enabled = this.last_player.bus_namespace in settings.scrobbler_allowlist; } private void on_accounts_changed () { scrobble_button.visible = account_manager.accounts.length > 0; } private void on_scrobble_client_toggle () { update_scrobble_button (); if (scrobble_button.enabled) { settings.remove_from_allowlist (this.last_player.bus_namespace); } else { settings.add_to_allowlist (this.last_player.bus_namespace); } scrobble_button.enabled = !scrobble_button.enabled; } #endif } turntable/src/Widgets/Cover.vala000066400000000000000000000467721512353730000172150ustar00rootroot00000000000000public class Turntable.Widgets.Cover : Gtk.Widget { public const float FADE_WIDTH = 64f; const Gsk.ColorStop[] GRADIENT = { { 0f, { 0, 0, 0, 1f } }, { 1f, { 0, 0, 0, 0f } }, }; const Gsk.ColorStop[] GRADIENT_SHINE = { { 0f, { 1, 1, 1, 0f } }, { 0.11f, { 1, 1, 1, 0.18f } }, { 0.13f, { 1, 1, 1, 0.18f } }, { 0.16f, { 1, 1, 1, 0f } }, { 0.5f, { 1, 1, 1, 0f } }, { 0.61f, { 1, 1, 1, 0.1f } }, { 0.63f, { 1, 1, 1, 0.1f } }, { 0.69f, { 1, 1, 1, 0f } }, { 1f, { 1, 1, 1, 0f } }, }; public signal void style_changed (Style style, Gtk.Orientation orientation); ~Cover () { debug ("Destroying"); } Gdk.RGBA vinyl_color; private void update_vinyl_color () { Gdk.RGBA new_color = { 0f, 0f, 0f, 1f }; if (this.extracted_colors != null && settings.component_extract_colors) { new_color = Adw.StyleManager.get_default ().dark ? extracted_colors.light : extracted_colors.dark; } if (new_color != vinyl_color) { vinyl_color = new_color; if (this.turntable) this.queue_draw (); } } Gtk.IconPaintable fallback_icon; Adw.TimedAnimation animation; Gsk.RoundedRect record_center; Graphene.Rect record_center_inner; public Gdk.Texture? cover { get; set; default = null; } private void update_record_rects () { int new_size = (int) (this.size * 0.666666666666); record_center_inner = Graphene.Rect () { origin = Graphene.Point () { x = 0, y = 0 }, size = Graphene.Size () { width = new_size * this.scale_factor, height = new_size * this.scale_factor } }; record_center = Gsk.RoundedRect ().init_from_rect ( record_center_inner, 9999f ); } public enum Style { CARD, TURNTABLE, SHADOW; public string to_string () { switch (this) { case TURNTABLE: return "turntable"; case SHADOW: return "shadow"; default: return "card"; } } public static Style from_string (string string_style) { switch (string_style) { case "turntable": return TURNTABLE; case "shadow": return SHADOW; default: return CARD; } } } public enum Scaling { LINEAR, NEAREST, TRILINEAR; public string to_string () { switch (this) { case NEAREST: return "nearest"; case TRILINEAR: return "trilinear"; default: return "linear"; } } public Gsk.ScalingFilter to_filter () { switch (this) { case NEAREST: return Gsk.ScalingFilter.NEAREST; case TRILINEAR: return Gsk.ScalingFilter.TRILINEAR; default: return Gsk.ScalingFilter.LINEAR; } } public static Scaling from_string (string string_scaling) { switch (string_scaling) { case "nearest": return NEAREST; case "trilinear": return TRILINEAR; default: return LINEAR; } } } private Style _style = Style.CARD; public Style style { get { return _style; } set { if (_style != value) { _style = value; this.turntable = value == Style.TURNTABLE; this.style_changed (value, this.orientation); this.queue_draw (); } } } private Gtk.Orientation _orientation = Gtk.Orientation.HORIZONTAL; public Gtk.Orientation orientation { get { return _orientation; } set { if (_orientation != value) { _orientation = value; this.style_changed (this.style, value); this.queue_draw (); } } } private bool _turntable = false; private bool turntable { get { return _turntable; } set { if (_turntable != value) { _turntable = value; if (value) { animation.play (); } else { animation.pause (); this.queue_draw (); } this.notify_property ("turntable-playing"); } } } public bool turntable_playing { get { return this.turntable && animation.state == Adw.AnimationState.PLAYING; } set { if (this.turntable) { if (value) { if (animation.state == Adw.AnimationState.PAUSED) { animation.resume (); } else { animation.play (); } } else { animation.pause (); } } } } private Utils.Color.ExtractedColors? _extracted_colors = null; public Utils.Color.ExtractedColors? extracted_colors { get { return _extracted_colors; } set { _extracted_colors = value; update_vinyl_color (); this.notify_property ("extracted-colors"); } } public class CoverLoader : GLib.Object { private string file_path; private Cancellable cancellable = new Cancellable (); private Gdk.Texture? texture = null; private Utils.Color.ExtractedColors? extracted_colors = null; public signal void done (owned Gdk.Texture texture); public signal void done_completely (owned Utils.Color.ExtractedColors? extracted_colors); private uint done_idle_id = 0; private uint done_completely_idle_id = 0; private bool extract = true; ~CoverLoader () { debug ("[CoverLoader] Destroying"); if (done_idle_id > 0) GLib.Source.remove (done_idle_id); if (done_completely_idle_id > 0) GLib.Source.remove (done_completely_idle_id); done_idle_id = done_completely_idle_id = 0; this.texture = null; this.extracted_colors = null; } public CoverLoader (string file_path) { this.file_path = file_path; this.extract = settings.component_extract_colors; } public CoverLoader.painter (Gdk.Texture texture) { this.texture = texture; } public void fetch () { debug ("[CoverLoader] Spawned Fetch"); string clean_path = file_path; if (clean_path.has_prefix ("file://")) { clean_path = this.file_path.splice (0, 7); } // var t_texture = Gdk.Texture.from_filename (clean_path); // if (cancellable.is_cancelled ()) return; // this.texture = t_texture; // GLib.Idle.add_once (done_idle); Gly.Frame frame; try { Gly.Loader loader; GLib.InputStream? in_stream = null; if (clean_path.has_prefix ("data:")) { int comma_pos = clean_path.index_of (","); if (comma_pos == -1) throw new Error.literal (-1, 3, "Invalid base64 encoded image"); var base64_data = GLib.Base64.decode (clean_path.substring (comma_pos + 1)); loader = new Gly.Loader.for_bytes (new GLib.Bytes.take (base64_data)); } else if (clean_path.down ().has_prefix ("https://")) { var session = new Soup.Session () { user_agent = @"$(Build.NAME)/$(Build.VERSION) libsoup/$(Soup.get_major_version()).$(Soup.get_minor_version()).$(Soup.get_micro_version()) ($(Soup.MAJOR_VERSION).$(Soup.MINOR_VERSION).$(Soup.MICRO_VERSION))" // vala-lint=line-length }; var msg = new Soup.Message ("GET", this.file_path); in_stream = session.send (msg, cancellable); if (msg.response_headers.get_content_type (null) == "text/html") throw new Error.literal (-1, 1, "text/html"); loader = new Gly.Loader.for_stream (in_stream); } else { loader = new Gly.Loader (GLib.File.new_for_path (clean_path)); } var img = loader.load (); if (in_stream != null) in_stream.close (); frame = img.next_frame (); this.texture = GlyGtk4.frame_get_texture (frame); } catch (Error e) { debug ("Couldn't get texture %s: %s", clean_path, e.message); stop_it (true); return; } if (cancellable.is_cancelled ()) { stop_it (true); return; } done_idle_id = GLib.Idle.add_once (done_idle); if (!this.extract) { stop_it (false); return; } extract_colors_from_frame (frame); frame = null; } private inline void stop_it (bool both = false) { if (both) done_idle_id = GLib.Idle.add_once (done_idle); done_completely_idle_id = GLib.Idle.add_once (done_completely_idle); } private void extract_colors_from_frame (Gly.Frame? frame) { if (frame == null) { stop_it (false); return; } Gdk.RGBA avg = Utils.Color.get_prominent_color (frame, cancellable); if (cancellable.is_cancelled ()) { stop_it (false); return; } this.extracted_colors = Utils.Color.get_contrasting_colors (avg); done_completely_idle_id = GLib.Idle.add_once (done_completely_idle); } public void extract_colors () { debug ("[CoverLoader] Spawned Extract"); if (this.texture == null) { stop_it (false); return; } try { var loader = new Gly.Loader.for_bytes (this.texture.save_to_png_bytes ()); var img = loader.load (); extract_colors_from_frame (img.next_frame ()); } catch (Error e) { debug ("Couldn't get frame from temp file: %s", e.message); stop_it (false); } } private void done_idle () { done_idle_id = 0; done ((owned) this.texture); } private void done_completely_idle () { done_completely_idle_id = 0; this.texture = null; done_completely (this.extracted_colors); // flatpak crashes? // this.extracted_colors = null; // done_completely_idle_id = 0; } public void cancel () { cancellable.cancel (); } } private CoverLoader? working_loader = null; public string? file_path { set { if (working_loader != null) { working_loader.cancel (); working_loader = null; } if (value == null) { cover = null; this.extracted_colors = null; this.queue_draw (); } else { // if (cache.contains (value)) { // debug ("[CoverLoader] Cache Hit") // var cache_item = cache.get (value); // this.cover = cache_item.texture; // this.extract_colors = cache_item.colors; // this.queue_draw (); // return; // } try { working_loader = new CoverLoader (value); working_loader.done.connect (queue_draw_cb); working_loader.done_completely.connect (done_completely_cb); new GLib.Thread.try (@"CoverLoader $value", working_loader.fetch); } catch { if (working_loader != null) { working_loader.cancel (); working_loader = null; } cover = null; this.extracted_colors = null; this.queue_draw (); } } } } private void queue_draw_cb (owned Gdk.Texture? texture) { cover = (owned) texture; this.queue_draw (); } private void done_completely_cb (owned Utils.Color.ExtractedColors? extracted_colors) { working_loader = null; this.extracted_colors = extracted_colors; } private int32 _size = 192; public int32 size { get { return _size; } set { if (_size != value) { _size = value; update_record_rects (); this.queue_resize (); } } } private bool _fit_cover = true; public bool fit_cover { get { return _fit_cover; } set { if (_fit_cover != value) { _fit_cover = value; this.queue_draw (); } } } private Gsk.ScalingFilter _scaling_filter = Gsk.ScalingFilter.LINEAR; public Gsk.ScalingFilter scaling_filter { get { return _scaling_filter; } set { if (_scaling_filter != value) { _scaling_filter = value; this.queue_draw (); } } } static construct { set_css_name ("picture"); set_accessible_role (Gtk.AccessibleRole.NONE); // it's probably better if it doesn't get announced } // fix leak // callback animation target seems to leak public double animation_cb { set { this.queue_draw (); } } construct { update_record_rects (); this.overflow = Gtk.Overflow.HIDDEN; this.notify["scale-factor"].connect (on_scale_factor_changed); animation = new Adw.TimedAnimation (this, 0.0, 1.0, 5000, new Adw.PropertyAnimationTarget (this, "animation-cb")) { easing = Adw.Easing.LINEAR, repeat_count = 0, follow_enable_animations_setting = false // it's slow and opt-in }; fallback_icon = (Gtk.IconTheme.get_for_display (Gdk.Display.get_default ())).lookup_icon ( "music-note-outline-symbolic", null, 48, this.scale_factor, Gtk.TextDirection.NONE, Gtk.IconLookupFlags.PRELOAD ); Adw.StyleManager.get_default ().notify["dark"].connect (update_vinyl_color); settings.notify["component-extract-colors"].connect (update_extracted_colors_setting); update_vinyl_color (); } private void on_scale_factor_changed () { update_record_rects (); this.queue_draw (); } private void update_extracted_colors_setting () { if (settings.component_extract_colors && this.cover != null) { working_loader = new CoverLoader.painter (this.cover); try { working_loader.done.connect (queue_draw_cb); working_loader.done_completely.connect (done_completely_cb); new GLib.Thread.try ("CoverLoader - Painter", working_loader.extract_colors); } catch { if (working_loader != null) { working_loader.cancel (); working_loader = null; } this.extracted_colors = null; this.queue_draw (); } } else { update_vinyl_color (); } } public override Gtk.SizeRequestMode get_request_mode () { return Gtk.SizeRequestMode.CONSTANT_SIZE; } public override void measure ( Gtk.Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline ) { minimum = this.size; natural = this.size; minimum_baseline = -1; natural_baseline = -1; } public override void snapshot (Gtk.Snapshot snapshot) { float width = this.get_width () * this.scale_factor; float height = this.get_height () * this.scale_factor; float ratio = cover == null ? 1f : (float) cover.get_intrinsic_aspect_ratio (); float w = 0; float h = 0; float t_w = 0; float t_h = 0; if (ratio == 1) { w = width; h = height; if (this.turntable) { t_w = this.record_center_inner.size.width; t_h = this.record_center_inner.size.height; } } else if (ratio > 1) { if (fit_cover) { w = height * ratio; h = height; if (this.turntable) { t_w = this.record_center_inner.size.height * ratio; t_h = this.record_center_inner.size.height; } } else { w = width; h = width / ratio; if (this.turntable) { t_w = this.record_center_inner.size.width; t_h = this.record_center_inner.size.width / ratio; } } } else { if (fit_cover) { w = width; h = width / ratio; if (this.turntable) { t_w = this.record_center_inner.size.width; t_h = this.record_center_inner.size.width / ratio; } } else { w = height * ratio; h = height; if (this.turntable) { t_w = this.record_center_inner.size.height * ratio; t_h = this.record_center_inner.size.height; } } } float x = (width - Math.ceilf (w)) / 2f; float y = Math.floorf ((height - h)) / 2f; snapshot.save (); if (this.scale_factor != 1) snapshot.scale (1.0f / this.scale_factor, 1.0f / this.scale_factor); if (this.turntable) { var snapshot_vinyl_color_groove = vinyl_color; snapshot_vinyl_color_groove.alpha = 0.8f; snapshot.append_repeating_radial_gradient ( Graphene.Rect () { origin = Graphene.Point () { x = 0, y = 0 }, size = Graphene.Size () { width = width, height = height } }, Graphene.Point () { x = width / 2, y = height / 2 }, 2f, 2f, 0f, 2f, { { 0f, vinyl_color }, { 0.95f, vinyl_color }, { 1f, snapshot_vinyl_color_groove }, } ); snapshot.append_conic_gradient ( Graphene.Rect () { origin = Graphene.Point () { x = 0, y = 0 }, size = Graphene.Size () { width = width, height = height } }, Graphene.Point () { x = width / 2, y = height / 2 }, 0, GRADIENT_SHINE ); var translation_point = Graphene.Point () { x = width / 2, y = height / 2 }; snapshot.translate (translation_point); // snapshot.scale (0.65f / this.scale_factor, 0.65f / this.scale_factor); snapshot.rotate ((float) (360 * animation.value)); snapshot.translate (Graphene.Point () { x = - translation_point.x, y = - translation_point.y }); } else if (this.style == Style.SHADOW) { snapshot.push_mask (Gsk.MaskMode.INVERTED_ALPHA); if (this.orientation == Gtk.Orientation.HORIZONTAL) { var new_fade = is_rtl ? 0 : width - FADE_WIDTH; snapshot.append_linear_gradient ( Graphene.Rect () { origin = Graphene.Point () { x = new_fade, y = 0 }, size = Graphene.Size () { width = FADE_WIDTH, height = height } }, Graphene.Point () { x = is_rtl ? new_fade : width, y = 0 }, Graphene.Point () { x = is_rtl ? FADE_WIDTH : new_fade, y = 0 }, GRADIENT ); snapshot.pop (); snapshot.push_clip (Graphene.Rect () { origin = Graphene.Point () { x = 0, y = 0 }, size = Graphene.Size () { width = width, height = Math.ceilf (height) + 1 } }); } else { var new_fade = height - FADE_WIDTH; snapshot.append_linear_gradient ( Graphene.Rect () { origin = Graphene.Point () { x = 0, y = new_fade }, size = Graphene.Size () { width = width, height = FADE_WIDTH } }, Graphene.Point () { x = 0, y = height }, Graphene.Point () { x = 0, y = new_fade }, GRADIENT ); snapshot.pop (); snapshot.push_clip (Graphene.Rect () { origin = Graphene.Point () { x = 0, y = 0 }, size = Graphene.Size () { width = Math.ceilf (width) + 1, height = height } }); } } if (cover == null) { Gdk.RGBA note_color = this.get_color (); if (this.turntable) { var translation_point = Graphene.Point () { x = width / 2 - this.record_center_inner.size.width / 2, y = height / 2 - this.record_center_inner.size.height / 2 }; snapshot.translate (translation_point); snapshot.push_rounded_clip (this.record_center); snapshot.append_color ( { 1f - vinyl_color.red, 1f - vinyl_color.green, 1f - vinyl_color.blue, 1f }, record_center_inner ); snapshot.pop (); snapshot.translate (Graphene.Point () { x = -translation_point.x, y = -translation_point.y }); note_color = {0, 0, 0, 0.5f}; } int scaled_size = 64 * this.scale_factor; // always int snapshot.translate (Graphene.Point () { x = width / 2 - scaled_size / 2, y = height / 2 - scaled_size / 2 }); fallback_icon.snapshot_symbolic (snapshot, scaled_size, scaled_size, {note_color}); } else { float texture_w = w; float texture_h = h; if (this.turntable) { texture_w = t_w; texture_h = t_h; var center_point = Graphene.Point () { x = width / 2 - this.record_center_inner.size.width / 2, y = height / 2 - this.record_center_inner.size.height / 2 }; snapshot.push_rounded_clip ( Gsk.RoundedRect ().init_from_rect ( Graphene.Rect () { origin = center_point, size = this.record_center_inner.size }, 9999f ) ); if (ratio == 1) { snapshot.translate (center_point); } else if (this.fit_cover) { float new_x = 0; float new_y = 0; if (ratio < 1) { new_x = width / 2 - this.record_center_inner.size.width / 2; new_y = height / 2 - texture_h / 2; } else { new_y = height / 2 - this.record_center_inner.size.height / 2; new_x = width / 2 - texture_w / 2; } snapshot.translate (Graphene.Point () { x = new_x, y = new_y }); } else { snapshot.translate (Graphene.Point () { x = width / 2 - texture_w / 2, y = height / 2 - texture_h / 2 }); } } else if (this.style == Style.SHADOW) { snapshot.translate (Graphene.Point () { x = x, y = y }); } else { snapshot.translate (Graphene.Point () { x = x, y = y }); } snapshot.append_scaled_texture ( cover, this.scaling_filter, Graphene.Rect () { origin = Graphene.Point () { x = 0, y = 0 }, size = Graphene.Size () { width = texture_w, height = texture_h } } ); if (this.turntable) { snapshot.pop (); } } if (style == Style.SHADOW) { snapshot.pop (); snapshot.pop (); } snapshot.restore (); base.snapshot (snapshot); } } turntable/src/Widgets/MPRISControls.vala000066400000000000000000000137711512353730000205460ustar00rootroot00000000000000public class Turntable.Widgets.MPRISControls : Gtk.Grid { public enum Command { PREVIOUS, PLAY_PAUSE, NEXT, SHUFFLE, LOOP_NONE, LOOP_TRACK, LOOP_PLAYLIST; } public signal void commanded (Command command); public class StatefulLoopButton : Gtk.Button { // this is the command to trigger when clicked public Command next_command { get; private set; default = Command.LOOP_PLAYLIST; } public Mpris.Entry.LoopStatus loop_status { set { switch (value) { case NONE: // translators: repeat = loop, tooltip text this.tooltip_text = _("No Repeat"); this.icon_name = "playlist-consecutive-symbolic"; this.next_command = LOOP_PLAYLIST; break; case PLAYLIST: // translators: repeat = loop, tooltip text this.tooltip_text = _("Repeat Playlist"); this.icon_name = "playlist-repeat-symbolic"; this.next_command = LOOP_TRACK; break; case TRACK: // translators: repeat = loop, track = song, tooltip text this.tooltip_text = _("Repeat Track"); this.icon_name = "playlist-repeat-song-symbolic"; this.next_command = LOOP_NONE; break; default: assert_not_reached (); } } } construct { this.loop_status = NONE; } } public bool playing { set { button_play.icon_name = value ? "pause-large-symbolic" : "play-large-symbolic"; // translators: button tooltip text button_play.tooltip_text = value ? _("Pause") : _("Play"); } } public bool can_control { set { button_play.sensitive = button_loop.sensitive = button_shuffle.sensitive = value; if (!value) { button_prev.sensitive = button_next.sensitive = false; } } } public bool can_go_back { set { button_prev.sensitive = value; } } public bool can_go_next { set { button_next.sensitive = value; } } public bool shuffle { set { button_shuffle.active = value; } } public Mpris.Entry.LoopStatus loop_status { set { button_loop.loop_status = value; } } public bool more_controls { set { button_loop.visible = button_shuffle.visible = value; } } public void grab_play_focus () { button_play.grab_focus (); } bool _newline = false; public bool newline { get { return _newline; } set { if (_newline == value) return; _newline = value; Gtk.GridLayout layout_manager = (Gtk.GridLayout) this.get_layout_manager (); Gtk.GridLayoutChild button_shuffle_layout_child = (Gtk.GridLayoutChild) layout_manager.get_layout_child (button_shuffle); Gtk.GridLayoutChild button_prev_layout_child = (Gtk.GridLayoutChild) layout_manager.get_layout_child (button_prev); Gtk.GridLayoutChild button_play_layout_child = (Gtk.GridLayoutChild) layout_manager.get_layout_child (button_play); Gtk.GridLayoutChild button_next_layout_child = (Gtk.GridLayoutChild) layout_manager.get_layout_child (button_next); Gtk.GridLayoutChild button_loop_layout_child = (Gtk.GridLayoutChild) layout_manager.get_layout_child (button_loop); if (value) { button_shuffle_layout_child.column = 0; button_shuffle_layout_child.row = 1; button_prev_layout_child.column = 0; button_prev_layout_child.row = 0; button_play_layout_child.column = 1; button_play_layout_child.row = 0; button_next_layout_child.column = 2; button_next_layout_child.row = 0; button_loop_layout_child.column = 2; button_loop_layout_child.row = 1; } else { button_shuffle_layout_child.column = 0; button_shuffle_layout_child.row = 0; button_prev_layout_child.column = 1; button_prev_layout_child.row = 0; button_play_layout_child.column = 2; button_play_layout_child.row = 0; button_next_layout_child.column = 3; button_next_layout_child.row = 0; button_loop_layout_child.column = 4; button_loop_layout_child.row = 0; } } } Gtk.Button button_play; Gtk.Button button_prev; Gtk.Button button_next; StatefulLoopButton button_loop; Gtk.ToggleButton button_shuffle; construct { this.column_spacing = this.row_spacing = 8; button_shuffle = new Gtk.ToggleButton () { icon_name = "playlist-shuffle-symbolic", halign = Gtk.Align.CENTER, valign = Gtk.Align.CENTER, // translators: button tooltip text tooltip_text = _("Shuffle") }; button_shuffle.add_css_class ("circular"); this.attach (button_shuffle, 0, 0); button_prev = new Gtk.Button.from_icon_name (is_rtl ? "skip-forward-large-symbolic" : "skip-backward-large-symbolic") { css_classes = {"circular"}, halign = Gtk.Align.CENTER, valign = Gtk.Align.CENTER, // translators: button tooltip text tooltip_text = _("Previous Song") }; this.attach (button_prev, 1, 0); button_play = new Gtk.Button.from_icon_name ("play-large-symbolic") { css_classes = {"circular", "large"}, halign = Gtk.Align.CENTER, valign = Gtk.Align.CENTER, tooltip_text = _("Play") }; this.attach (button_play, 2, 0); button_next = new Gtk.Button.from_icon_name (is_rtl ? "skip-backward-large-symbolic" : "skip-forward-large-symbolic") { css_classes = {"circular"}, halign = Gtk.Align.CENTER, valign = Gtk.Align.CENTER, // translators: button tooltip text tooltip_text = _("Next Song") }; this.attach (button_next, 3, 0); button_loop = new StatefulLoopButton () { css_classes = {"circular"}, halign = Gtk.Align.CENTER, valign = Gtk.Align.CENTER }; this.attach (button_loop, 4, 0); button_next.clicked.connect (play_next); button_play.clicked.connect (play_pause); button_prev.clicked.connect (play_back); button_shuffle.clicked.connect (toggle_shuffle); button_loop.clicked.connect (loop_update); } private void loop_update () { commanded (button_loop.next_command); } private void toggle_shuffle () { // hack for togglebutton being checked regardless on clicked button_shuffle.active = !button_shuffle.active; commanded (Command.SHUFFLE); } private void play_next () { commanded (Command.NEXT); } private void play_pause () { commanded (Command.PLAY_PAUSE); } private void play_back () { commanded (Command.PREVIOUS); } } turntable/src/Widgets/Marquee.vala000066400000000000000000000132001512353730000175120ustar00rootroot00000000000000// Ported from Amberol https://gitlab.gnome.org/World/amberol/-/blob/36872777b77b0bdae8811867d3acce3185bef8fd/src/marquee.rs public class Turntable.Widgets.Marquee : Gtk.Widget { const float SPACING = 32f; const uint ANIMATION_DURATION = 250; Adw.TimedAnimation animation; Gtk.Label label; bool label_fits = false; Gdk.RGBA black = Gdk.RGBA () { red = 0f, green = 0f, blue = 0f, alpha = 1f }; Gdk.RGBA transparent = Gdk.RGBA () { red = 0f, green = 0f, blue = 0f, alpha = 0f }; private float _rotation_progress = 0.0f; public float rotation_progress { get { return _rotation_progress; } set { float new_val = rem_euclid (value, 1.0f); if (_rotation_progress != new_val) { _rotation_progress = new_val; this.queue_draw (); } } } private float _width_chars = 1f; public float width_chars { get { return _width_chars; } set { if (_width_chars != value) { _width_chars = value; this.queue_resize (); } } } public int32 width_pixels { get { Pango.FontMetrics metrics = this.get_pango_context ().get_metrics (null, null); int char_width = int.max ( metrics.get_approximate_char_width (), metrics.get_approximate_digit_width () ); return (int32) (char_width * this.width_chars / Pango.SCALE); } } public float xalign { get { return label.xalign; } set { label.xalign = value; } } private bool _force_width = false; public bool force_width { get { return _force_width; } set { if (_force_width != value) { _force_width = value; this.queue_resize (); } } } private float rem_euclid (float value, float mod) { float r = value % mod; return (r < 0.0) ? r + mod : r; } static construct { set_css_name ("label"); set_accessible_role (Gtk.AccessibleRole.LABEL); } uint animation_end_id = 0; private void on_animation_end () { animation_end_id = GLib.Timeout.add_once (1500, start_animation_once); } private void start_animation_once () { if (animation_end_id == 0) return; animation_end_id = 0; start_animation (); } private void start_animation () { if (label_fits || animation.state == Adw.AnimationState.PLAYING) return; animation.play (); } private void stop_animation () { animation.pause (); } private string _content = ""; public string content { get { return _content; } set { if (_content != value) { _content = value == null ? "" : value; label.label = _content; if (animation.state == Adw.AnimationState.PLAYING) animation.skip (); } } } construct { label = new Gtk.Label (""); label.set_parent (this); animation = new Adw.TimedAnimation (this, 0.0, 1.0, ANIMATION_DURATION, new Adw.PropertyAnimationTarget (this, "rotation-progress")) { easing = Adw.Easing.EASE_IN_OUT_CUBIC }; animation.done.connect (on_animation_end); } ~Marquee () { debug ("Destroying"); label.unparent (); if (animation_end_id != 0) GLib.Source.remove (animation_end_id); animation_end_id = 0; } public override void measure ( Gtk.Orientation orientation, int for_size, out int minimum, out int natural, out int minimum_baseline, out int natural_baseline ) { label.measure (orientation, for_size, out minimum, out natural, out minimum_baseline, out natural_baseline); if (orientation == Gtk.Orientation.HORIZONTAL) { minimum = 0; natural = this.force_width ? 308 : int.min (308, natural); minimum_baseline = -1; natural_baseline = -1; } } public override void size_allocate (int width, int height, int baseline) { Gtk.Requisition natural; label.get_preferred_size (null, out natural); int child_width = int.max (natural.width, width); label.allocate (child_width, natural.height, -1, null); animation.duration = (uint) (int.max (child_width, 20) * 30); if (label.get_width () > width) { label_fits = false; start_animation (); } else { label_fits = true; stop_animation (); } } public override void snapshot (Gtk.Snapshot snapshot) { if (label_fits) { base.snapshot (snapshot); return; } int width = this.get_width (); Gtk.Snapshot parent_snapshot = new Gtk.Snapshot (); base.snapshot (parent_snapshot); Gsk.RenderNode? node = parent_snapshot.to_node (); if (node == null) { base.snapshot (snapshot); return; } Graphene.Rect node_bounds = node.get_bounds (); float label_width = node_bounds.size.width; float label_height = node_bounds.size.height; float gradient_width = SPACING * 0.5f; Graphene.Rect bounds = Graphene.Rect () { origin = Graphene.Point () { x = -gradient_width, y = node_bounds.origin.y }, size = Graphene.Size () { width = width + gradient_width, height = label_height } }; snapshot.push_mask (Gsk.MaskMode.INVERTED_ALPHA); Graphene.Point l_start = bounds.get_top_left (); Graphene.Point l_end = bounds.get_top_left (); l_end.x += gradient_width; snapshot.append_linear_gradient (bounds, l_start, l_end, { Gsk.ColorStop () { offset = 0.0f, color = black }, Gsk.ColorStop () { offset = 1.0f, color = transparent } }); Graphene.Point r_start = bounds.get_top_right (); Graphene.Point r_end = bounds.get_top_right (); r_start.x -= gradient_width; snapshot.append_linear_gradient (bounds, r_start, r_end, { Gsk.ColorStop () { offset = 0.0f, color = transparent }, Gsk.ColorStop () { offset = 1.0f, color = black } }); snapshot.pop (); snapshot.push_clip (bounds); snapshot.translate (Graphene.Point () { x = - (label_width + SPACING) * this.rotation_progress, y = 0f }); snapshot.append_node (node); snapshot.translate (Graphene.Point () { x = label_width + SPACING, y = 0f }); snapshot.append_node (node); snapshot.pop (); snapshot.pop (); } } turntable/src/Widgets/ProgressBin.vala000066400000000000000000000200261512353730000203540ustar00rootroot00000000000000public class Turntable.Widgets.ProgressBin : Adw.Bin { Gdk.RGBA color; Gdk.RGBA accent_color; Adw.TimedAnimation animation; Gtk.Overlay overlay; Gtk.Image client_icon_widget; Adw.TimedAnimation client_icon_animation; ~ProgressBin () { debug ("Destroying"); } public enum ClientIconStyle { NONE, SYMBOLIC, FULL_COLOR; public string to_string () { switch (this) { case NONE: return "none"; case FULL_COLOR: return "full-color"; default: return "symbolic"; } } public static ClientIconStyle from_string (string string_style) { switch (string_style) { case "none": return NONE; case "full-color": return FULL_COLOR; default: return SYMBOLIC; } } } private void update_client_icon () { string name = this.client_icon; switch (this.client_icon_style) { case NONE: client_icon_widget.visible = false; break; case FULL_COLOR: client_icon_widget.visible = true; if (name.down ().has_suffix ("-symbolic")) { name = name.substring (0, name.length - 9); } if (client_icon_widget.has_css_class ("dim-label")) client_icon_widget.remove_css_class ("dim-label"); break; case SYMBOLIC: client_icon_widget.visible = true; if (!name.down ().has_suffix ("-symbolic")) { name = @"$name-symbolic"; } if (!client_icon_widget.has_css_class ("dim-label")) client_icon_widget.add_css_class ("dim-label"); break; } _client_icon = client_icon_widget.icon_name = name; } private bool _client_icon_revealed = true; public bool client_icon_revealed { get { return _client_icon_revealed; } set { if (value != _client_icon_revealed) { _client_icon_revealed = value; client_icon_animation.value_from = client_icon_animation.value; client_icon_animation.value_to = value ? 1 : 0; client_icon_animation.play (); } } } public Gtk.Widget content { set { overlay.child = value; } } private ClientIconStyle _client_icon_style = ClientIconStyle.SYMBOLIC; public ClientIconStyle client_icon_style { get { return _client_icon_style; } set { if (value != _client_icon_style) { _client_icon_style = value; update_client_icon (); } } } public bool client_icon_large { get { return client_icon_widget.icon_size == Gtk.IconSize.LARGE; } set { client_icon_widget.icon_size = value ? Gtk.IconSize.LARGE : Gtk.IconSize.NORMAL; } } private string _client_icon = "application-x-executable-symbolic"; public string client_icon { get { return _client_icon; } set { if (value == null) value = "application-x-executable-symbolic"; if (_client_icon != value) { _client_icon = value; update_client_icon (); } } } public string? client_name { set { // translators: default string when MPRIS Client (aka Music playing app) doesn't have a name client_icon_widget.tooltip_text = value == null || value == "" ? _("Unknown Client") : value; } } private Utils.Color.ExtractedColors? _extracted_colors = null; public Utils.Color.ExtractedColors? extracted_colors { get { return _extracted_colors; } set { _extracted_colors = value; update_color (); } } private bool _enabled = true; public bool enabled { get { return _enabled; } set { if (_enabled != value) { _enabled = value; this.queue_draw (); } } } private bool _extract_colors_enabled = true; public bool extract_colors_enabled { get { return _extract_colors_enabled; } set { if (_extract_colors_enabled != value) { _extract_colors_enabled = value; update_color (); } } } private void update_color () { Gdk.RGBA new_color = accent_color; if (this.extracted_colors != null && this.extract_colors_enabled) { new_color = Adw.StyleManager.get_default ().dark ? extracted_colors.dark : extracted_colors.light; new_color.alpha = 0.5f; } if (new_color.red != color.red || new_color.green != color.green || new_color.blue != color.blue || new_color.alpha != color.alpha) { color = new_color; if (this.progress != 0) this.queue_draw (); } } private double _progress = 0; public double progress { get { return _progress; } set { double new_val = value.clamp (0.0, 1.0); if (_progress != new_val) { animation.value_from = animation.state == Adw.AnimationState.PLAYING ? animation.value : _progress; animation.value_to = new_val; _progress = new_val; if (this.enabled) animation.play (); } } } // private int32 _offset = 0; // public int32 offset { // get { return _offset; } // set { // if (_offset != value && value >= 0) { // _offset = value; // this.queue_draw (); // } // } // } private Gtk.Orientation _orientation = Gtk.Orientation.HORIZONTAL; public Gtk.Orientation orientation { get { return _orientation; } set { if (_orientation != value) { _orientation = value; this.queue_draw (); } } } private void update_accent_color () { accent_color = Adw.StyleManager.get_default ().get_accent_color_rgba (); accent_color.alpha = 0.5f; update_color (); } // fix leak // callback animation target seems to leak public double animation_cb { set { this.queue_draw (); } } construct { var default_sm = Adw.StyleManager.get_default (); if (default_sm.system_supports_accent_colors) { default_sm.notify["accent-color-rgba"].connect (update_accent_color); default_sm.notify["dark"].connect (update_color); update_accent_color (); } else { accent_color = { 120 / 255.0f, 174 / 255.0f, 237 / 255.0f, 0.5f }; } animation = new Adw.TimedAnimation (this, 0.0, 1.0, PROGRESS_UPDATE_TIME, new Adw.PropertyAnimationTarget (this, "animation-cb")) { easing = Adw.Easing.LINEAR }; overlay = new Gtk.Overlay (); client_icon_widget = new Gtk.Image.from_icon_name ("application-x-executable-symbolic") { tooltip_text = _("Unknown Client"), valign = Gtk.Align.END, halign = Gtk.Align.END, margin_end = 6, margin_bottom = 6, icon_size = Gtk.IconSize.LARGE, can_target = false }; client_icon_animation = new Adw.TimedAnimation (this, 0.0, 1.0, 250, new Adw.PropertyAnimationTarget (client_icon_widget, "opacity")) { easing = Adw.Easing.LINEAR }; overlay.add_overlay (client_icon_widget); this.child = overlay; } private weak Gdk.Texture? _cover = null; public weak Gdk.Texture? cover { get { return _cover; } set { if (_cover != value) { _cover = value; this.queue_draw (); } } } public override void snapshot (Gtk.Snapshot snapshot) { int height = this.get_height (); int width = this.get_width (); if (_cover != null && _cover is Gdk.Texture) { snapshot.push_blur (20); double ratio = _cover.get_intrinsic_aspect_ratio (); if (ratio == 0) { _cover.snapshot (snapshot, width, height); } else { double w = 0.0; double h = 0.0; double picture_ratio = (double) width / height; if (ratio > picture_ratio) { w = height * ratio; h = height; } else { w = width; h = width / ratio; } w = Math.ceil (w); h = Math.ceil (h); double x = (width - w) / 2; double y = Math.floor (height - h) / 2; snapshot.save (); snapshot.translate (Graphene.Point () { x = (float) x - 20, y = (float) y - 20 }); _cover.snapshot (snapshot, w + 40, h + 40); snapshot.restore (); } snapshot.pop (); } if (this.enabled && this.animation.value > 0) { switch (this.orientation) { case Gtk.Orientation.VERTICAL: snapshot.append_color ( this.color, Graphene.Rect () { origin = Graphene.Point () { x = 0, y = 0 }, size = Graphene.Size () { height = (float) (height * this.animation.value), width = this.get_width () } } ); break; default: snapshot.append_color ( this.color, Graphene.Rect () { origin = Graphene.Point () { x = 0, y = 0 }, size = Graphene.Size () { height = height, width = (float) (width * this.animation.value) } } ); break; } } base.snapshot (snapshot); } } turntable/src/Widgets/ProgressScale.vala000066400000000000000000000115521512353730000206770ustar00rootroot00000000000000public class Turntable.Widgets.ProgressScale : Adw.BreakpointBin { ~ProgressScale () { debug ("Destroying"); if (changed_timeout > 0) GLib.Source.remove (changed_timeout); } public signal void progress_changed (double new_progress); public enum Style { NONE, KNOB, OVERLAY; public string to_string () { switch (this) { case OVERLAY: return "overlay"; case KNOB: return "knob"; default: return "none"; } } public static Style from_string (string string_style) { switch (string_style.down ()) { case "overlay": return OVERLAY; case "knob": return KNOB; default: return NONE; } } } private Style _style = NONE; public Style style { get { return _style; } set { _style = value; switch (value) { case NONE: this.visible = false; return; case KNOB: this.css_classes = {"progressscale" }; this.visible = true; return; case OVERLAY: this.css_classes = {"progressscale", "overlay"}; this.visible = true; return; default: assert_not_reached (); } } } private double _progress = 0; public double progress { get { return scale.get_value (); } set { double new_val = value.clamp (0.0, 1.0); if (_progress != new_val) { _progress = new_val; scale.set_value (new_val); } } } public int64 playtime { set { playtime_label.label = playtime_label.label = micro_to_mmss (value); } } public int64 length { set { length_label.label = micro_to_mmss (value); } } private string micro_to_mmss (int64 value) { int64 total_seconds = value / 1000000; return "%02d:%02d".printf ((int) (total_seconds / 60), (int) (total_seconds % 60)); } bool _newline = false; public bool newline { get { return _newline; } set { if (_newline == value) return; _newline = value; Gtk.GridLayout layout_manager = (Gtk.GridLayout) grid.get_layout_manager (); Gtk.GridLayoutChild playtime_label_layout_child = (Gtk.GridLayoutChild) layout_manager.get_layout_child (playtime_label); Gtk.GridLayoutChild scale_layout_child = (Gtk.GridLayoutChild) layout_manager.get_layout_child (scale); Gtk.GridLayoutChild length_label_layout_child = (Gtk.GridLayoutChild) layout_manager.get_layout_child (length_label); if (value) { playtime_label_layout_child.column = 0; playtime_label_layout_child.row = 1; playtime_label_layout_child.column_span = 1; playtime_label_layout_child.row_span = 1; scale_layout_child.column = 0; scale_layout_child.row = 0; scale_layout_child.column_span = 3; scale_layout_child.row_span = 1; length_label_layout_child.column = 2; length_label_layout_child.row = 1; length_label_layout_child.column_span = 1; length_label_layout_child.row_span = 1; } else { playtime_label_layout_child.column = 0; playtime_label_layout_child.row = 0; playtime_label_layout_child.column_span = 1; playtime_label_layout_child.row_span = 1; scale_layout_child.column = 1; scale_layout_child.row = 0; scale_layout_child.column_span = 1; scale_layout_child.row_span = 1; length_label_layout_child.column = 2; length_label_layout_child.row = 0; length_label_layout_child.column_span = 1; length_label_layout_child.row_span = 1; } } } Gtk.Grid grid; Gtk.Label playtime_label; Gtk.Label length_label; Gtk.Scale scale; construct { this.visible = false; this.width_request = 156; this.height_request = 51; grid = new Gtk.Grid () { valign = CENTER }; playtime_label = new Gtk.Label ("0:00") { focusable = false, halign = START, valign = CENTER, css_classes = { "dim-label", "numeric", "small-text" } }; grid.attach (playtime_label, 0, 0); scale = new Gtk.Scale.with_range (HORIZONTAL, 0, 1, 0.01) { hexpand = true, valign = CENTER, focusable = true, draw_value = false }; scale.change_value.connect (on_value_changed); grid.attach (scale, 1, 0); length_label = new Gtk.Label ("0:00") { focusable = false, halign = END, valign = CENTER, css_classes = { "dim-label", "numeric", "small-text" } }; grid.attach (length_label, 2, 0); this.child = grid; var bp = new Adw.Breakpoint ( new Adw.BreakpointCondition.length ( Adw.BreakpointConditionLengthType.MAX_WIDTH, 200, Adw.LengthUnit.PX ) ); bp.add_setter (this, "newline", true); this.add_breakpoint (bp); } uint changed_timeout = 0; double final_new_value = 0; private bool on_value_changed (Gtk.ScrollType scroll, double new_value) { final_new_value = new_value; if (changed_timeout > 0) GLib.Source.remove (changed_timeout); changed_timeout = GLib.Timeout.add (PROGRESS_UPDATE_TIME, on_position_changed, Priority.LOW); return true; } private bool on_position_changed () { if (changed_timeout == 0) return GLib.Source.REMOVE; changed_timeout = 0; progress_changed (final_new_value); return GLib.Source.REMOVE; } } turntable/src/Widgets/Tonearm.vala000066400000000000000000000112421512353730000175240ustar00rootroot00000000000000public class Turntable.Widgets.Tonearm : Adw.Bin { Adw.TimedAnimation animation; const int ARM_WIDTH = 10; const int CIRCLE_SIZE = 36; const Gsk.ColorStop[] ARM_GRADIENT = { {0, {0.6039f, 0.6f, 0.5882f, 1}}, {0.5f, {0.8706f, 0.8667f, 0.8549f, 1}}, {1, {0.4667f, 0.4627f, 0.4824f, 1}}, }; const Gsk.ColorStop[] NEEDLE_GRADIENT = { {0, {0.3686f, 0.3608f, 0.3922f, 1}}, {0.5f, {0.6039f, 0.6f, 0.5882f, 1}}, {1, {0.2392f, 0.2196f, 0.2745f, 1}}, }; const Gsk.ColorStop[] CIRCLE_GRADIENT = { {0, {0.7529f, 0.7490f, 0.7373f, 0.3f}}, {1, {0.8706f, 0.8667f, 0.8549f, 0.3f}}, }; ~Tonearm () { debug ("Destroying"); } // fix leak // callback animation target seems to leak public double animation_cb { set { this.queue_draw (); } } private int arm_height = 150; private float rotation_start = 2.5f; private float rotation_end = 15.5f; private Views.Window.Size _size = REGULAR; public Views.Window.Size size { get { return _size; } set { _size = value; if (enabled) update_cover_style (); } } private bool tonearm_visible = false; private bool _enabled = false; public bool enabled { get { return _enabled; } set { if (_enabled != value) { _enabled = value; if (value) { update_size (); update_cover_style (); } else { this.queue_draw (); } } } } private double _progress = 0; public double progress { get { return _progress; } set { if (this.enabled && this.tonearm_visible) { double new_val = value.clamp (0.0, 1.0); if (_progress != new_val) { animation.value_from = animation.state == Adw.AnimationState.PLAYING ? animation.value : _progress; animation.value_to = new_val; _progress = new_val; animation.play (); } } } } private void update_cover_style () { bool is_turntable = settings.cover_style == Widgets.Cover.Style.TURNTABLE.to_string (); switch (this.size) { case SMALL: this.tonearm_visible = false; break; case BIG: this.tonearm_visible = is_turntable; arm_height = 192; rotation_start = 1.5f; rotation_end = 14.6f; break; default: this.tonearm_visible = is_turntable; arm_height = 150; rotation_start = 2.5f; rotation_end = 15.5f; break; } this.queue_draw (); } construct { this.size = Views.Window.Size.REGULAR; animation = new Adw.TimedAnimation (this, 0.0, 1.0, PROGRESS_UPDATE_TIME, new Adw.PropertyAnimationTarget (this, "animation-cb")) { easing = Adw.Easing.LINEAR }; settings.notify["cover-size"].connect (update_size); settings.notify["cover-style"].connect (update_cover_style); if (this.enabled) update_size (); } private void update_size () { this.size = Views.Window.Size.from_string (settings.cover_size); } public override void snapshot (Gtk.Snapshot snapshot) { base.snapshot (snapshot); if (!this.enabled || !this.tonearm_visible) return; snapshot.translate ({ this.get_width () - 8 , 0 }); snapshot.save (); snapshot.translate ({-CIRCLE_SIZE / 2, 0}); var rounded_rect = Gsk.RoundedRect ().init_from_rect ({ { -ARM_WIDTH / 2, 0 }, { ARM_WIDTH, arm_height + CIRCLE_SIZE / 2 } }, 3f); int y = CIRCLE_SIZE / 2 + 8; var transform = new Gsk.Transform (); transform = transform.translate ({ 0, y }); transform = transform.rotate ((float) (rotation_start + (rotation_end - rotation_start) * animation.value)); transform = transform.translate ({ 0, -y }); snapshot.transform (transform); snapshot.push_rounded_clip (rounded_rect); snapshot.append_linear_gradient ( { {-ARM_WIDTH / 2, 0}, { ARM_WIDTH, arm_height + CIRCLE_SIZE / 2 } }, { -ARM_WIDTH / 2, 0 }, { ARM_WIDTH, 0 }, ARM_GRADIENT ); snapshot.pop (); Graphene.Rect rect = { {- (ARM_WIDTH + 2) / 2, arm_height + CIRCLE_SIZE / 2 - 24}, { (ARM_WIDTH + 2), 20 } }; rounded_rect = Gsk.RoundedRect ().init_from_rect (rect, 3f); snapshot.push_rounded_clip (rounded_rect); snapshot.append_linear_gradient ( rect, { rect.origin.x, 0 }, { rect.size.width, 0 }, NEEDLE_GRADIENT ); snapshot.pop (); snapshot.restore (); snapshot.translate ({-CIRCLE_SIZE, 8 }); rounded_rect = Gsk.RoundedRect ().init_from_rect ({{0, 0}, {CIRCLE_SIZE, CIRCLE_SIZE + 2}}, 9999f); snapshot.push_rounded_clip (rounded_rect); snapshot.append_color ({0.7529f, 0.7490f, 0.7373f, 1}, {{0, 0}, {CIRCLE_SIZE, CIRCLE_SIZE + 2}}); snapshot.pop (); Graphene.Rect circle_rect = {{0, 0}, {CIRCLE_SIZE, CIRCLE_SIZE}}; rounded_rect = Gsk.RoundedRect ().init_from_rect (circle_rect, 9999f); snapshot.push_rounded_clip (rounded_rect); snapshot.append_linear_gradient ( circle_rect, { 0, 0 }, { 0, CIRCLE_SIZE }, CIRCLE_GRADIENT ); snapshot.pop (); } } turntable/src/Widgets/meson.build000066400000000000000000000002651512353730000174170ustar00rootroot00000000000000sources += files( 'ControlsOverlay.vala', 'Cover.vala', 'Marquee.vala', 'MPRISControls.vala', 'ProgressBin.vala', 'ProgressScale.vala', 'Tonearm.vala' ) turntable/src/meson.build000066400000000000000000000004121512353730000160030ustar00rootroot00000000000000sources += configure_file( input : 'Build.vala.in', output : 'Build.vala', configuration : config ) sources += files( 'Application.vala' ) subdir('Mpris') if scrobbling subdir('Scrobbling') endif subdir('Utils') subdir('Views') subdir('Widgets') turntable/vala-lint.conf000066400000000000000000000000511512353730000156070ustar00rootroot00000000000000[Checks] use-of-tabs=off line-length=off