├── .gitignore ├── LICENSE.md ├── README.md ├── REUSE.toml ├── cli ├── gen-icon.js ├── get-version.sh └── update-po.sh ├── jsconfig.json ├── meson.build ├── meson.options ├── po ├── LINGUAS ├── POTFILES.in ├── meson.build ├── nl.po └── zh_CN.po ├── res ├── data │ ├── extension.gresource.xml.in │ ├── meson.build │ ├── metadata.json.in │ ├── mpris.xml │ └── scalable │ │ └── status │ │ └── meson.build ├── meson.build └── schema │ ├── meson.build │ └── schemas.gschema.xml.in └── src ├── const.js ├── extension.js ├── fubar.js ├── lyric.js ├── menu.js ├── mpris.js ├── paper.js ├── prefs.js ├── ui.js └── util.js /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | po/*.pot 3 | 4 | node_modules 5 | package.json 6 | package-lock.json 7 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # GNU GENERAL PUBLIC LICENSE 2 | 3 | Version 3, 29 June 2007 4 | 5 | Copyright (C) 2007 Free Software Foundation, Inc. 6 | 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this 9 | license document, but changing it is not allowed. 10 | 11 | ## Preamble 12 | 13 | The GNU General Public License is a free, copyleft license for 14 | software and other kinds of works. 15 | 16 | The licenses for most software and other practical works are designed 17 | to take away your freedom to share and change the works. By contrast, 18 | the GNU General Public License is intended to guarantee your freedom 19 | to share and change all versions of a program--to make sure it remains 20 | free software for all its users. We, the Free Software Foundation, use 21 | the GNU General Public License for most of our software; it applies 22 | also to any other work released this way by its authors. You can apply 23 | it to your programs, too. 24 | 25 | When we speak of free software, we are referring to freedom, not 26 | price. Our General Public Licenses are designed to make sure that you 27 | have the freedom to distribute copies of free software (and charge for 28 | them if you wish), that you receive source code or can get it if you 29 | want it, that you can change the software or use pieces of it in new 30 | free programs, and that you know you can do these things. 31 | 32 | To protect your rights, we need to prevent others from denying you 33 | these rights or asking you to surrender the rights. Therefore, you 34 | have certain responsibilities if you distribute copies of the 35 | software, or if you modify it: responsibilities to respect the freedom 36 | of others. 37 | 38 | For example, if you distribute copies of such a program, whether 39 | gratis or for a fee, you must pass on to the recipients the same 40 | freedoms that you received. You must make sure that they, too, receive 41 | or can get the source code. And you must show them these terms so they 42 | know their rights. 43 | 44 | Developers that use the GNU GPL protect your rights with two steps: 45 | (1) assert copyright on the software, and (2) offer you this License 46 | giving you legal permission to copy, distribute and/or modify it. 47 | 48 | For the developers' and authors' protection, the GPL clearly explains 49 | that there is no warranty for this free software. For both users' and 50 | authors' sake, the GPL requires that modified versions be marked as 51 | changed, so that their problems will not be attributed erroneously to 52 | authors of previous versions. 53 | 54 | Some devices are designed to deny users access to install or run 55 | modified versions of the software inside them, although the 56 | manufacturer can do so. This is fundamentally incompatible with the 57 | aim of protecting users' freedom to change the software. The 58 | systematic pattern of such abuse occurs in the area of products for 59 | individuals to use, which is precisely where it is most unacceptable. 60 | Therefore, we have designed this version of the GPL to prohibit the 61 | practice for those products. If such problems arise substantially in 62 | other domains, we stand ready to extend this provision to those 63 | domains in future versions of the GPL, as needed to protect the 64 | freedom of users. 65 | 66 | Finally, every program is threatened constantly by software patents. 67 | States should not allow patents to restrict development and use of 68 | software on general-purpose computers, but in those that do, we wish 69 | to avoid the special danger that patents applied to a free program 70 | could make it effectively proprietary. To prevent this, the GPL 71 | assures that patents cannot be used to render the program non-free. 72 | 73 | The precise terms and conditions for copying, distribution and 74 | modification follow. 75 | 76 | ## TERMS AND CONDITIONS 77 | 78 | ### 0. Definitions. 79 | 80 | "This License" refers to version 3 of the GNU General Public License. 81 | 82 | "Copyright" also means copyright-like laws that apply to other kinds 83 | of works, such as semiconductor masks. 84 | 85 | "The Program" refers to any copyrightable work licensed under this 86 | License. Each licensee is addressed as "you". "Licensees" and 87 | "recipients" may be individuals or organizations. 88 | 89 | To "modify" a work means to copy from or adapt all or part of the work 90 | in a fashion requiring copyright permission, other than the making of 91 | an exact copy. The resulting work is called a "modified version" of 92 | the earlier work or a work "based on" the earlier work. 93 | 94 | A "covered work" means either the unmodified Program or a work based 95 | on the Program. 96 | 97 | To "propagate" a work means to do anything with it that, without 98 | permission, would make you directly or secondarily liable for 99 | infringement under applicable copyright law, except executing it on a 100 | computer or modifying a private copy. Propagation includes copying, 101 | distribution (with or without modification), making available to the 102 | public, and in some countries other activities as well. 103 | 104 | To "convey" a work means any kind of propagation that enables other 105 | parties to make or receive copies. Mere interaction with a user 106 | through a computer network, with no transfer of a copy, is not 107 | conveying. 108 | 109 | An interactive user interface displays "Appropriate Legal Notices" to 110 | the extent that it includes a convenient and prominently visible 111 | feature that (1) displays an appropriate copyright notice, and (2) 112 | tells the user that there is no warranty for the work (except to the 113 | extent that warranties are provided), that licensees may convey the 114 | work under this License, and how to view a copy of this License. If 115 | the interface presents a list of user commands or options, such as a 116 | menu, a prominent item in the list meets this criterion. 117 | 118 | ### 1. Source Code. 119 | 120 | The "source code" for a work means the preferred form of the work for 121 | making modifications to it. "Object code" means any non-source form of 122 | a work. 123 | 124 | A "Standard Interface" means an interface that either is an official 125 | standard defined by a recognized standards body, or, in the case of 126 | interfaces specified for a particular programming language, one that 127 | is widely used among developers working in that language. 128 | 129 | The "System Libraries" of an executable work include anything, other 130 | than the work as a whole, that (a) is included in the normal form of 131 | packaging a Major Component, but which is not part of that Major 132 | Component, and (b) serves only to enable use of the work with that 133 | Major Component, or to implement a Standard Interface for which an 134 | implementation is available to the public in source code form. A 135 | "Major Component", in this context, means a major essential component 136 | (kernel, window system, and so on) of the specific operating system 137 | (if any) on which the executable work runs, or a compiler used to 138 | produce the work, or an object code interpreter used to run it. 139 | 140 | The "Corresponding Source" for a work in object code form means all 141 | the source code needed to generate, install, and (for an executable 142 | work) run the object code and to modify the work, including scripts to 143 | control those activities. However, it does not include the work's 144 | System Libraries, or general-purpose tools or generally available free 145 | programs which are used unmodified in performing those activities but 146 | which are not part of the work. For example, Corresponding Source 147 | includes interface definition files associated with source files for 148 | the work, and the source code for shared libraries and dynamically 149 | linked subprograms that the work is specifically designed to require, 150 | such as by intimate data communication or control flow between those 151 | subprograms and other parts of the work. 152 | 153 | The Corresponding Source need not include anything that users can 154 | regenerate automatically from other parts of the Corresponding Source. 155 | 156 | The Corresponding Source for a work in source code form is that same 157 | work. 158 | 159 | ### 2. Basic Permissions. 160 | 161 | All rights granted under this License are granted for the term of 162 | copyright on the Program, and are irrevocable provided the stated 163 | conditions are met. This License explicitly affirms your unlimited 164 | permission to run the unmodified Program. The output from running a 165 | covered work is covered by this License only if the output, given its 166 | content, constitutes a covered work. This License acknowledges your 167 | rights of fair use or other equivalent, as provided by copyright law. 168 | 169 | You may make, run and propagate covered works that you do not convey, 170 | without conditions so long as your license otherwise remains in force. 171 | You may convey covered works to others for the sole purpose of having 172 | them make modifications exclusively for you, or provide you with 173 | facilities for running those works, provided that you comply with the 174 | terms of this License in conveying all material for which you do not 175 | control copyright. Those thus making or running the covered works for 176 | you must do so exclusively on your behalf, under your direction and 177 | control, on terms that prohibit them from making any copies of your 178 | copyrighted material outside their relationship with you. 179 | 180 | Conveying under any other circumstances is permitted solely under the 181 | conditions stated below. Sublicensing is not allowed; section 10 makes 182 | it unnecessary. 183 | 184 | ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 185 | 186 | No covered work shall be deemed part of an effective technological 187 | measure under any applicable law fulfilling obligations under article 188 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 189 | similar laws prohibiting or restricting circumvention of such 190 | measures. 191 | 192 | When you convey a covered work, you waive any legal power to forbid 193 | circumvention of technological measures to the extent such 194 | circumvention is effected by exercising rights under this License with 195 | respect to the covered work, and you disclaim any intention to limit 196 | operation or modification of the work as a means of enforcing, against 197 | the work's users, your or third parties' legal rights to forbid 198 | circumvention of technological measures. 199 | 200 | ### 4. Conveying Verbatim Copies. 201 | 202 | You may convey verbatim copies of the Program's source code as you 203 | receive it, in any medium, provided that you conspicuously and 204 | appropriately publish on each copy an appropriate copyright notice; 205 | keep intact all notices stating that this License and any 206 | non-permissive terms added in accord with section 7 apply to the code; 207 | keep intact all notices of the absence of any warranty; and give all 208 | recipients a copy of this License along with the Program. 209 | 210 | You may charge any price or no price for each copy that you convey, 211 | and you may offer support or warranty protection for a fee. 212 | 213 | ### 5. Conveying Modified Source Versions. 214 | 215 | You may convey a work based on the Program, or the modifications to 216 | produce it from the Program, in the form of source code under the 217 | terms of section 4, provided that you also meet all of these 218 | conditions: 219 | 220 | - a) The work must carry prominent notices stating that you modified 221 | it, and giving a relevant date. 222 | - b) The work must carry prominent notices stating that it is 223 | released under this License and any conditions added under 224 | section 7. This requirement modifies the requirement in section 4 225 | to "keep intact all notices". 226 | - c) You must license the entire work, as a whole, under this 227 | License to anyone who comes into possession of a copy. This 228 | License will therefore apply, along with any applicable section 7 229 | additional terms, to the whole of the work, and all its parts, 230 | regardless of how they are packaged. This License gives no 231 | permission to license the work in any other way, but it does not 232 | invalidate such permission if you have separately received it. 233 | - d) If the work has interactive user interfaces, each must display 234 | Appropriate Legal Notices; however, if the Program has interactive 235 | interfaces that do not display Appropriate Legal Notices, your 236 | work need not make them do so. 237 | 238 | A compilation of a covered work with other separate and independent 239 | works, which are not by their nature extensions of the covered work, 240 | and which are not combined with it such as to form a larger program, 241 | in or on a volume of a storage or distribution medium, is called an 242 | "aggregate" if the compilation and its resulting copyright are not 243 | used to limit the access or legal rights of the compilation's users 244 | beyond what the individual works permit. Inclusion of a covered work 245 | in an aggregate does not cause this License to apply to the other 246 | parts of the aggregate. 247 | 248 | ### 6. Conveying Non-Source Forms. 249 | 250 | You may convey a covered work in object code form under the terms of 251 | sections 4 and 5, provided that you also convey the machine-readable 252 | Corresponding Source under the terms of this License, in one of these 253 | ways: 254 | 255 | - a) Convey the object code in, or embodied in, a physical product 256 | (including a physical distribution medium), accompanied by the 257 | Corresponding Source fixed on a durable physical medium 258 | customarily used for software interchange. 259 | - b) Convey the object code in, or embodied in, a physical product 260 | (including a physical distribution medium), accompanied by a 261 | written offer, valid for at least three years and valid for as 262 | long as you offer spare parts or customer support for that product 263 | model, to give anyone who possesses the object code either (1) a 264 | copy of the Corresponding Source for all the software in the 265 | product that is covered by this License, on a durable physical 266 | medium customarily used for software interchange, for a price no 267 | more than your reasonable cost of physically performing this 268 | conveying of source, or (2) access to copy the Corresponding 269 | Source from a network server at no charge. 270 | - c) Convey individual copies of the object code with a copy of the 271 | written offer to provide the Corresponding Source. This 272 | alternative is allowed only occasionally and noncommercially, and 273 | only if you received the object code with such an offer, in accord 274 | with subsection 6b. 275 | - d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | - e) Convey the object code using peer-to-peer transmission, 288 | provided you inform other peers where the object code and 289 | Corresponding Source of the work are being offered to the general 290 | public at no charge under subsection 6d. 291 | 292 | A separable portion of the object code, whose source code is excluded 293 | from the Corresponding Source as a System Library, need not be 294 | included in conveying the object code work. 295 | 296 | A "User Product" is either (1) a "consumer product", which means any 297 | tangible personal property which is normally used for personal, 298 | family, or household purposes, or (2) anything designed or sold for 299 | incorporation into a dwelling. In determining whether a product is a 300 | consumer product, doubtful cases shall be resolved in favor of 301 | coverage. For a particular product received by a particular user, 302 | "normally used" refers to a typical or common use of that class of 303 | product, regardless of the status of the particular user or of the way 304 | in which the particular user actually uses, or expects or is expected 305 | to use, the product. A product is a consumer product regardless of 306 | whether the product has substantial commercial, industrial or 307 | non-consumer uses, unless such uses represent the only significant 308 | mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to 312 | install and execute modified versions of a covered work in that User 313 | Product from a modified version of its Corresponding Source. The 314 | information must suffice to ensure that the continued functioning of 315 | the modified object code is in no case prevented or interfered with 316 | solely because modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or 331 | updates for a work that has been modified or installed by the 332 | recipient, or for the User Product in which it has been modified or 333 | installed. Access to a network may be denied when the modification 334 | itself materially and adversely affects the operation of the network 335 | or violates the rules and protocols for communication across the 336 | network. 337 | 338 | Corresponding Source conveyed, and Installation Information provided, 339 | in accord with this section must be in a format that is publicly 340 | documented (and with an implementation available to the public in 341 | source code form), and must require no special password or key for 342 | unpacking, reading or copying. 343 | 344 | ### 7. Additional Terms. 345 | 346 | "Additional permissions" are terms that supplement the terms of this 347 | License by making exceptions from one or more of its conditions. 348 | Additional permissions that are applicable to the entire Program shall 349 | be treated as though they were included in this License, to the extent 350 | that they are valid under applicable law. If additional permissions 351 | apply only to part of the Program, that part may be used separately 352 | under those permissions, but the entire Program remains governed by 353 | this License without regard to the additional permissions. 354 | 355 | When you convey a copy of a covered work, you may at your option 356 | remove any additional permissions from that copy, or from any part of 357 | it. (Additional permissions may be written to require their own 358 | removal in certain cases when you modify the work.) You may place 359 | additional permissions on material, added by you to a covered work, 360 | for which you have or can give appropriate copyright permission. 361 | 362 | Notwithstanding any other provision of this License, for material you 363 | add to a covered work, you may (if authorized by the copyright holders 364 | of that material) supplement the terms of this License with terms: 365 | 366 | - a) Disclaiming warranty or limiting liability differently from the 367 | terms of sections 15 and 16 of this License; or 368 | - b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | - c) Prohibiting misrepresentation of the origin of that material, 372 | or requiring that modified versions of such material be marked in 373 | reasonable ways as different from the original version; or 374 | - d) Limiting the use for publicity purposes of names of licensors 375 | or authors of the material; or 376 | - e) Declining to grant rights under trademark law for use of some 377 | trade names, trademarks, or service marks; or 378 | - f) Requiring indemnification of licensors and authors of that 379 | material by anyone who conveys the material (or modified versions 380 | of it) with contractual assumptions of liability to the recipient, 381 | for any liability that these contractual assumptions directly 382 | impose on those licensors and authors. 383 | 384 | All other non-permissive additional terms are considered "further 385 | restrictions" within the meaning of section 10. If the Program as you 386 | received it, or any part of it, contains a notice stating that it is 387 | governed by this License along with a term that is a further 388 | restriction, you may remove that term. If a license document contains 389 | a further restriction but permits relicensing or conveying under this 390 | License, you may add to a covered work material governed by the terms 391 | of that license document, provided that the further restriction does 392 | not survive such relicensing or conveying. 393 | 394 | If you add terms to a covered work in accord with this section, you 395 | must place, in the relevant source files, a statement of the 396 | additional terms that apply to those files, or a notice indicating 397 | where to find the applicable terms. 398 | 399 | Additional terms, permissive or non-permissive, may be stated in the 400 | form of a separately written license, or stated as exceptions; the 401 | above requirements apply either way. 402 | 403 | ### 8. Termination. 404 | 405 | You may not propagate or modify a covered work except as expressly 406 | provided under this License. Any attempt otherwise to propagate or 407 | modify it is void, and will automatically terminate your rights under 408 | this License (including any patent licenses granted under the third 409 | paragraph of section 11). 410 | 411 | However, if you cease all violation of this License, then your license 412 | from a particular copyright holder is reinstated (a) provisionally, 413 | unless and until the copyright holder explicitly and finally 414 | terminates your license, and (b) permanently, if the copyright holder 415 | fails to notify you of the violation by some reasonable means prior to 416 | 60 days after the cessation. 417 | 418 | Moreover, your license from a particular copyright holder is 419 | reinstated permanently if the copyright holder notifies you of the 420 | violation by some reasonable means, this is the first time you have 421 | received notice of violation of this License (for any work) from that 422 | copyright holder, and you cure the violation prior to 30 days after 423 | your receipt of the notice. 424 | 425 | Termination of your rights under this section does not terminate the 426 | licenses of parties who have received copies or rights from you under 427 | this License. If your rights have been terminated and not permanently 428 | reinstated, you do not qualify to receive new licenses for the same 429 | material under section 10. 430 | 431 | ### 9. Acceptance Not Required for Having Copies. 432 | 433 | You are not required to accept this License in order to receive or run 434 | a copy of the Program. Ancillary propagation of a covered work 435 | occurring solely as a consequence of using peer-to-peer transmission 436 | to receive a copy likewise does not require acceptance. However, 437 | nothing other than this License grants you permission to propagate or 438 | modify any covered work. These actions infringe copyright if you do 439 | not accept this License. Therefore, by modifying or propagating a 440 | covered work, you indicate your acceptance of this License to do so. 441 | 442 | ### 10. Automatic Licensing of Downstream Recipients. 443 | 444 | Each time you convey a covered work, the recipient automatically 445 | receives a license from the original licensors, to run, modify and 446 | propagate that work, subject to this License. You are not responsible 447 | for enforcing compliance by third parties with this License. 448 | 449 | An "entity transaction" is a transaction transferring control of an 450 | organization, or substantially all assets of one, or subdividing an 451 | organization, or merging organizations. If propagation of a covered 452 | work results from an entity transaction, each party to that 453 | transaction who receives a copy of the work also receives whatever 454 | licenses to the work the party's predecessor in interest had or could 455 | give under the previous paragraph, plus a right to possession of the 456 | Corresponding Source of the work from the predecessor in interest, if 457 | the predecessor has it or can get it with reasonable efforts. 458 | 459 | You may not impose any further restrictions on the exercise of the 460 | rights granted or affirmed under this License. For example, you may 461 | not impose a license fee, royalty, or other charge for exercise of 462 | rights granted under this License, and you may not initiate litigation 463 | (including a cross-claim or counterclaim in a lawsuit) alleging that 464 | any patent claim is infringed by making, using, selling, offering for 465 | sale, or importing the Program or any portion of it. 466 | 467 | ### 11. Patents. 468 | 469 | A "contributor" is a copyright holder who authorizes use under this 470 | License of the Program or a work on which the Program is based. The 471 | work thus licensed is called the contributor's "contributor version". 472 | 473 | A contributor's "essential patent claims" are all patent claims owned 474 | or controlled by the contributor, whether already acquired or 475 | hereafter acquired, that would be infringed by some manner, permitted 476 | by this License, of making, using, or selling its contributor version, 477 | but do not include claims that would be infringed only as a 478 | consequence of further modification of the contributor version. For 479 | purposes of this definition, "control" includes the right to grant 480 | patent sublicenses in a manner consistent with the requirements of 481 | this License. 482 | 483 | Each contributor grants you a non-exclusive, worldwide, royalty-free 484 | patent license under the contributor's essential patent claims, to 485 | make, use, sell, offer for sale, import and otherwise run, modify and 486 | propagate the contents of its contributor version. 487 | 488 | In the following three paragraphs, a "patent license" is any express 489 | agreement or commitment, however denominated, not to enforce a patent 490 | (such as an express permission to practice a patent or covenant not to 491 | sue for patent infringement). To "grant" such a patent license to a 492 | party means to make such an agreement or commitment not to enforce a 493 | patent against the party. 494 | 495 | If you convey a covered work, knowingly relying on a patent license, 496 | and the Corresponding Source of the work is not available for anyone 497 | to copy, free of charge and under the terms of this License, through a 498 | publicly available network server or other readily accessible means, 499 | then you must either (1) cause the Corresponding Source to be so 500 | available, or (2) arrange to deprive yourself of the benefit of the 501 | patent license for this particular work, or (3) arrange, in a manner 502 | consistent with the requirements of this License, to extend the patent 503 | license to downstream recipients. "Knowingly relying" means you have 504 | actual knowledge that, but for the patent license, your conveying the 505 | covered work in a country, or your recipient's use of the covered work 506 | in a country, would infringe one or more identifiable patents in that 507 | country that you have reason to believe are valid. 508 | 509 | If, pursuant to or in connection with a single transaction or 510 | arrangement, you convey, or propagate by procuring conveyance of, a 511 | covered work, and grant a patent license to some of the parties 512 | receiving the covered work authorizing them to use, propagate, modify 513 | or convey a specific copy of the covered work, then the patent license 514 | you grant is automatically extended to all recipients of the covered 515 | work and works based on it. 516 | 517 | A patent license is "discriminatory" if it does not include within the 518 | scope of its coverage, prohibits the exercise of, or is conditioned on 519 | the non-exercise of one or more of the rights that are specifically 520 | granted under this License. You may not convey a covered work if you 521 | are a party to an arrangement with a third party that is in the 522 | business of distributing software, under which you make payment to the 523 | third party based on the extent of your activity of conveying the 524 | work, and under which the third party grants, to any of the parties 525 | who would receive the covered work from you, a discriminatory patent 526 | license (a) in connection with copies of the covered work conveyed by 527 | you (or copies made from those copies), or (b) primarily for and in 528 | connection with specific products or compilations that contain the 529 | covered work, unless you entered into that arrangement, or that patent 530 | license was granted, prior to 28 March 2007. 531 | 532 | Nothing in this License shall be construed as excluding or limiting 533 | any implied license or other defenses to infringement that may 534 | otherwise be available to you under applicable patent law. 535 | 536 | ### 12. No Surrender of Others' Freedom. 537 | 538 | If conditions are imposed on you (whether by court order, agreement or 539 | otherwise) that contradict the conditions of this License, they do not 540 | excuse you from the conditions of this License. If you cannot convey a 541 | covered work so as to satisfy simultaneously your obligations under 542 | this License and any other pertinent obligations, then as a 543 | consequence you may not convey it at all. For example, if you agree to 544 | terms that obligate you to collect a royalty for further conveying 545 | from those to whom you convey the Program, the only way you could 546 | satisfy both those terms and this License would be to refrain entirely 547 | from conveying the Program. 548 | 549 | ### 13. Use with the GNU Affero General Public License. 550 | 551 | Notwithstanding any other provision of this License, you have 552 | permission to link or combine any covered work with a work licensed 553 | under version 3 of the GNU Affero General Public License into a single 554 | combined work, and to convey the resulting work. The terms of this 555 | License will continue to apply to the part which is the covered work, 556 | but the special requirements of the GNU Affero General Public License, 557 | section 13, concerning interaction through a network will apply to the 558 | combination as such. 559 | 560 | ### 14. Revised Versions of this License. 561 | 562 | The Free Software Foundation may publish revised and/or new versions 563 | of the GNU General Public License from time to time. Such new versions 564 | will be similar in spirit to the present version, but may differ in 565 | detail to address new problems or concerns. 566 | 567 | Each version is given a distinguishing version number. If the Program 568 | specifies that a certain numbered version of the GNU General Public 569 | License "or any later version" applies to it, you have the option of 570 | following the terms and conditions either of that numbered version or 571 | of any later version published by the Free Software Foundation. If the 572 | Program does not specify a version number of the GNU General Public 573 | License, you may choose any version ever published by the Free 574 | Software Foundation. 575 | 576 | If the Program specifies that a proxy can decide which future versions 577 | of the GNU General Public License can be used, that proxy's public 578 | statement of acceptance of a version permanently authorizes you to 579 | choose that version for the Program. 580 | 581 | Later license versions may give you additional or different 582 | permissions. However, no additional obligations are imposed on any 583 | author or copyright holder as a result of your choosing to follow a 584 | later version. 585 | 586 | ### 15. Disclaimer of Warranty. 587 | 588 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 589 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 590 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT 591 | WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT 592 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 593 | A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND 594 | PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 595 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR 596 | CORRECTION. 597 | 598 | ### 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR 602 | CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 603 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES 604 | ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT 605 | NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR 606 | LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM 607 | TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER 608 | PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 609 | 610 | ### 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | ## How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these 626 | terms. 627 | 628 | To do so, attach the following notices to the program. It is safest to 629 | attach them to the start of each source file to most effectively state 630 | the exclusion of warranty; and each file should have at least the 631 | "copyright" line and a pointer to where the full notice is found. 632 | 633 | 634 | Copyright (C) 635 | 636 | This program is free software: you can redistribute it and/or modify 637 | it under the terms of the GNU General Public License as published by 638 | the Free Software Foundation, either version 3 of the License, or 639 | (at your option) any later version. 640 | 641 | This program is distributed in the hope that it will be useful, 642 | but WITHOUT ANY WARRANTY; without even the implied warranty of 643 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 644 | GNU General Public License for more details. 645 | 646 | You should have received a copy of the GNU General Public License 647 | along with this program. If not, see . 648 | 649 | Also add information on how to contact you by electronic and paper 650 | mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands \`show w' and \`show c' should show the 661 | appropriate parts of the General Public License. Of course, your 662 | program's commands might be different; for a GUI interface, you would 663 | use an "about box". 664 | 665 | You should also get your employer (if you work as a programmer) or 666 | school, if any, to sign a "copyright disclaimer" for the program, if 667 | necessary. For more information on this, and how to apply and follow 668 | the GNU GPL, see . 669 | 670 | The GNU General Public License does not permit incorporating your 671 | program into proprietary programs. If your program is a subroutine 672 | library, you may consider it more useful to permit linking proprietary 673 | applications with the library. If this is what you want to do, use the 674 | GNU Lesser General Public License instead of this License. But first, 675 | please read . 676 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 5 | # desktop-lyric 6 | 7 | Show the lyric of playing songs on the desktop. 8 | >很多歌消失了。 —— *汪曾祺 《徙》*\ 9 | [![license]](/LICENSE.md) 10 | 11 | ![bee](https://user-images.githubusercontent.com/17917040/107332354-08111f80-6aef-11eb-9c7a-f8799c834501.png) 12 | 13 | ## Installation 14 | 15 | ### Manual 16 | 17 | The latest and supported version should only work on the [current stable version](https://release.gnome.org/calendar/#branches) of GNOME Shell. 18 | 19 | ```bash 20 | git clone https://github.com/tuberry/desktop-lyric.git && cd desktop-lyric 21 | meson setup build && meson install -C build 22 | # meson setup build -Dtarget=system && meson install -C build # system-wide, default --prefix=/usr/local 23 | ``` 24 | 25 | For older versions, it's recommended to install via: 26 | 27 | ```bash 28 | gdbus call --session --dest org.gnome.Shell --object-path /org/gnome/Shell \ 29 | --method org.gnome.Shell.Extensions.InstallRemoteExtension 'desktop-lyric@tuberry' 30 | ``` 31 | 32 | It's quite the same as installing from: 33 | 34 | ### E.G.O 35 | 36 | [Get it on GNOME Extensions][EGO] 37 | 38 | ## Features 39 | 40 | ![dlpref](https://github.com/user-attachments/assets/ec12edb5-d4a0-4e9e-aec6-db37baecb3c6) 41 | 42 | ## Notes 43 | 44 | * High CPU usage; 45 | * Draw at an even pace so that exact synchronization with the song is not guaranteed; 46 | 47 | ## Contributions 48 | 49 | Feel free to open an issue or PR in the repo for any question or idea. 50 | 51 | ### Translations 52 | 53 | To initialize or update the po file from sources: 54 | 55 | ```bash 56 | bash ./cli/update-po.sh [your_lang_code] # like zh_CN, default to $LANG 57 | ``` 58 | 59 | ### Developments 60 | 61 | To install GJS TypeScript type [definitions](https://www.npmjs.com/package/@girs/gnome-shell): 62 | 63 | ```bash 64 | npm install @girs/gnome-shell --save-dev 65 | ``` 66 | 67 | ## Acknowledgements 68 | 69 | * [lyrics-finder]: online lyrics 70 | * [osdlyrics]: some names 71 | 72 | [license]:https://img.shields.io/badge/license-GPLv3+-green.svg 73 | [lyrics-finder]:https://github.com/TheWeirdDev/lyrics-finder-gnome-ext 74 | [osdlyrics]:https://github.com/osdlyrics/osdlyrics 75 | [EGO]:https://extensions.gnome.org/extension/4006/desktop-lyric/ 76 | -------------------------------------------------------------------------------- /REUSE.toml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: tuberry 2 | # SPDX-License-Identifier: CC-BY-SA-4.0 3 | 4 | version = 1 5 | 6 | [[annotations]] 7 | path = ["**.in", ".gitignore", "jsconfig.json"] 8 | SPDX-FileCopyrightText = "tuberry" 9 | SPDX-License-Identifier = "GPL-3.0-or-later" 10 | 11 | [[annotations]] 12 | path = ["po/LINGUAS", "po/POTFILES.in"] 13 | SPDX-FileCopyrightText = "tuberry" 14 | SPDX-License-Identifier = "CC-BY-SA-4.0" 15 | 16 | [[annotations]] 17 | path = ["po/**po"] 18 | SPDX-License-Identifier = "GPL-3.0-or-later" 19 | -------------------------------------------------------------------------------- /cli/gen-icon.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Gio from 'gi://Gio'; 5 | 6 | const L = 16; // length (side) 7 | const M = 1 / 16; // margin 8 | const W = 1 - 2 * M; // width (content) 9 | const C = '#28282B'; // color 10 | const XFM = `fill="${C}" transform="translate(${M} ${M}) scale(${W} ${W})"`; 11 | const SVG = `viewBox="0 0 1 1" width="${L}" height="${L}" xmlns="http://www.w3.org/2000/svg"`; 12 | const save = (text, name) => Gio.File.new_for_path(ARGV.concat(name).join('/')) 13 | .replace_contents(text, null, false, Gio.FileCreateFlags.NONE, null); 14 | 15 | let a = 1 / 2, // c == e ==> a == 1 / 2 16 | b = (1 - Math.cos(Math.PI / 6)) * a, 17 | c = (1 - a) / 2, 18 | d = a + b, 19 | e = a / 2, 20 | play = [[a, 1 / 2], [b, 1 / 2 + a / 2], [b, c]]; 21 | 22 | save(` 23 | 24 | 25 | 26 | 27 | 28 | 29 | `); 30 | -------------------------------------------------------------------------------- /cli/get-version.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # SPDX-FileCopyrightText: tuberry 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | RET=$(curl -sSf https://extensions.gnome.org/extension/"$EGO"/ | grep data-svm | sed -e 's/.*: //; s/}}"//') # | xargs -I{} expr {} + 1) 6 | echo "${RET:?'ERROR: Failed to fetch version, build with -Dversion option to skip'}" 7 | -------------------------------------------------------------------------------- /cli/update-po.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # SPDX-FileCopyrightText: tuberry 3 | # SPDX-License-Identifier: GPL-3.0-or-later 4 | 5 | set -e 6 | set -o pipefail 7 | 8 | if [[ -d build ]]; then 9 | meson setup build --wipe 10 | else 11 | meson setup build 12 | fi 13 | LC=${1:-${LANG%%.*}} 14 | # DM=$(gjs -c "print(JSON.parse('$(meson introspect build --projectinfo)').descriptive_name)") 15 | DM=$(meson introspect build --projectinfo | python -c 'import sys,json; print(json.loads(sys.stdin.read())["descriptive_name"])') 16 | meson compile "${DM:?got no pot}-pot" -C build 17 | grep -Fqx "${LC:?got no LC code}" po/LINGUAS || (echo "$LC" >> po/LINGUAS; msginit --no-translator -l "$LC".UTF-8 -i po/"$DM".pot -o po/"$LC".po) 18 | msgmerge --backup=off -q -U po/"$LC".po po/"$DM".pot 19 | printf "\npo/%s.po is ready!\n" "$LC" 20 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noUnusedLocals": true, 4 | "noImplicitAny": true, 5 | "noEmit": true, 6 | "target": "ESNext", 7 | "allowJs": true, 8 | "checkJs": false, 9 | "strict": true, 10 | "module": "ESNext", 11 | "moduleResolution": "Bundler", 12 | "types": ["@girs/gjs", "@girs/gnome-shell/ambient"] 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: tuberry 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | project( 5 | 'gnome-shell-extension-desktop-lyric', 6 | license: 'GPL-3.0-or-later', 7 | version: '48.0', 8 | meson_version: '>= 1.4.0', 9 | ) 10 | 11 | fs = import('fs') 12 | i18n = import('i18n') 13 | gnome = import('gnome') 14 | 15 | _name = 'Desktop Lyric' 16 | _version = get_option('version') 17 | _title = meson.project_name().replace('gnome-shell-extension-', '') 18 | target = get_option('target') 19 | 20 | metadata = { 21 | 'name': _name, 22 | 'uuid': _title + '@tuberry', 23 | 'gettext': meson.project_name(), 24 | 'url': 'https://github.com/tuberry' / _title, 25 | 'shell': meson.project_version().split('.')[0], 26 | 'rdnn': 'org.gnome.shell.extensions.' + _title, 27 | 'path': f'/org/gnome/shell/extensions/@_title@/', 28 | 'dbus': 'org.gnome.Shell.Extensions.' + _name.replace(' ', ''), 29 | 'version': (_version != 0) ? _version : run_command('cli/get-version.sh', check: true, env: ['EGO=4006' / _title]).stdout().strip(), 30 | 'description': 'Show the lyric of playing songs on the desktop' 31 | + (target == 'zip' ? '\\n\\nFor support, please report issues via the Homepage link below rather than the review section below it' : '') 32 | } 33 | 34 | if(target == 'system') 35 | datadir = get_option('datadir') 36 | locale_dir = get_option('localedir') 37 | schema_dir = datadir / 'glib-2.0' / 'schemas' 38 | target_dir = datadir / 'gnome-shell' / 'extensions' / metadata['uuid'] 39 | else 40 | target_root = (target == 'local') ? fs.expanduser('~/.local/share/gnome-shell/extensions/') : meson.project_build_root() 41 | target_dir = target_root / metadata['uuid'] 42 | locale_dir = target_dir / 'locale' 43 | schema_dir = target_dir / 'schemas' 44 | endif 45 | 46 | if(target == 'zip') 47 | zip_dir = get_option('desktop') ? fs.expanduser('~/Desktop') : target_root 48 | meson.add_install_script( 49 | find_program('7z'), 50 | 'a', 51 | zip_dir / '@0@_v@1@.zip'.format(metadata['gettext'], metadata['version']), 52 | target_dir / '*', 53 | ) 54 | endif 55 | 56 | subdir('po') 57 | subdir('res') 58 | install_subdir('src', strip_directory: true, install_dir: target_dir) 59 | -------------------------------------------------------------------------------- /meson.options: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: tuberry 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | option('target', type: 'combo', choices: ['zip', 'local', 'system'], value: 'local', description: 'build target') 5 | option('version', type: 'integer', min: 0, max: 999, value: 0, description: 'default to get-version.sh') 6 | option('desktop', type: 'boolean', value: false, description: 'save zip to user Desktop or not') 7 | -------------------------------------------------------------------------------- /po/LINGUAS: -------------------------------------------------------------------------------- 1 | nl 2 | zh_CN 3 | -------------------------------------------------------------------------------- /po/POTFILES.in: -------------------------------------------------------------------------------- 1 | src/ui.js 2 | src/prefs.js 3 | src/extension.js 4 | -------------------------------------------------------------------------------- /po/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: tuberry 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | i18n.gettext(metadata['gettext'], install_dir: locale_dir, preset: 'glib') 5 | -------------------------------------------------------------------------------- /po/nl.po: -------------------------------------------------------------------------------- 1 | # Dutch translations for gnome-shell-extension-desktop-lyric package. 2 | # Copyright (C) 2021 THE gnome-shell-extension-desktop-lyric'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the gnome-shell-extension-desktop-lyric package. 4 | # Automatically generated, 2021. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: gnome-shell-extension-desktop-lyric\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2023-02-10 05:36+0800\n" 11 | "PO-Revision-Date: 2021-09-14 18:13+0200\n" 12 | "Last-Translator: Heimen Stoffels \n" 13 | "Language-Team: none\n" 14 | "Language: nl\n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | "X-Generator: Poedit 3.0\n" 20 | 21 | #: src/ui.js:32 22 | msgid "Clear" 23 | msgstr "" 24 | 25 | #: src/ui.js:266 26 | msgid "Click or press ENTER to commit changes" 27 | msgstr "" 28 | 29 | #: src/prefs.js:37 30 | msgid "Active color" 31 | msgstr "Actieve kleur" 32 | 33 | #: src/prefs.js:38 34 | msgid "Outline color" 35 | msgstr "Contourkleur" 36 | 37 | #: src/prefs.js:39 38 | msgid "Inactive color" 39 | msgstr "Inactieve kleur" 40 | 41 | #: src/prefs.js:40 42 | msgid "Horizontal" 43 | msgstr "Horizontaal" 44 | 45 | #: src/prefs.js:40 46 | msgid "Vertical" 47 | msgstr "Verticaal" 48 | 49 | #: src/prefs.js:42 50 | msgid "Left" 51 | msgstr "" 52 | 53 | #: src/prefs.js:42 54 | msgid "Center" 55 | msgstr "" 56 | 57 | #: src/prefs.js:42 58 | msgid "Right" 59 | msgstr "" 60 | 61 | #: src/prefs.js:49 src/extension.js:229 62 | msgid "Mobilize" 63 | msgstr "" 64 | 65 | #: src/prefs.js:49 66 | msgid "Allow dragging to displace" 67 | msgstr "" 68 | 69 | #: src/prefs.js:50 70 | msgid "Systray position" 71 | msgstr "" 72 | 73 | #: src/prefs.js:51 74 | msgid "Refresh interval" 75 | msgstr "Ververstussenpoos" 76 | 77 | #: src/prefs.js:52 78 | msgid "Lyric orientation" 79 | msgstr "Songtekstoriëntatie" 80 | 81 | #: src/prefs.js:53 82 | msgid "Lyric location" 83 | msgstr "Songtekstlocatie" 84 | 85 | #: src/prefs.js:54 86 | msgid "Lyric colors" 87 | msgstr "Tekstkleuren" 88 | 89 | #: src/prefs.js:55 90 | msgid "Font name" 91 | msgstr "Lettertype" 92 | 93 | #: src/extension.js:227 94 | msgid "Invisiblize" 95 | msgstr "" 96 | 97 | #: src/extension.js:228 98 | msgid "Minimize" 99 | msgstr "" 100 | 101 | #: src/extension.js:231 102 | msgid "Redownload" 103 | msgstr "" 104 | 105 | #: src/extension.js:232 106 | msgid "Resynchronize" 107 | msgstr "Opnieuw synchroniseren" 108 | 109 | #: src/extension.js:234 110 | msgid "Settings" 111 | msgstr "Voorkeuren" 112 | 113 | #~ msgid "Hide lyric" 114 | #~ msgstr "Songtekst verbergen" 115 | 116 | #~ msgid "Unlock position" 117 | #~ msgstr "Losmaken" 118 | 119 | #~ msgid "0.5s Slower" 120 | #~ msgstr "0.5s langzamer" 121 | 122 | #~ msgid "0.5s Faster" 123 | #~ msgstr "0.5s sneller" 124 | 125 | #~ msgid "Enable systray" 126 | #~ msgstr "Systeemvakpictogram tonen" 127 | -------------------------------------------------------------------------------- /po/zh_CN.po: -------------------------------------------------------------------------------- 1 | # Chinese translations for gnome-shell-extension-desktop-lyric package 2 | # gnome-shell-extension-desktop-lyric 软件包的简体中文翻译. 3 | # Copyright (C) 2021 THE gnome-shell-extension-desktop-lyric'S COPYRIGHT HOLDER 4 | # This file is distributed under the same license as the gnome-shell-extension-desktop-lyric package. 5 | # Automatically generated, 2021. 6 | # 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: gnome-shell-extension-desktop-lyric 8\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2021-08-18 14:05+0800\n" 12 | "PO-Revision-Date: 2021-08-18 14:05+0800\n" 13 | "Last-Translator: Automatically generated\n" 14 | "Language-Team: none\n" 15 | "Language: zh_CN\n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: src/ui.js:384 21 | msgid "Mismatched filetype" 22 | msgstr "文件类型不匹配" 23 | 24 | #: src/ui.js:516 25 | msgid "Click or press ENTER to apply changes" 26 | msgstr "单击或按回车应用更改" 27 | 28 | #: src/prefs.js:24 29 | msgid "Horizontal" 30 | msgstr "水平" 31 | 32 | #: src/prefs.js:24 33 | msgid "Vertical" 34 | msgstr "竖直" 35 | 36 | #: src/prefs.js:25 src/prefs.js:44 37 | msgid "NetEase Cloud" 38 | msgstr "网易云" 39 | 40 | #: src/prefs.js:25 src/prefs.js:45 41 | msgid "LRCLIB" 42 | msgstr "LRCLIB" 43 | 44 | #: src/prefs.js:26 45 | msgid "Left" 46 | msgstr "左侧" 47 | 48 | #: src/prefs.js:26 49 | msgid "Center" 50 | msgstr "中间" 51 | 52 | #: src/prefs.js:26 53 | msgid "Right" 54 | msgstr "右侧" 55 | 56 | #: src/prefs.js:33 57 | msgid "_Show progress" 58 | msgstr "显示进度(_S)" 59 | 60 | #: src/prefs.js:34 61 | msgid "S_ystray position" 62 | msgstr "托盘位置(_Y)" 63 | 64 | #: src/prefs.js:35 65 | msgid "_Refresh interval" 66 | msgstr "刷新间隔(_R)" 67 | 68 | #: src/prefs.js:35 69 | msgid "ms" 70 | msgstr "毫秒" 71 | 72 | #: src/prefs.js:36 73 | msgid "Desktop" 74 | msgstr "桌面" 75 | 76 | #: src/prefs.js:37 77 | msgid "_Mobilize" 78 | msgstr "解锁位置(_M)" 79 | 80 | #: src/prefs.js:37 81 | msgid "Allow dragging to displace" 82 | msgstr "允许拖拽以变更位置" 83 | 84 | #: src/prefs.js:38 85 | msgid "_Opacity" 86 | msgstr "不透明度(_O)" 87 | 88 | #: src/prefs.js:39 89 | msgid "_Font" 90 | msgstr "字体(_F)" 91 | 92 | #: src/prefs.js:40 93 | msgid "Or_ientation" 94 | msgstr "方向(_I)" 95 | 96 | #: src/prefs.js:41 97 | msgid "Online" 98 | msgstr "在线" 99 | 100 | #: src/prefs.js:41 101 | msgid "Try to download and save the missing lyrics" 102 | msgstr "尝试下载并保存缺失歌词" 103 | 104 | #: src/prefs.js:42 105 | msgid "_Provider" 106 | msgstr "提供方(_P)" 107 | 108 | #: src/prefs.js:42 109 | msgid "" 110 | "Prefer lyrics from Mpris metadata" 112 | msgstr "" 113 | "Mpris 元数据提供的歌词优先" 115 | 116 | #: src/prefs.js:43 117 | msgid "URL" 118 | msgstr "网址" 119 | 120 | #: src/prefs.js:47 121 | msgid "F_allback" 122 | msgstr "备选(_A)" 123 | 124 | #: src/prefs.js:47 125 | msgid "Use the first result when searches cannot be matched precisely" 126 | msgstr "搜索无法精确匹配时使用首个结果" 127 | 128 | #: src/prefs.js:48 129 | msgid "_Location" 130 | msgstr "位置(_L)" 131 | 132 | #: src/prefs.js:48 133 | msgid "Filename format: Title-Artist1,Artist2-Album.lrc" 134 | msgstr "文件名格式: 标题-艺术家1,艺术家2-专辑.lrc" 135 | 136 | #: src/extension.js:57 137 | msgid "Invisiblize" 138 | msgstr "隐藏" 139 | 140 | #: src/extension.js:58 141 | msgid "Minimize" 142 | msgstr "最小化" 143 | 144 | #: src/extension.js:61 145 | msgid "Unload" 146 | msgstr "清空文件" 147 | 148 | #: src/extension.js:62 149 | msgid "Reload" 150 | msgstr "重新加载" 151 | 152 | #: src/extension.js:65 153 | msgid "Settings" 154 | msgstr "设置" 155 | 156 | #: src/extension.js:74 157 | msgid "Mobilize" 158 | msgstr "解锁位置" 159 | 160 | #~ msgid "Resynchronize" 161 | #~ msgstr "重新同步" 162 | 163 | #~ msgid "All" 164 | #~ msgstr "全部" 165 | 166 | #~ msgid "Normal" 167 | #~ msgstr "常规" 168 | 169 | #~ msgid "Symbolic" 170 | #~ msgstr "符号" 171 | 172 | #~ msgid "Clear" 173 | #~ msgstr "清除" 174 | 175 | #~ msgid "Try to download missing lyrics from the specified online provider" 176 | #~ msgstr "尝试从指定的在线提供方下载缺失歌词" 177 | 178 | #~ msgid "Unit: millisecond" 179 | #~ msgstr "单位: 毫秒" 180 | 181 | #~ msgid "Active color" 182 | #~ msgstr "已唱颜色" 183 | 184 | #~ msgid "Outline color" 185 | #~ msgstr "轮廓颜色" 186 | 187 | #~ msgid "Inactive color" 188 | #~ msgstr "未唱颜色" 189 | 190 | #~ msgid "Lyric colors" 191 | #~ msgstr "歌词颜色" 192 | 193 | #~ msgid "left" 194 | #~ msgstr "左侧" 195 | 196 | #~ msgid "Enable systray" 197 | #~ msgstr "托盘图标" 198 | 199 | #~ msgid "Unlock position" 200 | #~ msgstr "解锁位置" 201 | 202 | #~ msgid "0.5s Slower" 203 | #~ msgstr "减慢半秒" 204 | 205 | #~ msgid "0.5s Faster" 206 | #~ msgstr "加快半秒" 207 | -------------------------------------------------------------------------------- /res/data/extension.gresource.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | @mpris@ 5 | 6 | 7 | scalable/status/@icon@ 8 | 9 | 10 | -------------------------------------------------------------------------------- /res/data/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: tuberry 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | configure_file( 5 | input: 'metadata.json.in', 6 | output: 'metadata.json', 7 | configuration: metadata, 8 | install_dir: target_dir, 9 | ) 10 | 11 | mpris = configure_file( 12 | input: 'mpris.xml', 13 | output: '@0@.MprisPlayer.xml'.format(metadata['dbus']), 14 | copy: true, 15 | ) 16 | 17 | subdir('scalable/status') # HACK: for https://github.com/mesonbuild/meson/issues/2320 18 | 19 | foreach name: ['extension'] 20 | gres = configure_file( 21 | input: f'@name@.gresource.xml.in', 22 | output: f'@name@.gresource.xml', 23 | configuration: {'icon': icon, 'mpris': fs.name(mpris.full_path())}, 24 | ) 25 | gnome.compile_resources( 26 | name, gres, 27 | source_dir: '@OUTDIR@', 28 | dependencies: [mpris, tray], 29 | gresource_bundle: true, 30 | install: true, 31 | install_dir: target_dir / 'resource', 32 | ) 33 | endforeach 34 | -------------------------------------------------------------------------------- /res/data/metadata.json.in: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@name@", 3 | "description": "@description@", 4 | "gettext-domain": "@gettext@", 5 | "settings-schema": "@rdnn@", 6 | "uuid": "@uuid@", 7 | "shell-version": [ 8 | "@shell@" 9 | ], 10 | "url": "@url@", 11 | "version": @version@ 12 | } 13 | -------------------------------------------------------------------------------- /res/data/mpris.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /res/data/scalable/status/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: tuberry 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | icon = 'lyric-symbolic.svg' 5 | 6 | tray = custom_target( 7 | output: icon, 8 | build_by_default: true, 9 | build_always_stale: true, 10 | command: [ 11 | find_program('gjs'), 12 | '-m', 13 | '@SOURCE_ROOT@' / 'cli/gen-icon.js', 14 | '@OUTDIR@', 15 | icon, 16 | ], 17 | ) 18 | -------------------------------------------------------------------------------- /res/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: tuberry 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | subdir('data') 5 | subdir('schema') 6 | 7 | if fs.is_dir('style') 8 | subdir('style') 9 | endif 10 | -------------------------------------------------------------------------------- /res/schema/meson.build: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: tuberry 2 | # SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | gscm = configure_file( 5 | input: 'schemas.gschema.xml.in', 6 | output: '@0@.gschema.xml'.format(metadata['rdnn']), 7 | configuration: metadata, 8 | install_dir: schema_dir, 9 | ) 10 | 11 | if(target == 'system') 12 | gnome.post_install(glib_compile_schemas: true) 13 | elif(target == 'local') 14 | custom_target( 15 | depend_files: [gscm], 16 | output: 'gschemas.compiled', 17 | build_by_default: true, 18 | build_always_stale: true, 19 | command: [ 20 | find_program('glib-compile-schemas'), 21 | '--strict', 22 | '--targetdir=@OUTDIR@', 23 | '@OUTDIR@', 24 | ], 25 | install: true, 26 | install_dir: schema_dir, 27 | ) 28 | endif 29 | -------------------------------------------------------------------------------- /res/schema/schemas.gschema.xml.in: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | false 6 | minimize to panel 7 | 8 | 9 | false 10 | make lyric draggable or not 11 | 12 | 13 | true 14 | auto download missing lyrics 15 | 16 | 17 | 0 18 | online lyric provider (0 - ncm, 1 - lrclib) 19 | 20 | 21 | true 22 | fallback when no match 23 | 24 | 25 | true 26 | show lyric playing progress 27 | 28 | 29 | 0 30 | systray status area (0 - left, 1 - center, 2 - right) 31 | 32 | 33 | 34 | 0 35 | lyric orientation (0 - horizontal, 1 - vertical) 36 | 37 | 38 | 39 | 60 40 | lyric refresh interval (ms) 41 | 42 | 43 | "" 44 | location to save and read lyrics 45 | 46 | 47 | 48 | 50 49 | opacity percent of desktop lyric 50 | 51 | 52 | "Sans 12" 53 | font name of desktop lyric 54 | 55 | 56 | (200, 200) 57 | desktop lyric place 58 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/const.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | export const URL = { 5 | NCM: 'https://music.163.com/', 6 | LRCLIB: 'https://lrclib.net/', 7 | }; 8 | 9 | export const Key = { 10 | MINI: 'minimize', 11 | DRAG: 'draggable', 12 | FONT: 'font-name', 13 | SITE: 'lyric-place', 14 | AREA: 'systray-area', 15 | ONLN: 'online-lyrics', 16 | OPCT: 'lyric-opacity', 17 | PRGR: 'show-progress', 18 | PATH: 'lyric-location', 19 | FABK: 'online-fallback', 20 | PRVD: 'online-provider', 21 | SPAN: 'refresh-interval', 22 | ORNT: 'lyric-orientation', 23 | }; 24 | -------------------------------------------------------------------------------- /src/extension.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import * as T from './util.js'; 5 | import * as M from './menu.js'; 6 | import * as F from './fubar.js'; 7 | import {Key as K} from './const.js'; 8 | 9 | import Lyric from './lyric.js'; 10 | import Mpris from './mpris.js'; 11 | import * as Paper from './paper.js'; 12 | 13 | const {_} = F; 14 | 15 | class DesktopLyric extends F.Mortal { 16 | constructor(gset) { 17 | super(); 18 | this.#bindSettings(gset); 19 | this.#buildSources(); 20 | } 21 | 22 | #bindSettings(gset) { 23 | this.$set = new F.Setting(gset, [ 24 | [K.AREA, null, () => this.#onAreaSet()], 25 | [K.MINI, null, () => this.#onMiniSet()], 26 | [K.DRAG, null, x => this.#onDragSet(x)], 27 | [K.SPAN, null, x => this.$src.play.reload(x)], 28 | ], this); 29 | } 30 | 31 | #buildSources() { 32 | let tray = F.Source.new(() => this.#genSystray(), true), 33 | play = F.Source.newTimer((x = this[K.SPAN]) => [() => this.setPosition(this.paper.moment + x + 0.225), x], false), 34 | paper = F.Source.new(() => this[K.MINI] ? new Paper.Panel(tray.hub, this.$set) : new Paper.Desktop(this[K.DRAG], this.$set), true), 35 | lyric = new Lyric(this.$set), 36 | mpris = T.hook({ 37 | update: (_p, x) => this.setSong(x), 38 | active: (_p, x) => this.setActive(x), 39 | status: (_p, x) => this.setPlaying(x), 40 | seeked: (_p, x) => this.setPosition(x), 41 | }, new Mpris()), 42 | sync = F.Source.newDefer(x => x.length && this.setPosition(this.$pos = x.at(0)), // HACK: workaround for stale positions from buggy NCM mpris when changing songs 43 | async n => (x => this.$pos !== x && [x])(await mpris.getPosition().catch(T.nop)) || (n > 5 && []), 500); 44 | this.$src = F.Source.tie({play, paper, tray, lyric, mpris, sync}, this); // NOTE: `paper` prior `tray` to avoid double free 45 | } 46 | 47 | get paper() { 48 | return this.$src.paper.hub; 49 | } 50 | 51 | get tray() { 52 | return this.$src.tray.hub; 53 | } 54 | 55 | #genSystray() { 56 | return new M.Systray({ 57 | hide: new M.SwitchItem(_('Invisiblize'), false, () => this.#viewPaper()), 58 | mini: new M.SwitchItem(_('Minimize'), this[K.MINI], x => this.$set.set(K.MINI, x)), 59 | drag: this[K.MINI] ? null : this.#genDragItem(), 60 | sep0: new M.Separator(), 61 | tidy: new M.Item(_('Unload'), () => { this.setLyric(''); this.$src.lyric.unload(this.song); }), 62 | load: new M.Item(_('Reload'), () => this.loadLyric(true)), 63 | // sync: new M.Item(_('Resynchronize'), () => this.$src.sync.revive()), 64 | sep1: new M.Separator(), 65 | sets: new M.Item(_('Settings'), () => F.me().openPreferences()), 66 | }, 'lyric-symbolic', this[K.AREA] ? 0 : 5, ['left', 'center', 'right'][this[K.AREA]] ?? 'left', {visible: this.$src?.mpris.active ?? false}); 67 | } 68 | 69 | #viewPaper() { 70 | F.view(this.$src.mpris.status && !this.tray.$menu.hide.state, this.paper); 71 | } 72 | 73 | #genDragItem() { 74 | return new M.SwitchItem(_('Mobilize'), this[K.DRAG], x => this.$set.set(K.DRAG, x)); 75 | } 76 | 77 | #onMiniSet() { 78 | M.record(!this[K.MINI], this.tray, () => this.#genDragItem(), 'drag', 'sep0'); 79 | this.$src.paper.revive(this[K.MINI]); 80 | this.loadLyric(); 81 | } 82 | 83 | #onAreaSet() { 84 | if(this[K.MINI]) { 85 | this.setPlaying(false); 86 | this.$src.paper.dispel(); 87 | } 88 | this.$src.tray.revive(); 89 | this.#onMiniSet(); 90 | } 91 | 92 | #onDragSet(drag) { 93 | if(this[K.MINI]) return; 94 | this.paper.setDrag(drag); 95 | this.tray.$menu.drag.setToggleState(drag); 96 | } 97 | 98 | setPlaying(playing) { 99 | this.#viewPaper(); 100 | this.$src.play.toggle(playing && this.paper); 101 | } 102 | 103 | setActive(active) { 104 | F.view(active, this.tray); 105 | if(active) return; 106 | this.setPlaying(false); 107 | this.paper.clearLyric(); 108 | delete this.song; 109 | } 110 | 111 | setPosition(pos) { 112 | this.paper.setMoment(pos); 113 | } 114 | 115 | setSong(song) { 116 | if(T.homolog(this.song, song, ['title', 'album', 'lyric', 'artist'])) { 117 | this.paper?.setLength(this.song.length = song.length); // HACK: workaround for jumping lengths from NCM mpris 118 | this.$src.sync.revive(); 119 | } else { 120 | this.song = song; 121 | this.loadLyric(); 122 | } 123 | } 124 | 125 | loadLyric(reload) { 126 | if(!this.song) return; 127 | if(this.song.lyric === null) { 128 | this.setLyric(''); 129 | this.$src.lyric.load(this.song, reload).then(x => this.setLyric(x)).catch(T.nop); 130 | } else { 131 | this.setLyric(this.song.lyric); 132 | } 133 | } 134 | 135 | setLyric(lyrics) { 136 | if(!this.paper) return; 137 | this.paper.song = this[K.MINI] ? Lyric.name(this.song, ' - ', '/') : ''; 138 | this.paper.setLength(this.song.length); 139 | this.paper.setLyrics(lyrics); 140 | this.setPlaying(this.$src.mpris.status); 141 | this.$src.sync.revive(); 142 | } 143 | } 144 | 145 | export default class extends F.Extension { $klass = DesktopLyric; } 146 | -------------------------------------------------------------------------------- /src/fubar.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import St from 'gi://St'; 5 | import Gio from 'gi://Gio'; 6 | import GLib from 'gi://GLib'; 7 | import Meta from 'gi://Meta'; 8 | import Shell from 'gi://Shell'; 9 | import GObject from 'gi://GObject'; 10 | 11 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 12 | import * as Signals from 'resource:///org/gnome/shell/misc/signals.js'; 13 | import * as FileUtils from 'resource:///org/gnome/shell/misc/fileUtils.js'; 14 | import * as Extensions from 'resource:///org/gnome/shell/extensions/extension.js'; 15 | import * as SignalTracker from 'resource:///org/gnome/shell/misc/signalTracker.js'; 16 | 17 | import * as T from './util.js'; 18 | 19 | const ruin = o => o.destroy(); 20 | const raise = x => { throw Error(x); }; // NOTE: https://github.com/tc39/proposal-throw-expressions#todo 21 | // NOTE: see https://gitlab.gnome.org/GNOME/gnome-shell/-/merge_requests/2542 22 | const onus = o => [o, o[hub]].find(x => GObject.type_is_a(x, GObject.Object) && GObject.signal_lookup('destroy', x)) ?? raise('undestroyable'); 23 | 24 | export const _ = Extensions.gettext; 25 | export const hub = Symbol('Hidden Unique Binder'); 26 | export const offstage = x => !Main.uiGroup.contains(x); 27 | export const me = () => Extension.lookupByURL(import.meta.url); // NOTE: https://github.com/tc39/proposal-json-modules 28 | export const debug = (...xs) => me().getLogger().debug(...xs); // FIXME: see https://gitlab.gnome.org/GNOME/gobject-introspection/-/issues/491 29 | export const theme = () => St.ThemeContext.get_for_stage(global.stage); 30 | export const marks = (x, m) => x.clutterText.set_markup(`\u{200b}${m}`); // HACK: workaround for https://gitlab.gnome.org/GNOME/mutter/-/issues/1324 31 | export const omit = (o, ...ks) => ks.forEach(k => { ruin(o[k]); delete o[k]; }); 32 | export const view = (v, ...ws) => ws.forEach(w => w && !T.xnor(v, w.visible) && (v ? w.show() : w.hide())); // NOTE: https://github.com/tc39/proposal-optional-chaining-assignment 33 | export const connect = (tracker, ...args) => (t => args.reduce((p, x) => (x.connectObject ? p.push([x]) : p.at(-1).push(x), p), []) 34 | .forEach(([emitter, ...xs]) => emitter.connectObject(...xs, t)))(onus(tracker)); 35 | export const disconnect = (tracker, ...args) => (t => args.forEach(emitter => emitter?.disconnectObject(t)))(onus(tracker)); 36 | export const open = uri => Gio.AppInfo.launch_default_for_uri(uri, global.create_app_launch_context(0, -1)); 37 | export const copy = (text, primary) => St.Clipboard.get_default().set_text(primary ? St.ClipboardType.PRIMARY : St.ClipboardType.CLIPBOARD, text); 38 | export const paste = primary => new Promise((resolve, reject) => St.Clipboard.get_default().get_text(primary ? St.ClipboardType.PRIMARY 39 | : St.ClipboardType.CLIPBOARD, (_c, x) => x ? resolve(x) : reject(Error('empty')))); 40 | 41 | export class DBusProxy extends Gio.DBusProxy { 42 | static { 43 | T.enrol(this); 44 | } 45 | 46 | [hub] = new SignalTracker.TransientSignalHolder(this); 47 | 48 | constructor(name, object, callback, hooks, signals, xml, cancel = null, bus = Gio.DBus.session, gFlags = Gio.DBusProxyFlags.NONE) { 49 | let info = Gio.DBusInterfaceInfo.new_for_xml(xml ?? FileUtils.loadInterfaceXML(name)); 50 | super({gConnection: bus, gName: name, gObjectPath: object, gInterfaceInfo: info, gFlags, gInterfaceName: info.name}); 51 | if(signals) T.each(xs => this.connectSignal(...xs), signals, 2); 52 | if(hooks) connect(this, this, ...hooks); 53 | this.init_async(GLib.PRIORITY_DEFAULT, cancel).then(() => callback(this, null)).catch(e => callback(null, e)); 54 | } 55 | 56 | destroy() { 57 | Signals.EventEmitter.prototype.disconnectAll.call(this); 58 | omit(this, hub); 59 | } 60 | } 61 | 62 | export class Mortal extends Signals.EventEmitter { 63 | [hub] = new SignalTracker.TransientSignalHolder(this); 64 | 65 | destroy() { 66 | this.emit('destroy'); 67 | this.disconnectAll(); 68 | omit(this, hub); 69 | } 70 | } 71 | 72 | export class Extension extends Extensions.Extension { 73 | constructor(...args) { 74 | T.load(`${T.ROOT}/resource/extension.gresource`); 75 | super(...args); 76 | } 77 | 78 | enable() { 79 | this[hub] = new this.$klass(this.getSettings()); 80 | } 81 | 82 | disable() { 83 | omit(this, hub); 84 | } 85 | } 86 | 87 | export class Source { 88 | /** @template T * @param {T} doom * @return {T} */ 89 | static tie = (doom, host) => (host.connect('destroy', () => omit(doom, ...Object.keys(doom))), doom); 90 | 91 | static cancelled = error => error.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED); 92 | static newCancel(...args) { 93 | return T.seq(x => { x.reborn = (...ys) => { x.revive(...ys); return x.hub; }; }, 94 | new Source(() => new Gio.Cancellable(), x => x.cancel(), ...args)); 95 | } 96 | 97 | static newDBus(name, path, host, ...args) { 98 | let impl = new Source(x => T.seq(y => y.export(x, path), Gio.DBusExportedObject.wrapJSObject(FileUtils.loadInterfaceXML(name), host)), x => x.unexport()); 99 | return new Source(() => Gio.DBus.own_name(Gio.BusType.SESSION, name, Gio.BusNameOwnerFlags.NONE, x => impl.summon(x), null, null), 100 | x => { Gio.bus_unown_name(x); impl.dispel(); }, ...args); 101 | } 102 | 103 | static newKeys(gset, key, callback, ...args) { 104 | return new Source(() => Main.wm.addKeybinding(key, gset, Meta.KeyBindingFlags.NONE, Shell.ActionMode.ALL, callback), 105 | () => Main.wm.removeKeybinding(key), ...args); 106 | } 107 | 108 | static newTimer(callback, remove = true, clear, ...args) { 109 | return remove ? new Source((...xs) => setTimeout(...callback(...xs)), clear ? x => clear(clearTimeout(x)) : clearTimeout, ...args) 110 | : new Source((...xs) => setInterval(...callback(...xs)), clear ? x => clear(clearInterval(x)) : clearInterval, ...args); 111 | } 112 | 113 | static newDefer(callback, until, interval, clear, ...args) { // polling until... 114 | return Source.new(() => T.seq(async (x, y, z = 0) => { while(!(y = await until(z++))) await new Promise(r => x.revive(r)); callback(y); }, 115 | Source.newTimer(x => [x, interval], true, clear)), ...args); 116 | } 117 | 118 | static newHandler(emitter, signal, callback, ...args) { 119 | return new Source(() => emitter.connect(signal, callback), x => emitter.disconnect(x), ...args); 120 | } 121 | 122 | static newMonitor(file, changed, ...args) { 123 | return new Source((cancel = null) => T.hook({changed}, T.fopen(file).monitor(Gio.FileMonitorFlags.NONE, cancel)), x => x.cancel(), ...args); 124 | } 125 | 126 | static newInjector(overrides, ...args) { 127 | let mgr = new Extensions.InjectionManager(); /* eslint-disable-next-line no-invalid-this */ 128 | return new Source(() => T.each(([p, m]) => T.unit(m, Object.entries).forEach(([n, f]) => mgr.overrideMethod(p, n, g => function (...xs) { return f(this, g, xs); })), overrides, 2), 129 | () => mgr.clear(), ...args); 130 | } 131 | 132 | static new(summon, ...args) { 133 | return new Source(summon, undefined, ...args); 134 | } 135 | 136 | constructor(summon, dispel = ruin, enable, ...args) { 137 | this.summon = (...xs) => { this[hub] = summon(...xs); }; 138 | this.dispel = () => { if(this.active) dispel(this[hub]), delete this[hub]; }; 139 | this.revive = (...xs) => { this.dispel(); this.summon(...xs); }; 140 | this.reload = (...xs) => { if(this.active) this.revive(...xs); }; 141 | this.switch = (b, ...xs) => { b ? this.revive(...xs) : this.dispel(); }; 142 | this.invoke = (f, ...xs) => { this.revive(...xs); return f().finally(() => this.dispel()); }; 143 | this.toggle = (b, ...xs) => { if(!T.xnor(b, this.active)) b ? this.summon(...xs) : this.dispel(); }; 144 | if(enable) this.summon(...args); 145 | } 146 | 147 | get hub() { 148 | return this[hub]; 149 | } 150 | 151 | get active() { 152 | return Object.hasOwn(this, hub); 153 | } 154 | 155 | destroy() { 156 | this.dispel(); 157 | this.despel = this.summon = T.nop; 158 | } 159 | } 160 | 161 | export class Setting { 162 | constructor(gset, ...args) { 163 | this[hub] = T.str(gset) ? new Gio.Settings({schema: gset}) : gset; 164 | this.tie(...args); 165 | } 166 | 167 | get hub() { 168 | return this[hub]; 169 | } 170 | 171 | set(field, value) { 172 | this[hub].set_value(field, new GLib.Variant(this[hub].get_value(field).get_type_string(), value)); 173 | } 174 | 175 | not(field) { 176 | this[hub].set_boolean(field, !this[hub].get_boolean(field)); 177 | } 178 | 179 | tie(ring, host, cast, post) { 180 | T.unit(ring, Object.values).forEach(args => { 181 | let [keys, turn, back, init] = T.unit(args); 182 | let [key, field = keys] = T.unit(keys); 183 | if(key in host) throw Error(`key conflict: ${field}`); 184 | let call = (f, x) => f(x, key) ?? x, 185 | pipe = (f, g) => f ? () => call(f, g()) : g, 186 | read = pipe(turn, () => this[hub].get_value(field).recursiveUnpack()), 187 | load = T.thunk(() => (host[key] = read())); 188 | if(init) return; 189 | let sync = [post, cast, back, load].reduceRight((p, x) => pipe(x, p)); 190 | connect(host, this[hub], `changed::${field}`, () => void sync()); 191 | }); 192 | cast?.(); 193 | return this; 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/lyric.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Soup from 'gi://Soup'; 5 | 6 | import * as T from './util.js'; 7 | import * as F from './fubar.js'; 8 | import {Key as K, URL} from './const.js'; 9 | 10 | class NeteaseProvider { 11 | static getlrc = `${URL.NCM}api/song/lyric?`; 12 | static search = `${URL.NCM}api/search/get/web?`; 13 | 14 | static #match({name: u, album: {name: v}, artists: w}, {title: x, album: y}, z) { 15 | return x === u && (!y || y === v) && (!z.length || T.homolog(z, w.map(a => a.name).sort())); 16 | } 17 | 18 | static async fetch(song, client, cancel, fallback) { 19 | let singer = song.artist.toSorted(), 20 | {songs} = JSON.parse(await T.request('POST', this.search, {s: Lyric.name(song), limit: '30', type: '1'}, cancel, null, client)).result, 21 | {id} = songs.toSorted((a, b) => Math.abs(a.duration - song.length) - Math.abs(b.duration - song.length)).find(x => this.#match(x, song, singer)) ?? (fallback && songs[0]); 22 | return JSON.parse(await T.request('GET', this.getlrc, {id: id.toString(), lv: '1'}, cancel, null, client)).lrc.lyric; // kv: '0', tv: '0' 23 | } 24 | } 25 | 26 | class LRCLIBProvider { // Ref: https://lrclib.net/docs 27 | static getlrc = `${URL.LRCLIB}api/get?`; 28 | static search = `${URL.LRCLIB}api/search?`; 29 | static header = {'User-Agent': 'Desktop Lyric/48 (https://github.com/tuberry/desktop-lyric)'}; // TODO: ? import metadata.json 30 | 31 | static #match({trackName: u, albumName: v, artistName: w}, {title: x, album: y}, z) { 32 | return x === u && (!y || y === v) && (!z || z === w.length); 33 | } 34 | 35 | static async fetch(song, client, cancel, fallback) { 36 | let length = song.length / 1000; // ms to s 37 | try { 38 | let {title: track_name, artist, album: album_name} = song; 39 | return JSON.parse(await T.request('GET', this.getlrc, {track_name, artist_name: artist.join(', '), album_name, duration: String(length)}, cancel, this.header, client)).syncedLyrics; 40 | } catch(e) { 41 | if(F.Source.cancelled(e)) throw e; 42 | let singer = song.artist.join(' ').length; // HACK: messy seprator: e.g. https://lrclib.net/api/search?q=%E5%A4%B1%E7%9C%A0%E9%A3%9E%E8%A1%8C 43 | let songs = JSON.parse(await T.request('GET', this.search, {q: Lyric.name(song)}, cancel, this.header, client)); 44 | return (songs.toSorted((a, b) => Math.abs(a.duration - length) - Math.abs(b.duration - length)).find(x => this.#match(x, song, singer)) ?? (fallback && songs[0])).syncedLyrics; 45 | } 46 | } 47 | } 48 | 49 | const Providers = [NeteaseProvider, LRCLIBProvider]; 50 | 51 | export default class Lyric extends F.Mortal { 52 | static name({title, artist, album}, sepTitle = ' ', sepArtist = ' ', useAlbum = false) { 53 | return [title, artist.join(sepArtist), useAlbum ? album : ''].filter(T.id).join(sepTitle); 54 | } 55 | 56 | constructor(set) { 57 | super(); 58 | this.#bindSettings(set); 59 | this.#buildSources(); 60 | } 61 | 62 | #bindSettings(set) { 63 | this.$set = set.tie([ 64 | K.PATH, K.FABK, [K.PRVD, x => Providers[x]], 65 | [K.ONLN, null, x => this.$src.client.toggle(x)], 66 | ], this); 67 | } 68 | 69 | #buildSources() { 70 | let cancel = F.Source.newCancel(); 71 | let client = new F.Source(() => new Soup.Session({timeout: 30}), x => x?.abort(), this[K.ONLN]); 72 | this.$src = F.Source.tie({cancel, client}, this); 73 | } 74 | 75 | async load(song, reload, cancel = this.$src.cancel.reborn()) { 76 | let file = T.fopen(this.path(song)); 77 | try { 78 | if(reload) throw Error('dirty'); 79 | let [contents] = await T.fread(file, cancel); 80 | return T.decode(contents); 81 | } catch(e) { 82 | if(F.Source.cancelled(e) || !this.$src.client.active) throw e; 83 | try { 84 | let lyric = await this[K.PRVD].fetch(song, this.$src.client.hub, cancel, this[K.FABK]); 85 | T.fwrite(file, lyric || ' ').catch(T.nop); 86 | return lyric; 87 | } catch(e1) { 88 | if(reload) T.fdelete(file).catch(T.nop), this.warn(song, file); 89 | throw e1; 90 | } 91 | } 92 | } 93 | 94 | unload(song) { 95 | T.seq(p => p && T.exist(p) && T.fwrite(p, ' ').catch(T.nop), this.path(song)); 96 | } 97 | 98 | warn(song) { 99 | F.me().getLogger().warn(`Failed to download lyrics for <${Lyric.name(song)}>`); 100 | } 101 | 102 | path(song) { 103 | return this[K.PATH] && `${this[K.PATH]}/${Lyric.name(song, '-', ',', true).replaceAll('/', '/')}.lrc`; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/menu.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import St from 'gi://St'; 5 | import Clutter from 'gi://Clutter'; 6 | 7 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 8 | import * as PanelMenu from 'resource:///org/gnome/shell/ui/panelMenu.js'; 9 | import * as PopupMenu from 'resource:///org/gnome/shell/ui/popupMenu.js'; 10 | import * as BoxPointer from 'resource:///org/gnome/shell/ui/boxpointer.js'; 11 | 12 | import * as T from './util.js'; 13 | import * as F from './fubar.js'; 14 | 15 | export const Separator = PopupMenu.PopupSeparatorMenuItem; 16 | 17 | export const itemize = (x, y) => Object.values(x).forEach(z => z && y.addMenuItem(z)); 18 | 19 | export function upsert(table, insert, list, update, spread = x => x._getMenuItems()) { 20 | let items = spread(table); 21 | let delta = list.length - items.length; 22 | if(delta > 0) for(let i = 0; i < delta; i++) insert(table); 23 | else if(delta < 0) do items.at(delta).destroy(); while(++delta < 0); 24 | spread(table).forEach((x, i, a) => update(list[i], x, i, a)); 25 | } // NOTE: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/forEach 26 | 27 | export function record(ok, tray, ...args) { 28 | if(!tray) return; 29 | let {menu, $menu} = tray; 30 | T.each(([gen, key, pos]) => { 31 | if(T.xnor(ok, $menu[key])) return; 32 | ok ? menu.addMenuItem($menu[key] = gen?.() ?? new Separator(), 33 | pos ? menu._getMenuItems().findIndex(x => x === $menu[pos]) : undefined) : F.omit($menu, key); 34 | }, args, 3); 35 | } 36 | 37 | export function altNum(key, event, item) { // Ref: https://gitlab.gnome.org/GNOME/mutter/-/blob/main/clutter/clutter/clutter-keysyms.h 38 | return T.seq(x => x && [...item].filter(y => y instanceof St.Button).at(key - Clutter.KEY_1)?.emit('clicked', Clutter.BUTTON_PRIMARY), 39 | event.get_state() & Clutter.ModifierType.MOD1_MASK && key >= Clutter.KEY_0 && key <= Clutter.KEY_9); 40 | } // NOTE: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Iterator/find 41 | 42 | export class Systray extends PanelMenu.Button { 43 | static { 44 | T.enrol(this); 45 | } 46 | 47 | constructor(menu, icon = '', pos, box, prop, text) { 48 | let {uuid, metadata: {name}} = F.me(); 49 | super(0.5, text ?? name, !menu); 50 | this.$box = new St.BoxLayout({styleClass: 'panel-status-indicators-box'}); 51 | this.add_child(this.$box); 52 | this.$icon = new St.Icon({iconName: icon, styleClass: 'system-status-icon'}); 53 | this.$box.add_child(this.$icon); 54 | Main.panel.addToStatusArea(uuid, this, pos, box); 55 | if(menu) itemize(this.$menu = menu, this.menu); 56 | this.set(prop); 57 | } 58 | } 59 | 60 | export class Button extends St.Button { 61 | static { 62 | T.enrol(this); 63 | } 64 | 65 | constructor(param, callback, icon = '', tip) { 66 | super({canFocus: true, ...param}); 67 | this.#buildSources(); 68 | this.$callback = callback; 69 | this.set_child(new St.Icon({styleClass: 'popup-menu-icon'})); 70 | this.connect('clicked', (...xs) => this.$callback(...xs)); 71 | if(icon !== null) this.setup(icon); 72 | this.setTip(tip); 73 | } 74 | 75 | #buildSources() { 76 | let tip = new F.Source((...xs) => this.#genTip(...xs)); 77 | let show = F.Source.newTimer(() => [() => this.#showTip(true), 250], true, () => this.#showTip(false)); 78 | this.$src = F.Source.tie({tip, show}, this); 79 | } 80 | 81 | #genTip(text) { 82 | let tip = new BoxPointer.BoxPointer(St.Side.TOP); 83 | tip.bin.set_child(new St.Label({styleClass: 'dash-label'})); 84 | tip.set({$text: text, visible: false, styleClass: 'popup-menu-boxpointer'}); 85 | F.connect(tip, this, 'notify::hover', x => this.$src.show.toggle(x.hover)); 86 | return tip; 87 | } 88 | 89 | #showTip(show) { 90 | if(!this.tip) return; 91 | if(show) { 92 | this.tip.setPosition(this, 0.1); 93 | if(F.offstage(this.tip)) Main.layoutManager.addTopChrome(this.tip); 94 | this.tip.open(BoxPointer.PopupAnimation.FULL); 95 | } else { 96 | if(F.offstage(this.tip)) return; 97 | this.tip.close(BoxPointer.PopupAnimation.FADE); 98 | Main.layoutManager.removeChrome(this.tip); 99 | } 100 | } 101 | 102 | setup(icon) { 103 | this.child.set_icon_name(icon); 104 | } 105 | 106 | get tip() { 107 | return this.$src.tip.hub; 108 | } 109 | 110 | $setTip() { 111 | this.tip?.bin.child.set_text(this.tip.$text); 112 | } 113 | 114 | setTip(tip) { 115 | this.$src.tip.toggle(tip, tip); 116 | this.$setTip(); 117 | } 118 | } 119 | 120 | export class StateButton extends Button { 121 | static { 122 | T.enrol(this); 123 | } 124 | 125 | $setTip() { 126 | this.tip?.bin.child.set_text(this.tip.$text[this.$state ? 0 : 1]); 127 | } 128 | 129 | setup(icon) { 130 | let [state, ...icons] = icon; 131 | this.$icon = icons; 132 | this.toggleState(state ?? this.$state); 133 | } 134 | 135 | toggleState(state = !this.$state) { 136 | if(state === this.$state) return; 137 | this.$state = state; 138 | this.child?.set_icon_name(this.$icon[this.$state ? 0 : 1]); 139 | this.$setTip(); 140 | } 141 | } 142 | 143 | export class Item extends PopupMenu.PopupMenuItem { 144 | static { 145 | T.enrol(this); 146 | } 147 | 148 | constructor(text = '', callback, param, prop) { 149 | super(text, param); 150 | this.$callback = callback; 151 | this.connect('activate', (...xs) => this.$callback(...xs)); 152 | this.set(prop); 153 | } 154 | 155 | setup(label, callback) { 156 | this.label.set_text(label); 157 | this.$callback = callback; 158 | } 159 | } 160 | 161 | export class ToolItem extends PopupMenu.PopupBaseMenuItem { 162 | static { 163 | T.enrol(this); 164 | } 165 | 166 | constructor(tool, param, prop) { 167 | super({activate: false, can_focus: false, ...param}); 168 | this.setup(tool); 169 | this.set(prop); 170 | } 171 | 172 | setup(tool) { 173 | if(this.$tool) F.omit(this, ...this.$tool); 174 | this.$tool = T.unit(tool, Object.entries).flatMap(([k, v]) => { 175 | if(k in this) throw Error(`key conflict: ${k}`); 176 | else return v ? [(this.add_child(this[k] ??= v), k)] : []; 177 | }); 178 | } 179 | } 180 | 181 | export class SwitchItem extends PopupMenu.PopupSwitchMenuItem { 182 | static { 183 | T.enrol(this); 184 | } 185 | 186 | constructor(text, active, callback, param, prop) { 187 | super(text, active, param); 188 | this.connect('toggled', (_a, state) => callback(state)); 189 | this.set(prop); 190 | } 191 | } 192 | 193 | export class RadioItem extends PopupMenu.PopupSubMenuMenuItem { 194 | static { 195 | T.enrol(this); 196 | } 197 | 198 | static getopt = o => T.omap(o, ([k, v]) => [[v, F._(T.upcase(k))]]); 199 | 200 | constructor(category, options, chosen, callback, prop) { 201 | super(''); 202 | this.$category = category; 203 | this.$callback = callback; 204 | this.setup(options, chosen); 205 | this.set(prop); 206 | } 207 | 208 | choose(chosen) { 209 | this.$chosen = chosen; 210 | this.label.set_text(`${this.$category}:${this.$options[chosen] ?? ''}`); 211 | this.menu._getMenuItems().forEach((x, i) => x.setOrnament(chosen === i ? PopupMenu.Ornament.DOT : PopupMenu.Ornament.NO_DOT)); 212 | } 213 | 214 | setup(options, chosen = this.$chosen) { 215 | this.$options = options; 216 | upsert(this.menu, x => x.addMenuItem(new Item()), Object.entries(options), ([k, v], x) => x.setup(v, () => this.$callback(k))); 217 | this.choose(chosen); 218 | } 219 | } 220 | 221 | export class DatumItemBase extends PopupMenu.PopupMenuItem { 222 | static { 223 | T.enrol(this); 224 | } 225 | 226 | constructor(label, icon, callback, datum) { 227 | super(''); 228 | this.set_can_focus(false); 229 | this.label.add_style_class_name(label); 230 | this.label.set({xExpand: true, canFocus: true}); 231 | this.add_child(this.$btn = new Button({styleClass: icon}, () => this.$onClick())); 232 | if(callback) this.$onActivate = callback; 233 | if(datum) this.setup(datum); 234 | } 235 | 236 | #click() { 237 | if(this.$btn.visible) this.$btn.emit('clicked', Clutter.BUTTON_PRIMARY); 238 | } 239 | 240 | vfunc_key_press_event(event) { 241 | if(event.get_key_symbol() === Clutter.KEY_Control_L) this.#click(); 242 | return super.vfunc_key_press_event(event); 243 | } 244 | 245 | activate(event) { 246 | switch(event.type()) { 247 | case Clutter.EventType.BUTTON_RELEASE: 248 | case Clutter.EventType.PAD_BUTTON_RELEASE: 249 | switch(event.get_button()) { 250 | case Clutter.BUTTON_SECONDARY: this.#click(); return; 251 | default: this.$onActivate(); break; 252 | } 253 | break; 254 | default: this.$onActivate(); break; 255 | } 256 | super.activate(event); 257 | } 258 | 259 | destroy() { // HACK: workaround for dangling ref & defocus on destroy & focus 260 | if(this.active) Object.defineProperty(this, 'active', {set: T.nop}); 261 | if(this.active || this.label.has_key_focus() || this.$btn.has_key_focus()) this._getTopMenu()?.actor.grab_key_focus(); 262 | super.destroy(); 263 | } 264 | } 265 | 266 | export class DatasetSection extends PopupMenu.PopupMenuSection { 267 | constructor(gen, dataset) { 268 | super(); 269 | this.$genItem = gen; 270 | if(dataset) this.setup(dataset); 271 | } 272 | 273 | setup(dataset) { 274 | upsert(this, x => x.addMenuItem(this.$genItem()), dataset, (d, x) => x.setup(d)); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /src/mpris.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Gio from 'gi://Gio'; 5 | import Shell from 'gi://Shell'; 6 | 7 | import * as FileUtils from 'resource:///org/gnome/shell/misc/fileUtils.js'; 8 | 9 | import * as T from './util.js'; 10 | import * as F from './fubar.js'; 11 | 12 | const MPRIS_IFACE = FileUtils.loadInterfaceXML('org.mpris.MediaPlayer2'); 13 | 14 | export default class Mpris extends F.Mortal { 15 | constructor() { 16 | super(); 17 | this.#buildSources(FileUtils.loadInterfaceXML('org.gnome.Shell.Extensions.DesktopLyric.MprisPlayer')); 18 | } 19 | 20 | #buildSources(playerIface) { 21 | let dbus = new F.DBusProxy('org.freedesktop.DBus', '/org/freedesktop/DBus', x => x && this.#onMprisChange(), null, 22 | ['NameOwnerChanged', (_p, _s, [name, old, neo]) => { if(neo && !old) this.#buildMpris(name).catch(T.nop); }]), 23 | mpris = new F.Source(x => new F.DBusProxy(x, '/org/mpris/MediaPlayer2', y => y && this.$src.player.revive(y.gName), 24 | ['notify::g-name-owner', (...xs) => this.#onMprisChange(...xs)], null, MPRIS_IFACE)), 25 | player = new F.Source(x => new F.DBusProxy(x, '/org/mpris/MediaPlayer2', (...xs) => this.#onPlayerReady(...xs), 26 | ['g-properties-changed', (...xs) => this.#onPlayerChange(...xs)], ['Seeked', (_p, _s, [pos]) => this.emit('seeked', pos / 1000)], playerIface)); 27 | this.$src = F.Source.tie({dbus, mpris, player}, this); 28 | } 29 | 30 | #activate(active) { 31 | this.emit('active', this.active = active); 32 | } 33 | 34 | #onPlayerReady(proxy) { 35 | if(!proxy) return; 36 | this.#activate(true); 37 | this.#update(proxy.Metadata); 38 | } 39 | 40 | async #buildMpris(name) { 41 | if(this.$src.mpris.active) return; 42 | if(!name.startsWith('org.mpris.MediaPlayer2.')) throw Error('non mpris'); 43 | let {DesktopEntry, Identity} = await Gio.DBusProxy.makeProxyWrapper(MPRIS_IFACE).newAsync(Gio.DBus.session, name, '/org/mpris/MediaPlayer2'), 44 | app = DesktopEntry ? `${DesktopEntry}.desktop` : Identity ? Shell.AppSystem.search(Identity)[0]?.[0] : null, 45 | cat = app ? Shell.AppSystem.get_default().lookup_app(app)?.get_app_info().get_string('Categories').split(';') : null; 46 | // HACK: allow terminal music apps (no DesktopEntry), see also https://gitlab.gnome.org/GNOME/glib/-/issues/1584 47 | if(cat?.reduce((p, x) => { p[0] &&= x !== 'AudioVideo'; p[1] ||= x === 'Video'; return p; }, [true, false]).some(T.id)) throw Error('non musical'); 48 | this.$src.mpris.summon(name); 49 | } 50 | 51 | #onMprisChange(proxy) { 52 | if(proxy?.gNameOwner) return; 53 | this.$src.mpris.dispel(); 54 | this.$src.player.dispel(); 55 | this.#activate(false); 56 | this.$src.dbus.ListNamesAsync(([xs]) => Promise.any(xs.map(x => this.#buildMpris(x))).catch(T.nop)); 57 | } 58 | 59 | #onPlayerChange(proxy, prop) { 60 | if(prop.lookup_value('Metadata', null)) this.#update(proxy.Metadata); 61 | if(prop.lookup_value('PlaybackStatus', null)) this.emit('status', this.status); 62 | } 63 | 64 | #update(metadata) { 65 | let { 66 | 'xesam:title': title, 'xesam:artist': artist, 'xesam:asText': lyric, 67 | 'xesam:album': album, 'mpris:length': length = 0, 68 | } = T.vmap(metadata, v => v.deepUnpack()); // Ref: https://www.freedesktop.org/wiki/Specifications/mpris-spec/metadata 69 | if(!T.str(title) || !title) return; 70 | this.emit('update', { 71 | artist: artist?.every?.(T.str) ? artist.flatMap(x => x.split('/')).filter(T.id) : [], 72 | length: length / 1000, album: T.str(album) ? album : '', lyric: T.str(lyric) ? lyric : null, title, 73 | }); 74 | } 75 | 76 | async getPosition() { // Ref: https://www.andyholmes.ca/articles/dbus-in-gjs.html 77 | let pos = await Gio.DBus.session.call(this.$src.mpris.hub.gName, '/org/mpris/MediaPlayer2', 'org.freedesktop.DBus.Properties', 78 | 'Get', T.pickle(['org.mpris.MediaPlayer2.Player', 'Position']), null, Gio.DBusCallFlags.NONE, -1, null); 79 | return pos.recursiveUnpack().at(0) / 1000; 80 | } 81 | 82 | get status() { 83 | return this.$src.player.hub?.PlaybackStatus === 'Playing'; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/paper.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import St from 'gi://St'; 5 | import Meta from 'gi://Meta'; 6 | import Cairo from 'gi://cairo'; 7 | import Pango from 'gi://Pango'; 8 | import Shell from 'gi://Shell'; 9 | import PangoCairo from 'gi://PangoCairo'; 10 | 11 | import * as DND from 'resource:///org/gnome/shell/ui/dnd.js'; 12 | import * as Main from 'resource:///org/gnome/shell/ui/main.js'; 13 | import * as MiscUtil from 'resource:///org/gnome/shell/misc/util.js'; 14 | 15 | import * as T from './util.js'; 16 | import * as F from './fubar.js'; 17 | import {Key as K} from './const.js'; 18 | 19 | const time2ms = time => Math.round(time.split(':').reduce((p, x) => parseFloat(x) + p * 60, 0) * 1000); // '1:1' => 61000 ms 20 | const color2rgba = ({red, green, blue, alpha = 255}, opacity) => [red, green, blue].map(x => x / 255).concat(opacity ?? alpha / 255); 21 | 22 | function findMaxLE(sorted, value, lower = 0, upper = sorted.length - 1) { // sorted: ascending 23 | if(sorted[upper] <= value) { 24 | return upper; 25 | } else { 26 | while(lower <= upper) { 27 | let index = (lower + upper) >>> 1; 28 | if(sorted[index] <= value && sorted[index + 1] > value) return index; 29 | else if(sorted[index] > value) upper = index - 1; 30 | else lower = index + 1; 31 | } 32 | return -1; 33 | } 34 | } 35 | 36 | class PaperBase extends St.DrawingArea { 37 | static { 38 | T.enrol(this); 39 | } 40 | 41 | constructor(set, param) { 42 | super(param); 43 | this.#clearLyric(); 44 | this.$bindSettings(set); 45 | this.$buildWidgets(); 46 | } 47 | 48 | $bindSettings(set) { 49 | this.$set = set.tie([[K.PRGR, x => !x]], this, () => { this.$lrc = `\u{200b}${this.$lrc}`; this.queue_repaint(); }); // NOTE: force redrawing 50 | } 51 | 52 | $buildWidgets() { 53 | F.connect(this, F.theme(), 'changed', T.thunk(() => this.$onColorChange())); 54 | } 55 | 56 | vfunc_repaint() { 57 | let cr = this.get_context(), 58 | [w, h] = this.get_surface_size(), 59 | pl = PangoCairo.create_layout(cr); 60 | this.$setupLayout(cr, h, pl); 61 | this.$colorLayout(cr, w, pl); 62 | this.$showLayout(cr, pl); 63 | 64 | cr.$dispose(); 65 | } 66 | 67 | #clearLyric() { 68 | this.$len = 0; 69 | this.setLyrics(this.song = ''); 70 | [this.$pos, this.$lrc] = this.getLyric(); 71 | } 72 | 73 | getLyric(now = this.moment) { 74 | let index = findMaxLE(this.$tags, now); 75 | if(index < 0) return [0, this.song]; 76 | let key = this.$tags[index]; 77 | let [len, lrc] = this.$lrcs.get(key); 78 | return [len > 0 ? (now - key) / len : 0, lrc]; 79 | } 80 | 81 | $setupLayout(_cr, _h, pl) { 82 | pl.set_font_description(this.$font); 83 | pl.set_text(this.$lrc, -1); 84 | } 85 | 86 | $colorLayout(cr, w, pl) { 87 | let [pw] = pl.get_pixel_size(); 88 | cr.moveTo(pw > w ? this.$pos * (w - pw) : 0, 0); 89 | if(this[K.PRGR]) { 90 | cr.setSourceRGBA(...this.homochromyColor); 91 | } else { 92 | let gd = this[K.ORNT] ? new Cairo.LinearGradient(0, 0, 0, pw) : new Cairo.LinearGradient(0, 0, pw, 0); 93 | gd.addColorStopRGBA(0, ...this.activeColor); 94 | gd.addColorStopRGBA(this.$pos, ...this.activeColor); 95 | gd.addColorStopRGBA(this.$pos, ...this.inactiveColor); 96 | gd.addColorStopRGBA(1, ...this.inactiveColor); 97 | cr.setSource(gd); 98 | } 99 | } 100 | 101 | $showLayout(cr, pl) { 102 | PangoCairo.show_layout(cr, pl); 103 | } 104 | 105 | clearLyric() { 106 | this.#clearLyric(); 107 | this.queue_repaint(); 108 | } 109 | 110 | setLength(len) { 111 | this.$len = len; 112 | if(!this.$lrcs.size) return; 113 | let end = this.$tags.at(-1); 114 | this.$lrcs.set(end, [Math.max(len - end, 0), this.$lrcs.get(end).at(-1)]); 115 | } 116 | 117 | setMoment(moment) { 118 | this.moment = moment; 119 | let {$pos, $lrc: $txt} = this; 120 | [this.$pos, this.$lrc] = this.getLyric(); 121 | if(!this.visible || (this.$pos === $pos || this[K.PRGR]) && this.$lrc === $txt) return; 122 | this.queue_repaint(); 123 | } 124 | 125 | setLyrics(lyrics) { 126 | this.$lrcs = lyrics.split(/\n/) 127 | .reduce((p, x) => { 128 | let i = x.lastIndexOf(']') + 1; 129 | if(i === 0) return p; 130 | let l = x.slice(i).trim(); 131 | x.slice(0, i).match(/(?<=\[)[.:\d]+(?=])/g)?.forEach(t => p.push([time2ms(t), l])); 132 | return p; 133 | }, []).sort(([x], [y]) => x - y) 134 | .reduce((p, [t, l], i, a) => p.set(t, [(a[i + 1]?.[0] ?? Math.max(this.$len, t)) - t, l]), new Map()); 135 | this.$tags = Array.from(this.$lrcs.keys()); 136 | } 137 | } 138 | 139 | export class Panel extends PaperBase { 140 | static { 141 | T.enrol(this); 142 | } 143 | 144 | constructor(tray, ...args) { 145 | super(...args); 146 | tray.$box.add_child(this); 147 | } 148 | 149 | $buildWidgets() { 150 | F.connect(this, Main.panel.statusArea.quickSettings, 'style-changed', T.thunk(() => this.$onStyleChange())); 151 | this.$naturalWidth = 0; 152 | super.$buildWidgets(); 153 | } 154 | 155 | $onStyleChange() { 156 | let theme = Main.panel.statusArea.quickSettings.get_theme_node(); 157 | this.$font = theme.get_font(); 158 | this.inactiveColor = color2rgba(theme.get_foreground_color()); 159 | let [w, h] = Main.panel.get_size(); 160 | this.$maxWidth = w / 3; 161 | this.set_height(h); 162 | this.$onColorChange(); 163 | } 164 | 165 | get homochromyColor() { 166 | return this.inactiveColor; 167 | } 168 | 169 | $onColorChange() { 170 | this.activeColor = color2rgba(F.theme().get_accent_color()[0]).map((x, i) => MiscUtil.lerp(x, this.inactiveColor[i], 0.2)); 171 | } 172 | 173 | setMoment(moment) { 174 | super.setMoment(moment); 175 | this.set_width(Math.min(this.$maxWidth, this.$naturalWidth + 4)); 176 | } 177 | 178 | $setupLayout(cr, h, pl) { 179 | super.$setupLayout(cr, h, pl); 180 | let [pw, ph] = pl.get_pixel_size(); 181 | this.$naturalWidth = pw; 182 | cr.translate(0, (h - ph) / 2); 183 | } 184 | } 185 | 186 | export class Desktop extends PaperBase { 187 | static { 188 | T.enrol(this); 189 | } 190 | 191 | constructor(drag, ...args) { 192 | super(...args); 193 | this.setDrag(drag); 194 | } 195 | 196 | $buildWidgets() { 197 | super.$buildWidgets(); 198 | Main.uiGroup.add_child(this); 199 | F.connect(this, F.theme(), 'notify::scale-factor', T.thunk(() => this.$onFontSet())); 200 | this.$src = F.Source.tie({drag: new F.Source(() => this.$genDraggable(), x => x._dragComplete())}, this); 201 | } 202 | 203 | $bindSettings(set) { 204 | super.$bindSettings(set); 205 | this.$setIF = new F.Setting('org.gnome.desktop.interface', [ 206 | [['scaling', 'text-scaling-factor']], 207 | ], this, null, () => this.$onFontSet()); 208 | this.$set.tie([ 209 | [K.ORNT, x => this.$onOrientSet(x)], 210 | [K.OPCT, x => x / 100, () => this.$onColorChange()], 211 | [K.SITE, x => T.seq(y => this.set_position(...y), x), null, true], 212 | ], this).tie([K.FONT], this, null, () => this.$onFontSet()); 213 | } 214 | 215 | get homochromyColor() { 216 | return this.activeColor; 217 | } 218 | 219 | $onFontSet() { 220 | this.$font = Pango.FontDescription.from_string(this[K.FONT] ?? 'Sans 11'); 221 | this.$font.set_size(this.$font.get_size() * F.theme().scaleFactor * (this.scaling ?? 1)); 222 | } 223 | 224 | $genDraggable() { 225 | let ret = DND.makeDraggable(this, {dragActorOpacity: 200}); 226 | ret._dragActorDropped = () => { 227 | ret._dragComplete(); 228 | global.display.set_cursor(Meta.Cursor.DEFAULT); 229 | this.$set.set(K.SITE, this.get_position()); 230 | this.$set.set(K.DRAG, false); 231 | return true; 232 | }; 233 | return ret; 234 | } 235 | 236 | setDrag(drag) { 237 | if(drag) Main.layoutManager.trackChrome(this); 238 | else Main.layoutManager.untrackChrome(this); 239 | this.reactive = drag; 240 | this.$src.drag.toggle(drag); 241 | Shell.util_set_hidden_from_pick(this, !drag); 242 | } 243 | 244 | $onOrientSet(orient) { 245 | let [w, h] = global.display.get_size(); 246 | orient ? this.set_size(0.18 * w, h) : this.set_size(w, 0.3 * h); 247 | } 248 | 249 | $onColorChange() { 250 | [this.activeColor, this.inactiveColor] = F.theme().get_accent_color().map(x => color2rgba(x, this[K.OPCT])); 251 | this.outlineColor = this.inactiveColor.map(x => 1 - x).with(3, 0.2); 252 | } 253 | 254 | $showLayout(cr, pl) { 255 | if(this[K.ORNT]) { 256 | pl.get_context().set_base_gravity(Pango.Gravity.EAST); 257 | cr.moveTo(pl.get_pixel_size().at(1), 0); 258 | cr.rotate(Math.PI / 2); 259 | } 260 | super.$showLayout(cr, pl); 261 | PangoCairo.layout_path(cr, pl); 262 | cr.setSourceRGBA(...this.outlineColor); 263 | cr.stroke(); 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/prefs.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import * as UI from './ui.js'; 5 | import * as T from './util.js'; 6 | import {Key as K, URL} from './const.js'; 7 | 8 | const {_} = UI; 9 | 10 | class DesktopLyricPrefs extends UI.Page { 11 | static { 12 | T.enrol(this); 13 | } 14 | 15 | $buildWidgets() { 16 | return [ 17 | [K.FONT, new UI.Font()], 18 | [K.DRAG, new UI.Switch()], 19 | [K.ONLN, new UI.Switch()], 20 | [K.PRGR, new UI.Switch()], 21 | [K.FABK, new UI.Switch()], 22 | [K.OPCT, new UI.Spin(20, 100, 1)], 23 | [K.SPAN, new UI.Spin(20, 500, 10)], 24 | [K.ORNT, new UI.Drop([_('Horizontal'), _('Vertical')])], 25 | [K.PRVD, new UI.Drop([_('NetEase Cloud'), _('LRCLIB')])], 26 | [K.AREA, new UI.Drop([_('Left'), _('Center'), _('Right')])], 27 | [K.PATH, new UI.File({folder: true, size: true, open: true})], 28 | ]; 29 | } 30 | 31 | $buildUI() { 32 | this.$add([null, [ 33 | [[_('_Show progress')], K.PRGR], 34 | [[_('S_ystray position')], K.AREA], 35 | [[_('_Refresh interval')], K.SPAN, UI.Spin.unit(_('ms'))], 36 | ]], [[[_('Desktop')]], [ 37 | [[_('_Mobilize'), _('Allow dragging to displace')], K.DRAG], 38 | [[_('_Opacity')], K.OPCT, UI.Spin.unit('%')], 39 | [[_('_Font')], K.FONT], 40 | [[_('Or_ientation')], K.ORNT], 41 | ]], [[[_('Online'), _('Try to download and save the missing lyrics')], K.ONLN], [ 42 | [[_('_Provider'), _('Prefer lyrics from Mpris metadata')], 43 | new UI.Help(({h}) => [h(_('URL')), [ 44 | [_('NetEase Cloud'), `${URL.NCM}`], 45 | [_('LRCLIB'), `${URL.LRCLIB}`], 46 | ]]), K.PRVD], 47 | [[_('F_allback'), _('Use the first result when searches cannot be matched precisely')], K.FABK], 48 | [[_('_Location'), _('Filename format: Title-Artist1,Artist2-Album.lrc')], K.PATH], 49 | ]]); 50 | } 51 | } 52 | 53 | export default class extends UI.Prefs { $klass = DesktopLyricPrefs; } 54 | -------------------------------------------------------------------------------- /src/ui.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Adw from 'gi://Adw'; 5 | import Gdk from 'gi://Gdk'; 6 | import Gio from 'gi://Gio'; 7 | import Gtk from 'gi://Gtk'; 8 | import GLib from 'gi://GLib'; 9 | import Pango from 'gi://Pango'; 10 | import GObject from 'gi://GObject'; 11 | import * as Gettext from 'gettext'; 12 | import * as Extensions from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js'; 13 | 14 | import * as T from './util.js'; 15 | 16 | const {BIND} = T; 17 | 18 | Gio._promisify(Gtk.FileDialog.prototype, 'open'); 19 | Gio._promisify(Gtk.FileDialog.prototype, 'select_folder'); 20 | 21 | export const _ = Extensions.gettext; 22 | export const _G = (x, y = 'gtk40') => Gettext.domain(y).gettext(x); 23 | export const me = () => Extensions.ExtensionPreferences.lookupByURL(import.meta.url); 24 | 25 | export const setv = Symbol('Set Value'); 26 | export const getv = Symbol('Get Default Value'); 27 | export const esse = Symbol('Default Binding Key'); 28 | 29 | export const once = (o, f, s = 'notify::value') => { let id = o.connect(s, () => { o.disconnect(id); f(); }); }; 30 | export const gtype = (o, v) => o.constructor[GObject.properties]?.[v]?.value_type; 31 | export const enrol = (c, v) => T.enrol(c, {value: v ?? null}); 32 | 33 | export class Prefs extends Extensions.ExtensionPreferences { 34 | constructor(...args) { 35 | T.load(`${T.ROOT}/resource/prefs.gresource`); 36 | super(...args); 37 | } 38 | 39 | getPreferencesWidget() { 40 | if(this.$klass) return new this.$klass(this.getSettings()); 41 | } 42 | } 43 | 44 | export class Page extends Adw.PreferencesPage { 45 | static { 46 | T.enrol(this); 47 | } 48 | 49 | #bind(gset, key, gobj, prop) { 50 | prop ??= gobj[esse] ?? 'value'; 51 | gobj[getv] = gset.get_default_value(key).recursiveUnpack(); 52 | gobj[setv] = val => { gobj[prop] = val ?? gobj[getv]; }; 53 | if(gtype(gobj, prop) !== GObject.TYPE_JSOBJECT) { 54 | gset.bind(key, gobj, prop, Gio.SettingsBindFlags.DEFAULT); 55 | } else { // HACK: workaround for https://gitlab.gnome.org/GNOME/gjs/-/issues/397 56 | gobj[prop] = gset.get_value(key).recursiveUnpack(); 57 | gobj.connect(`notify::${prop}`, () => gset.set_value(key, gobj.$picklev?.() ?? T.pickle(gobj[prop], false))); 58 | } 59 | return gobj; 60 | } 61 | 62 | #tie = (a, s) => Object.fromEntries(a.map(([k, o, p]) => [k, this.#bind(s, k, o, p)])); 63 | 64 | constructor(gset, param) { 65 | super({useUnderline: true, ...param}); 66 | this.$tie = (x, s = gset) => { if(Array.isArray(x)) this.$blk = Object.assign(this.#tie(x, s), this.$blk); }; 67 | T.seq(x => x && this.$tie(x), this.$buildWidgets?.(gset)); 68 | T.seq(x => x && this.$add([null, x]), this.$buildUI?.()); 69 | } 70 | 71 | $add(...grps) { 72 | let sensitize = (a, b) => a.bind_property(a[esse], b, 'sensitive', GObject.BindingFlags.SYNC_CREATE); 73 | grps.forEach(grp => this.add(grp instanceof Adw.PreferencesGroup ? grp : T.str(grp) ? this.$blk[grp] : T.seq(g => { 74 | let [[[title = '', subtitle = ''], suffix = null], rows, param] = (grp[0] ??= [[]], grp); 75 | g.set({title, description: subtitle, headerSuffix: T.str(suffix) ? this.$blk[suffix] : suffix, ...param}); 76 | rows = rows.map(row => row instanceof Gtk.Widget ? row : T.str(row) ? this.$blk[row] : T.seq(r => { 77 | row = row.map(x => T.str(x) ? this.$blk[x] : x); 78 | let [prefix, [title_, subtitle_ = ''], ...suffix1] = (Array.isArray(row[0]) && row.unshift(null), row); 79 | r.set({title: title_, subtitle: subtitle_}); 80 | if(prefix) r.add_prefix(prefix); 81 | if(prefix instanceof Check) { 82 | r.set_activatable_widget(prefix); 83 | suffix1.forEach(x => { r.add_suffix(x); sensitize(prefix, x); }); 84 | } else if(suffix1.length) { 85 | r.set_activatable_widget(suffix1.find(x => !(x instanceof Help)) ?? null); 86 | suffix1.forEach(x => r.add_suffix(x)); 87 | } 88 | }, new Adw.ActionRow({useUnderline: true}))); 89 | if(g.headerSuffix instanceof Switch) rows.forEach(r => { g.add(r); sensitize(g.headerSuffix, r); }); 90 | else rows.forEach(r => g.add(r)); 91 | }, new Adw.PreferencesGroup()))); 92 | } 93 | } 94 | 95 | export class Box extends Gtk.Box { 96 | static { 97 | T.enrol(this); 98 | } 99 | 100 | static newV = (cs, p, ...xs) => new Box(cs, {orientation: Gtk.Orientation.VERTICAL, valign: Gtk.Align.FILL, ...p}, ...xs); 101 | 102 | constructor(children, param, linked = true) { 103 | super({valign: Gtk.Align.CENTER, ...param}); 104 | children?.forEach(x => x && this.append(x)); 105 | if(linked) this.add_css_class('linked'); 106 | } 107 | } 108 | 109 | export class Spin extends Gtk.SpinButton { 110 | static unit = x => new Gtk.Label({label: x, cssClasses: ['dimmed']}); // TODO: ? embed to Spin 111 | 112 | static { 113 | T.enrol(this); 114 | } 115 | 116 | constructor(lower, upper, stepIncrement, tooltipText = '', param) { 117 | super({tooltipText, valign: Gtk.Align.CENTER, adjustment: new Gtk.Adjustment({lower, upper, stepIncrement}), ...param}); 118 | } 119 | } 120 | 121 | export class Switch extends Gtk.Switch { 122 | static { 123 | T.enrol(this); 124 | } 125 | 126 | [esse] = 'active'; 127 | 128 | constructor(param) { 129 | super({valign: Gtk.Align.CENTER, ...param}); 130 | } 131 | } 132 | 133 | export class Check extends Gtk.CheckButton { 134 | static get pad() { return new Gtk.CheckButton({sensitive: false, opacity: 0}); } 135 | 136 | static { 137 | T.enrol(this); 138 | } 139 | 140 | [esse] = 'active'; 141 | } 142 | 143 | export class Drop extends Gtk.DropDown { 144 | static { 145 | T.enrol(this); 146 | } 147 | 148 | [esse] = 'selected'; 149 | 150 | constructor(strv, tooltipText = '', param) { 151 | super({model: Gtk.StringList.new(strv), valign: Gtk.Align.CENTER, tooltipText, ...param}); 152 | } 153 | } 154 | 155 | export class Font extends Gtk.FontDialogButton { 156 | static { 157 | enrol(this, ''); 158 | } 159 | 160 | constructor(param) { 161 | super({valign: Gtk.Align.CENTER, dialog: new Gtk.FontDialog(), ...param}); 162 | this.bind_property_full('font-desc', this, 'value', BIND, (_b, x) => [true, x.to_string()], (_b, x) => [true, Pango.FontDescription.from_string(x)]); 163 | } 164 | } 165 | 166 | export class Help extends Gtk.MenuButton { 167 | static { 168 | T.enrol(this); 169 | } 170 | 171 | static typeset(build, param) { 172 | let keys = x => new Gtk.ShortcutLabel({accelerator: x}), 173 | mark = (x, y, z) => new Gtk.Label({label: x, cssClasses: y ? T.unit(y) : [], useMarkup: true, halign: Gtk.Align.START, ...z}), 174 | dict = (a, n = 2) => T.array(Math.ceil(a.length / n), () => a.splice(0, n).map((x, i) => i % 2 ? x : mark(`${x}`, null, {selectable: true}))), 175 | head = (x, z) => mark(`${x}`, null, z), 176 | wrap = x => x instanceof Gtk.Widget ? x : mark(x); 177 | return Box.newV(build({k: keys, m: mark, d: dict, h: head}).map(x => T.seq(w => T.unit(x).forEach((y, i) => 178 | T.unit(y).forEach((z, j) => z && w.attach(wrap(z), j, i, 1, 1))), new Gtk.Grid({vexpand: true, rowSpacing: 4, columnSpacing: 12}))), 179 | {valign: Gtk.Align.START, spacing: 6, ...param}, false); 180 | } 181 | 182 | constructor(help, param, param1) { 183 | super({hasFrame: false, valign: Gtk.Align.CENTER, popover: new Gtk.Popover(), ...param1}); 184 | if(help) this.setup(help, param); 185 | } 186 | 187 | setup(help, param, error) { 188 | switch(T.type(help)) { 189 | case 'function': help = Help.typeset(help, param); break; 190 | case 'string': help = new Gtk.Label({label: help, ...param}); break; 191 | } 192 | this.popover.set_child(help); 193 | this.set_icon_name(error ? 'dialog-error-symbolic' : 'help-about-symbolic'); 194 | } 195 | } 196 | 197 | export class Sign extends Gtk.Box { 198 | static { 199 | T.enrol(this); 200 | } 201 | 202 | constructor(fallbackIcon, reverse, labelParam, iconParam) { 203 | super({spacing: 5}); 204 | this.$fallbackIcon = fallbackIcon; 205 | this.$icon = new Gtk.Image(iconParam); 206 | this.$label = new Gtk.Label(labelParam); 207 | if(reverse) [this.$label, this.$icon].forEach(x => this.append(x)); 208 | else [this.$icon, this.$label].forEach(x => this.append(x)); 209 | } 210 | 211 | setup(icon, label) { 212 | this.$label.set_label(label || _G('(None)')); 213 | if(icon instanceof Gio.Icon) this.$icon.set_from_gicon(icon); 214 | else this.$icon.iconName = icon || this.$fallbackIcon; 215 | } 216 | } 217 | 218 | export class Dialog extends Adw.Window { // FIXME: revert from Adw.Dialog since https://gitlab.gnome.org/GNOME/libadwaita/-/merge_requests/1415 breaks ECK on close 219 | static { 220 | T.enrol(this, null, {Signals: {chosen: {param_types: [GObject.TYPE_JSOBJECT]}}}); 221 | } 222 | 223 | constructor(build, param) { 224 | super({widthRequest: 360, heightRequest: 320, modal: true, hideOnClose: true, ...param}); 225 | this.connect('chosen', (_d, value) => this.$chosen?.resolve(value)); 226 | this.connect('close-request', () => this.$chosen?.reject(Error('cancelled'))); 227 | this.add_controller(T.hook({'key-pressed': (...xs) => this.$onKeyPress(...xs)}, new Gtk.EventControllerKey())); 228 | this.$buildContent(build); 229 | } 230 | 231 | $buildContent(build) { 232 | this.set_content(build instanceof Gtk.Widget ? T.seq(w => w.add_top_bar(new Adw.HeaderBar({showTitle: false})), 233 | new Adw.ToolbarView({content: build})) : this.$buildWidgets(build)); 234 | } 235 | 236 | $buildWidgets(build) { 237 | let {content, filter, title} = build(this), search, 238 | close = T.hook({clicked: () => this.close()}, Gtk.Button.new_with_mnemonic(_G('_Cancel'))), 239 | select = T.hook({clicked: () => this.$onChosen()}, Gtk.Button.new_with_mnemonic(_G('_OK'))), 240 | header = new Adw.HeaderBar({showEndTitleButtons: false, showStartTitleButtons: false, titleWidget: title || null}); 241 | select.add_css_class('suggested-action'); 242 | header.pack_start(close); 243 | header.pack_end(select); 244 | if(filter) { 245 | let button = new Gtk.ToggleButton({iconName: 'system-search-symbolic'}); 246 | let entry = T.hook({'search-changed': x => filter.set_search(x.get_text())}, new Gtk.SearchEntry({halign: Gtk.Align.CENTER})); 247 | search = new Gtk.SearchBar({showCloseButton: false, child: entry, keyCaptureWidget: this}); 248 | search.connect_entry(entry); 249 | button.bind_property('active', search, 'search-mode-enabled', BIND); 250 | this.connect('close-request', () => { button.set_active(false); content.scroll_to(0, Gtk.ListScrollFlags.FOCUS, null); }); 251 | header.pack_end(button); 252 | } 253 | return Box.newV([header, search, new Gtk.ScrolledWindow({child: content})], null, false); 254 | } 255 | 256 | $onKeyPress(_w, key) { 257 | switch(key) { 258 | case Gdk.KEY_Escape: this.close(); break; 259 | case Gdk.KEY_Return: 260 | case Gdk.KEY_KP_Enter: 261 | case Gdk.KEY_ISO_Enter: this.$onChosen(); break; 262 | } 263 | } 264 | 265 | $onChosen(chosen = this.getChosen?.()) { 266 | if(chosen !== undefined) this.emit('chosen', [chosen]); 267 | this.close(); 268 | } 269 | 270 | choose(root, initial) { 271 | this.$chosen = Promise.withResolvers(); 272 | if(this.transient_for !== root) this.set_transient_for(root); 273 | this.present(); 274 | this.initChosen?.(initial); 275 | return this.$chosen.promise; 276 | } 277 | } 278 | 279 | export class DialogButtonBase extends Box { 280 | static { 281 | enrol(this, ''); 282 | } 283 | 284 | constructor(opt, child, reset, param) { 285 | super(); 286 | this.$opt = opt; 287 | this[setv] = v => { this.value = v; }; 288 | if(reset) this.append(T.hook({clicked: () => this[setv]()}, new Gtk.Button({iconName: 'edit-clear-symbolic', tooltipText: _G('Reset')}))); 289 | this.prepend(this.$btn = T.hook({clicked: () => this.$onClick().then(x => this.$onSetv(x)).catch(T.nop)}, new Gtk.Button({child, ...param}))); 290 | this.$buildDND(gtype(this, 'gvalue')); 291 | } 292 | 293 | $onClick() { 294 | return this.dlg.choose(this.get_root(), this.$getInitial?.() ?? this.value); 295 | } 296 | 297 | $onSetv([value]) { 298 | value.constructor === this.value?.constructor ? this[setv](value) : this.gvalue = value; 299 | } 300 | 301 | $buildDND(gType) { 302 | if(!gType) return; 303 | this.$onDrop = (_t, v) => { this.gvalue = v; }; 304 | this.$onDrag = src => { T.seq(x => x && src.set_icon(x, 10, 10), this.$genSwatch?.()); return Gdk.ContentProvider.new_for_value(this.gvalue); }; 305 | this.$btn.add_controller(T.hook({drop: (...xs) => this.$onDrop(...xs)}, Gtk.DropTarget.new(gType, Gdk.DragAction.COPY))); 306 | this.$btn.add_controller(T.hook({prepare: (...xs) => this.$onDrag(...xs)}, new Gtk.DragSource({actions: Gdk.DragAction.COPY}))); 307 | this.$bindv = (to, from) => this.bind_property_full('value', this, 'gvalue', BIND, to, from); 308 | this.connect('notify::gvalue', () => this.$onGValueSet?.(this.gvalue)); 309 | } 310 | 311 | get dlg() { 312 | return (this.$dialog ??= this.$genDialog(this.$opt)); 313 | } 314 | 315 | vfunc_mnemonic_activate() { 316 | this.$btn.activate(); 317 | } 318 | } 319 | 320 | export class App extends DialogButtonBase { 321 | static { 322 | T.enrol(this, {gvalue: Gio.DesktopAppInfo}); 323 | } 324 | 325 | constructor(opt, param) { 326 | super(opt, new Sign('application-x-executable-symbolic'), true, param); 327 | this.$bindv((_b, x) => [true, Gio.DesktopAppInfo.new(x)], (_b, x) => [true, x?.get_id() ?? '']); 328 | this.$onGValueSet = v => this.$btn.child.setup(...v ? [v.get_icon(), v.get_display_name()] : []); 329 | } 330 | 331 | $genSwatch() { 332 | return Gtk.IconTheme.get_for_display(Gdk.Display.get_default()).lookup_by_gicon(this.gvalue.get_icon(), 32, 1, Gtk.TextDirection.NONE, Gtk.IconLookupFlags.FORCE_SVG); 333 | } 334 | 335 | $genDialog(opt) { 336 | return new Dialog(dlg => { 337 | let factory = T.hook({ 338 | setup: (_f, x) => x.set_child(new Sign('application-x-executable-symbolic')), 339 | bind: (_f, x) => x.get_child().setup(...(y => [y.get_icon() || '', y.get_display_name()])(x.get_item())), 340 | }, new Gtk.SignalListItemFactory()), 341 | filter = Gtk.CustomFilter.new(null), 342 | list = new Gio.ListStore({itemType: Gio.DesktopAppInfo}), 343 | select = new Gtk.SingleSelection({model: new Gtk.FilterListModel({model: list, filter})}), 344 | content = T.hook({activate: () => dlg.$onChosen()}, new Gtk.ListView({model: select, factory, vexpand: true})); 345 | list.splice(0, 0, opt?.noDisplay ? Gio.AppInfo.get_all() : Gio.AppInfo.get_all().filter(x => x.should_show())); 346 | filter.set_search = s => filter.set_filter_func(s ? (a => x => a.has(x.get_id()))(new Set(Gio.DesktopAppInfo.search(s).flat())) : null); 347 | dlg.getChosen = () => select.get_selected_item(); 348 | return {content, filter}; 349 | }, {title: _G('Select Application')}); 350 | } 351 | } 352 | 353 | export class File extends DialogButtonBase { 354 | static { 355 | T.enrol(this, {gvalue: Gio.File}); 356 | } 357 | 358 | constructor(opt, param, icon = 'document-open-symbolic') { 359 | super(opt, new Sign(icon), true, param); 360 | if(opt?.folder) opt.filter = {mimeTypes: ['inode/directory']}; 361 | if(opt?.filter) this.$filter = new Gtk.FileFilter(opt.filter); 362 | if(opt?.size) this.$btn.child.$label.set_use_markup(true); 363 | if(opt?.open) { 364 | this.insert_child_after(T.hook({clicked: () => Gtk.FileLauncher.new(this.gvalue).launch(this.get_root(), null, null)}, 365 | new Gtk.Button({iconName: 'document-open-symbolic'})), this.$btn); 366 | } 367 | this.$onSetv = x => { this.gvalue = x; }; 368 | this.$bindv((_b, x) => [true, T.fopen(x)], (_b, x) => [true, x.get_path()]); 369 | this.$onGValueSet = v => T.fquery(v, Gio.FILE_ATTRIBUTE_STANDARD_DISPLAY_NAME, Gio.FILE_ATTRIBUTE_STANDARD_ICON) 370 | .then(x => this.setup(x.get_icon(), x.get_display_name())).catch(() => this.setup()); 371 | } 372 | 373 | $genDialog() { 374 | return new Gtk.FileDialog({modal: true, title: this.$opt?.title ?? null, defaultFilter: this.$filter ?? null}); 375 | } 376 | 377 | $onDrop(_t, file) { 378 | if(!this.$filter) { 379 | this.gvalue = file; 380 | } else { 381 | T.fquery(file, Gio.FILE_ATTRIBUTE_STANDARD_CONTENT_TYPE).then(y => { 382 | if(this.$filter.match(y)) this.gvalue = file; else throw Error(); 383 | }).catch(() => { 384 | this.get_root().add_toast(new Adw.Toast({title: _('Mismatched filetype'), timeout: 7})); 385 | }); 386 | } 387 | } 388 | 389 | $onClick() { 390 | this.dlg.set_initial_file(this.gvalue); 391 | return this.$opt?.folder ? this.dlg.select_folder(this.get_root(), null) : this.dlg.open(this.get_root(), null); 392 | } 393 | 394 | setup(icon, text) { 395 | if(this.$opt.size) { 396 | let size = T.essay(() => GLib.format_size(this.gvalue.measure_disk_usage(Gio.FileMeasureFlags.NONE, null, null)[1]), () => ''); 397 | text = `${T.escape(text)}${size && ` ${size}`}`; 398 | } 399 | this.$btn.child.setup(icon, text); 400 | } 401 | } 402 | 403 | export class Icon extends DialogButtonBase { 404 | static { 405 | T.enrol(this, {gvalue: Gio.ThemedIcon}); 406 | } 407 | 408 | static Type = {ALL: 0, NORMAL: 1, SYMBOLIC: 2}; 409 | 410 | constructor(opt, param) { 411 | super(opt, new Sign('image-missing'), true, param); 412 | this.$bindv((_b, x) => [true, Gio.ThemedIcon.new(x)], (_b, x) => [true, x.to_string()]); 413 | this.$onGValueSet = v => v ? this.$btn.child.setup(this.gvalue, this.value.replace(/-symbolic$/, '')) : this.$btn.child.setup(); 414 | } 415 | 416 | $genSwatch() { 417 | return Gtk.IconTheme.get_for_display(Gdk.Display.get_default()).lookup_by_gicon(this.gvalue, 32, 1, Gtk.TextDirection.NONE, Gtk.IconLookupFlags.FORCE_SVG); 418 | } 419 | 420 | $genDialog(opt) { 421 | return new Dialog(dlg => { 422 | let factory = T.hook({ 423 | setup: (_f, x) => x.set_child(new Gtk.Image({iconSize: Gtk.IconSize.LARGE})), 424 | bind: (_f, {child, item}) => { child.iconName = child.tooltipText = item.string; }, 425 | }, new Gtk.SignalListItemFactory()), 426 | filter = T.seq(w => [new Gtk.StringFilter({expression: new Gtk.PropertyExpression(Gtk.StringObject, null, 'string')}), 427 | new Gtk.BoolFilter()].forEach(x => w.append(x)), new Gtk.EveryFilter()), 428 | title = T.seq(w => ['image-missing', 'image-x-generic', 'image-x-generic-symbolic'].forEach(x => 429 | w.add(new Adw.Toggle({iconName: x}))), new Adw.ToggleGroup()), 430 | model = Gtk.StringList.new(Gtk.IconTheme.get_for_display(Gdk.Display.get_default()).get_icon_names()), 431 | select = new Gtk.SingleSelection({model: new Gtk.FilterListModel({model, filter})}), 432 | content = T.hook({activate: () => dlg.$onChosen()}, new Gtk.GridView({model: select, factory, vexpand: true})); 433 | title.set_active(opt?.type ?? Icon.Type.SYMBOLIC); 434 | title.bind_property_full('active', filter.get_item(1), 'expression', GObject.BindingFlags.SYNC_CREATE, (_b, x) => { 435 | switch(x) { 436 | case Icon.Type.ALL: return [true, Gtk.ConstantExpression.new_for_value(true)]; 437 | case Icon.Type.NORMAL: return [true, new Gtk.ClosureExpression(GObject.TYPE_BOOLEAN, y => !y.string.endsWith('-symbolic'), null)]; 438 | case Icon.Type.SYMBOLIC: return [true, new Gtk.ClosureExpression(GObject.TYPE_BOOLEAN, y => y.string.endsWith('-symbolic'), null)]; 439 | } 440 | }, null); 441 | dlg.getChosen = () => select.get_selected_item().get_string(); 442 | return {content, title, filter: filter.get_item(0)}; 443 | }); 444 | } 445 | } 446 | 447 | export class Keys extends DialogButtonBase { 448 | static genStatusPage() { 449 | return new Adw.StatusPage({ 450 | iconName: 'preferences-desktop-keyboard-shortcuts-symbolic', title: _G('Enter the new shortcut', 'gnome-control-center-2.0'), 451 | description: _G('Press Esc to cancel or Backspace to disable the keyboard shortcut', 'gnome-control-center-2.0'), 452 | }); 453 | } 454 | 455 | static { 456 | enrol(this); 457 | } 458 | 459 | constructor(param) { 460 | super(null, new Gtk.ShortcutLabel({disabledText: _G('New accelerator…')}), false, {hasFrame: false, ...param}); 461 | this.connect('notify::value', () => this.$btn.child.set_accelerator(this.value?.[0] ?? '')); 462 | this.$picklev = () => new GLib.Variant('as', this.value); 463 | } 464 | 465 | $validate(mask, keyval, keycode) { // from: https://gitlab.gnome.org/GNOME/gnome-control-center/-/blob/main/panels/keyboard/keyboard-shortcuts.c 466 | return (Gtk.accelerator_valid(keyval, mask) || (keyval === Gdk.KEY_Tab && mask !== 0)) && 467 | !(mask === 0 || mask === Gdk.SHIFT_MASK && keycode !== 0 && 468 | ((keyval >= Gdk.KEY_a && keyval <= Gdk.KEY_z) || 469 | (keyval >= Gdk.KEY_A && keyval <= Gdk.KEY_Z) || 470 | (keyval >= Gdk.KEY_0 && keyval <= Gdk.KEY_9) || 471 | (keyval >= Gdk.KEY_kana_fullstop && keyval <= Gdk.KEY_semivoicedsound) || 472 | (keyval >= Gdk.KEY_Arabic_comma && keyval <= Gdk.KEY_Arabic_sukun) || 473 | (keyval >= Gdk.KEY_Serbian_dje && keyval <= Gdk.KEY_Cyrillic_HARDSIGN) || 474 | (keyval >= Gdk.KEY_Greek_ALPHAaccent && keyval <= Gdk.KEY_Greek_omega) || 475 | (keyval >= Gdk.KEY_hebrew_doublelowline && keyval <= Gdk.KEY_hebrew_taf) || 476 | (keyval >= Gdk.KEY_Thai_kokai && keyval <= Gdk.KEY_Thai_lekkao) || 477 | (keyval >= Gdk.KEY_Hangul_Kiyeog && keyval <= Gdk.KEY_Hangul_J_YeorinHieuh) || 478 | (keyval === Gdk.KEY_space && mask === 0) || [Gdk.KEY_Home, Gdk.KEY_Left, Gdk.KEY_Up, Gdk.KEY_Right, Gdk.KEY_Down, Gdk.KEY_Page_Up, 479 | Gdk.KEY_Page_Down, Gdk.KEY_End, Gdk.KEY_Tab, Gdk.KEY_KP_Enter, Gdk.KEY_Return, Gdk.KEY_Mode_switch].includes(keyval))); 480 | } 481 | 482 | $genDialog() { 483 | return T.seq(dlg => { 484 | dlg.$onKeyPress = (_w, keyval, keycode, state) => { 485 | let mask = state & Gtk.accelerator_get_default_mod_mask() & ~Gdk.ModifierType.LOCK_MASK; 486 | if(!mask && keyval === Gdk.KEY_Escape) return dlg.close(); 487 | if(keyval === Gdk.KEY_BackSpace) return dlg.$onChosen([]); 488 | if(this.$validate(mask, keyval, keycode)) dlg.$onChosen([Gtk.accelerator_name_with_keycode(null, keyval, keycode, mask)]); 489 | }; 490 | }, new Dialog(Keys.genStatusPage())); 491 | } 492 | } 493 | 494 | export class Entry extends Gtk.Stack { 495 | static { 496 | enrol(this, ''); 497 | } 498 | 499 | constructor(placeholder, mime, tooltip, param) { 500 | super({valign: Gtk.Align.CENTER, hhomogeneous: true, ...param}); 501 | this.$buildWidgets(placeholder, mime, tooltip); 502 | } 503 | 504 | $buildWidgets(placeholderText = '', mimeTypes, tooltipText = '') { 505 | let label = new Gtk.Entry({hexpand: true, sensitive: false, placeholderText}), 506 | apply = w => { label.set_text(w.text); this.set_visible_child(label.parent); }, 507 | entry = mimeTypes ? T.hook({ 508 | activate: w => apply(w), 509 | 'icon-press': w => new Gtk.FileDialog({modal: true, defaultFilter: new Gtk.FileFilter({mimeTypes})}) 510 | .open(this.get_root(), null).then(x => w.set_text(x.get_path())).catch(T.nop), 511 | }, new Gtk.Entry({hexpand: true, enableUndo: true, secondaryIconName: 'document-open-symbolic', placeholderText})) 512 | : T.hook({activate: w => apply(w)}, new Gtk.Entry({hexpand: true, enableUndo: true, placeholderText})), 513 | edit = T.hook({clicked: () => { entry.set_text(label.text); entry.grab_focus(); this.set_visible_child(entry.parent); }}, 514 | new Gtk.Button({iconName: 'document-edit-symbolic', tooltipText})), 515 | done = T.hook({clicked: () => apply(entry)}, new Gtk.Button({ 516 | cssClasses: ['suggested-action'], iconName: 'object-select-symbolic', tooltipText: _('Click or press ENTER to apply changes'), 517 | })); 518 | [[label, edit], [entry, done]].forEach(x => this.add_child(new Box(x, {hexpand: true}))); 519 | this.$toggle = () => this.get_visible_child() === edit.parent ? edit.activate() : done.activate(); 520 | this.bind_property('value', label, 'text', BIND); 521 | } 522 | 523 | vfunc_mnemonic_activate() { 524 | this.$toggle(); 525 | } 526 | } 527 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | // SPDX-FileCopyrightText: tuberry 2 | // SPDX-License-Identifier: GPL-3.0-or-later 3 | 4 | import Gio from 'gi://Gio'; 5 | import GLib from 'gi://GLib'; 6 | import GObject from 'gi://GObject'; 7 | import Soup from 'gi://Soup/?version=3.0'; 8 | 9 | Gio._promisify(Gio.File.prototype, 'copy_async'); 10 | Gio._promisify(Gio.File.prototype, 'delete_async'); 11 | Gio._promisify(Gio.File.prototype, 'query_info_async'); 12 | Gio._promisify(Gio.File.prototype, 'load_contents_async'); 13 | Gio._promisify(Gio.File.prototype, 'replace_contents_async'); 14 | Gio._promisify(Gio.File.prototype, 'enumerate_children_async'); 15 | Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async'); 16 | 17 | export const ROOT = GLib.path_get_dirname(import.meta.url.slice(7)); 18 | export const PIPE = Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE; 19 | export const BIND = GObject.BindingFlags.BIDIRECTIONAL | GObject.BindingFlags.SYNC_CREATE; 20 | 21 | export const id = x => x; 22 | export const nop = () => {}; 23 | /** @template T * @param {T} x * @return {T} */ 24 | export const seq = (f, x) => (f(x), x); 25 | export const xnor = (x, y) => !x === !y; 26 | export const Y = f => (...xs) => f(Y(f))(...xs); // Y combinator 27 | export const thunk = (f, ...xs) => (f(...xs), f); 28 | export const str = x => x?.constructor === String; 29 | export const decode = x => new TextDecoder().decode(x); 30 | export const encode = x => new TextEncoder().encode(x); 31 | export const vmap = (o, f) => omap(o, ([k, v]) => [[k, f(v)]]); 32 | export const lot = x => x[Math.floor(Math.random() * x.length)]; 33 | export const escape = (x, i = -1) => GLib.markup_escape_text(x, i); 34 | export const unit = (x, f = y => [y]) => Array.isArray(x) ? x : f(x); 35 | export const array = (n, f = id) => Array.from({length: n}, (_x, i) => f(i)); 36 | export const omap = (o, f) => Object.fromEntries(Object.entries(o).flatMap(f)); 37 | export const each = (f, a, s) => { for(let i = 0, n = a.length; i < n;) f(a.slice(i, i += s)); }; 38 | export const upcase = (s, f = x => x.toLowerCase()) => s.charAt(0).toUpperCase() + f(s.slice(1)); 39 | export const type = x => Object.prototype.toString.call(x).replace(/\[object (\w+)\]/, (_m, p) => p.toLowerCase()); 40 | export const format = (x, f) => x.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (m, a, b) => b ? f(b) ?? m : f(a) === undefined ? m : `{${a}}`); 41 | /** @template T * @param {T} x * @return {T} */ // NOTE: see https://github.com/tc39/proposal-type-annotations & https://github.com/jsdoc/jsdoc/issues/1986 42 | export const hook = (o, x) => (Object.entries(o).forEach(([k, v]) => x.connect(k, v)), x); 43 | export const essay = (f, g = nop) => { try { return f(); } catch(e) { return g(e); } }; // NOTE: https://github.com/arthurfiorette/proposal-safe-assignment-operator 44 | export const load = x => exist(x) && Gio.Resource.load(x)._register(); 45 | export const exist = x => GLib.file_test(x, GLib.FileTest.EXISTS); 46 | 47 | export const fquery = (x, ...ys) => fopen(x).query_info_async(ys.join(','), Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, null); 48 | export const fwrite = (x, y, c = null) => fopen(x).replace_contents_async(encode(y), null, false, Gio.FileCreateFlags.NONE, c); 49 | export const fcopy = (x, y, c = null) => fopen(x).copy_async(fopen(y), Gio.FileCopyFlags.NONE, GLib.PRIORITY_DEFAULT, c, null); 50 | export const fopen = x => str(x) ? x ? Gio.File.new_for_commandline_arg(x) : Gio.File.new_for_path(x) : x; 51 | export const fdelete = (x, c = null) => fopen(x).delete_async(GLib.PRIORITY_DEFAULT, c); 52 | export const fread = (x, c = null) => fopen(x).load_contents_async(c); 53 | 54 | export async function readdir(dir, func, attr = Gio.FILE_ATTRIBUTE_STANDARD_NAME, cancel = null) { 55 | return Array.fromAsync(await fopen(dir).enumerate_children_async(attr, Gio.FileQueryInfoFlags.NONE, GLib.PRIORITY_DEFAULT, cancel), func); 56 | } 57 | 58 | export function search(needle, haystack) { // Ref: https://github.com/bevacqua/fuzzysearch 59 | let tmp, iter = haystack[Symbol.iterator](); // TODO: Iterator.from() 60 | out: for(let char of needle) { 61 | while(!(tmp = iter.next()).done) if(tmp.value === char) continue out; 62 | return false; 63 | } 64 | return true; 65 | } 66 | 67 | export function enrol(klass, pspec, param) { 68 | if(pspec) { 69 | let spec = (k, t, ...vs) => [[k, GObject.ParamSpec[t](k, null, null, GObject.ParamFlags.READWRITE, ...vs)]]; 70 | GObject.registerClass({ 71 | Properties: omap(pspec, ([key, value]) => (kind => { 72 | switch(kind) { 73 | case 'array': return spec(key, ...value); 74 | case 'null': return spec(key, 'jsobject'); 75 | case 'function': return spec(key, 'object', value); 76 | default: return spec(key, kind, value); 77 | } 78 | })(type(value))), ...param, 79 | }, klass); 80 | } else { 81 | param ? GObject.registerClass(param, klass) : GObject.registerClass(klass); 82 | } 83 | } 84 | 85 | export function homolog(cat, dog, keys, cmp = (x, y, _k) => x === y) { // cat, dog: JSON-compatible object 86 | let list = (f, x, y) => x.length === y.length && f(x), 87 | dict = keys ? f => f(keys) : (f, x, y) => list(f, Object.keys(x), Object.keys(y)), 88 | kind = (x, y) => (t => t === type(y) ? t : NaN)(type(x)); 89 | return Y(f => (a, b, k) => { 90 | switch(kind(a, b)) { 91 | case 'array': return list(() => a.every((x, i) => f(x, b[i])), a, b); 92 | case 'object': return dict(xs => xs.every(x => f(a[x], b[x])), a, b); 93 | default: return cmp(a, b, k); 94 | } 95 | })(cat, dog); 96 | } 97 | 98 | export function pickle(value, tuple = true, number = 'u') { // value: JSON-compatible 99 | let list = tuple ? x => GLib.Variant.new_tuple(x) : x => new GLib.Variant('av', x); 100 | return Y(f => v => { 101 | switch(type(v)) { 102 | case 'array': return list(v.map(f)); 103 | case 'object': return new GLib.Variant('a{sv}', vmap(v, f)); 104 | case 'string': return GLib.Variant.new_string(v); 105 | case 'number': return new GLib.Variant(number, v); 106 | case 'boolean': return GLib.Variant.new_boolean(v); 107 | case 'null': return new GLib.Variant('mv', v); 108 | default: return GLib.Variant.new_string(String(v)); 109 | } 110 | })(value); 111 | } 112 | 113 | export async function request(method, url, param, cancel = null, header = null, session = new Soup.Session()) { 114 | let msg = param ? Soup.Message.new_from_encoded_form(method, url, Soup.form_encode_hash(param)) : Soup.Message.new(method, url); 115 | if(header) Object.entries(header).forEach(([k, v]) => msg.request_headers.append(k, v)); 116 | let ans = await session.send_and_read_async(msg, GLib.PRIORITY_DEFAULT, cancel); 117 | if(msg.statusCode !== Soup.Status.OK) throw Error(msg.get_reason_phrase()); 118 | return decode(ans.get_data()); 119 | } 120 | 121 | export async function execute(cmd, env, cancel = null, tty = new Gio.SubprocessLauncher({flags: PIPE})) { 122 | if(env) Object.entries(env).forEach(([k, v]) => tty.setenv(k, v, true)); 123 | let proc = tty.spawnv(['bash', '-c', cmd]), 124 | [stdout, stderr] = await proc.communicate_utf8_async(null, cancel), 125 | status = proc.get_exit_status(); 126 | if(status) throw Error(stderr?.trimEnd() ?? '', {cause: {status, cmdline: cmd}}); 127 | return stdout?.trimEnd() ?? ''; 128 | } 129 | --------------------------------------------------------------------------------