diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index c3c3ae2..89ad0fe 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -5,7 +5,7 @@ body: - type: markdown attributes: value: | - ## ⚠️ Make sure you are able to reproduce the bug with the [latest version](https://github.com/vfsfitvnm/vimusic/releases/latest). + ## ⚠️ Make sure you are able to reproduce the bug with the [latest version](https://github.com/hamy/muza/releases/latest). ## ⚠️ Make sure there is no issue about this bug already. - type: textarea @@ -61,7 +61,7 @@ body: - type: input id: muza-version attributes: - label: ViMusic version + label: muza version placeholder: | Example: "0.5.4" validations: diff --git a/.gitignore b/.gitignore index 878c2cd..25d9b60 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,4 @@ /captures .externalNativeBuild .cxx -local.properties +local.properties \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 94a9ed0..0000000 --- a/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/README.md b/README.md index 8f179b9..f2e6dfe 100644 --- a/README.md +++ b/README.md @@ -7,24 +7,24 @@ --- - + ## Описание Muza - это удобное и простое в использовании музыкальное приложение, которое позволяет слушать любимую музыку бесплатно и без каких-либо ограничений. С помощью Muza вы можете создавать плейлисты, составлять музыкальные коллекции и наслаждаться звучанием того, что действительно нравится. -Мы предоставляем доступ к миллионам треков разных жанров и стилей, чтобы каждый пользователь мог найти здесь что-то для себя. +Миллионы треков разных жанров и стилей, чтобы каждый пользователь мог найти здесь что-то для себя. Кроме того, в приложении доступна функция чтения текстов песен, которая поможет подпевать и понимать глубинный смысл написанных слов. @@ -34,6 +34,9 @@ Muza - это удобное и простое в использовании м ## Функции - Воспроизведение (почти) любой песни или видео с YouTube Music +- Сохранение треков и плейлистов в кэш +- Поддержка прокси +- Поддержка Piped - Фоновое воспроизведение - Кэшируйте аудиофрагменты для автономного воспроизведения - Поиск песен, альбомов, видео исполнителей и списков воспроизведения @@ -48,15 +51,14 @@ Muza - это удобное и простое в использовании м - Нормализация звука - Поддержка Андроид Авто - Постоянная очередь -- Открывать ссылки на YouTube/YouTube Music (`смотреть`, `плейлист`, `канал`) - ... ## Скачать [Скачать из RuStore](https://apps.rustore.ru/app/it.hamy.muza) [Скачать из Гитхаба](https://github.com/hammsterr/muza/releases/latest) +alt="Скачать из Гитхаба" +height="80">](https://github.com/hammsterr/muza/releases/latest) ## Благодарности diff --git a/app/X/app-X.apk b/app/X/app-X.apk new file mode 100644 index 0000000..97f7f7d Binary files /dev/null and b/app/X/app-X.apk differ diff --git a/app/X/baselineProfiles/0/app-X.dm b/app/X/baselineProfiles/0/app-X.dm new file mode 100644 index 0000000..0e9c9f0 Binary files /dev/null and b/app/X/baselineProfiles/0/app-X.dm differ diff --git a/app/X/baselineProfiles/1/app-X.dm b/app/X/baselineProfiles/1/app-X.dm new file mode 100644 index 0000000..775e2d3 Binary files /dev/null and b/app/X/baselineProfiles/1/app-X.dm differ diff --git a/app/X/output-metadata.json b/app/X/output-metadata.json new file mode 100644 index 0000000..cacfefb --- /dev/null +++ b/app/X/output-metadata.json @@ -0,0 +1,37 @@ +{ + "version": 3, + "artifactType": { + "type": "APK", + "kind": "Directory" + }, + "applicationId": "it.hamy.muza.x", + "variantName": "X", + "elements": [ + { + "type": "SINGLE", + "filters": [], + "attributes": [], + "versionCode": 30, + "versionName": "0.6.0-X", + "outputFile": "app-X.apk" + } + ], + "elementType": "File", + "baselineProfiles": [ + { + "minApi": 28, + "maxApi": 30, + "baselineProfiles": [ + "baselineProfiles/1/app-X.dm" + ] + }, + { + "minApi": 31, + "maxApi": 2147483647, + "baselineProfiles": [ + "baselineProfiles/0/app-X.dm" + ] + } + ], + "minSdkVersionForDexing": 21 +} \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e3e6b59..2b88230 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,18 +1,22 @@ plugins { - id("com.android.application") - kotlin("android") - kotlin("kapt") + alias(libs.plugins.android.application) + alias(libs.plugins.kotlin.android) + alias(libs.plugins.ksp) } android { - compileSdk = 33 + compileSdk = 34 defaultConfig { - applicationId = "it.hamy.muza" + applicationId = project.group.toString() + minSdk = 21 - targetSdk = 33 - versionCode = 20 - versionName = "0.5.4.1rus" + targetSdk = 34 + + versionCode = 30 + versionName = project.version.toString() + + multiDexEnabled = true } splits { @@ -22,35 +26,50 @@ android { } } - namespace = "it.hamy.muza" + signingConfigs { + create("ci") { + storeFile = System.getenv("ANDROID_NIGHTLY_KEYSTORE")?.let { file(it) } + storePassword = System.getenv("ANDROID_NIGHTLY_KEYSTORE_PASSWORD") + keyAlias = System.getenv("ANDROID_NIGHTLY_KEYSTORE_ALIAS") + keyPassword = System.getenv("ANDROID_NIGHTLY_KEYSTORE_PASSWORD") + } + } + + namespace = project.group.toString() buildTypes { debug { applicationIdSuffix = ".debug" - manifestPlaceholders["appName"] = "Debug" + versionNameSuffix = "-DEBUG" + manifestPlaceholders["appName"] = "Muza (Debug)" } release { - isMinifyEnabled = true - isShrinkResources = true + isMinifyEnabled = false + isShrinkResources = false manifestPlaceholders["appName"] = "Muza" - signingConfig = signingConfigs.getByName("debug") proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + buildConfigField("String", "RELEASE_HACK", "\"AndroidWhyTfDidYouMakeMeDoThis\"") } - } - sourceSets.all { - kotlin.srcDir("src/$name/kotlin") + create("X") { + initWith(getByName("release")) + matchingFallbacks += "release" + + applicationIdSuffix = ".x" + versionNameSuffix = "-X" + manifestPlaceholders["appName"] = "Muza X" + signingConfig = signingConfigs.findByName("ci") + } } buildFeatures { compose = true + buildConfig = true } compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 } composeOptions { @@ -58,24 +77,29 @@ android { } kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" - jvmTarget = "1.8" + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") + } + + packaging { + resources.excludes.add("META-INF/**/*") } } -kapt { - arguments { - arg("room.schemaLocation", "$projectDir/schemas") - } +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} + +ksp { + arg("room.schemaLocation", "$projectDir/schemas") } dependencies { - implementation(projects.composePersist) - implementation(projects.composeRouting) - implementation(projects.composeReordering) - - + implementation(projects.compose.persist) + implementation(projects.compose.preferences) + implementation(projects.compose.routing) + implementation(projects.compose.reordering) + implementation(platform(libs.compose.bom)) implementation(libs.compose.activity) implementation(libs.compose.foundation) implementation(libs.compose.ui) @@ -83,22 +107,34 @@ dependencies { implementation(libs.compose.ripple) implementation(libs.compose.shimmer) implementation(libs.compose.coil) + implementation(libs.compose.material3) implementation(libs.palette) implementation(libs.exoplayer) - implementation(libs.exoplayer.okhttp) + implementation(libs.exoplayer.workmanager) + implementation(libs.workmanager) + implementation(libs.workmanager.ktx) + + implementation(libs.kotlin.coroutines) + implementation(libs.kotlin.immutable) implementation(libs.room) - implementation("androidx.media3:media3-datasource-okhttp:1.0.0-alpha03") + ksp(libs.room.compiler) + + implementation(projects.providers.github) + implementation(projects.providers.innertube) + implementation(projects.providers.kugou) + implementation(projects.providers.lrclib) + implementation(projects.providers.piped) + implementation(projects.core.data) + implementation(projects.core.ui) + + coreLibraryDesugaring(libs.desugaring) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) implementation ("com.yandex.android:mobileads:6.4.0") implementation("com.google.android.gms:play-services-ads-identifier:18.0.1") - - kapt(libs.room.compiler) - - implementation(projects.innertube) - implementation(projects.kugou) - - coreLibraryDesugaring(libs.desugaring) } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4d78e7e..87e6763 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,5 +1,6 @@ -keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation +#noinspection ShrinkerUnresolvedReference -if @kotlinx.serialization.Serializable class ** -keepclassmembers class <1> { static <1>$Companion Companion; @@ -9,6 +10,7 @@ static **$* *; } -keepclassmembers class <2>$<3> { + #noinspection ShrinkerUnresolvedReference kotlinx.serialization.KSerializer serializer(...); } @@ -17,6 +19,7 @@ } -keepclassmembers class <1> { public static <1> INSTANCE; + #noinspection ShrinkerUnresolvedReference kotlinx.serialization.KSerializer serializer(...); } @@ -32,5 +35,4 @@ -dontwarn org.openjsse.javax.net.ssl.SSLSocket -dontwarn org.openjsse.net.ssl.OpenJSSE -dontwarn org.slf4j.impl.StaticLoggerBinder - -keep class com.yandex** { *; } \ No newline at end of file diff --git a/app/schemas/it.hamy.muza.DatabaseInitializer/23.json b/app/schemas/it.hamy.muza.DatabaseInitializer/23.json index 7cc0ae4..f264666 100644 --- a/app/schemas/it.hamy.muza.DatabaseInitializer/23.json +++ b/app/schemas/it.hamy.muza.DatabaseInitializer/23.json @@ -2,11 +2,11 @@ "formatVersion": 1, "database": { "version": 23, - "identityHash": "205c24811149a247279bcbfdc2d6c396", + "identityHash": "7f599a26d50b2917fe68a176f414b0f2", "entities": [ { "tableName": "Song", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", "fields": [ { "fieldPath": "id", @@ -49,6 +49,12 @@ "columnName": "totalPlayTimeMs", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false } ], "primaryKey": { @@ -666,7 +672,7 @@ ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '205c24811149a247279bcbfdc2d6c396')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f599a26d50b2917fe68a176f414b0f2')" ] } } \ No newline at end of file diff --git a/app/schemas/it.hamy.muza.DatabaseInitializer/24.json b/app/schemas/it.hamy.muza.DatabaseInitializer/24.json new file mode 100644 index 0000000..a021728 --- /dev/null +++ b/app/schemas/it.hamy.muza.DatabaseInitializer/24.json @@ -0,0 +1,678 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "7f599a26d50b2917fe68a176f414b0f2", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f599a26d50b2917fe68a176f414b0f2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.hamy.muza.DatabaseInitializer/25.json b/app/schemas/it.hamy.muza.DatabaseInitializer/25.json new file mode 100644 index 0000000..cf20268 --- /dev/null +++ b/app/schemas/it.hamy.muza.DatabaseInitializer/25.json @@ -0,0 +1,684 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "35bed92752541c2739a932832debd361", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '35bed92752541c2739a932832debd361')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.hamy.muza.DatabaseInitializer/26.json b/app/schemas/it.hamy.muza.DatabaseInitializer/26.json new file mode 100644 index 0000000..3e6a59b --- /dev/null +++ b/app/schemas/it.hamy.muza.DatabaseInitializer/26.json @@ -0,0 +1,733 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "722e6d30eeb0cd89a3ad80e3eb83d0f1", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '722e6d30eeb0cd89a3ad80e3eb83d0f1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.hamy.muza.DatabaseInitializer/27.json b/app/schemas/it.hamy.muza.DatabaseInitializer/27.json new file mode 100644 index 0000000..c9ee1e2 --- /dev/null +++ b/app/schemas/it.hamy.muza.DatabaseInitializer/27.json @@ -0,0 +1,740 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "ed8f47508639d4245327fdcde0cfa553", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, `blacklisted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "blacklisted", + "columnName": "blacklisted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed8f47508639d4245327fdcde0cfa553')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.hamy.muza.DatabaseInitializer/28.json b/app/schemas/it.hamy.muza.DatabaseInitializer/28.json new file mode 100644 index 0000000..221890c --- /dev/null +++ b/app/schemas/it.hamy.muza.DatabaseInitializer/28.json @@ -0,0 +1,752 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "0423cc07b12ce198d7fe19d7f2f1ad08", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, `blacklisted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "blacklisted", + "columnName": "blacklisted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `description` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, `otherInfo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "otherInfo", + "columnName": "otherInfo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0423cc07b12ce198d7fe19d7f2f1ad08')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json new file mode 100644 index 0000000..2ccea01 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json @@ -0,0 +1,304 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "b93575bd08c10513f0bfc997b832c280", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b93575bd08c10513f0bfc997b832c280')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/10.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/10.json new file mode 100644 index 0000000..4bb176e --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/10.json @@ -0,0 +1,536 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "b4ab81f091f9f0d359631c1426b04c49", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumId` TEXT, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Album", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b4ab81f091f9f0d359631c1426b04c49')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/11.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/11.json new file mode 100644 index 0000000..14a249f --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/11.json @@ -0,0 +1,518 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "b621c39ef38afe8991277568a67d5f3d", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b621c39ef38afe8991277568a67d5f3d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/12.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/12.json new file mode 100644 index 0000000..18d7ff5 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/12.json @@ -0,0 +1,518 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "fe9703c1e23ef700d9698e0440e4ad7f", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'fe9703c1e23ef700d9698e0440e4ad7f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json new file mode 100644 index 0000000..d4644bf --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/13.json @@ -0,0 +1,530 @@ +{ + "formatVersion": 1, + "database": { + "version": 13, + "identityHash": "61cd3db93beeafd3ca398be54544c752", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '61cd3db93beeafd3ca398be54544c752')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/14.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/14.json new file mode 100644 index 0000000..c83eeb4 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/14.json @@ -0,0 +1,598 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "6bc345258fdae98dcae16e60ab7a7f2f", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '6bc345258fdae98dcae16e60ab7a7f2f')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/15.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/15.json new file mode 100644 index 0000000..b6258b8 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/15.json @@ -0,0 +1,586 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "19f6f6ce7ce279de7853df4b8bd77180", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '19f6f6ce7ce279de7853df4b8bd77180')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json new file mode 100644 index 0000000..9d1e8f2 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/16.json @@ -0,0 +1,592 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "0cbca5b4016755ebf227461349581201", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0cbca5b4016755ebf227461349581201')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/17.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/17.json new file mode 100644 index 0000000..5e40c0a --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/17.json @@ -0,0 +1,598 @@ +{ + "formatVersion": 1, + "database": { + "version": 17, + "identityHash": "8f32fc7dcf9836d05d1ba4acbee7f57e", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8f32fc7dcf9836d05d1ba4acbee7f57e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json new file mode 100644 index 0000000..e0c912d --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/18.json @@ -0,0 +1,610 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "c8f776e899b181081f0230bffec99ac5", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c8f776e899b181081f0230bffec99ac5')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json new file mode 100644 index 0000000..8196898 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/19.json @@ -0,0 +1,670 @@ +{ + "formatVersion": 1, + "database": { + "version": 19, + "identityHash": "b9a9bb1674c7c50be2fab48de5afed43", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b9a9bb1674c7c50be2fab48de5afed43')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json new file mode 100644 index 0000000..e01fdb5 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/2.json @@ -0,0 +1,336 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "a595020ea35da1c5de6c6ee75ec234fe", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a595020ea35da1c5de6c6ee75ec234fe')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json new file mode 100644 index 0000000..a683d90 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/20.json @@ -0,0 +1,670 @@ +{ + "formatVersion": 1, + "database": { + "version": 20, + "identityHash": "251e713953aacd84fd33b471ed4af391", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, `shuffleVideoId` TEXT, `shufflePlaylistId` TEXT, `radioVideoId` TEXT, `radioPlaylistId` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shuffleVideoId", + "columnName": "shuffleVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shufflePlaylistId", + "columnName": "shufflePlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioVideoId", + "columnName": "radioVideoId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radioPlaylistId", + "columnName": "radioPlaylistId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '251e713953aacd84fd33b471ed4af391')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json new file mode 100644 index 0000000..a1e0a3d --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/21.json @@ -0,0 +1,646 @@ +{ + "formatVersion": 1, + "database": { + "version": 21, + "identityHash": "5afda34f61cc45ecd6102a7285ec92d2", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `info` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5afda34f61cc45ecd6102a7285ec92d2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/22.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/22.json new file mode 100644 index 0000000..be1b361 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/22.json @@ -0,0 +1,640 @@ +{ + "formatVersion": 1, + "database": { + "version": 22, + "identityHash": "ca98e767afd3ae8c801377ee3d18c71e", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `lyrics` TEXT, `synchronizedLyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synchronizedLyrics", + "columnName": "synchronizedLyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ca98e767afd3ae8c801377ee3d18c71e')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json new file mode 100644 index 0000000..f264666 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/23.json @@ -0,0 +1,678 @@ +{ + "formatVersion": 1, + "database": { + "version": 23, + "identityHash": "7f599a26d50b2917fe68a176f414b0f2", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f599a26d50b2917fe68a176f414b0f2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/24.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/24.json new file mode 100644 index 0000000..a021728 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/24.json @@ -0,0 +1,678 @@ +{ + "formatVersion": 1, + "database": { + "version": 24, + "identityHash": "7f599a26d50b2917fe68a176f414b0f2", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7f599a26d50b2917fe68a176f414b0f2')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/25.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/25.json new file mode 100644 index 0000000..cf20268 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/25.json @@ -0,0 +1,684 @@ +{ + "formatVersion": 1, + "database": { + "version": 25, + "identityHash": "35bed92752541c2739a932832debd361", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '35bed92752541c2739a932832debd361')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/26.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/26.json new file mode 100644 index 0000000..3e6a59b --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/26.json @@ -0,0 +1,733 @@ +{ + "formatVersion": 1, + "database": { + "version": 26, + "identityHash": "722e6d30eeb0cd89a3ad80e3eb83d0f1", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '722e6d30eeb0cd89a3ad80e3eb83d0f1')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/27.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/27.json new file mode 100644 index 0000000..c9ee1e2 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/27.json @@ -0,0 +1,740 @@ +{ + "formatVersion": 1, + "database": { + "version": 27, + "identityHash": "ed8f47508639d4245327fdcde0cfa553", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, `blacklisted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "blacklisted", + "columnName": "blacklisted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'ed8f47508639d4245327fdcde0cfa553')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/28.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/28.json new file mode 100644 index 0000000..221890c --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/28.json @@ -0,0 +1,752 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "0423cc07b12ce198d7fe19d7f2f1ad08", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessBoost` REAL, `blacklisted` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessBoost", + "columnName": "loudnessBoost", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "blacklisted", + "columnName": "blacklisted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongPlaylistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongPlaylistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongPlaylistMap_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongPlaylistMap_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `thumbnailUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT, `description` TEXT, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, `shareUrl` TEXT, `timestamp` INTEGER, `bookmarkedAt` INTEGER, `otherInfo` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "shareUrl", + "columnName": "shareUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "otherInfo", + "columnName": "otherInfo", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongAlbumMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `position` INTEGER, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `Album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_SongAlbumMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongAlbumMap_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongAlbumMap_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `itag` INTEGER, `mimeType` TEXT, `bitrate` INTEGER, `contentLength` INTEGER, `lastModified` INTEGER, `loudnessDb` REAL, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "lastModified", + "columnName": "lastModified", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_Event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_Event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, `startTime` INTEGER, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fixed", + "columnName": "fixed", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "synced", + "columnName": "synced", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "startTime", + "columnName": "startTime", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "PipedSession", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `apiBaseUrl` TEXT NOT NULL, `token` TEXT NOT NULL, `username` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "apiBaseUrl", + "columnName": "apiBaseUrl", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "token", + "columnName": "token", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_PipedSession_apiBaseUrl_username", + "unique": true, + "columnNames": [ + "apiBaseUrl", + "username" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_PipedSession_apiBaseUrl_username` ON `${TABLE_NAME}` (`apiBaseUrl`, `username`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongPlaylistMap", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongPlaylistMap ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0423cc07b12ce198d7fe19d7f2f1ad08')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/3.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/3.json new file mode 100644 index 0000000..6ef56c2 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/3.json @@ -0,0 +1,342 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "f2169b1328eebb0c7f353018e2ae4bd3", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f2169b1328eebb0c7f353018e2ae4bd3')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/4.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/4.json new file mode 100644 index 0000000..d422cb0 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/4.json @@ -0,0 +1,310 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "d5720e465abdf99b583c183298f18340", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd5720e465abdf99b583c183298f18340')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/5.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/5.json new file mode 100644 index 0000000..e80f181 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/5.json @@ -0,0 +1,322 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "c16206386ea59ba9109b1e116ec61ea0", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'c16206386ea59ba9109b1e116ec61ea0')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json new file mode 100644 index 0000000..e9ad490 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/6.json @@ -0,0 +1,354 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "7d53e052483019da2b9d7056072cea79", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7d53e052483019da2b9d7056072cea79')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/7.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/7.json new file mode 100644 index 0000000..c5f21ad --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/7.json @@ -0,0 +1,511 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "5f75673891ab82a14afcb6d95cc6e1e4", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5f75673891ab82a14afcb6d95cc6e1e4')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/8.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/8.json new file mode 100644 index 0000000..649c55e --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/8.json @@ -0,0 +1,511 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "446e2ef392a547f6b2d4318c9f5dd4cf", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumId` TEXT, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '446e2ef392a547f6b2d4318c9f5dd4cf')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/9.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/9.json new file mode 100644 index 0000000..a4013af --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/9.json @@ -0,0 +1,419 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "22e88f327e3340760100610939e9a158", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumId` TEXT, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "artistsText", + "columnName": "artistsText", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `info` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "info", + "columnName": "info", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongArtistMap", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `Artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_SongArtistMap_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongArtistMap_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongArtistMap_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `thumbnailUrl` TEXT, `year` TEXT, `authorsText` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorsText", + "columnName": "authorsText", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SearchQuery", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "QueuedMediaItem", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `mediaItem` BLOB NOT NULL, `position` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaItem", + "columnName": "mediaItem", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '22e88f327e3340760100610939e9a158')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08d4410..765c86b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,21 +2,41 @@ + + + + - + + + + + + + + + + + + android:theme="@style/Theme.ViMusic.NoActionBar" + tools:ignore="UnusedAttribute"> + @@ -53,43 +76,68 @@ + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + android:scheme="https" + tools:ignore="IntentFilterUniqueDataAttributes" /> + + + + + + @@ -97,13 +145,25 @@ android:name=".service.PlayerService" android:exported="false" android:foregroundServiceType="mediaPlayback"> + + + + + - + tools:ignore="ExportedService"> + - + + + + + @@ -111,11 +171,35 @@ android:name=".service.PlayerService$NotificationDismissReceiver" android:exported="false" /> - + - + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index 6e73617..e199b1c 100644 Binary files a/app/src/main/ic_launcher-playstore.png and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/kotlin/it/hamy/muza/Database.kt b/app/src/main/kotlin/it/hamy/muza/Database.kt index dc4e982..58e9d36 100644 --- a/app/src/main/kotlin/it/hamy/muza/Database.kt +++ b/app/src/main/kotlin/it/hamy/muza/Database.kt @@ -1,12 +1,13 @@ package it.hamy.muza import android.content.ContentValues -import android.content.Context import android.database.SQLException import android.database.sqlite.SQLiteDatabase.CONFLICT_IGNORE import android.os.Parcel +import androidx.annotation.OptIn import androidx.core.database.getFloatOrNull import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi import androidx.room.AutoMigration import androidx.room.Dao import androidx.room.Delete @@ -31,6 +32,7 @@ import androidx.room.migration.Migration import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteQuery +import io.ktor.http.Url import it.hamy.muza.enums.AlbumSortBy import it.hamy.muza.enums.ArtistSortBy import it.hamy.muza.enums.PlaylistSortBy @@ -38,11 +40,12 @@ import it.hamy.muza.enums.SongSortBy import it.hamy.muza.enums.SortOrder import it.hamy.muza.models.Album import it.hamy.muza.models.Artist -import it.hamy.muza.models.SongWithContentLength import it.hamy.muza.models.Event +import it.hamy.muza.models.EventWithSong import it.hamy.muza.models.Format import it.hamy.muza.models.Info import it.hamy.muza.models.Lyrics +import it.hamy.muza.models.PipedSession import it.hamy.muza.models.Playlist import it.hamy.muza.models.PlaylistPreview import it.hamy.muza.models.PlaylistWithSongs @@ -52,59 +55,104 @@ import it.hamy.muza.models.Song import it.hamy.muza.models.SongAlbumMap import it.hamy.muza.models.SongArtistMap import it.hamy.muza.models.SongPlaylistMap +import it.hamy.muza.models.SongWithContentLength import it.hamy.muza.models.SortedSongPlaylistMap -import kotlin.jvm.Throws +import it.hamy.muza.service.LOCAL_KEY_PREFIX import kotlinx.coroutines.flow.Flow -import it.hamy.muza.models.EventWithSong @Dao +@Suppress("TooManyFunctions") interface Database { - companion object : Database by DatabaseInitializer.Instance.database + companion object : Database by DatabaseInitializer.instance.database @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID ASC") + @Query("SELECT * FROM Song WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' ORDER BY ROWID ASC") @RewriteQueriesToDropUnusedColumns fun songsByRowIdAsc(): Flow> @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY ROWID DESC") + @Query("SELECT * FROM Song WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' ORDER BY ROWID DESC") @RewriteQueriesToDropUnusedColumns fun songsByRowIdDesc(): Flow> @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title ASC") + @Query("SELECT * FROM Song WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' ORDER BY title ASC") @RewriteQueriesToDropUnusedColumns fun songsByTitleAsc(): Flow> @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY title DESC") + @Query("SELECT * FROM Song WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' ORDER BY title DESC") @RewriteQueriesToDropUnusedColumns fun songsByTitleDesc(): Flow> @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs ASC") + @Query( + """ + SELECT * FROM Song + WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' + ORDER BY totalPlayTimeMs ASC + """ + ) @RewriteQueriesToDropUnusedColumns fun songsByPlayTimeAsc(): Flow> @Transaction - @Query("SELECT * FROM Song WHERE totalPlayTimeMs > 0 ORDER BY totalPlayTimeMs DESC") + @Query( + """ + SELECT * FROM Song + WHERE id NOT LIKE '$LOCAL_KEY_PREFIX%' + ORDER BY totalPlayTimeMs DESC + LIMIT :limit + """ + ) @RewriteQueriesToDropUnusedColumns - fun songsByPlayTimeDesc(): Flow> + fun songsByPlayTimeDesc(limit: Int = -1): Flow> - fun songs(sortBy: SongSortBy, sortOrder: SortOrder): Flow> { - return when (sortBy) { - SongSortBy.PlayTime -> when (sortOrder) { - SortOrder.Ascending -> songsByPlayTimeAsc() - SortOrder.Descending -> songsByPlayTimeDesc() - } - SongSortBy.Title -> when (sortOrder) { - SortOrder.Ascending -> songsByTitleAsc() - SortOrder.Descending -> songsByTitleDesc() - } - SongSortBy.DateAdded -> when (sortOrder) { - SortOrder.Ascending -> songsByRowIdAsc() - SortOrder.Descending -> songsByRowIdDesc() - } + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY ROWID ASC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByRowIdAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY ROWID DESC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByRowIdDesc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY title ASC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByTitleAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY title DESC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByTitleDesc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY totalPlayTimeMs ASC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByPlayTimeAsc(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id LIKE '$LOCAL_KEY_PREFIX%' ORDER BY totalPlayTimeMs DESC") + @RewriteQueriesToDropUnusedColumns + fun localSongsByPlayTimeDesc(): Flow> + + @Suppress("CyclomaticComplexMethod") + fun songs(sortBy: SongSortBy, sortOrder: SortOrder, isLocal: Boolean = false) = when (sortBy) { + SongSortBy.PlayTime -> when (sortOrder) { + SortOrder.Ascending -> if (isLocal) localSongsByPlayTimeAsc() else songsByPlayTimeAsc() + SortOrder.Descending -> if (isLocal) localSongsByPlayTimeDesc() else songsByPlayTimeDesc() + } + + SongSortBy.Title -> when (sortOrder) { + SortOrder.Ascending -> if (isLocal) localSongsByTitleAsc() else songsByTitleAsc() + SortOrder.Descending -> if (isLocal) localSongsByTitleDesc() else songsByTitleDesc() + } + + SongSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> if (isLocal) localSongsByRowIdAsc() else songsByRowIdAsc() + SortOrder.Descending -> if (isLocal) localSongsByRowIdDesc() else songsByRowIdDesc() } } @@ -119,7 +167,7 @@ interface Database { @Query("DELETE FROM QueuedMediaItem") fun clearQueue() - @Query("SELECT * FROM SearchQuery WHERE query LIKE :query ORDER BY id DESC") + @Query("SELECT * FROM SearchQuery WHERE `query` LIKE :query ORDER BY id DESC") fun queries(query: String): Flow> @Query("SELECT COUNT (*) FROM SearchQuery") @@ -158,27 +206,31 @@ interface Database { @Query("SELECT * FROM Artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt ASC") fun artistsByRowIdAsc(): Flow> - fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder): Flow> { - return when (sortBy) { - ArtistSortBy.Name -> when (sortOrder) { - SortOrder.Ascending -> artistsByNameAsc() - SortOrder.Descending -> artistsByNameDesc() - } - ArtistSortBy.DateAdded -> when (sortOrder) { - SortOrder.Ascending -> artistsByRowIdAsc() - SortOrder.Descending -> artistsByRowIdDesc() - } + fun artists(sortBy: ArtistSortBy, sortOrder: SortOrder) = when (sortBy) { + ArtistSortBy.Name -> when (sortOrder) { + SortOrder.Ascending -> artistsByNameAsc() + SortOrder.Descending -> artistsByNameDesc() + } + + ArtistSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> artistsByRowIdAsc() + SortOrder.Descending -> artistsByRowIdDesc() } } @Query("SELECT * FROM Album WHERE id = :id") fun album(id: String): Flow - @Query("SELECT timestamp FROM Album WHERE id = :id") - fun albumTimestamp(id: String): Long? - @Transaction - @Query("SELECT * FROM Song JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId WHERE SongAlbumMap.albumId = :albumId AND position IS NOT NULL ORDER BY position") + @Query( + """ + SELECT * FROM Song + JOIN SongAlbumMap ON Song.id = SongAlbumMap.songId + WHERE SongAlbumMap.albumId = :albumId AND + position IS NOT NULL + ORDER BY position + """ + ) @RewriteQueriesToDropUnusedColumns fun albumSongs(albumId: String): Flow> @@ -200,79 +252,144 @@ interface Database { @Query("SELECT * FROM Album WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt DESC") fun albumsByRowIdDesc(): Flow> - fun albums(sortBy: AlbumSortBy, sortOrder: SortOrder): Flow> { - return when (sortBy) { - AlbumSortBy.Title -> when (sortOrder) { - SortOrder.Ascending -> albumsByTitleAsc() - SortOrder.Descending -> albumsByTitleDesc() - } - AlbumSortBy.Year -> when (sortOrder) { - SortOrder.Ascending -> albumsByYearAsc() - SortOrder.Descending -> albumsByYearDesc() - } - AlbumSortBy.DateAdded -> when (sortOrder) { - SortOrder.Ascending -> albumsByRowIdAsc() - SortOrder.Descending -> albumsByRowIdDesc() - } + fun albums(sortBy: AlbumSortBy, sortOrder: SortOrder) = when (sortBy) { + AlbumSortBy.Title -> when (sortOrder) { + SortOrder.Ascending -> albumsByTitleAsc() + SortOrder.Descending -> albumsByTitleDesc() + } + + AlbumSortBy.Year -> when (sortOrder) { + SortOrder.Ascending -> albumsByYearAsc() + SortOrder.Descending -> albumsByYearDesc() + } + + AlbumSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> albumsByRowIdAsc() + SortOrder.Descending -> albumsByRowIdDesc() } } @Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id") fun incrementTotalPlayTimeMs(id: String, addition: Long) + @Query("SELECT * FROM PipedSession") + fun pipedSessions(): Flow> + + @Query("SELECT * FROM Playlist WHERE id = :id") + fun playlist(id: Long): Flow + + // TODO: apparently this is an edge-case now? + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query( + """ + SELECT * FROM SortedSongPlaylistMap + INNER JOIN Song on Song.id = SortedSongPlaylistMap.songId + WHERE playlistId = :id + ORDER BY SortedSongPlaylistMap.position + """ + ) + fun playlistSongs(id: Long): Flow?> + @Transaction @Query("SELECT * FROM Playlist WHERE id = :id") fun playlistWithSongs(id: Long): Flow @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name ASC") + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist + ORDER BY name ASC + """ + ) fun playlistPreviewsByNameAsc(): Flow> @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID ASC") + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist + ORDER BY ROWID ASC + """ + ) fun playlistPreviewsByDateAddedAsc(): Flow> @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount ASC") + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist + ORDER BY songCount ASC + """ + ) fun playlistPreviewsByDateSongCountAsc(): Flow> @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY name DESC") + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist + ORDER BY name DESC + """ + ) fun playlistPreviewsByNameDesc(): Flow> @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY ROWID DESC") + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist + ORDER BY ROWID DESC + """ + ) fun playlistPreviewsByDateAddedDesc(): Flow> @Transaction - @Query("SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist ORDER BY songCount DESC") + @Query( + """ + SELECT id, name, (SELECT COUNT(*) FROM SongPlaylistMap WHERE playlistId = id) as songCount FROM Playlist + ORDER BY songCount DESC + """ + ) fun playlistPreviewsByDateSongCountDesc(): Flow> fun playlistPreviews( sortBy: PlaylistSortBy, sortOrder: SortOrder - ): Flow> { - return when (sortBy) { - PlaylistSortBy.Name -> when (sortOrder) { - SortOrder.Ascending -> playlistPreviewsByNameAsc() - SortOrder.Descending -> playlistPreviewsByNameDesc() - } - PlaylistSortBy.SongCount -> when (sortOrder) { - SortOrder.Ascending -> playlistPreviewsByDateSongCountAsc() - SortOrder.Descending -> playlistPreviewsByDateSongCountDesc() - } - PlaylistSortBy.DateAdded -> when (sortOrder) { - SortOrder.Ascending -> playlistPreviewsByDateAddedAsc() - SortOrder.Descending -> playlistPreviewsByDateAddedDesc() - } + ) = when (sortBy) { + PlaylistSortBy.Name -> when (sortOrder) { + SortOrder.Ascending -> playlistPreviewsByNameAsc() + SortOrder.Descending -> playlistPreviewsByNameDesc() + } + + PlaylistSortBy.SongCount -> when (sortOrder) { + SortOrder.Ascending -> playlistPreviewsByDateSongCountAsc() + SortOrder.Descending -> playlistPreviewsByDateSongCountDesc() + } + + PlaylistSortBy.DateAdded -> when (sortOrder) { + SortOrder.Ascending -> playlistPreviewsByDateAddedAsc() + SortOrder.Descending -> playlistPreviewsByDateAddedDesc() } } - @Query("SELECT thumbnailUrl FROM Song JOIN SongPlaylistMap ON id = songId WHERE playlistId = :id ORDER BY position LIMIT 4") + @Query( + """ + SELECT thumbnailUrl FROM Song + JOIN SongPlaylistMap ON id = songId + WHERE playlistId = :id + ORDER BY position + LIMIT 4 + """ + ) fun playlistThumbnailUrls(id: Long): Flow> @Transaction - @Query("SELECT * FROM Song JOIN SongArtistMap ON Song.id = SongArtistMap.songId WHERE SongArtistMap.artistId = :artistId AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC") + @Query( + """ + SELECT * FROM Song + JOIN SongArtistMap ON Song.id = SongArtistMap.songId + WHERE SongArtistMap.artistId = :artistId AND + totalPlayTimeMs > 0 + ORDER BY Song.ROWID DESC + """ + ) @RewriteQueriesToDropUnusedColumns fun artistSongs(artistId: String): Flow> @@ -280,10 +397,41 @@ interface Database { fun format(songId: String): Flow @Transaction - @Query("SELECT Song.*, contentLength FROM Song JOIN Format ON id = songId WHERE contentLength IS NOT NULL AND totalPlayTimeMs > 0 ORDER BY Song.ROWID DESC") + @Query( + """ + SELECT Song.*, contentLength FROM Song + JOIN Format ON id = songId + WHERE contentLength IS NOT NULL + ORDER BY Song.ROWID DESC + """ + ) fun songsWithContentLength(): Flow> - @Query(""" + @Query("SELECT id FROM Song WHERE blacklisted") + suspend fun blacklistedIds(): List + + @Query("SELECT blacklisted FROM Song WHERE id = :songId") + fun blacklisted(songId: String): Flow + + @Query("SELECT COUNT (*) FROM Song where blacklisted") + fun blacklistLength(): Flow + + @Transaction + @Query("UPDATE Song SET blacklisted = NOT blacklisted WHERE blacklisted") + fun resetBlacklist() + + @Transaction + @Query("UPDATE Song SET blacklisted = NOT blacklisted WHERE id = :songId") + fun toggleBlacklist(songId: String) + + suspend fun filterBlacklistedSongs(songs: List): List { + val blacklistedIds = blacklistedIds() + return songs.filter { it.mediaId !in blacklistedIds } + } + + @Transaction + @Query( + """ UPDATE SongPlaylistMap SET position = CASE WHEN position < :fromPosition THEN position + 1 @@ -291,7 +439,8 @@ interface Database { ELSE :toPosition END WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition,:toPosition) and MAX(:fromPosition,:toPosition) - """) + """ + ) fun move(playlistId: Long, fromPosition: Int, toPosition: Int) @Query("DELETE FROM SongPlaylistMap WHERE playlistId = :id") @@ -303,6 +452,12 @@ interface Database { @Query("SELECT loudnessDb FROM Format WHERE songId = :songId") fun loudnessDb(songId: String): Flow + @Query("SELECT Song.loudnessBoost FROM Song WHERE id = :songId") + fun loudnessBoost(songId: String): Flow + + @Query("UPDATE Song SET loudnessBoost = :loudnessBoost WHERE id = :songId") + fun setLoudnessBoost(songId: String, loudnessBoost: Float?) + @Query("SELECT * FROM Song WHERE title LIKE :query OR artistsText LIKE :query") fun search(query: String): Flow> @@ -313,15 +468,42 @@ interface Database { fun songArtistInfo(songId: String): List @Transaction - @Query("SELECT Song.* FROM Event JOIN Song ON Song.id = songId GROUP BY songId ORDER BY SUM(CAST(playTime AS REAL) / (((:now - timestamp) / 86400000) + 1)) DESC LIMIT 1") + @Query( + """ + SELECT Song.* FROM Event + JOIN Song ON Song.id = songId + WHERE Song.id NOT LIKE '$LOCAL_KEY_PREFIX%' + GROUP BY songId + ORDER BY SUM(playTime) + DESC LIMIT :limit + """ + ) @RewriteQueriesToDropUnusedColumns - fun trending(now: Long = System.currentTimeMillis()): Flow + fun trending(limit: Int = 3): Flow> + + @Transaction + @Query( + """ + SELECT Song.* FROM Event + JOIN Song ON Song.id = songId + WHERE (:now - Event.timestamp) <= :period AND + Song.id NOT LIKE '$LOCAL_KEY_PREFIX%' + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT :limit + """ + ) + @RewriteQueriesToDropUnusedColumns + fun trending( + limit: Int = 3, + now: Long = System.currentTimeMillis(), + period: Long + ): Flow> @Transaction @Query("SELECT * FROM Event ORDER BY timestamp DESC") fun events(): Flow> - @Query("SELECT COUNT (*) FROM Event") fun eventsCount(): Flow @@ -365,6 +547,9 @@ interface Database { @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(artists: List, songArtistMaps: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(pipedSession: PipedSession) + @Transaction fun insert(mediaItem: MediaItem, block: (Song) -> Song = { it }) { val song = Song( @@ -415,12 +600,12 @@ interface Database { @Upsert fun upsert(album: Album, songAlbumMaps: List) - @Upsert - fun upsert(songAlbumMap: SongAlbumMap) - @Upsert fun upsert(artist: Artist) + @Delete + fun delete(song: Song) + @Delete fun delete(searchQuery: SearchQuery) @@ -430,6 +615,9 @@ interface Database { @Delete fun delete(songPlaylistMap: SongPlaylistMap) + @Delete + fun delete(pipedSession: PipedSession) + @RawQuery fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int @@ -452,11 +640,10 @@ interface Database { Format::class, Event::class, Lyrics::class, + PipedSession::class ], - views = [ - SortedSongPlaylistMap::class - ], - version = 23, + views = [SortedSongPlaylistMap::class], + version = 28, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), @@ -477,28 +664,42 @@ interface Database { AutoMigration(from = 19, to = 20), AutoMigration(from = 20, to = 21, spec = DatabaseInitializer.From20To21Migration::class), AutoMigration(from = 21, to = 22, spec = DatabaseInitializer.From21To22Migration::class), - ], + AutoMigration(from = 23, to = 24), + AutoMigration(from = 24, to = 25), + AutoMigration(from = 25, to = 26), + AutoMigration(from = 26, to = 27), + AutoMigration(from = 27, to = 28) + ] ) @TypeConverters(Converters::class) abstract class DatabaseInitializer protected constructor() : RoomDatabase() { abstract val database: Database companion object { - lateinit var Instance: DatabaseInitializer + @Volatile + lateinit var instance: DatabaseInitializer + + private fun buildDatabase() = Room + .databaseBuilder( + context = Dependencies.application.applicationContext, + klass = DatabaseInitializer::class.java, + name = "data.db" + ) + .addMigrations( + From8To9Migration(), + From10To11Migration(), + From14To15Migration(), + From22To23Migration(), + From23To24Migration() + ) + .build() - context(Context) operator fun invoke() { - if (!::Instance.isInitialized) { - Instance = Room - .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") - .addMigrations( - From8To9Migration(), - From10To11Migration(), - From14To15Migration(), - From22To23Migration() - ) - .build() - } + if (!::instance.isInitialized) reload() + } + + fun reload() = synchronized(this) { + instance = buildDatabase() } } @@ -509,81 +710,118 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { class From7To8Migration : AutoMigrationSpec class From8To9Migration : Migration(8, 9) { - override fun migrate(it: SupportSQLiteDatabase) { - it.query(SimpleSQLiteQuery("SELECT DISTINCT browseId, text, Info.id FROM Info JOIN Song ON Info.id = Song.albumId;")) - .use { cursor -> - val albumValues = ContentValues(2) - while (cursor.moveToNext()) { - albumValues.put("id", cursor.getString(0)) - albumValues.put("title", cursor.getString(1)) - it.insert("Album", CONFLICT_IGNORE, albumValues) + override fun migrate(db: SupportSQLiteDatabase) { + db.query( + SimpleSQLiteQuery( + query = "SELECT DISTINCT browseId, text, Info.id FROM Info JOIN Song ON Info.id = Song.albumId;" + ) + ).use { cursor -> + val albumValues = ContentValues(2) + while (cursor.moveToNext()) { + albumValues.put("id", cursor.getString(0)) + albumValues.put("title", cursor.getString(1)) + db.insert("Album", CONFLICT_IGNORE, albumValues) - it.execSQL( - "UPDATE Song SET albumId = '${cursor.getString(0)}' WHERE albumId = ${ - cursor.getLong( - 2 - ) - }" - ) - } + db.execSQL( + "UPDATE Song SET albumId = '${cursor.getString(0)}' WHERE albumId = ${ + cursor.getLong( + 2 + ) + }" + ) } + } - it.query(SimpleSQLiteQuery("SELECT GROUP_CONCAT(text, ''), SongWithAuthors.songId FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId GROUP BY songId;")) - .use { cursor -> - val songValues = ContentValues(1) - while (cursor.moveToNext()) { - songValues.put("artistsText", cursor.getString(0)) - it.update( - "Song", - CONFLICT_IGNORE, - songValues, - "id = ?", - arrayOf(cursor.getString(1)) - ) - } + db.query( + SimpleSQLiteQuery( + query = """ + SELECT GROUP_CONCAT(text, ''), SongWithAuthors.songId FROM Info + JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId + GROUP BY songId; + """.trimIndent() + ) + ).use { cursor -> + val songValues = ContentValues(1) + while (cursor.moveToNext()) { + songValues.put("artistsText", cursor.getString(0)) + db.update( + table = "Song", + conflictAlgorithm = CONFLICT_IGNORE, + values = songValues, + whereClause = "id = ?", + whereArgs = arrayOf(cursor.getString(1)) + ) } + } - it.query(SimpleSQLiteQuery("SELECT browseId, text, Info.id FROM Info JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId WHERE browseId NOT NULL;")) - .use { cursor -> - val artistValues = ContentValues(2) - while (cursor.moveToNext()) { - artistValues.put("id", cursor.getString(0)) - artistValues.put("name", cursor.getString(1)) - it.insert("Artist", CONFLICT_IGNORE, artistValues) + db.query( + SimpleSQLiteQuery( + query = """ + SELECT browseId, text, Info.id FROM Info + JOIN SongWithAuthors ON Info.id = SongWithAuthors.authorInfoId + WHERE browseId NOT NULL; + """.trimIndent() + ) + ).use { cursor -> + val artistValues = ContentValues(2) + while (cursor.moveToNext()) { + artistValues.put("id", cursor.getString(0)) + artistValues.put("name", cursor.getString(1)) + db.insert("Artist", CONFLICT_IGNORE, artistValues) - it.execSQL( - "UPDATE SongWithAuthors SET authorInfoId = '${cursor.getString(0)}' WHERE authorInfoId = ${ - cursor.getLong( - 2 - ) - }" - ) - } + db.execSQL( + "UPDATE SongWithAuthors SET authorInfoId = '${cursor.getString(0)}' WHERE authorInfoId = ${ + cursor.getLong(2) + }" + ) } + } - it.execSQL("INSERT INTO SongArtistMap(songId, artistId) SELECT songId, authorInfoId FROM SongWithAuthors") + db.execSQL("INSERT INTO SongArtistMap(songId, artistId) SELECT songId, authorInfoId FROM SongWithAuthors") - it.execSQL("DROP TABLE Info;") - it.execSQL("DROP TABLE SongWithAuthors;") + db.execSQL("DROP TABLE Info;") + db.execSQL("DROP TABLE SongWithAuthors;") } } class From10To11Migration : Migration(10, 11) { - override fun migrate(it: SupportSQLiteDatabase) { - it.query(SimpleSQLiteQuery("SELECT id, albumId FROM Song;")).use { cursor -> + override fun migrate(db: SupportSQLiteDatabase) { + db.query(SimpleSQLiteQuery("SELECT id, albumId FROM Song;")).use { cursor -> val songAlbumMapValues = ContentValues(2) while (cursor.moveToNext()) { songAlbumMapValues.put("songId", cursor.getString(0)) songAlbumMapValues.put("albumId", cursor.getString(1)) - it.insert("SongAlbumMap", CONFLICT_IGNORE, songAlbumMapValues) + db.insert("SongAlbumMap", CONFLICT_IGNORE, songAlbumMapValues) } } - it.execSQL("CREATE TABLE IF NOT EXISTS `Song_new` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, `loudnessDb` REAL, `contentLength` INTEGER, PRIMARY KEY(`id`))") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `Song_new` ( + `id` TEXT NOT NULL, + `title` TEXT NOT NULL, + `artistsText` TEXT, + `durationText` TEXT NOT NULL, + `thumbnailUrl` TEXT, `lyrics` TEXT, + `likedAt` INTEGER, + `totalPlayTimeMs` INTEGER NOT NULL, + `loudnessDb` REAL, + `contentLength` INTEGER, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) - it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength) SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength FROM Song;") - it.execSQL("DROP TABLE Song;") - it.execSQL("ALTER TABLE Song_new RENAME TO Song;") + db.execSQL( + """ + INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, + likedAt, totalPlayTimeMs, loudnessDb, contentLength) SELECT id, title, artistsText, + durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs, loudnessDb, contentLength + FROM Song; + """.trimIndent() + ) + db.execSQL("DROP TABLE Song;") + db.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } @@ -592,23 +830,43 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { class From11To12Migration : AutoMigrationSpec class From14To15Migration : Migration(14, 15) { - override fun migrate(it: SupportSQLiteDatabase) { - it.query(SimpleSQLiteQuery("SELECT id, loudnessDb, contentLength FROM Song;")) + override fun migrate(db: SupportSQLiteDatabase) { + db.query(SimpleSQLiteQuery("SELECT id, loudnessDb, contentLength FROM Song;")) .use { cursor -> val formatValues = ContentValues(3) while (cursor.moveToNext()) { formatValues.put("songId", cursor.getString(0)) formatValues.put("loudnessDb", cursor.getFloatOrNull(1)) formatValues.put("contentLength", cursor.getFloatOrNull(2)) - it.insert("Format", CONFLICT_IGNORE, formatValues) + db.insert("Format", CONFLICT_IGNORE, formatValues) } } - it.execSQL("CREATE TABLE IF NOT EXISTS `Song_new` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `lyrics` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `Song_new` ( + `id` TEXT NOT NULL, + `title` TEXT NOT NULL, + `artistsText` TEXT, + `durationText` TEXT NOT NULL, + `thumbnailUrl` TEXT, + `lyrics` TEXT, + `likedAt` INTEGER, + `totalPlayTimeMs` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) - it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs) SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs FROM Song;") - it.execSQL("DROP TABLE Song;") - it.execSQL("ALTER TABLE Song_new RENAME TO Song;") + db.execSQL( + """ + INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs) + SELECT id, title, artistsText, durationText, thumbnailUrl, lyrics, likedAt, totalPlayTimeMs + FROM Song; + """.trimIndent() + ) + db.execSQL("DROP TABLE Song;") + db.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } @@ -616,7 +874,7 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { DeleteColumn("Artist", "shuffleVideoId"), DeleteColumn("Artist", "shufflePlaylistId"), DeleteColumn("Artist", "radioVideoId"), - DeleteColumn("Artist", "radioPlaylistId"), + DeleteColumn("Artist", "radioPlaylistId") ) class From20To21Migration : AutoMigrationSpec @@ -624,63 +882,103 @@ abstract class DatabaseInitializer protected constructor() : RoomDatabase() { class From21To22Migration : AutoMigrationSpec class From22To23Migration : Migration(22, 23) { - override fun migrate(it: SupportSQLiteDatabase) { - it.execSQL("CREATE TABLE IF NOT EXISTS Lyrics (`songId` TEXT NOT NULL, `fixed` TEXT, `synced` TEXT, PRIMARY KEY(`songId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE)") + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS Lyrics ( + `songId` TEXT NOT NULL, + `fixed` TEXT, + `synced` TEXT, + PRIMARY KEY(`songId`), + FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE + ) + """.trimIndent() + ) - it.query(SimpleSQLiteQuery("SELECT id, lyrics, synchronizedLyrics FROM Song;")).use { cursor -> - val lyricsValues = ContentValues(3) - while (cursor.moveToNext()) { - lyricsValues.put("songId", cursor.getString(0)) - lyricsValues.put("fixed", cursor.getString(1)) - lyricsValues.put("synced", cursor.getString(2)) - it.insert("Lyrics", CONFLICT_IGNORE, lyricsValues) + db.query(SimpleSQLiteQuery("SELECT id, lyrics, synchronizedLyrics FROM Song;")) + .use { cursor -> + val lyricsValues = ContentValues(3) + while (cursor.moveToNext()) { + lyricsValues.put("songId", cursor.getString(0)) + lyricsValues.put("fixed", cursor.getString(1)) + lyricsValues.put("synced", cursor.getString(2)) + db.insert("Lyrics", CONFLICT_IGNORE, lyricsValues) + } } - } - it.execSQL("CREATE TABLE IF NOT EXISTS Song_new (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `artistsText` TEXT, `durationText` TEXT, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))") - it.execSQL("INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs) SELECT id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs FROM Song;") - it.execSQL("DROP TABLE Song;") - it.execSQL("ALTER TABLE Song_new RENAME TO Song;") + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS Song_new ( + `id` TEXT NOT NULL, + `title` TEXT NOT NULL, + `artistsText` TEXT, + `durationText` TEXT, + `thumbnailUrl` TEXT, + `likedAt` INTEGER, + `totalPlayTimeMs` INTEGER NOT NULL, + PRIMARY KEY(`id`) + ) + """.trimIndent() + ) + db.execSQL( + """ + INSERT INTO Song_new(id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs) + SELECT id, title, artistsText, durationText, thumbnailUrl, likedAt, totalPlayTimeMs + FROM Song; + """.trimIndent() + ) + db.execSQL("DROP TABLE Song;") + db.execSQL("ALTER TABLE Song_new RENAME TO Song;") } } + + class From23To24Migration : Migration(23, 24) { + override fun migrate(db: SupportSQLiteDatabase) = + db.execSQL("ALTER TABLE Song ADD COLUMN loudnessBoost REAL") + } } @TypeConverters object Converters { @TypeConverter - fun mediaItemFromByteArray(value: ByteArray?): MediaItem? { - return value?.let { byteArray -> - runCatching { - val parcel = Parcel.obtain() - parcel.unmarshall(byteArray, 0, byteArray.size) - parcel.setDataPosition(0) - val bundle = parcel.readBundle(MediaItem::class.java.classLoader) - parcel.recycle() + @OptIn(UnstableApi::class) + fun mediaItemFromByteArray(value: ByteArray?): MediaItem? = value?.let { byteArray -> + runCatching { + val parcel = Parcel.obtain() + parcel.unmarshall(byteArray, 0, byteArray.size) + parcel.setDataPosition(0) + val bundle = parcel.readBundle(MediaItem::class.java.classLoader) + parcel.recycle() - bundle?.let(MediaItem.CREATOR::fromBundle) - }.getOrNull() - } + bundle?.let(MediaItem.CREATOR::fromBundle) + }.getOrNull() } @TypeConverter - fun mediaItemToByteArray(mediaItem: MediaItem?): ByteArray? { - return mediaItem?.toBundle()?.let { persistableBundle -> - val parcel = Parcel.obtain() - parcel.writeBundle(persistableBundle) - val bytes = parcel.marshall() - parcel.recycle() + @OptIn(UnstableApi::class) + fun mediaItemToByteArray(mediaItem: MediaItem?): ByteArray? = mediaItem?.toBundle()?.let { + val parcel = Parcel.obtain() + parcel.writeBundle(it) + val bytes = parcel.marshall() + parcel.recycle() - bytes - } + bytes } + + @TypeConverter + fun urlToString(url: Url) = url.toString() + + @TypeConverter + fun stringToUrl(string: String) = Url(string) } +@Suppress("UnusedReceiverParameter") val Database.internal: RoomDatabase - get() = DatabaseInitializer.Instance + get() = DatabaseInitializer.instance -fun query(block: () -> Unit) = DatabaseInitializer.Instance.queryExecutor.execute(block) +fun query(block: () -> Unit) = DatabaseInitializer.instance.queryExecutor.execute(block) -fun transaction(block: () -> Unit) = with(DatabaseInitializer.Instance) { +fun transaction(block: () -> Unit) = with(DatabaseInitializer.instance) { transactionExecutor.execute { runInTransaction(block) } diff --git a/app/src/main/kotlin/it/hamy/muza/Dependencies.kt b/app/src/main/kotlin/it/hamy/muza/Dependencies.kt index 71cc15d..ac61c29 100644 --- a/app/src/main/kotlin/it/hamy/muza/Dependencies.kt +++ b/app/src/main/kotlin/it/hamy/muza/Dependencies.kt @@ -1,6 +1,6 @@ package it.hamy.muza -import it.hamy.muza.preferences.PreferencesHolder +import it.hamy.compose.preferences.PreferencesHolder object Dependencies { lateinit var application: MainApplication @@ -11,4 +11,4 @@ object Dependencies { } } -open class GlobalPreferencesHolder : PreferencesHolder(Dependencies.application, "preferences") \ No newline at end of file +open class GlobalPreferencesHolder : PreferencesHolder(Dependencies.application, "preferences") diff --git a/app/src/main/kotlin/it/hamy/muza/MainActivity.kt b/app/src/main/kotlin/it/hamy/muza/MainActivity.kt index 9ec32d5..1456e12 100644 --- a/app/src/main/kotlin/it/hamy/muza/MainActivity.kt +++ b/app/src/main/kotlin/it/hamy/muza/MainActivity.kt @@ -4,28 +4,36 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection -import android.content.SharedPreferences import android.graphics.Bitmap import android.os.Bundle import android.os.IBinder +import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.tween -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.isImeVisible import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.material.ripple.LocalRippleTheme import androidx.compose.material.ripple.RippleAlpha @@ -34,6 +42,7 @@ import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -50,37 +59,29 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.unit.LayoutDirection -import androidx.compose.ui.unit.coerceIn +import androidx.compose.ui.unit.coerceAtLeast import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import androidx.core.view.WindowCompat import androidx.lifecycle.lifecycleScope import androidx.media3.common.MediaItem import androidx.media3.common.Player import com.valentinilk.shimmer.LocalShimmerTheme import com.valentinilk.shimmer.defaultShimmerTheme -import it.hamy.compose.persist.PersistMap -import it.hamy.compose.persist.PersistMapOwner - -import it.hamy.innertube.utils.ProxyPreferenceItem -import it.hamy.innertube.utils.ProxyPreferences -import it.hamy.muza.utils.isProxyEnabledKey -import it.hamy.muza.utils.proxyHostNameKey -import it.hamy.muza.utils.proxyModeKey -import it.hamy.muza.utils.proxyPortKey -import java.net.Proxy - - +import it.hamy.compose.persist.LocalPersistMap import it.hamy.innertube.Innertube import it.hamy.innertube.models.bodies.BrowseBody import it.hamy.innertube.requests.playlistPage import it.hamy.innertube.requests.song import it.hamy.muza.enums.ColorPaletteMode import it.hamy.muza.enums.ColorPaletteName -import it.hamy.muza.enums.ThumbnailRoundness +import it.hamy.muza.preferences.AppearancePreferences import it.hamy.muza.service.PlayerService +import it.hamy.muza.service.downloadState import it.hamy.muza.ui.components.BottomSheetMenu import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.rememberBottomSheetState +import it.hamy.muza.ui.components.themed.LinearProgressIndicator import it.hamy.muza.ui.screens.albumRoute import it.hamy.muza.ui.screens.artistRoute import it.hamy.muza.ui.screens.home.HomeScreen @@ -92,56 +93,58 @@ import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.colorPaletteOf import it.hamy.muza.ui.styling.dynamicColorPaletteOf import it.hamy.muza.ui.styling.typographyOf -import it.hamy.muza.utils.applyFontPaddingKey import it.hamy.muza.utils.asMediaItem -import it.hamy.muza.utils.colorPaletteModeKey -import it.hamy.muza.utils.colorPaletteNameKey import it.hamy.muza.utils.forcePlay -import it.hamy.muza.utils.getEnum import it.hamy.muza.utils.intent import it.hamy.muza.utils.isAtLeastAndroid6 import it.hamy.muza.utils.isAtLeastAndroid8 -import it.hamy.muza.utils.preferences -import it.hamy.muza.utils.thumbnailRoundnessKey -import it.hamy.muza.utils.useSystemFontKey +import it.hamy.muza.utils.isCompositionLaunched import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class MainActivity : ComponentActivity(), PersistMapOwner { +import it.hamy.innertube.utils.ProxyPreferenceItem +import it.hamy.innertube.utils.ProxyPreferences +import it.hamy.muza.preferences.getEnum +import it.hamy.muza.preferences.isProxyEnabledKey +import it.hamy.muza.preferences.proxyHostNameKey +import it.hamy.muza.preferences.proxyPortKey +import it.hamy.muza.preferences.proxyModeKey +import it.hamy.muza.preferences.preferences +import java.net.Proxy + +class MainActivity : ComponentActivity() { private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { - if (service is PlayerService.Binder) { - this@MainActivity.binder = service - } + if (service is PlayerService.Binder) this@MainActivity.binder = service } override fun onServiceDisconnected(name: ComponentName?) { binder = null + // Try to rebind, otherwise fail + unbindService(this) + bindService(intent(), this, Context.BIND_AUTO_CREATE) } } private var binder by mutableStateOf(null) - override lateinit var persistMap: PersistMap - - override fun onStart() { super.onStart() bindService(intent(), serviceConnection, Context.BIND_AUTO_CREATE) } - @OptIn(ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) + @Suppress("CyclomaticComplexMethod") + @OptIn(ExperimentalLayoutApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - @Suppress("DEPRECATION", "UNCHECKED_CAST") - persistMap = lastCustomNonConfigurationInstance as? PersistMap ?: PersistMap() - WindowCompat.setDecorFitsSystemWindows(window, false) val launchedFromNotification = intent?.extras?.getBoolean("expandPlayerBottomSheet") == true @@ -163,27 +166,27 @@ class MainActivity : ComponentActivity(), PersistMapOwner { var appearance by rememberSaveable( isSystemInDarkTheme, - stateSaver = Appearance.Companion + isCompositionLaunched(), + stateSaver = Appearance.AppearanceSaver ) { - with(preferences) { - val colorPaletteName = getEnum(colorPaletteNameKey, ColorPaletteName.Dynamic) - val colorPaletteMode = getEnum(colorPaletteModeKey, ColorPaletteMode.System) - val thumbnailRoundness = - getEnum(thumbnailRoundnessKey, ThumbnailRoundness.Слабое) - - val useSystemFont = getBoolean(useSystemFontKey, false) - val applyFontPadding = getBoolean(applyFontPaddingKey, false) - - val colorPalette = - colorPaletteOf(colorPaletteName, colorPaletteMode, isSystemInDarkTheme) + with(AppearancePreferences) { + val colorPalette = colorPaletteOf( + name = colorPaletteName, + mode = colorPaletteMode, + isDark = isSystemInDarkTheme + ) setSystemBarAppearance(colorPalette.isDark) mutableStateOf( Appearance( colorPalette = colorPalette, - typography = typographyOf(colorPalette.text, useSystemFont, applyFontPadding), - thumbnailShape = thumbnailRoundness.shape() + typography = typographyOf( + color = colorPalette.text, + useSystemFont = useSystemFont, + applyFontPadding = applyFontPadding + ), + thumbnailShapeCorners = thumbnailRoundness.dp ) ) } @@ -191,19 +194,22 @@ class MainActivity : ComponentActivity(), PersistMapOwner { DisposableEffect(binder, isSystemInDarkTheme) { var bitmapListenerJob: Job? = null + var appearanceUpdaterJob: Job? = null - fun setDynamicPalette(colorPaletteMode: ColorPaletteMode) { - val isDark = - colorPaletteMode == ColorPaletteMode.Dark || (colorPaletteMode == ColorPaletteMode.System && isSystemInDarkTheme) + fun setDynamicPalette( + name: ColorPaletteName, + mode: ColorPaletteMode + ) { + val isDark = mode == ColorPaletteMode.Dark || + mode == ColorPaletteMode.System && isSystemInDarkTheme binder?.setBitmapListener { bitmap: Bitmap? -> if (bitmap == null) { - val colorPalette = - colorPaletteOf( - ColorPaletteName.Dynamic, - colorPaletteMode, - isSystemInDarkTheme - ) + val colorPalette = colorPaletteOf( + name = name, + mode = mode, + isDark = isSystemInDarkTheme + ) setSystemBarAppearance(colorPalette.isDark) @@ -216,7 +222,11 @@ class MainActivity : ComponentActivity(), PersistMapOwner { } bitmapListenerJob = coroutineScope.launch(Dispatchers.IO) { - dynamicColorPaletteOf(bitmap, isDark)?.let { + dynamicColorPaletteOf( + bitmap = bitmap, + isDark = isDark, + isAmoled = name == ColorPaletteName.AMOLED + )?.let { withContext(Dispatchers.Main) { setSystemBarAppearance(it.isDark) } @@ -229,95 +239,88 @@ class MainActivity : ComponentActivity(), PersistMapOwner { } } - val listener = - SharedPreferences.OnSharedPreferenceChangeListener { sharedPreferences, key -> - when (key) { - colorPaletteNameKey, colorPaletteModeKey -> { - val colorPaletteName = - sharedPreferences.getEnum( - colorPaletteNameKey, - ColorPaletteName.Dynamic - ) + with(AppearancePreferences) { + fun setPalette(): Boolean { + if (colorPaletteName != ColorPaletteName.Dynamic && colorPaletteName != ColorPaletteName.AMOLED) + return false - val colorPaletteMode = - sharedPreferences.getEnum( - colorPaletteModeKey, - ColorPaletteMode.System - ) + setDynamicPalette(colorPaletteName, colorPaletteMode) + return true + } + setPalette() - if (colorPaletteName == ColorPaletteName.Dynamic) { - setDynamicPalette(colorPaletteMode) - } else { + appearanceUpdaterJob = coroutineScope.launch { + launch { + combine( + flow = colorPaletteNameProperty.stateFlow, + flow2 = colorPaletteModeProperty.stateFlow + ) { name, mode -> + if (!setPalette()) { bitmapListenerJob?.cancel() binder?.setBitmapListener(null) val colorPalette = colorPaletteOf( - colorPaletteName, - colorPaletteMode, - isSystemInDarkTheme + name = name, + mode = mode, + isDark = isSystemInDarkTheme ) setSystemBarAppearance(colorPalette.isDark) appearance = appearance.copy( colorPalette = colorPalette, - typography = appearance.typography.copy(colorPalette.text), + typography = appearance.typography.copy(colorPalette.text) ) } - } - - thumbnailRoundnessKey -> { - val thumbnailRoundness = - sharedPreferences.getEnum(key, ThumbnailRoundness.Слабое) - - appearance = appearance.copy( - thumbnailShape = thumbnailRoundness.shape() - ) - } - - useSystemFontKey, applyFontPaddingKey -> { - val useSystemFont = sharedPreferences.getBoolean(useSystemFontKey, false) - val applyFontPadding = sharedPreferences.getBoolean(applyFontPaddingKey, false) - - appearance = appearance.copy( - typography = typographyOf(appearance.colorPalette.text, useSystemFont, applyFontPadding), - ) + }.collect() + } + launch { + thumbnailRoundnessProperty.stateFlow.collectLatest { + appearance = appearance.copy(thumbnailShapeCorners = it.dp) } } + launch { + combine( + flow = useSystemFontProperty.stateFlow, + flow2 = applyFontPaddingProperty.stateFlow + ) { system, padding -> + appearance = appearance.copy( + typography = typographyOf( + appearance.colorPalette.text, + system, + padding + ) + ) + }.collectLatest { } + } } + } - with(preferences) { - registerOnSharedPreferenceChangeListener(listener) - - val colorPaletteName = getEnum(colorPaletteNameKey, ColorPaletteName.Dynamic) - if (colorPaletteName == ColorPaletteName.Dynamic) { - setDynamicPalette(getEnum(colorPaletteModeKey, ColorPaletteMode.System)) - } - - onDispose { - bitmapListenerJob?.cancel() - binder?.setBitmapListener(null) - unregisterOnSharedPreferenceChangeListener(listener) - } + onDispose { + bitmapListenerJob?.cancel() + appearanceUpdaterJob?.cancel() + binder?.setBitmapListener(null) } } - val rippleTheme = - remember(appearance.colorPalette.text, appearance.colorPalette.isDark) { - object : RippleTheme { - @Composable - override fun defaultColor(): Color = RippleTheme.defaultRippleColor( - contentColor = appearance.colorPalette.text, - lightTheme = !appearance.colorPalette.isDark - ) + val rippleTheme = remember( + appearance.colorPalette.text, + appearance.colorPalette.isDark + ) { + object : RippleTheme { + @Composable + override fun defaultColor(): Color = RippleTheme.defaultRippleColor( + contentColor = appearance.colorPalette.text, + lightTheme = !appearance.colorPalette.isDark + ) - @Composable - override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( - contentColor = appearance.colorPalette.text, - lightTheme = !appearance.colorPalette.isDark - ) - } + @Composable + override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( + contentColor = appearance.colorPalette.text, + lightTheme = !appearance.colorPalette.isDark + ) } + } val shimmerTheme = remember { defaultShimmerTheme.copy( @@ -325,15 +328,15 @@ class MainActivity : ComponentActivity(), PersistMapOwner { animation = tween( durationMillis = 800, easing = LinearEasing, - delayMillis = 250, + delayMillis = 250 ), repeatMode = RepeatMode.Restart ), shaderColors = listOf( Color.Unspecified.copy(alpha = 0.25f), Color.White.copy(alpha = 0.50f), - Color.Unspecified.copy(alpha = 0.25f), - ), + Color.Unspecified.copy(alpha = 0.25f) + ) ) } @@ -346,15 +349,32 @@ class MainActivity : ComponentActivity(), PersistMapOwner { val windowsInsets = WindowInsets.systemBars val bottomDp = with(density) { windowsInsets.getBottom(density).toDp() } - val playerBottomSheetState = rememberBottomSheetState( - dismissedBound = 0.dp, - collapsedBound = Dimensions.collapsedPlayer + bottomDp, - expandedBound = maxHeight, + val imeVisible = WindowInsets.isImeVisible + val imeBottomDp = with(density) { WindowInsets.ime.getBottom(density).toDp() } + val animatedBottomDp by animateDpAsState( + targetValue = if (imeVisible) 0.dp else bottomDp, + label = "" ) - val playerAwareWindowInsets by remember(bottomDp, playerBottomSheetState.value) { + val playerBottomSheetState = rememberBottomSheetState( + dismissedBound = 0.dp, + collapsedBound = Dimensions.items.collapsedPlayerHeight + bottomDp, + expandedBound = maxHeight + ) + + val playerAwareWindowInsets by remember( + bottomDp, + animatedBottomDp, + playerBottomSheetState.value, + imeVisible, + imeBottomDp + ) { derivedStateOf { - val bottom = playerBottomSheetState.value.coerceIn(bottomDp, playerBottomSheetState.collapsedBound) + val bottom = + if (imeVisible) imeBottomDp.coerceAtLeast(playerBottomSheetState.value) + else playerBottomSheetState.value.coerceIn( + animatedBottomDp..playerBottomSheetState.collapsedBound + ) windowsInsets .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) @@ -369,24 +389,52 @@ class MainActivity : ComponentActivity(), PersistMapOwner { LocalShimmerTheme provides shimmerTheme, LocalPlayerServiceBinder provides binder, LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, - LocalLayoutDirection provides LayoutDirection.Ltr + LocalLayoutDirection provides LayoutDirection.Ltr, + LocalPersistMap provides Dependencies.application.persistMap ) { - HomeScreen( - onPlaylistUrl = { url -> - onNewIntent(Intent.parseUri(url, 0)) - } - ) + val isDownloading by downloadState.collectAsState() - Player( - layoutState = playerBottomSheetState, - modifier = Modifier - .align(Alignment.BottomCenter) - ) + Box { + HomeScreen( + onPlaylistUrl = { url -> + onNewIntent(Intent.parseUri(url, 0)) + } + ) + } + + AnimatedVisibility( + visible = isDownloading, + modifier = Modifier.padding(playerAwareWindowInsets.asPaddingValues()) + ) { + LinearProgressIndicator( + modifier = Modifier + .fillMaxWidth() + .align(Alignment.TopCenter) + ) + } + + CompositionLocalProvider( + LocalAppearance provides if ( + AppearancePreferences.colorPaletteName == ColorPaletteName.AMOLED + ) appearance.copy( + colorPalette = dynamicColorPaletteOf( + accentColor = appearance.colorPalette.accent, + isDark = true, + isAmoled = false + ) + ) else appearance + ) { + Player( + layoutState = playerBottomSheetState, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } BottomSheetMenu( state = LocalMenuState.current, modifier = Modifier .align(Alignment.BottomCenter) + .imePadding() ) } @@ -394,17 +442,13 @@ class MainActivity : ComponentActivity(), PersistMapOwner { val player = binder?.player ?: return@DisposableEffect onDispose { } if (player.currentMediaItem == null) { - if (!playerBottomSheetState.isDismissed) { - playerBottomSheetState.dismiss() - } + if (!playerBottomSheetState.isDismissed) playerBottomSheetState.dismiss() } else { if (playerBottomSheetState.isDismissed) { if (launchedFromNotification) { intent.replaceExtras(Bundle()) playerBottomSheetState.expand(tween(700)) - } else { - playerBottomSheetState.collapse(tween(700)) - } + } else playerBottomSheetState.collapse(tween(700)) } } @@ -413,9 +457,7 @@ class MainActivity : ComponentActivity(), PersistMapOwner { if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null) { if (mediaItem.mediaMetadata.extras?.getBoolean("isFromPersistentQueue") != true) { playerBottomSheetState.expand(tween(500)) - } else { - playerBottomSheetState.collapse(tween(700)) - } + } else playerBottomSheetState.collapse(tween(700)) } } } @@ -430,15 +472,16 @@ class MainActivity : ComponentActivity(), PersistMapOwner { onNewIntent(intent) } + @Suppress("CyclomaticComplexMethod") override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) - val uri = intent?.data ?: return + val uri = intent?.data ?: intent?.getStringExtra(Intent.EXTRA_TEXT)?.toUri() ?: return - intent.data = null + intent?.data = null this.intent = null - Toast.makeText(this, "Opening url...", Toast.LENGTH_SHORT).show() + Log.d("MainActivity", "Opening url: $uri") lifecycleScope.launch(Dispatchers.IO) { when (val path = uri.pathSegments.firstOrNull()) { @@ -452,7 +495,7 @@ class MainActivity : ComponentActivity(), PersistMapOwner { } } } else { - playlistRoute.ensureGlobal(browseId) + playlistRoute.ensureGlobal(browseId, uri.getQueryParameter("params"), null) } } @@ -463,7 +506,11 @@ class MainActivity : ComponentActivity(), PersistMapOwner { else -> when { path == "watch" -> uri.getQueryParameter("v") uri.host == "youtu.be" -> path - else -> null + else -> { + Toast.makeText(this@MainActivity, "Can't open url $uri", Toast.LENGTH_SHORT) + .show() + null + } }?.let { videoId -> Innertube.song(videoId)?.getOrNull()?.let { song -> val binder = snapshotFlow { binder }.filterNotNull().first() @@ -476,39 +523,26 @@ class MainActivity : ComponentActivity(), PersistMapOwner { } } - override fun onRetainCustomNonConfigurationInstance() = persistMap - override fun onStop() { unbindService(serviceConnection) super.onStop() } - override fun onDestroy() { - if (!isChangingConfigurations) { - persistMap.clear() - } - - super.onDestroy() - } - private fun setSystemBarAppearance(isDark: Boolean) { with(WindowCompat.getInsetsController(window, window.decorView.rootView)) { isAppearanceLightStatusBars = !isDark isAppearanceLightNavigationBars = !isDark } - if (!isAtLeastAndroid6) { - window.statusBarColor = - (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() - } + if (!isAtLeastAndroid6) window.statusBarColor = + (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() - if (!isAtLeastAndroid8) { - window.navigationBarColor = - (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() - } + if (!isAtLeastAndroid8) window.navigationBarColor = + (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() } } val LocalPlayerServiceBinder = staticCompositionLocalOf { null } -val LocalPlayerAwareWindowInsets = staticCompositionLocalOf { TODO() } +val LocalPlayerAwareWindowInsets = + staticCompositionLocalOf { error("No player insets provided") } diff --git a/app/src/main/kotlin/it/hamy/muza/MainApplication.kt b/app/src/main/kotlin/it/hamy/muza/MainApplication.kt index 6125020..ad1b069 100644 --- a/app/src/main/kotlin/it/hamy/muza/MainApplication.kt +++ b/app/src/main/kotlin/it/hamy/muza/MainApplication.kt @@ -1,20 +1,22 @@ package it.hamy.muza import android.app.Application +import android.util.Log import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache +import coil.util.DebugLogger +import it.hamy.compose.persist.PersistMap +import it.hamy.muza.preferences.DataPreferences +import androidx.work.Configuration as WorkManagerConfiguration import com.yandex.mobile.ads.common.MobileAds -import it.hamy.muza.enums.CoilDiskCacheMaxSize -import it.hamy.muza.utils.coilDiskCacheMaxSizeKey -import it.hamy.muza.utils.getEnum -import it.hamy.muza.utils.preferences -class MainApplication : Application(), ImageLoaderFactory { + +class MainApplication : Application(), ImageLoaderFactory, WorkManagerConfiguration.Provider { override fun onCreate() { super.onCreate() - DatabaseInitializer() Dependencies.init(this) + DatabaseInitializer() MobileAds.initialize(this) { /** * Инициализация либы яндекса @@ -22,21 +24,21 @@ class MainApplication : Application(), ImageLoaderFactory { } } - override fun newImageLoader(): ImageLoader { - return ImageLoader.Builder(this) - .crossfade(true) - .respectCacheHeaders(false) - .diskCache( - DiskCache.Builder() - .directory(cacheDir.resolve("coil")) - .maxSizeBytes( - preferences.getEnum( - coilDiskCacheMaxSizeKey, - CoilDiskCacheMaxSize.`128MB` - ).bytes - ) - .build() - ) - .build() - } + override fun newImageLoader() = ImageLoader.Builder(this) + .crossfade(true) + .respectCacheHeaders(false) + .diskCache( + DiskCache.Builder() + .directory(cacheDir.resolve("coil")) + .maxSizeBytes(DataPreferences.coilDiskCacheMaxSize.bytes) + .build() + ) + .let { if (BuildConfig.DEBUG) it.logger(DebugLogger()) else it } + .build() + + val persistMap = PersistMap() + + override val workManagerConfiguration = WorkManagerConfiguration.Builder() + .setMinimumLoggingLevel(if (BuildConfig.DEBUG) Log.DEBUG else Log.INFO) + .build() } diff --git a/app/src/main/kotlin/it/hamy/muza/enums/CoilDiskCacheSize.kt b/app/src/main/kotlin/it/hamy/muza/enums/CoilDiskCacheSize.kt deleted file mode 100644 index 8919d4e..0000000 --- a/app/src/main/kotlin/it/hamy/muza/enums/CoilDiskCacheSize.kt +++ /dev/null @@ -1,18 +0,0 @@ -package it.hamy.muza.enums - -enum class CoilDiskCacheMaxSize { - `128MB`, - `256MB`, - `512MB`, - `1GB`, - `2GB`; - - val bytes: Long - get() = when (this) { - `128MB` -> 128 - `256MB` -> 256 - `512MB` -> 512 - `1GB` -> 1024 - `2GB` -> 2048 - } * 1000 * 1000L -} diff --git a/app/src/main/kotlin/it/hamy/muza/enums/ExoPlayerDiskCacheMaxSize.kt b/app/src/main/kotlin/it/hamy/muza/enums/ExoPlayerDiskCacheMaxSize.kt deleted file mode 100644 index 68eefdb..0000000 --- a/app/src/main/kotlin/it/hamy/muza/enums/ExoPlayerDiskCacheMaxSize.kt +++ /dev/null @@ -1,22 +0,0 @@ -package it.hamy.muza.enums - -enum class ExoPlayerDiskCacheMaxSize { - `32MB`, - `512MB`, - `1GB`, - `2GB`, - `4GB`, - `8GB`, - Unlimited; - - val bytes: Long - get() = when (this) { - `32MB` -> 32 - `512MB` -> 512 - `1GB` -> 1024 - `2GB` -> 2048 - `4GB` -> 4096 - `8GB` -> 8192 - Unlimited -> 0 - } * 1000 * 1000L -} diff --git a/app/src/main/kotlin/it/hamy/muza/enums/ThumbnailRoundness.kt b/app/src/main/kotlin/it/hamy/muza/enums/ThumbnailRoundness.kt deleted file mode 100644 index 59c6fc3..0000000 --- a/app/src/main/kotlin/it/hamy/muza/enums/ThumbnailRoundness.kt +++ /dev/null @@ -1,24 +0,0 @@ -package it.hamy.muza.enums - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.ui.graphics.RectangleShape -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp - -enum class ThumbnailRoundness { - Отключено, - Слабое, - Среднее, - Сильное, - Максимальное; - - fun shape(): Shape { - return when (this) { - Отключено -> RectangleShape - Слабое -> RoundedCornerShape(2.dp) - Среднее -> RoundedCornerShape(4.dp) - Сильное -> RoundedCornerShape(8.dp) - Максимальное -> RoundedCornerShape(14.dp) - } - } -} diff --git a/app/src/main/kotlin/it/hamy/muza/models/Album.kt b/app/src/main/kotlin/it/hamy/muza/models/Album.kt index 88e4fea..5904f80 100644 --- a/app/src/main/kotlin/it/hamy/muza/models/Album.kt +++ b/app/src/main/kotlin/it/hamy/muza/models/Album.kt @@ -9,10 +9,12 @@ import androidx.room.PrimaryKey data class Album( @PrimaryKey val id: String, val title: String? = null, + val description: String? = null, val thumbnailUrl: String? = null, val year: String? = null, val authorsText: String? = null, val shareUrl: String? = null, val timestamp: Long? = null, - val bookmarkedAt: Long? = null + val bookmarkedAt: Long? = null, + val otherInfo: String? = null ) diff --git a/app/src/main/kotlin/it/hamy/muza/models/Artist.kt b/app/src/main/kotlin/it/hamy/muza/models/Artist.kt index c701c47..eb3cd80 100644 --- a/app/src/main/kotlin/it/hamy/muza/models/Artist.kt +++ b/app/src/main/kotlin/it/hamy/muza/models/Artist.kt @@ -11,5 +11,5 @@ data class Artist( val name: String? = null, val thumbnailUrl: String? = null, val timestamp: Long? = null, - val bookmarkedAt: Long? = null, + val bookmarkedAt: Long? = null ) diff --git a/app/src/main/kotlin/it/hamy/muza/models/EventWithSong.kt b/app/src/main/kotlin/it/hamy/muza/models/EventWithSong.kt index 7dd2e8b..676e4c6 100644 --- a/app/src/main/kotlin/it/hamy/muza/models/EventWithSong.kt +++ b/app/src/main/kotlin/it/hamy/muza/models/EventWithSong.kt @@ -13,4 +13,4 @@ data class EventWithSong( entityColumn = "id" ) val song: Song -) \ No newline at end of file +) diff --git a/app/src/main/kotlin/it/hamy/muza/models/Lyrics.kt b/app/src/main/kotlin/it/hamy/muza/models/Lyrics.kt index 7fdf4de..f784476 100644 --- a/app/src/main/kotlin/it/hamy/muza/models/Lyrics.kt +++ b/app/src/main/kotlin/it/hamy/muza/models/Lyrics.kt @@ -12,12 +12,13 @@ import androidx.room.PrimaryKey entity = Song::class, parentColumns = ["id"], childColumns = ["songId"], - onDelete = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE ) ] ) -class Lyrics( +data class Lyrics( @PrimaryKey val songId: String, val fixed: String?, val synced: String?, + val startTime: Long? = null ) diff --git a/app/src/main/kotlin/it/hamy/muza/models/Mood.kt b/app/src/main/kotlin/it/hamy/muza/models/Mood.kt new file mode 100644 index 0000000..2a6de3d --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/models/Mood.kt @@ -0,0 +1,41 @@ +package it.hamy.muza.models + +import android.os.Parcel +import android.os.Parcelable +import androidx.compose.ui.graphics.Color +import it.hamy.innertube.Innertube + +data class Mood( + val name: String, + val color: Color, + val browseId: String?, + val params: String? +) : Parcelable { + constructor(parcel: Parcel) : this( + name = parcel.readString()!!, + color = Color(parcel.readLong()), + browseId = parcel.readString()!!, + params = parcel.readString()!! + ) + + override fun writeToParcel(parcel: Parcel, flags: Int) = with(parcel) { + writeString(name) + writeLong(color.value.toLong()) + writeString(browseId) + writeString(params) + } + + override fun describeContents(): Int = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = Mood(parcel) + override fun newArray(size: Int) = arrayOfNulls(size) + } +} + +fun Innertube.Mood.Item.toUiMood() = Mood( + name = title, + color = Color(stripeColor), + browseId = endpoint.browseId, + params = endpoint.params +) diff --git a/app/src/main/kotlin/it/hamy/muza/models/PipedSession.kt b/app/src/main/kotlin/it/hamy/muza/models/PipedSession.kt new file mode 100644 index 0000000..e83fb7f --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/models/PipedSession.kt @@ -0,0 +1,26 @@ +package it.hamy.muza.models + +import androidx.compose.runtime.Immutable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey +import io.ktor.http.Url +import it.hamy.piped.models.authenticatedWith + +@Immutable +@Entity( + indices = [ + Index( + value = ["apiBaseUrl", "username"], + unique = true + ) + ] +) +data class PipedSession( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val apiBaseUrl: Url, + val token: String, + val username: String // the username should never change on piped +) { + fun toApiSession() = apiBaseUrl authenticatedWith token +} diff --git a/app/src/main/kotlin/it/hamy/muza/models/PlaylistPreview.kt b/app/src/main/kotlin/it/hamy/muza/models/PlaylistPreview.kt index 091ee41..3e5e9c0 100644 --- a/app/src/main/kotlin/it/hamy/muza/models/PlaylistPreview.kt +++ b/app/src/main/kotlin/it/hamy/muza/models/PlaylistPreview.kt @@ -1,10 +1,18 @@ package it.hamy.muza.models import androidx.compose.runtime.Immutable -import androidx.room.Embedded @Immutable data class PlaylistPreview( - @Embedded val playlist: Playlist, + val id: Long, + val name: String, val songCount: Int -) +) { + val playlist by lazy { + Playlist( + id = id, + name = name, + browseId = null + ) + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/models/Song.kt b/app/src/main/kotlin/it/hamy/muza/models/Song.kt index cb96e23..fbaa583 100644 --- a/app/src/main/kotlin/it/hamy/muza/models/Song.kt +++ b/app/src/main/kotlin/it/hamy/muza/models/Song.kt @@ -1,6 +1,7 @@ package it.hamy.muza.models import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -13,24 +14,10 @@ data class Song( val durationText: String?, val thumbnailUrl: String?, val likedAt: Long? = null, - val totalPlayTimeMs: Long = 0 + val totalPlayTimeMs: Long = 0, + val loudnessBoost: Float? = null, + @ColumnInfo(defaultValue = "false") + val blacklisted: Boolean = false ) { - val formattedTotalPlayTime: String - get() { - val seconds = totalPlayTimeMs / 1000 - - val hours = seconds / 3600 - - return when { - hours == 0L -> "${seconds / 60}m" - hours < 24L -> "${hours}h" - else -> "${hours / 24}d" - } - } - - fun toggleLike(): Song { - return copy( - likedAt = if (likedAt == null) System.currentTimeMillis() else null - ) - } + fun toggleLike() = copy(likedAt = if (likedAt == null) System.currentTimeMillis() else null) } diff --git a/app/src/main/kotlin/it/hamy/muza/models/ui/UiMedia.kt b/app/src/main/kotlin/it/hamy/muza/models/ui/UiMedia.kt new file mode 100644 index 0000000..ae54dba --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/models/ui/UiMedia.kt @@ -0,0 +1,17 @@ +package it.hamy.muza.models.ui + +import androidx.media3.common.MediaItem + +data class UiMedia( + val id: String, + val title: String, + val artist: String, + val duration: Long +) + +fun MediaItem.toUiMedia(duration: Long) = UiMedia( + id = mediaId, + title = mediaMetadata.title?.toString().orEmpty(), + artist = mediaMetadata.artist?.toString().orEmpty(), + duration = duration +) diff --git a/app/src/main/kotlin/it/hamy/muza/preferences/AppearancePreferences.kt b/app/src/main/kotlin/it/hamy/muza/preferences/AppearancePreferences.kt new file mode 100644 index 0000000..94553be --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/preferences/AppearancePreferences.kt @@ -0,0 +1,23 @@ +package it.hamy.muza.preferences + +import it.hamy.muza.GlobalPreferencesHolder +import it.hamy.muza.enums.ColorPaletteMode +import it.hamy.muza.enums.ColorPaletteName +import it.hamy.muza.enums.ThumbnailRoundness + +object AppearancePreferences : GlobalPreferencesHolder() { + val colorPaletteNameProperty = enum(ColorPaletteName.Dynamic) + var colorPaletteName by colorPaletteNameProperty + val colorPaletteModeProperty = enum(ColorPaletteMode.System) + var colorPaletteMode by colorPaletteModeProperty + val thumbnailRoundnessProperty = enum(ThumbnailRoundness.Light) + var thumbnailRoundness by thumbnailRoundnessProperty + val useSystemFontProperty = boolean(false) + var useSystemFont by useSystemFontProperty + val applyFontPaddingProperty = boolean(false) + var applyFontPadding by applyFontPaddingProperty + val isShowingThumbnailInLockscreenProperty = boolean(false) + var isShowingThumbnailInLockscreen by isShowingThumbnailInLockscreenProperty + var swipeToHideSong by boolean(false) + var maxThumbnailSize by int(1920) +} diff --git a/app/src/main/kotlin/it/hamy/muza/preferences/DataPreferences.kt b/app/src/main/kotlin/it/hamy/muza/preferences/DataPreferences.kt index 99497b5..592442d 100644 --- a/app/src/main/kotlin/it/hamy/muza/preferences/DataPreferences.kt +++ b/app/src/main/kotlin/it/hamy/muza/preferences/DataPreferences.kt @@ -4,24 +4,33 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.res.stringResource import it.hamy.muza.GlobalPreferencesHolder import it.hamy.muza.R +import it.hamy.muza.enums.CoilDiskCacheSize +import it.hamy.muza.enums.ExoPlayerDiskCacheSize import kotlin.time.Duration import kotlin.time.Duration.Companion.days object DataPreferences : GlobalPreferencesHolder() { - var topListLength by int(10) - var topListPeriod by enum(TopListPeriod.AllTime) + var coilDiskCacheMaxSize by enum(CoilDiskCacheSize.`128MB`) + var exoPlayerDiskCacheMaxSize by enum(ExoPlayerDiskCacheSize.`2GB`) + var pauseHistory by boolean(false) + var pausePlaytime by boolean(false) + var pauseSearchHistory by boolean(false) + val topListLengthProperty = int(10) + var topListLength by topListLengthProperty + val topListPeriodProperty = enum(TopListPeriod.AllTime) + var topListPeriod by topListPeriodProperty var quickPicksSource by enum(QuickPicksSource.Trending) enum class TopListPeriod(val displayName: @Composable () -> String, val duration: Duration? = null) { - PastDay(displayName = { "Day" }, duration = 1.days), - PastWeek(displayName = { "Week" }, duration = 7.days), - PastMonth(displayName = { "Month" }, duration = 30.days), - PastYear(displayName = { "Year" }, 365.days), - AllTime(displayName = { "AllTime" }) + PastDay(displayName = { stringResource(R.string.past_24_hours) }, duration = 1.days), + PastWeek(displayName = { stringResource(R.string.past_week) }, duration = 7.days), + PastMonth(displayName = { stringResource(R.string.past_month) }, duration = 30.days), + PastYear(displayName = { stringResource(R.string.past_year) }, 365.days), + AllTime(displayName = { stringResource(R.string.all_time) }) } enum class QuickPicksSource(val displayName: @Composable () -> String) { - Trending(displayName = { "Trend" }), - LastInteraction(displayName = { "LastInteraction" }) + Trending(displayName = { stringResource(R.string.trending) }), + LastInteraction(displayName = { stringResource(R.string.last_interaction) }) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/it/hamy/muza/preferences/OrderPreferences.kt b/app/src/main/kotlin/it/hamy/muza/preferences/OrderPreferences.kt new file mode 100644 index 0000000..aa2beea --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/preferences/OrderPreferences.kt @@ -0,0 +1,22 @@ +package it.hamy.muza.preferences + +import it.hamy.muza.GlobalPreferencesHolder +import it.hamy.muza.enums.AlbumSortBy +import it.hamy.muza.enums.ArtistSortBy +import it.hamy.muza.enums.PlaylistSortBy +import it.hamy.muza.enums.SongSortBy +import it.hamy.muza.enums.SortOrder + +object OrderPreferences : GlobalPreferencesHolder() { + var songSortOrder by enum(SortOrder.Descending) + var localSongSortOrder by enum(SortOrder.Descending) + var playlistSortOrder by enum(SortOrder.Descending) + var albumSortOrder by enum(SortOrder.Descending) + var artistSortOrder by enum(SortOrder.Descending) + + var songSortBy by enum(SongSortBy.DateAdded) + var localSongSortBy by enum(SongSortBy.DateAdded) + var playlistSortBy by enum(PlaylistSortBy.DateAdded) + var albumSortBy by enum(AlbumSortBy.DateAdded) + var artistSortBy by enum(ArtistSortBy.DateAdded) +} diff --git a/app/src/main/kotlin/it/hamy/muza/preferences/PlayerPreferences.kt b/app/src/main/kotlin/it/hamy/muza/preferences/PlayerPreferences.kt new file mode 100644 index 0000000..c63255a --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/preferences/PlayerPreferences.kt @@ -0,0 +1,66 @@ +package it.hamy.muza.preferences + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import it.hamy.muza.GlobalPreferencesHolder +import it.hamy.muza.R + +object PlayerPreferences : GlobalPreferencesHolder() { + val isInvincibilityEnabledProperty = boolean(false) + var isInvincibilityEnabled by isInvincibilityEnabledProperty + val trackLoopEnabledProperty = boolean(false) + var trackLoopEnabled by trackLoopEnabledProperty + val queueLoopEnabledProperty = boolean(true) + var queueLoopEnabled by queueLoopEnabledProperty + val skipSilenceProperty = boolean(false) + var skipSilence by skipSilenceProperty + val volumeNormalizationProperty = boolean(false) + var volumeNormalization by volumeNormalizationProperty + val volumeNormalizationBaseGainProperty = float(5.00f) + var volumeNormalizationBaseGain by volumeNormalizationBaseGainProperty + val bassBoostProperty = boolean(false) + var bassBoost by bassBoostProperty + val bassBoostLevelProperty = int(5) + var bassBoostLevel by bassBoostLevelProperty + val resumePlaybackWhenDeviceConnectedProperty = boolean(false) + var resumePlaybackWhenDeviceConnected by resumePlaybackWhenDeviceConnectedProperty + val speedProperty = float(1f) + var speed by speedProperty + + var minimumSilence by long(2_000_000L) + var persistentQueue by boolean(true) + var isShowingLyrics by boolean(false) + var isShowingSynchronizedLyrics by boolean(false) + var isShowingPrevButtonCollapsed by boolean(false) + var stopWhenClosed by boolean(false) + var horizontalSwipeToClose by boolean(false) + var horizontalSwipeToRemoveItem by boolean(false) + var playerLayout by enum(PlayerLayout.Classic) + var seekBarStyle by enum(SeekBarStyle.Wavy) + var wavySeekBarQuality by enum(WavySeekBarQuality.Great) + var showLike by boolean(false) + + enum class PlayerLayout(val displayName: @Composable () -> String) { + Classic(displayName = { stringResource(R.string.classic_player_layout_name) }), + New(displayName = { stringResource(R.string.new_player_layout_name) }) + } + + enum class SeekBarStyle(val displayName: @Composable () -> String) { + Static(displayName = { stringResource(R.string.static_seek_bar_name) }), + Wavy(displayName = { stringResource(R.string.wavy_seek_bar_name) }) + } + + enum class WavySeekBarQuality( + val quality: Float, + val displayName: @Composable () -> String + ) { + Poor(quality = 50f, displayName = { stringResource(R.string.seek_bar_quality_poor) }), + Low(quality = 25f, displayName = { stringResource(R.string.seek_bar_quality_low) }), + Medium(quality = 15f, displayName = { stringResource(R.string.seek_bar_quality_medium) }), + High(quality = 5f, displayName = { stringResource(R.string.seek_bar_quality_high) }), + Great(quality = 1f, displayName = { stringResource(R.string.seek_bar_quality_great) }), + Subpixel(quality = 0.5f, displayName = { stringResource(R.string.seek_bar_quality_subpixel) }) + } + + val volumeNormalizationBaseGainRounded get() = (volumeNormalizationBaseGain * 100).toInt() +} diff --git a/app/src/main/kotlin/it/hamy/muza/utils/Preferences.kt b/app/src/main/kotlin/it/hamy/muza/preferences/ProxyPreferences.kt similarity index 64% rename from app/src/main/kotlin/it/hamy/muza/utils/Preferences.kt rename to app/src/main/kotlin/it/hamy/muza/preferences/ProxyPreferences.kt index 89b5bd0..d695158 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/Preferences.kt +++ b/app/src/main/kotlin/it/hamy/muza/preferences/ProxyPreferences.kt @@ -1,4 +1,4 @@ -package it.hamy.muza.utils +package it.hamy.muza.preferences import android.content.Context import android.content.SharedPreferences @@ -10,34 +10,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext import androidx.core.content.edit -const val colorPaletteNameKey = "colorPaletteName" -const val colorPaletteModeKey = "colorPaletteMode" -const val thumbnailRoundnessKey = "thumbnailRoundness" -const val coilDiskCacheMaxSizeKey = "coilDiskCacheMaxSize" -const val exoPlayerDiskCacheMaxSizeKey = "exoPlayerDiskCacheMaxSize" -const val isInvincibilityEnabledKey = "isInvincibilityEnabled" -const val useSystemFontKey = "useSystemFont" -const val applyFontPaddingKey = "applyFontPadding" -const val songSortOrderKey = "songSortOrder" -const val songSortByKey = "songSortBy" -const val playlistSortOrderKey = "playlistSortOrder" -const val playlistSortByKey = "playlistSortBy" -const val albumSortOrderKey = "albumSortOrder" -const val albumSortByKey = "albumSortBy" -const val artistSortOrderKey = "artistSortOrder" -const val artistSortByKey = "artistSortBy" -const val trackLoopEnabledKey = "trackLoopEnabled" -const val queueLoopEnabledKey = "queueLoopEnabled" -const val skipSilenceKey = "skipSilence" -const val volumeNormalizationKey = "volumeNormalization" -const val resumePlaybackWhenDeviceConnectedKey = "resumePlaybackWhenDeviceConnected" -const val persistentQueueKey = "persistentQueue" -const val isShowingSynchronizedLyricsKey = "isShowingSynchronizedLyrics" -const val isShowingThumbnailInLockscreenKey = "isShowingThumbnailInLockscreen" -const val homeScreenTabIndexKey = "homeScreenTabIndex" -const val searchResultScreenTabIndexKey = "searchResultScreenTabIndex" -const val artistScreenTabIndexKey = "artistScreenTabIndex" -const val pauseSearchHistoryKey = "pauseSearchHistory" const val isProxyEnabledKey = "isProxyEnabled" const val proxyHostNameKey = "proxyHostname" const val proxyPortKey = "proxyPortKey" @@ -116,4 +88,4 @@ inline fun mutableStatePreferenceOf( if (!areEquals) onStructuralInequality(b) return areEquals } - }) + }) \ No newline at end of file diff --git a/app/src/main/kotlin/it/hamy/muza/preferences/UIStatePreferences.kt b/app/src/main/kotlin/it/hamy/muza/preferences/UIStatePreferences.kt new file mode 100644 index 0000000..8cc6389 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/preferences/UIStatePreferences.kt @@ -0,0 +1,11 @@ +package it.hamy.muza.preferences + +import it.hamy.muza.GlobalPreferencesHolder + +object UIStatePreferences : GlobalPreferencesHolder() { + var homeScreenTabIndex by int(0) + var searchResultScreenTabIndex by int(0) + + var artistScreenTabIndexProperty = int(0) + var artistScreenTabIndex by artistScreenTabIndexProperty +} diff --git a/app/src/main/kotlin/it/hamy/muza/service/BitmapProvider.kt b/app/src/main/kotlin/it/hamy/muza/service/BitmapProvider.kt index 8aacac2..e31abdb 100644 --- a/app/src/main/kotlin/it/hamy/muza/service/BitmapProvider.kt +++ b/app/src/main/kotlin/it/hamy/muza/service/BitmapProvider.kt @@ -13,8 +13,8 @@ import it.hamy.muza.utils.thumbnail context(Context) class BitmapProvider( - private val bitmapSize: Int, - private val colorProvider: (isSystemInDarkMode: Boolean) -> Int + private val getBitmapSize: () -> Int, + private val getColor: (isDark: Boolean) -> Int ) { var lastUri: Uri? = null private set @@ -47,10 +47,14 @@ class BitmapProvider( lastIsSystemInDarkMode = isSystemInDarkMode - defaultBitmap = - Bitmap.createBitmap(bitmapSize, bitmapSize, Bitmap.Config.ARGB_8888).applyCanvas { - drawColor(colorProvider(isSystemInDarkMode)) - } + val size = getBitmapSize() + defaultBitmap = Bitmap.createBitmap( + /* width = */ size, + /* height = */ size, + /* config = */ Bitmap.Config.ARGB_8888 + ).applyCanvas { + drawColor(getColor(isSystemInDarkMode)) + } return lastBitmap == null } @@ -63,7 +67,7 @@ class BitmapProvider( lastEnqueued = applicationContext.imageLoader.enqueue( ImageRequest.Builder(applicationContext) - .data(uri.thumbnail(bitmapSize)) + .data(uri.thumbnail(getBitmapSize())) .allowHardware(false) .listener( onError = { _, _ -> diff --git a/app/src/main/kotlin/it/hamy/muza/service/PlaybackExceptions.kt b/app/src/main/kotlin/it/hamy/muza/service/PlaybackExceptions.kt index 067a5af..1a26ec6 100644 --- a/app/src/main/kotlin/it/hamy/muza/service/PlaybackExceptions.kt +++ b/app/src/main/kotlin/it/hamy/muza/service/PlaybackExceptions.kt @@ -1,11 +1,12 @@ +@file:OptIn(UnstableApi::class) + package it.hamy.muza.service +import androidx.annotation.OptIn import androidx.media3.common.PlaybackException +import androidx.media3.common.util.UnstableApi class PlayableFormatNotFoundException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) - class UnplayableException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) - class LoginRequiredException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) - class VideoIdMismatchException : PlaybackException(null, null, ERROR_CODE_REMOTE_ERROR) diff --git a/app/src/main/kotlin/it/hamy/muza/service/PlayerMediaBrowserService.kt b/app/src/main/kotlin/it/hamy/muza/service/PlayerMediaBrowserService.kt index ef3be29..2a84fe9 100644 --- a/app/src/main/kotlin/it/hamy/muza/service/PlayerMediaBrowserService.kt +++ b/app/src/main/kotlin/it/hamy/muza/service/PlayerMediaBrowserService.kt @@ -1,7 +1,5 @@ package it.hamy.muza.service -import android.media.MediaDescription as BrowserMediaDescription -import android.media.browse.MediaBrowser.MediaItem as BrowserMediaItem import android.content.ComponentName import android.content.ContentResolver import android.content.Context @@ -13,16 +11,18 @@ import android.os.IBinder import android.os.Process import android.service.media.MediaBrowserService import androidx.annotation.DrawableRes +import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.core.os.bundleOf -import androidx.media3.common.Player -import androidx.media3.datasource.cache.Cache +import androidx.media3.common.util.UnstableApi import it.hamy.muza.Database import it.hamy.muza.R import it.hamy.muza.models.Album import it.hamy.muza.models.PlaylistPreview import it.hamy.muza.models.Song import it.hamy.muza.models.SongWithContentLength +import it.hamy.muza.preferences.DataPreferences +import it.hamy.muza.preferences.OrderPreferences import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.forcePlayAtIndex import it.hamy.muza.utils.forceSeekToNext @@ -30,10 +30,15 @@ import it.hamy.muza.utils.forceSeekToPrevious import it.hamy.muza.utils.intent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import android.media.MediaDescription as BrowserMediaDescription +import android.media.browse.MediaBrowser.MediaItem as BrowserMediaItem class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val coroutineScope = CoroutineScope(Dispatchers.IO) @@ -42,18 +47,15 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private var bound = false override fun onDestroy() { - if (bound) { - unbindService(this) - } + if (bound) unbindService(this) super.onDestroy() } override fun onServiceConnected(className: ComponentName, service: IBinder) { - if (service is PlayerService.Binder) { - bound = true - sessionToken = service.mediaSession.sessionToken - service.mediaSession.setCallback(SessionCallback(service.player, service.cache)) - } + if (service !is PlayerService.Binder) return + bound = true + sessionToken = service.mediaSession.sessionToken + service.mediaSession.setCallback(SessionCallback(service)) } override fun onServiceDisconnected(name: ComponentName) = Unit @@ -62,35 +64,32 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { clientPackageName: String, clientUid: Int, rootHints: Bundle? - ): BrowserRoot? { - return if (clientUid == Process.myUid() - || clientUid == Process.SYSTEM_UID - || clientPackageName == "com.google.android.projection.gearhead" - ) { - bindService(intent(), this, Context.BIND_AUTO_CREATE) - BrowserRoot( - MediaId.root, - bundleOf("android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" to 1) - ) - } else { - null - } - } + ) = if (clientUid == Process.myUid() || clientUid == Process.SYSTEM_UID || + clientPackageName == "com.google.android.projection.gearhead" + ) { + bindService(intent(), this, Context.BIND_AUTO_CREATE) + BrowserRoot( + MediaId.ROOT.id, + bundleOf("android.media.browse.CONTENT_STYLE_BROWSABLE_HINT" to 1) + ) + } else null - override fun onLoadChildren(parentId: String, result: Result>) { - runBlocking(Dispatchers.IO) { - result.sendResult( - when (parentId) { - MediaId.root -> mutableListOf( - songsBrowserMediaItem, - playlistsBrowserMediaItem, - albumsBrowserMediaItem - ) + override fun onLoadChildren( + parentId: String, + result: Result> + ) = runBlocking(Dispatchers.IO) { + result.sendResult( + when (MediaId(parentId)) { + MediaId.ROOT -> mutableListOf( + songsBrowserMediaItem, + playlistsBrowserMediaItem, + albumsBrowserMediaItem + ) - MediaId.songs -> Database - .songsByPlayTimeDesc() + MediaId.SONGS -> + Database + .songsByPlayTimeDesc(limit = 30) .first() - .take(30) .also { lastSongs = it } .map { it.asBrowserMediaItem } .toMutableList() @@ -98,7 +97,8 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { if (isNotEmpty()) add(0, shuffleBrowserMediaItem) } - MediaId.playlists -> Database + MediaId.PLAYLISTS -> + Database .playlistPreviewsByDateAddedDesc() .first() .map { it.asBrowserMediaItem } @@ -106,18 +106,20 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { .apply { add(0, favoritesBrowserMediaItem) add(1, offlineBrowserMediaItem) + add(2, topBrowserMediaItem) + add(3, localBrowserMediaItem) } - MediaId.albums -> Database + MediaId.ALBUMS -> + Database .albumsByRowIdDesc() .first() .map { it.asBrowserMediaItem } .toMutableList() - else -> mutableListOf() - } - ) - } + else -> mutableListOf() + } + ) } private fun uriFor(@DrawableRes id: Int) = Uri.Builder() @@ -130,8 +132,8 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val shuffleBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.shuffle) - .setTitle("Shuffle") + .setMediaId(MediaId.SHUFFLE.id) + .setTitle(getString(R.string.shuffle)) .setIconUri(uriFor(R.drawable.shuffle)) .build(), BrowserMediaItem.FLAG_PLAYABLE @@ -140,19 +142,18 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val songsBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.songs) - .setTitle("Songs") + .setMediaId(MediaId.SONGS.id) + .setTitle(getString(R.string.songs)) .setIconUri(uriFor(R.drawable.musical_notes)) .build(), BrowserMediaItem.FLAG_BROWSABLE ) - private val playlistsBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.playlists) - .setTitle("Playlists") + .setMediaId(MediaId.PLAYLISTS.id) + .setTitle(getString(R.string.playlists)) .setIconUri(uriFor(R.drawable.playlist)) .build(), BrowserMediaItem.FLAG_BROWSABLE @@ -161,8 +162,8 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val albumsBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.albums) - .setTitle("Albums") + .setMediaId(MediaId.ALBUMS.id) + .setTitle(getString(R.string.albums)) .setIconUri(uriFor(R.drawable.disc)) .build(), BrowserMediaItem.FLAG_BROWSABLE @@ -171,8 +172,8 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val favoritesBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.favorites) - .setTitle("Favorites") + .setMediaId(MediaId.FAVORITES.id) + .setTitle(getString(R.string.favorites)) .setIconUri(uriFor(R.drawable.heart)) .build(), BrowserMediaItem.FLAG_PLAYABLE @@ -181,17 +182,42 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val offlineBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.offline) - .setTitle("Offline") + .setMediaId(MediaId.OFFLINE.id) + .setTitle(getString(R.string.offline)) .setIconUri(uriFor(R.drawable.airplane)) .build(), BrowserMediaItem.FLAG_PLAYABLE ) + private val topBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.TOP.id) + .setTitle( + getString( + R.string.format_my_top_playlist, + DataPreferences.topListLength.toString() + ) + ) + .setIconUri(uriFor(R.drawable.trending)) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + + private val localBrowserMediaItem + inline get() = BrowserMediaItem( + BrowserMediaDescription.Builder() + .setMediaId(MediaId.LOCAL.id) + .setTitle(getString(R.string.local)) + .setIconUri(uriFor(R.drawable.download)) + .build(), + BrowserMediaItem.FLAG_PLAYABLE + ) + private val Song.asBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.forSong(id)) + .setMediaId((MediaId.SONGS / id).id) .setTitle(title) .setSubtitle(artistsText) .setIconUri(thumbnailUrl?.toUri()) @@ -202,9 +228,15 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val PlaylistPreview.asBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.forPlaylist(playlist.id)) + .setMediaId((MediaId.PLAYLISTS / playlist.id.toString()).id) .setTitle(playlist.name) - .setSubtitle("$songCount songs") + .setSubtitle( + resources.getQuantityString( + R.plurals.song_count_plural, + songCount, + songCount + ) + ) .setIconUri(uriFor(R.drawable.playlist)) .build(), BrowserMediaItem.FLAG_PLAYABLE @@ -213,7 +245,7 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { private val Album.asBrowserMediaItem inline get() = BrowserMediaItem( BrowserMediaDescription.Builder() - .setMediaId(MediaId.forAlbum(id)) + .setMediaId((MediaId.ALBUMS / id).id) .setTitle(title) .setSubtitle(authorsText) .setIconUri(thumbnailUrl?.toUri()) @@ -221,81 +253,116 @@ class PlayerMediaBrowserService : MediaBrowserService(), ServiceConnection { BrowserMediaItem.FLAG_PLAYABLE ) - private inner class SessionCallback(private val player: Player, private val cache: Cache) : - MediaSession.Callback() { - override fun onPlay() = player.play() - override fun onPause() = player.pause() - override fun onSkipToPrevious() = player.forceSeekToPrevious() - override fun onSkipToNext() = player.forceSeekToNext() - override fun onSeekTo(pos: Long) = player.seekTo(pos) - override fun onSkipToQueueItem(id: Long) = player.seekToDefaultPosition(id.toInt()) + private inner class SessionCallback( + private val binder: PlayerService.Binder + ) : MediaSession.Callback() { + override fun onPlay() = binder.player.play() + override fun onPause() = binder.player.pause() + override fun onSkipToPrevious() = binder.player.forceSeekToPrevious() + override fun onSkipToNext() = binder.player.forceSeekToNext() + override fun onSeekTo(pos: Long) = binder.player.seekTo(pos) + override fun onSkipToQueueItem(id: Long) = binder.player.seekToDefaultPosition(id.toInt()) + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + if (query.isNullOrBlank()) return + binder.playFromSearch(query) + } + @OptIn(UnstableApi::class) override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) { val data = mediaId?.split('/') ?: return var index = 0 coroutineScope.launch { - val mediaItems = when (data.getOrNull(0)) { - MediaId.shuffle -> lastSongs + val mediaItems = when (data.getOrNull(0)?.let { MediaId(it) }) { + MediaId.SHUFFLE -> lastSongs - MediaId.songs -> data - .getOrNull(1) - ?.let { songId -> - index = lastSongs.indexOfFirst { it.id == songId } - lastSongs - } + MediaId.SONGS -> data.getOrNull(1)?.let { songId -> + index = lastSongs.indexOfFirst { it.id == songId } + lastSongs + } - MediaId.favorites -> Database - .favorites() - .first() - .shuffled() + MediaId.FAVORITES -> + Database + .favorites() + .first() + .shuffled() - MediaId.offline -> Database - .songsWithContentLength() - .first() - .filter { song -> - song.contentLength?.let { - cache.isCached(song.song.id, 0, it) - } ?: false - } - .map(SongWithContentLength::song) - .shuffled() + MediaId.OFFLINE -> + Database + .songsWithContentLength() + .first() + .filter { binder.isCached(it) } + .map(SongWithContentLength::song) + .shuffled() - MediaId.playlists -> data - .getOrNull(1) - ?.toLongOrNull() - ?.let(Database::playlistWithSongs) - ?.first() - ?.songs - ?.shuffled() + MediaId.TOP -> { + val duration = DataPreferences.topListPeriod.duration + val length = DataPreferences.topListLength - MediaId.albums -> data - .getOrNull(1) - ?.let(Database::albumSongs) - ?.first() + val flow = if (duration != null) Database.trending( + limit = length, + period = duration.inWholeMilliseconds + ) else Database + .songsByPlayTimeDesc(limit = length) + .distinctUntilChanged() + .cancellable() + + flow.first() + } + + MediaId.LOCAL -> + Database + .songs( + sortBy = OrderPreferences.localSongSortBy, + sortOrder = OrderPreferences.localSongSortOrder, + isLocal = true + ) + .map { songs -> songs.filter { it.durationText != "0:00" } } + .first() + + MediaId.PLAYLISTS -> + data + .getOrNull(1) + ?.toLongOrNull() + ?.let(Database::playlistWithSongs) + ?.first() + ?.songs + ?.shuffled() + + MediaId.ALBUMS -> + data + .getOrNull(1) + ?.let(Database::albumSongs) + ?.first() else -> emptyList() }?.map(Song::asMediaItem) ?: return@launch withContext(Dispatchers.Main) { - player.forcePlayAtIndex(mediaItems, index.coerceIn(0, mediaItems.size)) + binder.player.forcePlayAtIndex( + items = mediaItems, + index = index.coerceIn(0, mediaItems.size) + ) } } } } - private object MediaId { - const val root = "root" - const val songs = "songs" - const val playlists = "playlists" - const val albums = "albums" + @JvmInline + private value class MediaId(val id: String) : CharSequence by id { + companion object { + val ROOT = MediaId("root") + val SONGS = MediaId("songs") + val PLAYLISTS = MediaId("playlists") + val ALBUMS = MediaId("albums") - const val favorites = "favorites" - const val offline = "offline" - const val shuffle = "shuffle" + val FAVORITES = MediaId("favorites") + val OFFLINE = MediaId("offline") + val TOP = MediaId("top") + val LOCAL = MediaId("local") + val SHUFFLE = MediaId("shuffle") + } - fun forSong(id: String) = "songs/$id" - fun forPlaylist(id: Long) = "playlists/$id" - fun forAlbum(id: String) = "albums/$id" + operator fun div(other: String) = MediaId("$id/$other") } } diff --git a/app/src/main/kotlin/it/hamy/muza/service/PlayerService.kt b/app/src/main/kotlin/it/hamy/muza/service/PlayerService.kt index 1dc6e7c..272ba07 100644 --- a/app/src/main/kotlin/it/hamy/muza/service/PlayerService.kt +++ b/app/src/main/kotlin/it/hamy/muza/service/PlayerService.kt @@ -1,6 +1,5 @@ package it.hamy.muza.service -import android.os.Binder as AndroidBinder import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel @@ -10,9 +9,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.SharedPreferences import android.content.res.Configuration -import android.database.SQLException import android.graphics.Bitmap import android.graphics.Color import android.media.AudioDeviceCallback @@ -21,16 +18,21 @@ import android.media.AudioManager import android.media.MediaDescription import android.media.MediaMetadata import android.media.audiofx.AudioEffect +import android.media.audiofx.BassBoost import android.media.audiofx.LoudnessEnhancer import android.media.session.MediaSession import android.media.session.PlaybackState import android.net.Uri +import android.os.Bundle import android.os.Handler import android.text.format.DateUtils +import android.widget.Toast +import androidx.annotation.OptIn import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.startForegroundService import androidx.core.content.getSystemService import androidx.core.net.toUri @@ -41,16 +43,12 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.Timeline +import androidx.media3.common.audio.SonicAudioProcessor +import androidx.media3.common.util.UnstableApi import androidx.media3.database.StandaloneDatabaseProvider -import androidx.media3.datasource.DataSource - -import androidx.media3.datasource.okhttp.OkHttpDataSource -import it.hamy.innertube.utils.ProxyPreferences -import okhttp3.OkHttpClient -import java.net.InetSocketAddress -import java.net.Proxy -import java.time.Duration - +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.ResolvingDataSource import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheDataSource @@ -63,168 +61,186 @@ import androidx.media3.exoplayer.analytics.AnalyticsListener import androidx.media3.exoplayer.analytics.PlaybackStats import androidx.media3.exoplayer.analytics.PlaybackStatsListener import androidx.media3.exoplayer.audio.AudioRendererEventListener +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioOffloadSupportProvider import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.audio.DefaultAudioSink.DefaultAudioProcessorChain import androidx.media3.exoplayer.audio.MediaCodecAudioRenderer import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor -import androidx.media3.exoplayer.audio.SonicAudioProcessor import androidx.media3.exoplayer.mediacodec.MediaCodecSelector import androidx.media3.exoplayer.source.DefaultMediaSourceFactory -import androidx.media3.exoplayer.source.MediaSource -import androidx.media3.extractor.ExtractorsFactory -import androidx.media3.extractor.mkv.MatroskaExtractor -import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.extractor.DefaultExtractorsFactory import it.hamy.innertube.Innertube import it.hamy.innertube.models.NavigationEndpoint import it.hamy.innertube.models.bodies.PlayerBody +import it.hamy.innertube.models.bodies.SearchBody import it.hamy.innertube.requests.player +import it.hamy.innertube.requests.searchPage +import it.hamy.innertube.utils.from import it.hamy.muza.Database import it.hamy.muza.MainActivity import it.hamy.muza.R -import it.hamy.muza.enums.ExoPlayerDiskCacheMaxSize +import it.hamy.muza.enums.ExoPlayerDiskCacheSize import it.hamy.muza.models.Event import it.hamy.muza.models.QueuedMediaItem +import it.hamy.muza.models.Song +import it.hamy.muza.models.SongWithContentLength +import it.hamy.muza.preferences.AppearancePreferences +import it.hamy.muza.preferences.DataPreferences +import it.hamy.muza.preferences.PlayerPreferences import it.hamy.muza.query +import it.hamy.muza.transaction +import it.hamy.muza.utils.ConditionalCacheDataSourceFactory import it.hamy.muza.utils.InvincibleService import it.hamy.muza.utils.RingBuffer import it.hamy.muza.utils.TimerJob import it.hamy.muza.utils.YouTubeRadio import it.hamy.muza.utils.activityPendingIntent -import it.hamy.muza.utils.broadCastPendingIntent -import it.hamy.muza.utils.exoPlayerDiskCacheMaxSizeKey +import it.hamy.muza.utils.broadcastPendingIntent import it.hamy.muza.utils.findNextMediaItemById import it.hamy.muza.utils.forcePlayFromBeginning import it.hamy.muza.utils.forceSeekToNext import it.hamy.muza.utils.forceSeekToPrevious -import it.hamy.muza.utils.getEnum import it.hamy.muza.utils.intent +import it.hamy.muza.utils.isAtLeastAndroid10 +import it.hamy.muza.utils.isAtLeastAndroid12 import it.hamy.muza.utils.isAtLeastAndroid13 import it.hamy.muza.utils.isAtLeastAndroid6 import it.hamy.muza.utils.isAtLeastAndroid8 -import it.hamy.muza.utils.isInvincibilityEnabledKey -import it.hamy.muza.utils.isShowingThumbnailInLockscreenKey import it.hamy.muza.utils.mediaItems -import it.hamy.muza.utils.persistentQueueKey -import it.hamy.muza.utils.preferences -import it.hamy.muza.utils.queueLoopEnabledKey -import it.hamy.muza.utils.resumePlaybackWhenDeviceConnectedKey import it.hamy.muza.utils.shouldBePlaying -import it.hamy.muza.utils.skipSilenceKey +import it.hamy.muza.utils.thumbnail import it.hamy.muza.utils.timer -import it.hamy.muza.utils.trackLoopEnabledKey -import it.hamy.muza.utils.volumeNormalizationKey -import kotlin.math.roundToInt -import kotlin.system.exitProcess import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt +import kotlin.system.exitProcess +import android.os.Binder as AndroidBinder +import java.net.Proxy +import it.hamy.innertube.utils.ProxyPreferences +import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.time.Duration -@Suppress("DEPRECATION") -class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListener.Callback, - SharedPreferences.OnSharedPreferenceChangeListener { +const val LOCAL_KEY_PREFIX = "local:" + +@get:OptIn(UnstableApi::class) +val DataSpec.isLocal get() = key?.startsWith(LOCAL_KEY_PREFIX) == true + +val MediaItem.isLocal get() = mediaId.startsWith(LOCAL_KEY_PREFIX) +val Song.isLocal get() = id.startsWith(LOCAL_KEY_PREFIX) + +@kotlin.OptIn(ExperimentalCoroutinesApi::class) +@Suppress("LargeClass", "TooManyFunctions") // intended in this class: it is a service +@OptIn(UnstableApi::class) +class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListener.Callback { private lateinit var mediaSession: MediaSession private lateinit var cache: SimpleCache private lateinit var player: ExoPlayer - private val stateBuilder = PlaybackState.Builder() - .setActions( - PlaybackState.ACTION_PLAY - or PlaybackState.ACTION_PAUSE - or PlaybackState.ACTION_PLAY_PAUSE - or PlaybackState.ACTION_STOP - or PlaybackState.ACTION_SKIP_TO_PREVIOUS - or PlaybackState.ACTION_SKIP_TO_NEXT - or PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM - or PlaybackState.ACTION_SEEK_TO - or PlaybackState.ACTION_REWIND + private val stateBuilder + get() = PlaybackState.Builder().setActions( + (PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_STOP or + PlaybackState.ACTION_SKIP_TO_PREVIOUS or + PlaybackState.ACTION_SKIP_TO_NEXT or + PlaybackState.ACTION_SKIP_TO_QUEUE_ITEM or + PlaybackState.ACTION_SEEK_TO or + PlaybackState.ACTION_REWIND or + PlaybackState.ACTION_PLAY_FROM_SEARCH).let { + if (isAtLeastAndroid12) it or PlaybackState.ACTION_SET_PLAYBACK_SPEED else it + } + ).addCustomAction( + /* action = */ "LIKE", + /* name = */ "Like", + /* icon = */ if (isLikedState.value) R.drawable.heart else R.drawable.heart_outline ) + private val playbackStateMutex = Mutex() + private val metadataBuilder = MediaMetadata.Builder() private var notificationManager: NotificationManager? = null - private var timerJob: TimerJob? = null - private var radio: YouTubeRadio? = null private lateinit var bitmapProvider: BitmapProvider - private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job() + private val coroutineScope = CoroutineScope(Dispatchers.IO + Job()) + private var preferenceUpdaterJob: Job? = null private var volumeNormalizationJob: Job? = null - private var isPersistentQueueEnabled = false - private var isShowingThumbnailInLockscreen = true override var isInvincibilityEnabled = false private var audioManager: AudioManager? = null private var audioDeviceCallback: AudioDeviceCallback? = null private var loudnessEnhancer: LoudnessEnhancer? = null + private var bassBoost: BassBoost? = null private val binder = Binder() private var isNotificationStarted = false - override val notificationId: Int - get() = NotificationId + override val notificationId get() = NOTIFICATION_ID private lateinit var notificationActionReceiver: NotificationActionReceiver + private val mediaItemState = MutableStateFlow(null) + private val isLikedState = mediaItemState + .flatMapMerge { item -> + item?.mediaId?.let { Database.likedAt(it).distinctUntilChanged() } ?: flowOf(null) + } + .map { it != null } + .stateIn(coroutineScope, SharingStarted.Eagerly, false) + override fun onBind(intent: Intent?): AndroidBinder { super.onBind(intent) return binder } + @Suppress("CyclomaticComplexMethod") override fun onCreate() { super.onCreate() bitmapProvider = BitmapProvider( - bitmapSize = (256 * resources.displayMetrics.density).roundToInt(), - colorProvider = { isSystemInDarkMode -> + getBitmapSize = { + (512 * resources.displayMetrics.density) + .roundToInt() + .coerceAtMost(AppearancePreferences.maxThumbnailSize) + }, + getColor = { isSystemInDarkMode -> if (isSystemInDarkMode) Color.BLACK else Color.WHITE } ) createNotificationChannel() - preferences.registerOnSharedPreferenceChangeListener(this) - - val preferences = preferences - isPersistentQueueEnabled = preferences.getBoolean(persistentQueueKey, false) - isInvincibilityEnabled = preferences.getBoolean(isInvincibilityEnabledKey, false) - isShowingThumbnailInLockscreen = - preferences.getBoolean(isShowingThumbnailInLockscreenKey, false) - - val cacheEvictor = when (val size = - preferences.getEnum(exoPlayerDiskCacheMaxSizeKey, ExoPlayerDiskCacheMaxSize.`2GB`)) { - ExoPlayerDiskCacheMaxSize.Unlimited -> NoOpCacheEvictor() - else -> LeastRecentlyUsedCacheEvictor(size.bytes) - } - - // TODO: Remove in a future release - val directory = cacheDir.resolve("exoplayer").also { directory -> - if (directory.exists()) return@also - - directory.mkdir() - - cacheDir.listFiles()?.forEach { file -> - if (file.isDirectory && file.name.length == 1 && file.name.isDigitsOnly() || file.extension == "uid") { - if (!file.renameTo(directory.resolve(file.name))) { - file.deleteRecursively() - } - } - } - - filesDir.resolve("coil").deleteRecursively() - } - cache = SimpleCache(directory, cacheEvictor, StandaloneDatabaseProvider(this)) + cache = createCache(this) player = ExoPlayer.Builder(this, createRendersFactory(), createMediaSourceFactory()) .setHandleAudioBecomingNoisy(true) @@ -239,72 +255,163 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene .setUsePlatformDiagnostics(false) .build() - player.repeatMode = when { - preferences.getBoolean(trackLoopEnabledKey, false) -> Player.REPEAT_MODE_ONE - preferences.getBoolean(queueLoopEnabledKey, true) -> Player.REPEAT_MODE_ALL - else -> Player.REPEAT_MODE_OFF - } + updateRepeatMode() - player.skipSilenceEnabled = preferences.getBoolean(skipSilenceKey, false) + player.skipSilenceEnabled = PlayerPreferences.skipSilence player.addListener(this) player.addAnalyticsListener(PlaybackStatsListener(false, this)) maybeRestorePlayerQueue() mediaSession = MediaSession(baseContext, "PlayerService") - mediaSession.setFlags(MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS) mediaSession.setCallback(SessionCallback(player)) mediaSession.setPlaybackState(stateBuilder.build()) + mediaSession.setSessionActivity(activityPendingIntent()) mediaSession.isActive = true - notificationActionReceiver = NotificationActionReceiver(player) + coroutineScope.launch { + var first = true + combine(mediaItemState, isLikedState) { mediaItem, _ -> + // work around NPE in other processes + if (first) { + first = false + return@combine + } + if (mediaItem == null) return@combine + withContext(Dispatchers.Main) { + updatePlaybackState() + // work around NPE in other processes + handler.post { + runCatching { + applicationContext.getSystemService() + ?.notify(NOTIFICATION_ID, notification()) + } + } + } + }.collect() + } + + notificationActionReceiver = NotificationActionReceiver() val filter = IntentFilter().apply { addAction(Action.play.value) addAction(Action.pause.value) addAction(Action.next.value) addAction(Action.previous.value) + addAction(Action.like.value) } - registerReceiver(notificationActionReceiver, filter) + ContextCompat.registerReceiver( + /* context = */ this, + /* receiver = */ notificationActionReceiver, + /* filter = */ filter, + /* flags = */ ContextCompat.RECEIVER_NOT_EXPORTED + ) maybeResumePlaybackWhenDeviceConnected() + + fun CoroutineScope.subscribe( + state: StateFlow, + runner: (() -> Unit) -> Unit = { handler.post(it) }, + callback: suspend (T) -> () -> Unit + ) = launch { + state.collectLatest { + runner(callback(it)) + } + } + + preferenceUpdaterJob = coroutineScope.launch { + subscribe(PlayerPreferences.resumePlaybackWhenDeviceConnectedProperty.stateFlow) { + ::maybeResumePlaybackWhenDeviceConnected + } + subscribe(AppearancePreferences.isShowingThumbnailInLockscreenProperty.stateFlow) { + ::maybeShowSongCoverInLockScreen + } + subscribe(PlayerPreferences.trackLoopEnabledProperty.stateFlow) { + ::updateRepeatMode + } + subscribe(PlayerPreferences.queueLoopEnabledProperty.stateFlow) { + ::updateRepeatMode + } + subscribe(PlayerPreferences.volumeNormalizationProperty.stateFlow) { + ::maybeNormalizeVolume + } + subscribe(PlayerPreferences.volumeNormalizationBaseGainProperty.stateFlow) { + ::maybeNormalizeVolume + } + subscribe(PlayerPreferences.bassBoostProperty.stateFlow) { + ::maybeBassBoost + } + subscribe(PlayerPreferences.bassBoostLevelProperty.stateFlow) { + ::maybeBassBoost + } + subscribe(PlayerPreferences.speedProperty.stateFlow) { + { + player.setPlaybackSpeed(it.coerceAtLeast(0.01f)) + } + } + subscribe(PlayerPreferences.isInvincibilityEnabledProperty.stateFlow) { + { + this@PlayerService.isInvincibilityEnabled = it + } + } + subscribe(PlayerPreferences.skipSilenceProperty.stateFlow) { + { + player.skipSilenceEnabled = it + } + } + } + } + + private fun updateRepeatMode() { + player.repeatMode = when { + PlayerPreferences.trackLoopEnabled -> Player.REPEAT_MODE_ONE + PlayerPreferences.queueLoopEnabled -> Player.REPEAT_MODE_ALL + else -> Player.REPEAT_MODE_OFF + } } override fun onTaskRemoved(rootIntent: Intent?) { - if (!player.shouldBePlaying) { - broadCastPendingIntent().send() - } + if (!player.shouldBePlaying || PlayerPreferences.stopWhenClosed) + broadcastPendingIntent().send() super.onTaskRemoved(rootIntent) } - override fun onDestroy() { + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) = maybeSavePlayerQueue() - preferences.unregisterOnSharedPreferenceChangeListener(this) + override fun onDestroy() { + runCatching { + maybeSavePlayerQueue() - player.removeListener(this) - player.stop() - player.release() + player.removeListener(this) + player.stop() + player.release() - unregisterReceiver(notificationActionReceiver) + unregisterReceiver(notificationActionReceiver) - mediaSession.isActive = false - mediaSession.release() - cache.release() + mediaSession.isActive = false + mediaSession.release() + cache.release() - loudnessEnhancer?.release() + loudnessEnhancer?.release() + + preferenceUpdaterJob?.cancel() + + coroutineScope.cancel() + } super.onDestroy() } - override fun shouldBeInvincible(): Boolean { - return !player.shouldBePlaying - } + override fun shouldBeInvincible() = !player.shouldBePlaying override fun onConfigurationChanged(newConfig: Configuration) { - if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) { - notificationManager?.notify(NotificationId, notification()) + handler.post { + runCatching { + if (bitmapProvider.setDefaultBitmap() && player.currentMediaItem != null) + notificationManager?.notify(NOTIFICATION_ID, notification()) + } } super.onConfigurationChanged(newConfig) } @@ -318,48 +425,44 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene val totalPlayTimeMs = playbackStats.totalPlayTimeMs - if (totalPlayTimeMs > 5000) { - query { - Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs) - } + if (totalPlayTimeMs > 5000 && !DataPreferences.pausePlaytime) query { + Database.incrementTotalPlayTimeMs(mediaItem.mediaId, totalPlayTimeMs) } - if (totalPlayTimeMs > 30000) { - query { - try { - Database.insert( - Event( - songId = mediaItem.mediaId, - timestamp = System.currentTimeMillis(), - playTime = totalPlayTimeMs - ) + if (totalPlayTimeMs > 30000 && !DataPreferences.pauseHistory) query { + runCatching { + Database.insert( + Event( + songId = mediaItem.mediaId, + timestamp = System.currentTimeMillis(), + playTime = totalPlayTimeMs ) - } catch (_: SQLException) { - } + ) } } } override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + mediaItemState.update { mediaItem } + maybeRecoverPlaybackError() maybeNormalizeVolume() maybeProcessRadio() - if (mediaItem == null) { - bitmapProvider.listener?.invoke(null) - } else if (mediaItem.mediaMetadata.artworkUri == bitmapProvider.lastUri) { - bitmapProvider.listener?.invoke(bitmapProvider.lastBitmap) + when { + mediaItem == null -> bitmapProvider.listener?.invoke(null) + mediaItem.mediaMetadata.artworkUri == bitmapProvider.lastUri -> + bitmapProvider.listener?.invoke(bitmapProvider.lastBitmap) } - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO || reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK) updateMediaSessionQueue(player.currentTimeline) - } } override fun onTimelineChanged(timeline: Timeline, reason: Int) { - if (reason == Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) { - updateMediaSessionQueue(timeline) - } + if (reason != Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) return + updateMediaSessionQueue(timeline) + maybeSavePlayerQueue() } private fun updateMediaSessionQueue(timeline: Timeline) { @@ -370,9 +473,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene var startIndex = currentMediaItemIndex - 7 var endIndex = currentMediaItemIndex + 7 - if (startIndex < 0) { - endIndex -= startIndex - } + if (startIndex < 0) endIndex -= startIndex if (endIndex > lastIndex) { startIndex -= (endIndex - lastIndex) @@ -398,43 +499,39 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene } private fun maybeRecoverPlaybackError() { - if (player.playerError != null) { - player.prepare() - } + if (player.playerError != null) player.prepare() } - private fun maybeProcessRadio() { - radio?.let { radio -> - if (player.mediaItemCount - player.currentMediaItemIndex <= 3) { - coroutineScope.launch(Dispatchers.Main) { - player.addMediaItems(radio.process()) - } + private fun maybeProcessRadio() = radio?.let { radio -> + if (player.mediaItemCount - player.currentMediaItemIndex <= 3) + coroutineScope.launch(Dispatchers.Main) { + player.addMediaItems(radio.process()) } - } + Unit } private fun maybeSavePlayerQueue() { - if (!isPersistentQueueEnabled) return + if (!PlayerPreferences.persistentQueue) return val mediaItems = player.currentTimeline.mediaItems val mediaItemIndex = player.currentMediaItemIndex val mediaItemPosition = player.currentPosition - mediaItems.mapIndexed { index, mediaItem -> - QueuedMediaItem( - mediaItem = mediaItem, - position = if (index == mediaItemIndex) mediaItemPosition else null + query { + Database.clearQueue() + Database.insert( + mediaItems.mapIndexed { index, mediaItem -> + QueuedMediaItem( + mediaItem = mediaItem, + position = if (index == mediaItemIndex) mediaItemPosition else null + ) + } ) - }.let { queuedMediaItems -> - query { - Database.clearQueue() - Database.insert(queuedMediaItems) - } } } private fun maybeRestorePlayerQueue() { - if (!isPersistentQueueEnabled) return + if (!PlayerPreferences.persistentQueue) return query { val queuedSong = Database.queue() @@ -461,109 +558,147 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene isNotificationStarted = true startForegroundService(this@PlayerService, intent()) - startForeground(NotificationId, notification()) + startForeground(NOTIFICATION_ID, notification()) } } } + @Suppress("ReturnCount") private fun maybeNormalizeVolume() { - if (!preferences.getBoolean(volumeNormalizationKey, false)) { + if (!PlayerPreferences.volumeNormalization) { loudnessEnhancer?.enabled = false loudnessEnhancer?.release() loudnessEnhancer = null volumeNormalizationJob?.cancel() + volumeNormalizationJob?.invokeOnCompletion { volumeNormalizationJob = null } player.volume = 1f return } - if (loudnessEnhancer == null) { - loudnessEnhancer = LoudnessEnhancer(player.audioSessionId) - } + runCatching { + if (loudnessEnhancer == null) loudnessEnhancer = LoudnessEnhancer(player.audioSessionId) + }.onFailure { return } - player.currentMediaItem?.mediaId?.let { songId -> - volumeNormalizationJob?.cancel() - volumeNormalizationJob = coroutineScope.launch(Dispatchers.Main) { - Database.loudnessDb(songId).cancellable().collectLatest { loudnessDb -> - try { - loudnessEnhancer?.setTargetGain(-((loudnessDb ?: 0f) * 100).toInt() + 500) - loudnessEnhancer?.enabled = true - } catch (_: Exception) { } + val songId = player.currentMediaItem?.mediaId ?: return + volumeNormalizationJob?.cancel() + volumeNormalizationJob = coroutineScope.launch { + runCatching { + Database.loudnessDb(songId).cancellable().collectLatest { loudness -> + Database.loudnessBoost(songId).cancellable().collectLatest { boost -> + withContext(Dispatchers.Main) { + loudnessEnhancer?.setTargetGain( + PlayerPreferences.volumeNormalizationBaseGainRounded + + ((boost ?: 0f) * 100).toInt() - + ((loudness ?: 0f) * 100).toInt() + ) + loudnessEnhancer?.enabled = true + } + } } } } } - private fun maybeShowSongCoverInLockScreen() { - val bitmap = - if (isAtLeastAndroid13 || isShowingThumbnailInLockscreen) bitmapProvider.bitmap else null + private fun maybeBassBoost() { + if (!PlayerPreferences.bassBoost) { + runCatching { + bassBoost?.enabled = false + bassBoost?.release() + } + bassBoost = null + maybeNormalizeVolume() + return + } + + runCatching { + if (bassBoost == null) bassBoost = BassBoost(0, player.audioSessionId) + bassBoost?.setStrength(PlayerPreferences.bassBoostLevel.toShort()) + bassBoost?.enabled = true + }.onFailure { + Toast.makeText(this, R.string.error_bassboost_init, Toast.LENGTH_SHORT).show() + } + } + + private fun maybeShowSongCoverInLockScreen() = handler.post { + val bitmap = if (isAtLeastAndroid13 || AppearancePreferences.isShowingThumbnailInLockscreen) + bitmapProvider.bitmap else null metadataBuilder.putBitmap(MediaMetadata.METADATA_KEY_ART, bitmap) + metadataBuilder.putString( + MediaMetadata.METADATA_KEY_ART_URI, + player.mediaMetadata.artworkUri?.toString()?.thumbnail(512) + ) - if (isAtLeastAndroid13 && player.currentMediaItemIndex == 0) { - metadataBuilder.putText( - MediaMetadata.METADATA_KEY_TITLE, - "${player.mediaMetadata.title} " - ) - } + if (isAtLeastAndroid13 && player.currentMediaItemIndex == 0) metadataBuilder.putText( + MediaMetadata.METADATA_KEY_TITLE, + "${player.mediaMetadata.title} " + ) mediaSession.setMetadata(metadataBuilder.build()) } - @SuppressLint("NewApi") private fun maybeResumePlaybackWhenDeviceConnected() { if (!isAtLeastAndroid6) return - if (preferences.getBoolean(resumePlaybackWhenDeviceConnectedKey, false)) { - if (audioManager == null) { - audioManager = getSystemService(AUDIO_SERVICE) as AudioManager? - } + if (!PlayerPreferences.resumePlaybackWhenDeviceConnected) { + audioManager?.unregisterAudioDeviceCallback(audioDeviceCallback) + audioDeviceCallback = null + return + } + if (audioManager == null) audioManager = getSystemService() - audioDeviceCallback = object : AudioDeviceCallback() { - private fun canPlayMusic(audioDeviceInfo: AudioDeviceInfo): Boolean { - if (!audioDeviceInfo.isSink) return false - - return audioDeviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || - audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || - audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || - audioDeviceInfo.type == AudioDeviceInfo.TYPE_USB_HEADSET - } + audioDeviceCallback = + @SuppressLint("NewApi") + object : AudioDeviceCallback() { + private fun canPlayMusic(audioDeviceInfo: AudioDeviceInfo) = + audioDeviceInfo.isSink && ( + audioDeviceInfo.type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP || + audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || + audioDeviceInfo.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES + ) + .let { + if (!isAtLeastAndroid8) it else + it || audioDeviceInfo.type == AudioDeviceInfo.TYPE_USB_HEADSET + } override fun onAudioDevicesAdded(addedDevices: Array) { - if (!player.isPlaying && addedDevices.any(::canPlayMusic)) { - player.play() - } + if (!player.isPlaying && addedDevices.any(::canPlayMusic)) player.play() } override fun onAudioDevicesRemoved(removedDevices: Array) = Unit } - audioManager?.registerAudioDeviceCallback(audioDeviceCallback, handler) + audioManager?.registerAudioDeviceCallback(audioDeviceCallback, handler) + } - } else { - audioManager?.unregisterAudioDeviceCallback(audioDeviceCallback) - audioDeviceCallback = null + private fun sendOpenEqualizerIntent() = sendBroadcast( + Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + ) + + private fun sendCloseEqualizerIntent() = sendBroadcast( + Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + } + ) + + private fun updatePlaybackState() = coroutineScope.launch { + playbackStateMutex.withLock { + withContext(Dispatchers.Main) { + mediaSession.setPlaybackState( + stateBuilder + .setState(player.androidPlaybackState, player.currentPosition, 1f) + .setBufferedPosition(player.bufferedPosition) + .build() + ) + } } } - private fun sendOpenEqualizerIntent() { - sendBroadcast( - Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - } - ) - } - - private fun sendCloseEqualizerIntent() { - sendBroadcast( - Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) - } - ) - } - - private val Player.androidPlaybackState: Int + private val Player.androidPlaybackState get() = when (playbackState) { Player.STATE_BUFFERING -> if (playWhenReady) PlaybackState.STATE_BUFFERING else PlaybackState.STATE_PAUSED Player.STATE_READY -> if (playWhenReady) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED @@ -572,23 +707,28 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene else -> PlaybackState.STATE_NONE } + // legacy behavior may cause inconsistencies, but not available on sdk 24 or lower + @Suppress("DEPRECATION") override fun onEvents(player: Player, events: Player.Events) { - if (player.duration != C.TIME_UNSET) { - mediaSession.setMetadata( - metadataBuilder - .putText(MediaMetadata.METADATA_KEY_TITLE, player.mediaMetadata.title) - .putText(MediaMetadata.METADATA_KEY_ARTIST, player.mediaMetadata.artist) - .putText(MediaMetadata.METADATA_KEY_ALBUM, player.mediaMetadata.albumTitle) - .putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration) - .build() - ) - } + if (player.duration != C.TIME_UNSET) mediaSession.setMetadata( + metadataBuilder + .putText( + MediaMetadata.METADATA_KEY_TITLE, + player.mediaMetadata.title?.toString().orEmpty() + ) + .putText( + MediaMetadata.METADATA_KEY_ARTIST, + player.mediaMetadata.artist?.toString().orEmpty() + ) + .putText( + MediaMetadata.METADATA_KEY_ALBUM, + player.mediaMetadata.albumTitle?.toString().orEmpty() + ) + .putLong(MediaMetadata.METADATA_KEY_DURATION, player.duration) + .build() + ) - stateBuilder - .setState(player.androidPlaybackState, player.currentPosition, 1f) - .setBufferedPosition(player.bufferedPosition) - - mediaSession.setPlaybackState(stateBuilder.build()) + updatePlaybackState() if (events.containsAny( Player.EVENT_PLAYBACK_STATE_CHANGED, @@ -604,14 +744,14 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene makeInvincible(false) stopForeground(false) sendCloseEqualizerIntent() - notificationManager?.cancel(NotificationId) + notificationManager?.cancel(NOTIFICATION_ID) return } if (player.shouldBePlaying && !isNotificationStarted) { isNotificationStarted = true startForegroundService(this@PlayerService, intent()) - startForeground(NotificationId, notification) + startForeground(NOTIFICATION_ID, notification) makeInvincible(false) sendOpenEqualizerIntent() } else { @@ -621,34 +761,8 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene makeInvincible(true) sendCloseEqualizerIntent() } - notificationManager?.notify(NotificationId, notification) - } - } - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - persistentQueueKey -> isPersistentQueueEnabled = - sharedPreferences.getBoolean(key, isPersistentQueueEnabled) - - volumeNormalizationKey -> maybeNormalizeVolume() - - resumePlaybackWhenDeviceConnectedKey -> maybeResumePlaybackWhenDeviceConnected() - - isInvincibilityEnabledKey -> isInvincibilityEnabled = - sharedPreferences.getBoolean(key, isInvincibilityEnabled) - - skipSilenceKey -> player.skipSilenceEnabled = sharedPreferences.getBoolean(key, false) - isShowingThumbnailInLockscreenKey -> { - isShowingThumbnailInLockscreen = sharedPreferences.getBoolean(key, true) - maybeShowSongCoverInLockScreen() - } - - trackLoopEnabledKey, queueLoopEnabledKey -> { - player.repeatMode = when { - preferences.getBoolean(trackLoopEnabledKey, false) -> Player.REPEAT_MODE_ONE - preferences.getBoolean(queueLoopEnabledKey, true) -> Player.REPEAT_MODE_ALL - else -> Player.REPEAT_MODE_OFF + runCatching { + notificationManager?.notify(NOTIFICATION_ID, notification) } } } @@ -661,11 +775,13 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene val pauseIntent = Action.pause.pendingIntent val nextIntent = Action.next.pendingIntent val prevIntent = Action.previous.pendingIntent + val likeIntent = Action.like.pendingIntent val mediaMetadata = player.mediaMetadata + @Suppress("DEPRECATION") // support for SDK < 26 val builder = if (isAtLeastAndroid8) { - Notification.Builder(applicationContext, NotificationChannelId) + Notification.Builder(applicationContext, NOTIFICATION_CHANNEL_ID) } else { Notification.Builder(applicationContext) } @@ -676,15 +792,18 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene .setAutoCancel(false) .setOnlyAlertOnce(true) .setShowWhen(false) - .setSmallIcon(player.playerError?.let { R.drawable.alert_circle } - ?: R.drawable.app_icon) + .setSmallIcon( + player.playerError?.let { R.drawable.alert_circle } ?: R.drawable.app_icon + ) .setOngoing(false) - .setContentIntent(activityPendingIntent( - flags = PendingIntent.FLAG_UPDATE_CURRENT - ) { - putExtra("expandPlayerBottomSheet", true) - }) - .setDeleteIntent(broadCastPendingIntent()) + .setContentIntent( + activityPendingIntent( + flags = PendingIntent.FLAG_UPDATE_CURRENT + ) { + putExtra("expandPlayerBottomSheet", true) + } + ) + .setDeleteIntent(broadcastPendingIntent()) .setVisibility(Notification.VISIBILITY_PUBLIC) .setCategory(NotificationCompat.CATEGORY_TRANSPORT) .setStyle( @@ -692,17 +811,29 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene .setShowActionsInCompactView(0, 1, 2) .setMediaSession(mediaSession.sessionToken) ) - .addAction(R.drawable.play_skip_back, "Skip back", prevIntent) + .addAction(R.drawable.play_skip_back, getString(R.string.skip_back), prevIntent) .addAction( if (player.shouldBePlaying) R.drawable.pause else R.drawable.play, - if (player.shouldBePlaying) "Pause" else "Play", + if (player.shouldBePlaying) getString(R.string.pause) else getString(R.string.play), if (player.shouldBePlaying) pauseIntent else playIntent ) - .addAction(R.drawable.play_skip_forward, "Skip forward", nextIntent) + .addAction(R.drawable.play_skip_forward, getString(R.string.skip_forward), nextIntent) + .addAction( + if (isLikedState.value) R.drawable.heart else R.drawable.heart_outline, + getString(R.string.like), + likeIntent + ) bitmapProvider.load(mediaMetadata.artworkUri) { bitmap -> maybeShowSongCoverInLockScreen() - notificationManager?.notify(NotificationId, builder.setLargeIcon(bitmap).build()) + handler.post { + runCatching { + notificationManager?.notify( + NOTIFICATION_ID, + builder.setLargeIcon(bitmap).build() + ) + } + } } return builder.build() @@ -714,165 +845,67 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene if (!isAtLeastAndroid8) return notificationManager?.run { - if (getNotificationChannel(NotificationChannelId) == null) { - createNotificationChannel( - NotificationChannel( - NotificationChannelId, - "Now playing", - NotificationManager.IMPORTANCE_LOW - ).apply { - setSound(null, null) - enableLights(false) - enableVibration(false) - } - ) - } - - if (getNotificationChannel(SleepTimerNotificationChannelId) == null) { - createNotificationChannel( - NotificationChannel( - SleepTimerNotificationChannelId, - "Sleep timer", - NotificationManager.IMPORTANCE_LOW - ).apply { - setSound(null, null) - enableLights(false) - enableVibration(false) - } - ) - } - } - } - - private fun okHttpClient() : OkHttpClient{ - ProxyPreferences.preference?.let{ - return OkHttpClient.Builder() - .proxy(Proxy(it.proxyMode,InetSocketAddress(it.proxyHost,it.proxyPort))) - .connectTimeout(Duration.ofSeconds(16)) - .readTimeout(Duration.ofSeconds(8)) - .build() - } - return OkHttpClient.Builder() - .connectTimeout(Duration.ofSeconds(16)) - .readTimeout(Duration.ofSeconds(8)) - .build() - } - - private fun createCacheDataSource(): DataSource.Factory { - return CacheDataSource.Factory().setCache(cache).apply { - setUpstreamDataSourceFactory( - OkHttpDataSource.Factory(okHttpClient()) - .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") - ) - } - } - - private fun createDataSourceFactory(): DataSource.Factory { - val chunkLength = 512 * 1024L - val ringBuffer = RingBuffer?>(2) { null } - - return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> - val videoId = dataSpec.key ?: error("A key must be set") - - if (cache.isCached(videoId, dataSpec.position, chunkLength)) { - dataSpec - } else { - when (videoId) { - ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second) - ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second) - else -> { - val urlResult = runBlocking(Dispatchers.IO) { - Innertube.player(PlayerBody(videoId = videoId)) - }?.mapCatching { body -> - if (body.videoDetails?.videoId != videoId) { - throw VideoIdMismatchException() - } - - when (val status = body.playabilityStatus?.status) { - "OK" -> body.streamingData?.highestQualityFormat?.let { format -> - val mediaItem = runBlocking(Dispatchers.Main) { - player.findNextMediaItemById(videoId) - } - - if (mediaItem?.mediaMetadata?.extras?.getString("durationText") == null) { - format.approxDurationMs?.div(1000) - ?.let(DateUtils::formatElapsedTime)?.removePrefix("0") - ?.let { durationText -> - mediaItem?.mediaMetadata?.extras?.putString( - "durationText", - durationText - ) - Database.updateDurationText(videoId, durationText) - } - } - - query { - mediaItem?.let(Database::insert) - - Database.insert( - it.hamy.muza.models.Format( - songId = videoId, - itag = format.itag, - mimeType = format.mimeType, - bitrate = format.bitrate, - loudnessDb = body.playerConfig?.audioConfig?.normalizedLoudnessDb, - contentLength = format.contentLength, - lastModified = format.lastModified - ) - ) - } - - format.url - } ?: throw PlayableFormatNotFoundException() - - "UNPLAYABLE" -> throw UnplayableException() - "LOGIN_REQUIRED" -> throw LoginRequiredException() - else -> throw PlaybackException( - status, - null, - PlaybackException.ERROR_CODE_REMOTE_ERROR - ) - } - } - - urlResult?.getOrThrow()?.let { url -> - ringBuffer.append(videoId to url.toUri()) - dataSpec.withUri(url.toUri()) - .subrange(dataSpec.uriPositionOffset, chunkLength) - } ?: throw PlaybackException( - null, - urlResult?.exceptionOrNull(), - PlaybackException.ERROR_CODE_REMOTE_ERROR - ) - } + if (getNotificationChannel(NOTIFICATION_CHANNEL_ID) == null) createNotificationChannel( + NotificationChannel( + NOTIFICATION_CHANNEL_ID, + getString(R.string.now_playing), + NotificationManager.IMPORTANCE_LOW + ).apply { + setSound(null, null) + enableLights(false) + enableVibration(false) } - } + ) + + if (getNotificationChannel(SLEEP_TIMER_NOTIFICATION_CHANNEL_ID) == null) + createNotificationChannel( + NotificationChannel( + SLEEP_TIMER_NOTIFICATION_CHANNEL_ID, + getString(R.string.sleep_timer), + NotificationManager.IMPORTANCE_LOW + ).apply { + setSound(null, null) + enableLights(false) + enableVibration(false) + } + ) } } - private fun createMediaSourceFactory(): MediaSource.Factory { - return DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) - } - - private fun createExtractorsFactory(): ExtractorsFactory { - return ExtractorsFactory { - arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) - } - } + private fun createMediaSourceFactory() = DefaultMediaSourceFactory( + /* dataSourceFactory = */ createYouTubeDataSourceResolverFactory( + findMediaItem = { videoId -> + runBlocking(Dispatchers.Main) { + player.findNextMediaItemById(videoId) + } + }, + context = applicationContext, + cache = cache + ), + /* extractorsFactory = */ DefaultExtractorsFactory() + ) private fun createRendersFactory(): RenderersFactory { - val audioSink = DefaultAudioSink.Builder() + val minimumSilenceDuration = PlayerPreferences.minimumSilence.coerceIn(1000L..2_000_000L) + val audioSink = DefaultAudioSink.Builder(applicationContext) .setEnableFloatOutput(false) .setEnableAudioTrackPlaybackParams(false) - .setOffloadMode(DefaultAudioSink.OFFLOAD_MODE_DISABLED) + .setAudioOffloadSupportProvider(DefaultAudioOffloadSupportProvider(applicationContext)) .setAudioProcessorChain( DefaultAudioProcessorChain( - emptyArray(), - SilenceSkippingAudioProcessor(2_000_000, 20_000, 256), + arrayOf(), + SilenceSkippingAudioProcessor( + /* minimumSilenceDurationUs = */ minimumSilenceDuration, + /* paddingSilenceUs = */ minimumSilenceDuration / 100L, + /* silenceThresholdLevel = */ 256 + ), SonicAudioProcessor() ) ) .build() + .apply { + if (isAtLeastAndroid10) setOffloadMode(AudioSink.OFFLOAD_MODE_DISABLED) + } return RenderersFactory { handler: Handler?, _, audioListener: AudioRendererEventListener?, _, _ -> arrayOf( @@ -905,6 +938,12 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene var isLoadingRadio by mutableStateOf(false) private set + var invincible + get() = isInvincibilityEnabled + set(value) { + isInvincibilityEnabled = value + } + fun setBitmapListener(listener: ((Bitmap?) -> Unit)?) { bitmapProvider.listener = listener } @@ -914,8 +953,8 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene timerJob = coroutineScope.timer(delayMillis) { val notification = NotificationCompat - .Builder(this@PlayerService, SleepTimerNotificationChannelId) - .setContentTitle("Sleep timer ended") + .Builder(this@PlayerService, SLEEP_TIMER_NOTIFICATION_CHANNEL_ID) + .setContentTitle(getString(R.string.sleep_timer_ended)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setAutoCancel(true) .setOnlyAlertOnce(true) @@ -923,7 +962,7 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene .setSmallIcon(R.drawable.app_icon) .build() - notificationManager?.notify(SleepTimerNotificationId, notification) + notificationManager?.notify(SLEEP_TIMER_NOTIFICATION_ID, notification) stopSelf() exitProcess(0) @@ -949,15 +988,14 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene endpoint?.playlistId, endpoint?.playlistSetVideoId, endpoint?.params - ).let { + ).let { radioData -> isLoadingRadio = true radioJob = coroutineScope.launch(Dispatchers.Main) { - if (justAdd) { - player.addMediaItems(it.process().drop(1)) - } else { - player.forcePlayFromBeginning(it.process()) - } - radio = it + val items = radioData.process().let { Database.filterBlacklistedSongs(it) } + if (justAdd) player.addMediaItems(items.drop(1)) + else player.forcePlayFromBeginning(items) + + radio = radioData isLoadingRadio = false } } @@ -968,9 +1006,42 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene radioJob?.cancel() radio = null } + + /** + * This method should ONLY be called when the application (sc. activity) is in the foreground! + */ + fun restartForegroundOrStop() { + player.pause() + isInvincibilityEnabled = false + stopSelf() + } + + fun isCached(song: SongWithContentLength) = + song.contentLength?.let { cache.isCached(song.song.id, 0L, it) } ?: false + + fun playFromSearch(query: String) { + coroutineScope.launch { + Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Song.value + ), + fromMusicShelfRendererContent = Innertube.SongItem.Companion::from + )?.getOrNull()?.items?.firstOrNull()?.info?.endpoint?.let { playRadio(it) } + } + } } - private class SessionCallback(private val player: Player) : MediaSession.Callback() { + private fun likeAction() = mediaItemState.value?.let { mediaItem -> + transaction { + Database.like( + mediaItem.mediaId, + if (isLikedState.value) null else System.currentTimeMillis() + ) + } + }.let { } + + private inner class SessionCallback(private val player: Player) : MediaSession.Callback() { override fun onPlay() = player.play() override fun onPause() = player.pause() override fun onSkipToPrevious() = runCatching(player::forceSeekToPrevious).let { } @@ -978,16 +1049,32 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene override fun onSeekTo(pos: Long) = player.seekTo(pos) override fun onStop() = player.pause() override fun onRewind() = player.seekToDefaultPosition() - override fun onSkipToQueueItem(id: Long) = runCatching { player.seekToDefaultPosition(id.toInt()) }.let { } + override fun onSkipToQueueItem(id: Long) = + runCatching { player.seekToDefaultPosition(id.toInt()) }.let { } + + override fun onSetPlaybackSpeed(speed: Float) { + PlayerPreferences.speed = speed.coerceIn(0.01f..2f) + } + + override fun onPlayFromSearch(query: String?, extras: Bundle?) { + if (query.isNullOrBlank()) return + binder.playFromSearch(query) + } + + override fun onCustomAction(action: String, extras: Bundle?) { + super.onCustomAction(action, extras) + if (action == "LIKE") likeAction() + } } - private class NotificationActionReceiver(private val player: Player) : BroadcastReceiver() { + inner class NotificationActionReceiver internal constructor() : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { Action.pause.value -> player.pause() Action.play.value -> player.play() Action.next.value -> player.forceSeekToNext() Action.previous.value -> player.forceSeekToPrevious() + Action.like.value -> likeAction() } } } @@ -1014,14 +1101,169 @@ class PlayerService : InvincibleService(), Player.Listener, PlaybackStatsListene val play = Action("it.hamy.muza.play") val next = Action("it.hamy.muza.next") val previous = Action("it.hamy.muza.previous") + val like = Action("it.hamy.muza.like") } } - private companion object { - const val NotificationId = 1001 - const val NotificationChannelId = "default_channel_id" + companion object { + fun createDatabaseProvider(context: Context) = StandaloneDatabaseProvider(context) + fun createCache(context: Context) = with(context) { + val cacheEvictor = when (val size = DataPreferences.exoPlayerDiskCacheMaxSize) { + ExoPlayerDiskCacheSize.Unlimited -> NoOpCacheEvictor() + else -> LeastRecentlyUsedCacheEvictor(size.bytes) + } - const val SleepTimerNotificationId = 1002 - const val SleepTimerNotificationChannelId = "sleep_timer_channel_id" + val directory = cacheDir.resolve("exoplayer").also { directory -> + if (directory.exists()) return@also + + directory.mkdir() + + cacheDir.listFiles()?.forEach { + @Suppress("ComplexCondition") + if ( + (it.isDirectory && it.name.length == 1 && it.name.isDigitsOnly() || it.extension == "uid") && + !it.renameTo(directory.resolve(it.name)) + ) it.deleteRecursively() + } + + filesDir.resolve("coil").deleteRecursively() + } + + SimpleCache(directory, cacheEvictor, createDatabaseProvider(context)) + } + + private const val DEFAULT_CHUNK_LENGTH = 512 * 1024L + + // TODO: maybe fix this mess? + /** + * Creates a ResolvingDataSource.Factory for YouTube video's + * Call site MUST either: + * 1. Verify that the consumer of the factory always saves the MediaItem to the database + * before trying to resolve the MediaItem + * 2. Provide a usable MediaItem for the YouTube video with the videoId + * 3. Make sure the database has a MediaItem for the given videoId and return null when it + * does + */ + + + + private fun okHttpClient() : OkHttpClient { + ProxyPreferences.preference?.let{ + return OkHttpClient.Builder() + .proxy(Proxy(it.proxyMode, InetSocketAddress(it.proxyHost,it.proxyPort))) + .connectTimeout(Duration.ofSeconds(16)) + .readTimeout(Duration.ofSeconds(8)) + .build() + } + return OkHttpClient.Builder() + .connectTimeout(Duration.ofSeconds(16)) + .readTimeout(Duration.ofSeconds(8)) + .build() + } + + @Suppress("CyclomaticComplexMethod") + fun createYouTubeDataSourceResolverFactory( + findMediaItem: (videoId: String) -> MediaItem?, + context: Context, + cache: Cache, + chunkLength: Long? = DEFAULT_CHUNK_LENGTH + ): ResolvingDataSource.Factory { + val ringBuffer = RingBuffer?>(2) { null } + + return ResolvingDataSource.Factory( + ConditionalCacheDataSourceFactory( + cacheDataSourceFactory = CacheDataSource.Factory().setCache(cache), + upstreamDataSourceFactory = DefaultDataSource.Factory( + context, + DefaultHttpDataSource.Factory() + .setConnectTimeoutMs(16000) + .setReadTimeoutMs(8000) + .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") + ) + ) { !it.isLocal } + ) { dataSpec -> + // Thank you Android, for enforcing a Uri in the download request + val videoId = dataSpec.key?.removePrefix("https://youtube.com/watch?v=") + ?: error("A key must be set") + + when { + dataSpec.isLocal || cache.isCached( + videoId, + dataSpec.position, + chunkLength ?: DEFAULT_CHUNK_LENGTH + ) -> dataSpec + + videoId == ringBuffer[0]?.first -> + dataSpec.withUri(ringBuffer[0]!!.second) + + videoId == ringBuffer[1]?.first -> + dataSpec.withUri(ringBuffer[1]!!.second) + + else -> { + val body = runBlocking(Dispatchers.IO) { + Innertube.player(PlayerBody(videoId = videoId)) + }?.getOrThrow() + + if (body?.videoDetails?.videoId != videoId) throw VideoIdMismatchException() + + val format = body.streamingData?.highestQualityFormat + val url = when (val status = body.playabilityStatus?.status) { + "OK" -> format?.let { _ -> + val mediaItem = findMediaItem(videoId) + + if (mediaItem?.mediaMetadata?.extras?.getString("durationText") == null) + format.approxDurationMs?.div(1000) + ?.let(DateUtils::formatElapsedTime)?.removePrefix("0") + ?.let { durationText -> + mediaItem?.mediaMetadata?.extras?.putString( + "durationText", + durationText + ) + Database.updateDurationText(videoId, durationText) + } + + query { + mediaItem?.let(Database::insert) + + Database.insert( + it.hamy.muza.models.Format( + songId = videoId, + itag = format.itag, + mimeType = format.mimeType, + bitrate = format.bitrate, + loudnessDb = body.playerConfig?.audioConfig?.normalizedLoudnessDb, + contentLength = format.contentLength, + lastModified = format.lastModified + ) + ) + } + + format.url + } ?: throw PlayableFormatNotFoundException() + + "UNPLAYABLE" -> throw UnplayableException() + "LOGIN_REQUIRED" -> throw LoginRequiredException() + + else -> throw PlaybackException( + status, + null, + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } + + ringBuffer += videoId to url.toUri() + dataSpec.buildUpon() + .setKey(videoId) + .setUri(url.toUri()) + .build() + .let { spec -> + (chunkLength ?: format.contentLength)?.let { + spec.subrange(dataSpec.uriPositionOffset, it) + } ?: spec + } + } + } + } + } } } diff --git a/app/src/main/kotlin/it/hamy/muza/service/PrecacheService.kt b/app/src/main/kotlin/it/hamy/muza/service/PrecacheService.kt new file mode 100644 index 0000000..e20e522 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/service/PrecacheService.kt @@ -0,0 +1,268 @@ +package it.hamy.muza.service + +import android.content.ComponentName +import android.content.Context +import android.content.ServiceConnection +import android.net.Uri +import android.os.IBinder +import android.widget.Toast +import androidx.annotation.OptIn +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheSpan +import androidx.media3.datasource.cache.ContentMetadataMutations +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadManager +import androidx.media3.exoplayer.offline.DownloadNotificationHelper +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.media3.exoplayer.scheduler.Requirements +import androidx.media3.exoplayer.workmanager.WorkManagerScheduler +import it.hamy.muza.Database +import it.hamy.muza.R +import it.hamy.muza.transaction +import it.hamy.muza.utils.intent +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.asCoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import java.io.File +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import kotlin.time.Duration.Companion.milliseconds + +private val executor = Executors.newCachedThreadPool() +private val coroutineScope = CoroutineScope( + executor.asCoroutineDispatcher() + + SupervisorJob() + + CoroutineName("PrecacheService-Worker-Scope") +) + +// While the class is not a singleton (lifecycle), there should only be one download state at a time +private val mutableDownloadState = MutableStateFlow(false) +val downloadState = mutableDownloadState.asStateFlow() + +@OptIn(UnstableApi::class) +class PrecacheService : DownloadService( + /* foregroundNotificationId = */ DOWNLOAD_NOTIFICATION_ID, + /* foregroundNotificationUpdateInterval = */ 1000L, // default + /* channelId = */ DOWNLOAD_NOTIFICATION_CHANNEL_ID, + /* channelNameResourceId = */ R.string.pre_cache, + /* channelDescriptionResourceId = */ 0 +) { + private val downloadQueue = + Channel(onBufferOverflow = BufferOverflow.DROP_OLDEST) + + private val downloadNotificationHelper by lazy { + DownloadNotificationHelper( + this, + DOWNLOAD_NOTIFICATION_CHANNEL_ID + ) + } + + private val waiters = mutableListOf<() -> Unit>() + + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + if (service !is PlayerService.Binder) return + bound = true + binder = service + waiters.forEach { it() } + waiters.clear() + } + + override fun onServiceDisconnected(name: ComponentName?) { + bound = false + binder = null + waiters.forEach { it() } + waiters.clear() + } + } + + @get:Synchronized + @set:Synchronized + private var bound = false + private var binder: PlayerService.Binder? = null + + private var progressUpdaterJob: Job? = null + + @kotlin.OptIn(FlowPreview::class) + override fun getDownloadManager(): DownloadManager { + runCatching { + if (bound) unbindService(serviceConnection) + bindService(intent(), serviceConnection, Context.BIND_AUTO_CREATE) + } + + val cache = BlockingDeferredCache { + suspendCoroutine { + waiters += { it.resume(Unit) } + } + binder?.cache ?: error("PlayerService failed to start, crashing...") + } + + progressUpdaterJob?.cancel() + progressUpdaterJob = coroutineScope.launch { + downloadQueue.receiveAsFlow().debounce(100.milliseconds).collect { downloadManager -> + mutableDownloadState.update { !downloadManager.isIdle } + } + } + + return DownloadManager( + this, + PlayerService.createDatabaseProvider(this), + cache, + PlayerService.createYouTubeDataSourceResolverFactory( + findMediaItem = { null }, + context = this, + cache = cache, + chunkLength = null + ), + executor + ).apply { + maxParallelDownloads = 3 + minRetryCount = 1 + requirements = Requirements(Requirements.NETWORK) + addListener(object : DownloadManager.Listener { + override fun onIdle(downloadManager: DownloadManager) = + mutableDownloadState.update { false } + + override fun onDownloadChanged( + downloadManager: DownloadManager, + download: Download, + finalException: Exception? + ) { + downloadQueue.trySend(downloadManager) + } + + override fun onDownloadRemoved( + downloadManager: DownloadManager, + download: Download + ) { + downloadQueue.trySend(downloadManager) + } + }) + } + } + + override fun getScheduler() = WorkManagerScheduler(this, "precacher-work") + + override fun getForegroundNotification( + downloads: MutableList, + notMetRequirements: Int + ) = downloadNotificationHelper.buildProgressNotification( + /* context = */ this, + /* smallIcon = */ R.drawable.download, + /* contentIntent = */ null, + /* message = */ null, + /* downloads = */ downloads, + /* notMetRequirements = */ notMetRequirements + ) + + override fun onDestroy() { + super.onDestroy() + + runCatching { + if (bound) unbindService(serviceConnection) + } + } + + companion object { + fun scheduleCache(context: Context, mediaItem: MediaItem) { + if (mediaItem.isLocal) return + + val downloadRequest = DownloadRequest + .Builder( + /* id = */ mediaItem.mediaId, + /* uri = */ mediaItem.requestMetadata.mediaUri + ?: Uri.parse("https://youtube.com/watch?v=${mediaItem.mediaId}") + ) + .setCustomCacheKey(mediaItem.mediaId) + .setData(mediaItem.mediaId.encodeToByteArray()) + .build() + + transaction { + Database.insert(mediaItem) + coroutineScope.launch { + runCatching { + sendAddDownload( + /* context = */ context, + /* clazz = */ PrecacheService::class.java, + /* downloadRequest = */ downloadRequest, + /* foreground = */ true + ) + }.recoverCatching { + sendAddDownload( + /* context = */ context, + /* clazz = */ PrecacheService::class.java, + /* downloadRequest = */ downloadRequest, + /* foreground = */ false + ) + }.exceptionOrNull()?.printStackTrace()?.also { + Toast.makeText(context, R.string.error_pre_cache, Toast.LENGTH_SHORT).show() + } + } + } + } + } +} + +@Suppress("TooManyFunctions") +@OptIn(UnstableApi::class) +class BlockingDeferredCache(private val cache: Deferred) : Cache { + constructor(init: suspend () -> Cache) : this(coroutineScope.async { init() }) + + private val resolvedCache by lazy { runBlocking { cache.await() } } + + override fun getUid() = resolvedCache.uid + override fun release() = resolvedCache.release() + override fun addListener(key: String, listener: Cache.Listener) = + resolvedCache.addListener(key, listener) + + override fun removeListener(key: String, listener: Cache.Listener) = + resolvedCache.removeListener(key, listener) + + override fun getCachedSpans(key: String) = resolvedCache.getCachedSpans(key) + override fun getKeys(): MutableSet = resolvedCache.keys + override fun getCacheSpace() = resolvedCache.cacheSpace + override fun startReadWrite(key: String, position: Long, length: Long) = + resolvedCache.startReadWrite(key, position, length) + + override fun startReadWriteNonBlocking(key: String, position: Long, length: Long) = + resolvedCache.startReadWriteNonBlocking(key, position, length) + + override fun startFile(key: String, position: Long, length: Long) = + resolvedCache.startFile(key, position, length) + + override fun commitFile(file: File, length: Long) = resolvedCache.commitFile(file, length) + override fun releaseHoleSpan(holeSpan: CacheSpan) = resolvedCache.releaseHoleSpan(holeSpan) + override fun removeResource(key: String) = resolvedCache.removeResource(key) + override fun removeSpan(span: CacheSpan) = resolvedCache.removeSpan(span) + override fun isCached(key: String, position: Long, length: Long) = + resolvedCache.isCached(key, position, length) + + override fun getCachedLength(key: String, position: Long, length: Long) = + resolvedCache.getCachedLength(key, position, length) + + override fun getCachedBytes(key: String, position: Long, length: Long) = + resolvedCache.getCachedBytes(key, position, length) + + override fun applyContentMetadataMutations(key: String, mutations: ContentMetadataMutations) = + resolvedCache.applyContentMetadataMutations(key, mutations) + + override fun getContentMetadata(key: String) = resolvedCache.getContentMetadata(key) +} diff --git a/app/src/main/kotlin/it/hamy/muza/service/ServiceNotifications.kt b/app/src/main/kotlin/it/hamy/muza/service/ServiceNotifications.kt new file mode 100644 index 0000000..bbb52c5 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/service/ServiceNotifications.kt @@ -0,0 +1,10 @@ +package it.hamy.muza.service + +const val NOTIFICATION_ID = 1001 +const val NOTIFICATION_CHANNEL_ID = "default_channel_id" + +const val SLEEP_TIMER_NOTIFICATION_ID = 1002 +const val SLEEP_TIMER_NOTIFICATION_CHANNEL_ID = "sleep_timer_channel_id" + +const val DOWNLOAD_NOTIFICATION_ID = 1003 +const val DOWNLOAD_NOTIFICATION_CHANNEL_ID = "download_channel_id" diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/BottomSheet.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/BottomSheet.kt index 8c7278c..8effd3b 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/BottomSheet.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/BottomSheet.kt @@ -7,9 +7,12 @@ import androidx.compose.animation.core.AnimationVector1D import androidx.compose.animation.core.SpringSpec import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.tween +import androidx.compose.foundation.Indication +import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.DraggableState import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.fillMaxSize @@ -20,7 +23,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -43,58 +46,61 @@ import kotlinx.coroutines.launch @Composable fun BottomSheet( state: BottomSheetState, + collapsedContent: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, onDismiss: (() -> Unit)? = null, - collapsedContent: @Composable BoxScope.() -> Unit, + indication: Indication? = LocalIndication.current, content: @Composable BoxScope.() -> Unit -) { - Box( - modifier = modifier - .offset { - val y = (state.expandedBound - state.value) +) = Box( + modifier = modifier + .offset { + IntOffset( + x = 0, + y = (state.expandedBound - state.value) .roundToPx() .coerceAtLeast(0) - IntOffset(x = 0, y = y) - } - .pointerInput(state) { - val velocityTracker = VelocityTracker() - - detectVerticalDragGestures( - onVerticalDrag = { change, dragAmount -> - velocityTracker.addPointerInputChange(change) - state.dispatchRawDelta(dragAmount) - }, - onDragCancel = { - velocityTracker.resetTracking() - state.snapTo(state.collapsedBound) - }, - onDragEnd = { - val velocity = -velocityTracker.calculateVelocity().y - velocityTracker.resetTracking() - state.performFling(velocity, onDismiss) - } - ) - } - .fillMaxSize() - ) { - if (!state.isCollapsed) { - BackHandler(onBack = state::collapseSoft) - content() - } - - if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) { - Box( - modifier = Modifier - .graphicsLayer { - alpha = 1f - (state.progress * 16).coerceAtMost(1f) - } - .clickable(onClick = state::expandSoft) - .fillMaxWidth() - .height(state.collapsedBound), - content = collapsedContent ) } + .pointerInput(state) { + val velocityTracker = VelocityTracker() + + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + state.dispatchRawDelta(dragAmount) + }, + onDragCancel = { + velocityTracker.resetTracking() + state.snapTo(state.collapsedBound) + }, + onDragEnd = { + val velocity = -velocityTracker.calculateVelocity().y + velocityTracker.resetTracking() + state.performFling(velocity, onDismiss) + } + ) + } + .fillMaxSize() +) { + if (!state.isCollapsed) { + BackHandler(onBack = state::collapseSoft) + content() } + + if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) Box( + modifier = Modifier + .graphicsLayer { + alpha = 1f - (state.progress * 16).coerceAtMost(1f) + } + .clickable( + onClick = state::expandSoft, + indication = indication, + interactionSource = remember { MutableInteractionSource() } + ) + .fillMaxWidth() + .height(state.collapsedBound), + content = collapsedContent + ) } @Stable @@ -103,64 +109,44 @@ class BottomSheetState( private val coroutineScope: CoroutineScope, private val animatable: Animatable, private val onAnchorChanged: (Int) -> Unit, - val collapsedBound: Dp, + val collapsedBound: Dp ) : DraggableState by draggableState { - val dismissedBound: Dp - get() = animatable.lowerBound!! - - val expandedBound: Dp - get() = animatable.upperBound!! + private val dismissedBound get() = animatable.lowerBound!! + val expandedBound get() = animatable.upperBound!! val value by animatable.asState() - val isDismissed by derivedStateOf { - value == animatable.lowerBound!! - } - - val isCollapsed by derivedStateOf { - value == collapsedBound - } - - val isExpanded by derivedStateOf { - value == animatable.upperBound - } - + val isDismissed by derivedStateOf { value == animatable.lowerBound!! } + val isCollapsed by derivedStateOf { value == collapsedBound } + val isExpanded by derivedStateOf { value == animatable.upperBound } val progress by derivedStateOf { 1f - (animatable.upperBound!! - animatable.value) / (animatable.upperBound!! - collapsedBound) } fun collapse(animationSpec: AnimationSpec) { - onAnchorChanged(collapsedAnchor) + onAnchorChanged(COLLAPSED_ANCHOR) coroutineScope.launch { animatable.animateTo(collapsedBound, animationSpec) } } fun expand(animationSpec: AnimationSpec) { - onAnchorChanged(expandedAnchor) + onAnchorChanged(EXPANDED_ANCHOR) coroutineScope.launch { animatable.animateTo(animatable.upperBound!!, animationSpec) } } - private fun collapse() { - collapse(SpringSpec()) - } + private fun collapse() = collapse(SpringSpec()) - private fun expand() { - expand(SpringSpec()) - } + private fun expand() = expand(SpringSpec()) - fun collapseSoft() { - collapse(tween(300)) - } + fun collapseSoft() = collapse(tween(300)) - fun expandSoft() { - expand(tween(300)) - } + fun expandSoft() = expand(tween(300)) fun dismiss() { - onAnchorChanged(dismissedAnchor) + onAnchorChanged(DISMISSED_ANCHOR) coroutineScope.launch { animatable.animateTo(animatable.lowerBound!!) } @@ -172,17 +158,18 @@ class BottomSheetState( } } - fun performFling(velocity: Float, onDismiss: (() -> Unit)?) { - if (velocity > 250) { - expand() - } else if (velocity < -250) { + fun performFling(velocity: Float, onDismiss: (() -> Unit)?) = when { + velocity > 250 -> expand() + velocity < -250 -> { if (value < collapsedBound && onDismiss != null) { dismiss() onDismiss.invoke() } else { collapse() } - } else { + } + + else -> { val l0 = dismissedBound val l1 = (collapsedBound - dismissedBound) / 2 val l2 = (expandedBound - collapsedBound) / 2 @@ -193,10 +180,9 @@ class BottomSheetState( if (onDismiss != null) { dismiss() onDismiss.invoke() - } else { - collapse() - } + } else collapse() } + in l1..l2 -> collapse() in l2..l3 -> expand() else -> Unit @@ -205,20 +191,16 @@ class BottomSheetState( } val preUpPostDownNestedScrollConnection - get() = object : NestedScrollConnection { + get() = object : NestedScrollConnection { var isTopReached = false override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { - if (isExpanded && available.y < 0) { - isTopReached = false - } + if (isExpanded && available.y < 0) isTopReached = false return if (isTopReached && available.y < 0 && source == NestedScrollSource.Drag) { dispatchRawDelta(available.y) available - } else { - Offset.Zero - } + } else Offset.Zero } override fun onPostScroll( @@ -226,28 +208,20 @@ class BottomSheetState( available: Offset, source: NestedScrollSource ): Offset { - if (!isTopReached) { - isTopReached = consumed.y == 0f && available.y > 0 - } + if (!isTopReached) isTopReached = consumed.y == 0f && available.y > 0 return if (isTopReached && source == NestedScrollSource.Drag) { dispatchRawDelta(available.y) available - } else { - Offset.Zero - } + } else Offset.Zero } - override suspend fun onPreFling(available: Velocity): Velocity { - return if (isTopReached) { - val velocity = -available.y - performFling(velocity, null) + override suspend fun onPreFling(available: Velocity) = if (isTopReached) { + val velocity = -available.y + performFling(velocity, null) - available - } else { - Velocity.Zero - } - } + available + } else Velocity.Zero override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { isTopReached = false @@ -256,29 +230,27 @@ class BottomSheetState( } } -const val expandedAnchor = 2 -const val collapsedAnchor = 1 -const val dismissedAnchor = 0 +const val EXPANDED_ANCHOR = 2 +const val COLLAPSED_ANCHOR = 1 +const val DISMISSED_ANCHOR = 0 @Composable fun rememberBottomSheetState( dismissedBound: Dp, expandedBound: Dp, collapsedBound: Dp = dismissedBound, - initialAnchor: Int = dismissedAnchor + initialAnchor: Int = DISMISSED_ANCHOR ): BottomSheetState { val density = LocalDensity.current val coroutineScope = rememberCoroutineScope() - var previousAnchor by rememberSaveable { - mutableStateOf(initialAnchor) - } + var previousAnchor by rememberSaveable { mutableIntStateOf(initialAnchor) } return remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) { val initialValue = when (previousAnchor) { - expandedAnchor -> expandedBound - collapsedAnchor -> collapsedBound - dismissedAnchor -> dismissedBound + EXPANDED_ANCHOR -> expandedBound + COLLAPSED_ANCHOR -> collapsedBound + DISMISSED_ANCHOR -> dismissedBound else -> error("Unknown BottomSheet anchor") } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/Menu.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/Menu.kt index 0ea1cdd..1a5ea35 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/Menu.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/Menu.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue @@ -19,6 +20,7 @@ import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.dp val LocalMenuState = staticCompositionLocalOf { MenuState() } @@ -68,7 +70,7 @@ fun BottomSheetMenu( visible = state.isDisplayed, enter = slideInVertically { it }, exit = slideOutVertically { it }, - modifier = modifier + modifier = modifier.padding(top = 48.dp) ) { state.content() } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/MusicBars.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/MusicBars.kt index d56237f..d4fb3a1 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/MusicBars.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/MusicBars.kt @@ -2,148 +2,80 @@ package it.hamy.muza.ui.components import androidx.compose.animation.core.Animatable import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.fastForEachIndexed +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +// @formatter:off +@Suppress("MaximumLineLength") +private val steps = persistentListOf( + arrayOf(0.8f, 0.1f, 0.9f, 0.9f, 0.7f, 0.9f, 0.8f, 0.1f, 0.3f, 0.8f, 0.6f, 0.0f, 0.3f, 0.4f, 0.9f, 0.7f, 0.9f, 0.6f, 0.9f, 0.1f, 0.3f, 0.0f, 0.5f, 0.4f, 0.7f, 0.9f), + arrayOf(0.8f, 0.5f, 0.0f, 0.5f, 0.7f, 0.9f, 0.8f, 0.7f, 0.5f, 0.9f, 0.4f, 0.5f, 0.7f, 0.3f, 0.1f, 0.0f, 0.7f, 0.9f, 0.5f, 0.7f, 0.4f, 0.0f, 0.4f, 0.3f, 0.6f, 0.9f), + arrayOf(0.4f, 0.5f, 0.0f, 0.4f, 0.5f, 0.0f, 0.4f, 0.5f, 0.0f, 0.5f, 0.4f, 0.3f, 0.8f, 0.7f, 0.9f, 0.5f, 0.6f, 0.4f, 0.3f, 0.9f, 0.6f, 0.7f, 0.9f, 0.6f, 0.7f, 0.3f) +) +// @formatter:on + @Composable fun MusicBars( color: Color, modifier: Modifier = Modifier, barWidth: Dp = 4.dp, - cornerRadius: Dp = 16.dp + cornerRadius: Dp = 16.dp, + space: Dp = 4.dp ) { - val animatablesWithSteps = remember { - listOf( - Animatable(0f) to listOf( - 0.2f, - 0.8f, - 0.1f, - 0.1f, - 0.3f, - 0.1f, - 0.2f, - 0.8f, - 0.7f, - 0.2f, - 0.4f, - 0.9f, - 0.7f, - 0.6f, - 0.1f, - 0.3f, - 0.1f, - 0.4f, - 0.1f, - 0.8f, - 0.7f, - 0.9f, - 0.5f, - 0.6f, - 0.3f, - 0.1f - ), - Animatable(0f) to listOf( - 0.2f, - 0.5f, - 1.0f, - 0.5f, - 0.3f, - 0.1f, - 0.2f, - 0.3f, - 0.5f, - 0.1f, - 0.6f, - 0.5f, - 0.3f, - 0.7f, - 0.8f, - 0.9f, - 0.3f, - 0.1f, - 0.5f, - 0.3f, - 0.6f, - 1.0f, - 0.6f, - 0.7f, - 0.4f, - 0.1f - ), - Animatable(0f) to listOf( - 0.6f, - 0.5f, - 1.0f, - 0.6f, - 0.5f, - 1.0f, - 0.6f, - 0.5f, - 1.0f, - 0.5f, - 0.6f, - 0.7f, - 0.2f, - 0.3f, - 0.1f, - 0.5f, - 0.4f, - 0.6f, - 0.7f, - 0.1f, - 0.4f, - 0.3f, - 0.1f, - 0.4f, - 0.3f, - 0.7f - ) - ) - } + val animatables = remember { List(steps.size) { Animatable(0f) } } LaunchedEffect(Unit) { - animatablesWithSteps.forEach { (animatable, steps) -> + animatables.fastForEachIndexed { i, animatable -> launch { - while (true) { - steps.forEach { step -> - animatable.animateTo(step) - } + var step = 0 + val steps = steps[i] + while (isActive) { + animatable.animateTo(steps[step]) + step = (step + 1) % steps.size } } } } - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.Bottom, + Canvas( modifier = modifier + .fillMaxHeight() + .width(barWidth * animatables.size + space * animatables.lastIndex) ) { - animatablesWithSteps.forEach { (animatable) -> - Canvas( - modifier = Modifier - .fillMaxHeight() - .width(barWidth) - ) { - drawRoundRect( - color = color, - topLeft = Offset(x = 0f, y = size.height * (1 - animatable.value)), - size = size.copy(height = animatable.value * size.height), - cornerRadius = CornerRadius(cornerRadius.toPx()) - ) - } + val radius = CornerRadius(cornerRadius.toPx()) + val barWidthPx = barWidth.toPx() + val barHeightPx = size.height + val stride = barWidthPx + space.toPx() + + animatables.fastForEachIndexed { i, animatable -> + val value = animatable.value + + drawRoundRect( + color = color, + topLeft = Offset( + x = i * stride, + y = barHeightPx * value + ), + size = Size( + width = barWidthPx, + height = barHeightPx * (1 - value) + ), + cornerRadius = radius + ) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/SeekBar.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/SeekBar.kt index b207fd9..6c475d3 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/SeekBar.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/SeekBar.kt @@ -1,51 +1,184 @@ package it.hamy.muza.ui.components +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.ContentDrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.media3.common.C +import it.hamy.muza.models.ui.UiMedia +import it.hamy.muza.preferences.PlayerPreferences +import it.hamy.muza.service.PlayerService +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.formatAsDuration +import it.hamy.muza.utils.isCompositionLaunched +import it.hamy.muza.utils.semiBold +import kotlinx.coroutines.launch +import kotlin.math.PI import kotlin.math.roundToLong +import kotlin.math.sin @Composable fun SeekBar( - value: Long, - minimumValue: Long, - maximumValue: Long, - onDragStart: (Long) -> Unit, - onDrag: (Long) -> Unit, - onDragEnd: () -> Unit, + binder: PlayerService.Binder, + position: Long, + media: UiMedia, + modifier: Modifier = Modifier, + color: Color = LocalAppearance.current.colorPalette.text, + backgroundColor: Color = LocalAppearance.current.colorPalette.background2, + shape: Shape = RoundedCornerShape(8.dp), + isActive: Boolean = binder.player.isPlaying, + alwaysShowDuration: Boolean = false, + scrubberRadius: Dp = 6.dp, + style: PlayerPreferences.SeekBarStyle = PlayerPreferences.seekBarStyle +) { + val range = 0L..media.duration + val floatRange = 0f..media.duration.toFloat() + + when (style) { + PlayerPreferences.SeekBarStyle.Static -> { + var scrubbingPosition by remember(media) { mutableStateOf(null) } + + ClassicSeekBarBody( + position = scrubbingPosition ?: position, + duration = media.duration, + range = range, + onSeekStart = { scrubbingPosition = it }, + onSeek = { delta -> + scrubbingPosition = if (media.duration == C.TIME_UNSET) null + else scrubbingPosition?.let { (it + delta).coerceIn(range) } + }, + onSeekEnd = { + scrubbingPosition?.let(binder.player::seekTo) + scrubbingPosition = null + }, + color = color, + backgroundColor = backgroundColor, + showDuration = alwaysShowDuration || scrubbingPosition != null, + modifier = modifier, + scrubberRadius = scrubberRadius, + shape = shape + ) + } + + PlayerPreferences.SeekBarStyle.Wavy -> { + val scope = rememberCoroutineScope() + val compositionLaunched = isCompositionLaunched() + + val animatedPosition = remember { Animatable(position.toFloat()) } + var isSeeking by remember { mutableStateOf(false) } + + LaunchedEffect(media) { + if (compositionLaunched) animatedPosition.animateTo(0f) + } + + LaunchedEffect(position) { + if (!isSeeking && !animatedPosition.isRunning) animatedPosition.animateTo(position.toFloat()) + } + + WavySeekBarBody( + position = animatedPosition.value.roundToLong(), + duration = media.duration, + range = range, + onSeekStart = { + isSeeking = true + scope.launch { animatedPosition.animateTo(it.toFloat()) } + }, + onSeek = { delta -> + if (media.duration == C.TIME_UNSET) return@WavySeekBarBody + + isSeeking = true + scope.launch { + animatedPosition.snapTo( + (animatedPosition.value + delta) + .coerceIn(floatRange) + ) + } + }, + onSeekEnd = { + isSeeking = false + binder.player.seekTo(animatedPosition.targetValue.roundToLong()) + }, + color = color, + backgroundColor = backgroundColor, + modifier = modifier, + scrubberRadius = scrubberRadius, + shape = shape, + showDuration = alwaysShowDuration || isSeeking, + isActive = isActive + ) + } + } +} + +@Composable +private fun ClassicSeekBarBody( + position: Long, + duration: Long, + range: ClosedRange, + onSeekStart: (Long) -> Unit, + onSeek: (Long) -> Unit, + onSeekEnd: () -> Unit, color: Color, backgroundColor: Color, + scrubberRadius: Dp, + shape: Shape, + showDuration: Boolean, modifier: Modifier = Modifier, barHeight: Dp = 3.dp, scrubberColor: Color = color, - scrubberRadius: Dp = 6.dp, - shape: Shape = RectangleShape, - drawSteps: Boolean = false, -) { - val isDragging = remember { - MutableTransitionState(false) - } - + drawSteps: Boolean = false +) = Column { + val isDragging = remember { MutableTransitionState(false) } val transition = updateTransition(transitionState = isDragging, label = null) val currentBarHeight by transition.animateDp(label = "") { if (it) scrubberRadius else barHeight } @@ -53,56 +186,52 @@ fun SeekBar( Box( modifier = modifier - .pointerInput(minimumValue, maximumValue) { - if (maximumValue < minimumValue) return@pointerInput + .pointerInput(range) { + if (range.endInclusive < range.start) return@pointerInput var acc = 0f detectHorizontalDragGestures( - onDragStart = { - isDragging.targetState = true - }, + onDragStart = { isDragging.targetState = true }, onHorizontalDrag = { _, delta -> - acc += delta / size.width * (maximumValue - minimumValue) + acc += delta / size.width * (range.endInclusive - range.start).toFloat() if (acc !in -1f..1f) { - onDrag(acc.toLong()) + onSeek(acc.toLong()) acc -= acc.toLong() } }, onDragEnd = { isDragging.targetState = false acc = 0f - onDragEnd() + onSeekEnd() }, onDragCancel = { isDragging.targetState = false acc = 0f - onDragEnd() + onSeekEnd() } ) } - .pointerInput(minimumValue, maximumValue) { - if (maximumValue < minimumValue) return@pointerInput + .pointerInput(range.start, range.endInclusive) { + if (range.endInclusive < range.start) return@pointerInput detectTapGestures( onPress = { offset -> - onDragStart((offset.x / size.width * (maximumValue - minimumValue) + minimumValue).roundToLong()) + onSeekStart( + (offset.x / size.width * (range.endInclusive - range.start) + range.start).roundToLong() + ) }, - onTap = { - onDragEnd() - } + onTap = { onSeekEnd() } ) } .padding(horizontal = scrubberRadius) .drawWithContent { drawContent() - val scrubberPosition = if (maximumValue < minimumValue) { - 0f - } else { - (value.toFloat() - minimumValue) / (maximumValue - minimumValue) * size.width - } + val scrubberPosition = + if (range.endInclusive < range.start) 0f + else (position.toFloat() - range.start) / (range.endInclusive - range.start) * size.width drawCircle( color = scrubberColor, @@ -110,16 +239,15 @@ fun SeekBar( center = center.copy(x = scrubberPosition) ) - if (drawSteps) { - for (i in value + 1..maximumValue) { - val stepPosition = - (i.toFloat() - minimumValue) / (maximumValue - minimumValue) * size.width - drawCircle( - color = scrubberColor, - radius = scrubberRadius.toPx() / 2, - center = center.copy(x = stepPosition), - ) - } + if (drawSteps) for (i in position + 1..range.endInclusive) { + val stepPosition = + (i.toFloat() - range.start) / (range.endInclusive - range.start) * size.width + + drawCircle( + color = scrubberColor, + radius = scrubberRadius.toPx() / 2, + center = center.copy(x = stepPosition) + ) } } .height(scrubberRadius) @@ -135,9 +263,271 @@ fun SeekBar( Spacer( modifier = Modifier .height(currentBarHeight) - .fillMaxWidth((value.toFloat() - minimumValue) / (maximumValue - minimumValue)) + .fillMaxWidth((position.toFloat() - range.start) / (range.endInclusive - range.start).toFloat()) .background(color = color, shape = shape) .align(Alignment.CenterStart) ) } + + Duration( + position = position, + duration = duration, + show = showDuration + ) +} + +@Composable +private fun WavySeekBarBody( + position: Long, + duration: Long, + range: ClosedRange, + color: Color, + backgroundColor: Color, + shape: Shape, + onSeek: (Long) -> Unit, + onSeekStart: (Long) -> Unit, + onSeekEnd: () -> Unit, + showDuration: Boolean, + modifier: Modifier = Modifier, + isActive: Boolean = true, + scrubberRadius: Dp = 6.dp +) = Column { + val isDragging = remember { MutableTransitionState(false) } + + val transition = updateTransition(transitionState = isDragging, label = null) + + val currentAmplitude by transition.animateDp(label = "") { if (it || !isActive) 0.dp else 2.dp } + val currentScrubberHeight by transition.animateDp(label = "") { if (it) 20.dp else 15.dp } + + Box( + modifier = modifier + .pointerInput(range) { + if (range.endInclusive < range.start) return@pointerInput + + detectDrags( + isDragging = isDragging, + range = range, + onSeek = onSeek, + onSeekEnd = onSeekEnd + ) + } + .pointerInput(range) { + detectTaps( + range = range, + onSeekStart = onSeekStart, + onSeekEnd = onSeekEnd + ) + } + .padding(horizontal = scrubberRadius) + .drawWithContent { + drawContent() + + drawScrubber( + range = range, + position = position, + color = color, + height = currentScrubberHeight + ) + } + ) { + WavySeekBarContent( + backgroundColor = backgroundColor, + amplitude = { currentAmplitude }, + position = position, + range = range, + shape = shape, + color = color + ) + } + + Duration( + position = position, + duration = duration, + show = showDuration + ) +} + +private suspend fun PointerInputScope.detectDrags( + isDragging: MutableTransitionState, + range: ClosedRange, + onSeek: (delta: Long) -> Unit, + onSeekEnd: () -> Unit +) { + var acc = 0f + + detectHorizontalDragGestures( + onDragStart = { isDragging.targetState = true }, + onHorizontalDrag = { _, delta -> + acc += delta / size.width * (range.endInclusive - range.start).toFloat() + + if (acc !in -1f..1f) { + onSeek(acc.toLong()) + acc -= acc + } + }, + onDragEnd = { + isDragging.targetState = false + acc = 0f + onSeekEnd() + }, + onDragCancel = { + isDragging.targetState = false + acc = 0f + + onSeekEnd() + } + ) +} + +private suspend fun PointerInputScope.detectTaps( + range: ClosedRange, + onSeekStart: (updated: Long) -> Unit, + onSeekEnd: () -> Unit +) { + if (range.endInclusive < range.start) return + + detectTapGestures( + onPress = { offset -> + onSeekStart( + (offset.x / size.width * (range.endInclusive - range.start).toFloat() + range.start).toLong() + ) + }, + onTap = { onSeekEnd() } + ) +} + +private fun ContentDrawScope.drawScrubber( + range: ClosedRange, + position: Long, + color: Color, + height: Dp +) { + val scrubberPosition = if (range.endInclusive < range.start) 0f + else (position - range.start) / (range.endInclusive - range.start).toFloat() * size.width + + val widthPx = 5.dp.toPx() + val heightPx = height.toPx() + + drawRoundRect( + color = color, + topLeft = Offset( + x = scrubberPosition - widthPx / 2, + y = (size.height - heightPx) / 2f + ), + size = Size( + width = widthPx, + height = heightPx + ), + cornerRadius = CornerRadius(widthPx / 2) + ) +} + +@Composable +private fun WavySeekBarContent( + backgroundColor: Color, + amplitude: () -> Dp, + position: Long, + range: ClosedRange, + shape: Shape, + color: Color +) { + val fraction = (position - range.start) / (range.endInclusive - range.start).toFloat() + val progress by rememberInfiniteTransition(label = "").animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(2000, easing = LinearEasing)), + label = "" + ) + + Box( + modifier = Modifier + .fillMaxWidth() + .height(6.dp) + ) { + Spacer( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(1f - fraction) + .background(color = backgroundColor, shape = shape) + .align(Alignment.CenterEnd) + ) + + Canvas( + modifier = Modifier + .fillMaxWidth(fraction) + .height(amplitude()) + .align(Alignment.CenterStart) + ) { + drawPath( + path = wavePath( + size = size, + progress = progress + ), + color = color, + style = Stroke( + width = 3.dp.toPx(), + cap = StrokeCap.Round + ) + ) + } + } +} + +@Composable +private fun Duration( + position: Long, + duration: Long, + show: Boolean +) { + val typography = LocalAppearance.current.typography + + AnimatedVisibility( + visible = show, + enter = fadeIn() + expandVertically { -it }, + exit = fadeOut() + shrinkVertically { -it } + ) { + Column { + Spacer(Modifier.height(8.dp)) + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + BasicText( + text = formatAsDuration(position), + style = typography.xxs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (duration != C.TIME_UNSET) BasicText( + text = formatAsDuration(duration), + style = typography.xxs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } +} + +private fun Density.wavePath( + size: Size, + progress: Float, + quality: Float = PlayerPreferences.wavySeekBarQuality.quality +) = Path().apply { + val (width, height) = size + val progressTau = progress * 2 * PI.toFloat() + val scale = 7.dp.toPx() + + fun f(x: Float) = (sin(x / scale + progressTau) + 0.5f) * height + + moveTo(0f, f(0f)) + + var x = 0f + while (x < width) { + lineTo(x, f(x)) + x += quality + } + lineTo(width, f(width)) } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/ShimmerHost.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/ShimmerHost.kt index 4220e8c..4014342 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/ShimmerHost.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/ShimmerHost.kt @@ -19,20 +19,18 @@ fun ShimmerHost( horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalArrangement: Arrangement.Vertical = Arrangement.Top, content: @Composable ColumnScope.() -> Unit -) { - Column( - horizontalAlignment = horizontalAlignment, - verticalArrangement = verticalArrangement, - modifier = modifier - .shimmer() - .graphicsLayer(alpha = 0.99f) - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), - blendMode = BlendMode.DstIn - ) - }, - content = content - ) -} +) = Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + modifier = modifier + .shimmer() + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), + blendMode = BlendMode.DstIn + ) + }, + content = content +) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBanner.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBanner.kt deleted file mode 100644 index 69ef846..0000000 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBanner.kt +++ /dev/null @@ -1,87 +0,0 @@ -package it.hamy.muza.ui.components - -import android.os.CountDownTimer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import com.yandex.mobile.ads.banner.BannerAdEventListener -import com.yandex.mobile.ads.banner.BannerAdSize -import com.yandex.mobile.ads.banner.BannerAdView -import com.yandex.mobile.ads.common.AdRequest -import com.yandex.mobile.ads.common.AdRequestError -import com.yandex.mobile.ads.common.AdTheme -import com.yandex.mobile.ads.common.ImpressionData - -@Composable -fun YandexAdsBanner(id: String) { - AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> - BannerAdView(context).apply { - /** - * ID блока рекламы - */ - setAdUnitId(id) - /** - * Размер блока рекламы - */ - setAdSize(BannerAdSize.inlineSize(context, 110, 110)) - /** - * Билдер запроса - */ - val adRequest = AdRequest.Builder() - .setPreferredTheme(AdTheme.DARK) - .build() - - - val timer = object : CountDownTimer(4000, 1000) { - override fun onTick(millisUntilFinished: Long) { - // Здесь можно выполнить действия, которые нужно сделать каждую секунду - } - - override fun onFinish() { - // Здесь вызывается метод loadAd(adRequest) после истечения таймера - loadAd(adRequest) - // Здесь можно повторить таймер, чтобы он всегда повторялся - //start() - } - } - - /** - * Слушатель экшнов - */ - setBannerAdEventListener(object : BannerAdEventListener { - override fun onAdLoaded() { - // Запускаем таймер - timer.start() - } - - override fun onAdFailedToLoad(p0: AdRequestError) { - /** - * Тут дебажим ошибки - */ - } - - override fun onAdClicked() { - - } - - override fun onLeftApplication() { - - } - - override fun onReturnedToApplication() { - loadAd(adRequest) - } - - override fun onImpression(p0: ImpressionData?) { - - } - - }) - /** - * Запуск баннера - */ - loadAd(adRequest) - } - }) -} \ No newline at end of file diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBannerQuickPicksCenter.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBannerQuickPicksCenter.kt deleted file mode 100644 index 3f98f7d..0000000 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBannerQuickPicksCenter.kt +++ /dev/null @@ -1,90 +0,0 @@ -package it.hamy.muza.ui.components - -import android.os.CountDownTimer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.viewinterop.AndroidView -import com.yandex.mobile.ads.banner.BannerAdEventListener -import com.yandex.mobile.ads.banner.BannerAdSize -import com.yandex.mobile.ads.banner.BannerAdView -import com.yandex.mobile.ads.common.AdRequest -import com.yandex.mobile.ads.common.AdRequestError -import com.yandex.mobile.ads.common.AdTheme -import com.yandex.mobile.ads.common.ImpressionData - -@Composable -fun YandexAdsBannerQuickPicksCenter(id: String) { - AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> - BannerAdView(context).apply { - /** - * ID блока рекламы - */ - setAdUnitId(id) - /** - * Размер блока рекламы - */ - setAdSize(BannerAdSize.inlineSize(context, 260, 60)) - /** - * Билдер запроса - */ - val adRequest = AdRequest.Builder() - .setPreferredTheme(AdTheme.DARK) - .build() - - - val timer = object : CountDownTimer(4000, 1000) { - override fun onTick(millisUntilFinished: Long) { - // Здесь можно выполнить действия, которые нужно сделать каждую секунду - } - - override fun onFinish() { - // Здесь вызывается метод loadAd(adRequest) после истечения таймера - loadAd(adRequest) - // Здесь можно повторить таймер, чтобы он всегда повторялся - //start() - } - } - - - /** - * Слушатель экшнов - */ - setBannerAdEventListener(object : BannerAdEventListener { - override fun onAdLoaded() { - // Запускаем таймер - timer.start() - } - - override fun onAdFailedToLoad(p0: AdRequestError) { - /** - * Тут дебажим ошибки - */ - loadAd(adRequest) - } - - override fun onAdClicked() { - - } - - override fun onLeftApplication() { - - } - - override fun onReturnedToApplication() { - loadAd(adRequest) - } - - override fun onImpression(p0: ImpressionData?) { - - } - - }) - - /** - * Запуск баннера - */ - loadAd(adRequest) - } - }) -} \ No newline at end of file diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBanners.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBanners.kt new file mode 100644 index 0000000..2515323 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/YandexAdsBanners.kt @@ -0,0 +1,185 @@ +package it.hamy.muza.ui.components + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.yandex.mobile.ads.banner.BannerAdEventListener +import com.yandex.mobile.ads.banner.BannerAdSize +import com.yandex.mobile.ads.banner.BannerAdView +import com.yandex.mobile.ads.common.AdRequest +import com.yandex.mobile.ads.common.AdRequestError +import com.yandex.mobile.ads.common.AdTheme +import com.yandex.mobile.ads.common.ImpressionData + +@Composable +fun PlaylistAd(id: String) { + AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> + BannerAdView(context).apply { + /** + * ID блока рекламы + */ + setAdUnitId(id) + /** + * Размер блока рекламы + */ + setAdSize(BannerAdSize.inlineSize(context, 120, 120)) + /** + * Билдер запроса + */ + val adRequest = AdRequest.Builder() + .setPreferredTheme(AdTheme.DARK) + .build() + /** + * Слушатель экшнов + */ + setBannerAdEventListener(object : BannerAdEventListener { + override fun onAdLoaded() { + + } + + override fun onAdFailedToLoad(p0: AdRequestError) { + /** + * Тут дебажим ошибки + */ + } + + override fun onAdClicked() { + + } + + override fun onLeftApplication() { + + } + + override fun onReturnedToApplication() { + + } + + override fun onImpression(p0: ImpressionData?) { + + } + + }) + /** + * Запуск баннера + */ + loadAd(adRequest) + } + }) +} + +@Composable +fun QuickpicksAd(id: String) { + AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> + BannerAdView(context).apply { + /** + * ID блока рекламы + */ + setAdUnitId(id) + /** + * Размер блока рекламы + */ + setAdSize(BannerAdSize.inlineSize(context, 260, 60)) + /** + * Билдер запроса + */ + val adRequest = AdRequest.Builder() + .setPreferredTheme(AdTheme.DARK) + .build() + /** + * Слушатель экшнов + */ + setBannerAdEventListener(object : BannerAdEventListener { + override fun onAdLoaded() { + + } + + override fun onAdFailedToLoad(p0: AdRequestError) { + /** + * Тут дебажим ошибки + */ + } + + override fun onAdClicked() { + + } + + override fun onLeftApplication() { + + } + + override fun onReturnedToApplication() { + + } + + override fun onImpression(p0: ImpressionData?) { + + } + + }) + /** + * Запуск баннера + */ + loadAd(adRequest) + } + }) +} + +@Composable +fun NavigationAd(id: String) { + AndroidView(modifier = Modifier.fillMaxSize(), factory = { context -> + BannerAdView(context).apply { + /** + * ID блока рекламы + */ + setAdUnitId(id) + /** + * Размер блока рекламы + */ + setAdSize(BannerAdSize.inlineSize(context, 260, 60)) + /** + * Билдер запроса + */ + val adRequest = AdRequest.Builder() + .setPreferredTheme(AdTheme.DARK) + .build() + /** + * Слушатель экшнов + */ + setBannerAdEventListener(object : BannerAdEventListener { + override fun onAdLoaded() { + + } + + override fun onAdFailedToLoad(p0: AdRequestError) { + /** + * Тут дебажим ошибки + */ + } + + override fun onAdClicked() { + + } + + override fun onLeftApplication() { + + } + + override fun onReturnedToApplication() { + + } + + override fun onImpression(p0: ImpressionData?) { + + } + + }) + /** + * Запуск баннера + */ + loadAd(adRequest) + } + }) +} + diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Attribution.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Attribution.kt new file mode 100644 index 0000000..8e199ce --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Attribution.kt @@ -0,0 +1,76 @@ +package it.hamy.muza.ui.components.themed + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import it.hamy.muza.LocalPlayerAwareWindowInsets +import it.hamy.muza.R +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.align +import it.hamy.muza.utils.color +import it.hamy.muza.utils.secondary +import it.hamy.muza.utils.semiBold + +@Composable +fun Attribution( + text: String, + modifier: Modifier = Modifier +) = Column { + val (colorPalette, typography) = LocalAppearance.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val endPaddingValues = windowInsets + .only(WindowInsetsSides.End) + .asPaddingValues() + + val attributionsIndex = text.lastIndexOf("\n\n${stringResource(R.string.from_wikipedia)}") + + Row( + modifier = modifier.padding(endPaddingValues) + ) { + BasicText( + text = stringResource(R.string.quote_open), + style = typography.xxl.semiBold, + modifier = Modifier + .offset(y = (-8).dp) + .align(Alignment.Top) + ) + + BasicText( + text = if (attributionsIndex == -1) text else text.substring(0, attributionsIndex), + style = typography.xxs.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + BasicText( + text = stringResource(R.string.quote_close), + style = typography.xxl.semiBold, + modifier = Modifier + .offset(y = 4.dp) + .align(Alignment.Bottom) + ) + } + + if (attributionsIndex != -1) BasicText( + text = stringResource(R.string.wikipedia_cc_by_sa), + style = typography.xxs.color(colorPalette.textDisabled) + .align(TextAlign.End), + modifier = Modifier + .padding(horizontal = 16.dp) + .padding(bottom = 16.dp) + .padding(endPaddingValues) + ) +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/BigIconButton.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/BigIconButton.kt new file mode 100644 index 0000000..d64e09d --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/BigIconButton.kt @@ -0,0 +1,54 @@ +package it.hamy.muza.ui.components.themed + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import it.hamy.muza.ui.modifiers.pressable +import it.hamy.muza.ui.styling.LocalAppearance + +@Composable +fun BigIconButton( + @DrawableRes iconId: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + onPress: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null, + backgroundColor: Color = LocalAppearance.current.colorPalette.background2, + contentColor: Color = LocalAppearance.current.colorPalette.text, + shape: Shape = RoundedCornerShape(32.dp) +) = Box( + modifier + .clip(shape) + .let { + if (onPress == null && onCancel == null) it.clickable(onClick = onClick) + else it.pressable( + onPress = { onPress?.invoke() }, + onCancel = { onCancel?.invoke() }, + onRelease = onClick + ) + } + .background(backgroundColor) + .height(64.dp), + contentAlignment = Alignment.Center +) { + Image( + painter = painterResource(iconId), + contentDescription = null, + modifier = Modifier.size(24.dp), + colorFilter = ColorFilter.tint(contentColor) + ) +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Dialog.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Dialog.kt index 264f0ed..3537d35 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Dialog.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Dialog.kt @@ -1,8 +1,6 @@ package it.hamy.muza.ui.components.themed -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut +import androidx.annotation.IntRange import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -14,25 +12,25 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester @@ -40,20 +38,18 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.center import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextRange -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties +import it.hamy.muza.R import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.utils.center import it.hamy.muza.utils.drawCircle import it.hamy.muza.utils.medium -import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.delay @Composable @@ -62,70 +58,40 @@ fun TextFieldDialog( onDismiss: () -> Unit, onDone: (String) -> Unit, modifier: Modifier = Modifier, - cancelText: String = "Отмена", - doneText: String = "Готово", + cancelText: String = stringResource(R.string.cancel), + doneText: String = stringResource(R.string.done), initialTextInput: String = "", singleLine: Boolean = true, maxLines: Int = 1, onCancel: () -> Unit = onDismiss, - isTextInputValid: (String) -> Boolean = { it.isNotEmpty() } + isTextInputValid: (String) -> Boolean = { it.isNotEmpty() }, + keyboardOptions: KeyboardOptions = KeyboardOptions() ) { - val focusRequester = remember { - FocusRequester() - } - val (colorPalette, typography) = LocalAppearance.current + val focusRequester = remember { FocusRequester() } + val (_, typography) = LocalAppearance.current - var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue( - text = initialTextInput, - selection = TextRange(initialTextInput.length) - ) - ) - } + var value by rememberSaveable(initialTextInput) { mutableStateOf(initialTextInput) } DefaultDialog( onDismiss = onDismiss, modifier = modifier ) { - BasicTextField( - value = textFieldValue, - onValueChange = { textFieldValue = it }, + TextField( + value = value, + onValueChange = { value = it }, textStyle = typography.xs.semiBold.center, singleLine = singleLine, maxLines = maxLines, - keyboardOptions = KeyboardOptions(imeAction = if (singleLine) ImeAction.Done else ImeAction.None), + hintText = hintText, keyboardActions = KeyboardActions( onDone = { - if (isTextInputValid(textFieldValue.text)) { + if (isTextInputValid(value)) { onDismiss() - onDone(textFieldValue.text) + onDone(value) } } ), - cursorBrush = SolidColor(colorPalette.text), - decorationBox = { innerTextField -> - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(1f) - ) { - androidx.compose.animation.AnimatedVisibility( - visible = textFieldValue.text.isEmpty(), - enter = fadeIn(tween(100)), - exit = fadeOut(tween(100)), - ) { - BasicText( - text = hintText, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = typography.xs.semiBold.secondary, - ) - } - - innerTextField() - } - }, + keyboardOptions = keyboardOptions, modifier = Modifier .padding(all = 16.dp) .weight(weight = 1f, fill = false) @@ -134,8 +100,7 @@ fun TextFieldDialog( Row( horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { DialogTextButton( text = cancelText, @@ -146,9 +111,9 @@ fun TextFieldDialog( primary = true, text = doneText, onClick = { - if (isTextInputValid(textFieldValue.text)) { + if (isTextInputValid(value)) { onDismiss() - onDone(textFieldValue.text) + onDone(value) } } ) @@ -161,14 +126,39 @@ fun TextFieldDialog( } } +@Composable +fun NumberFieldDialog( + onDismiss: () -> Unit, + onDone: (T) -> Unit, + initialValue: T, + defaultValue: T, + convert: (String) -> T?, + range: ClosedRange, + modifier: Modifier = Modifier, + cancelText: String = stringResource(R.string.cancel), + doneText: String = stringResource(R.string.done), + onCancel: () -> Unit = onDismiss +) where T : Number, T : Comparable = TextFieldDialog( + hintText = "", + onDismiss = onDismiss, + onDone = { onDone((convert(it) ?: defaultValue).coerceIn(range)) }, + modifier = modifier, + cancelText = cancelText, + doneText = doneText, + initialTextInput = initialValue.toString(), + onCancel = onCancel, + isTextInputValid = { true }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) +) + @Composable fun ConfirmationDialog( text: String, onDismiss: () -> Unit, onConfirm: () -> Unit, modifier: Modifier = Modifier, - cancelText: String = "Отменить", - confirmText: String = "Продолжить", + cancelText: String = stringResource(R.string.cancel), + confirmText: String = stringResource(R.string.confirm), onCancel: () -> Unit = onDismiss ) { val (_, typography) = LocalAppearance.current @@ -180,14 +170,12 @@ fun ConfirmationDialog( BasicText( text = text, style = typography.xs.medium.center, - modifier = Modifier - .padding(all = 16.dp) + modifier = Modifier.padding(all = 16.dp) ) Row( horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { DialogTextButton( text = cancelText, @@ -206,129 +194,198 @@ fun ConfirmationDialog( } } -@OptIn(ExperimentalComposeUiApi::class) @Composable -inline fun DefaultDialog( - noinline onDismiss: () -> Unit, +fun DefaultDialog( + onDismiss: () -> Unit, modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, - crossinline content: @Composable ColumnScope.() -> Unit -) { - val (colorPalette) = LocalAppearance.current + horizontalPadding: Dp = 24.dp, + content: @Composable ColumnScope.() -> Unit +) = Dialog(onDismissRequest = onDismiss) { + Column( + horizontalAlignment = horizontalAlignment, + modifier = modifier + .padding(all = 48.dp) + .background( + color = LocalAppearance.current.colorPalette.background1, + shape = RoundedCornerShape(8.dp) + ) + .padding( + horizontal = horizontalPadding, + vertical = 16.dp + ), + content = content + ) +} - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false) - ) { - Column( - horizontalAlignment = horizontalAlignment, - modifier = modifier - .padding(all = 48.dp) - .background( - color = colorPalette.background1, - shape = RoundedCornerShape(8.dp) +@Composable +fun ValueSelectorDialog( + onDismiss: () -> Unit, + title: String, + selectedValue: T, + values: ImmutableList, + onValueSelected: (T) -> Unit, + modifier: Modifier = Modifier, + valueText: @Composable (T) -> String = { it.toString() } +) = Dialog(onDismissRequest = onDismiss) { + ValueSelectorDialogBody( + onDismiss = onDismiss, + title = title, + selectedValue = selectedValue, + values = values, + onValueSelected = onValueSelected, + modifier = modifier + .padding(all = 48.dp) + .background( + color = LocalAppearance.current.colorPalette.background1, + shape = RoundedCornerShape(8.dp) + ) + .padding(vertical = 16.dp), + valueText = valueText + ) +} + +@Composable +fun ValueSelectorDialogBody( + onDismiss: () -> Unit, + title: String, + selectedValue: T?, + values: ImmutableList, + onValueSelected: (T) -> Unit, + modifier: Modifier = Modifier, + valueText: @Composable (T) -> String = { it.toString() } +) = Column(modifier = modifier) { + val (colorPalette, typography) = LocalAppearance.current + + BasicText( + text = title, + style = typography.s.semiBold, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp) + ) + + Column(modifier = Modifier.verticalScroll(rememberScrollState())) { + values.forEach { value -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .clickable( + onClick = { + onDismiss() + onValueSelected(value) + } + ) + .padding(vertical = 12.dp, horizontal = 24.dp) + .fillMaxWidth() + ) { + if (selectedValue == value) Canvas( + modifier = Modifier + .size(18.dp) + .background( + color = colorPalette.accent, + shape = CircleShape + ) + ) { + drawCircle( + color = colorPalette.onAccent, + radius = 4.dp.toPx(), + center = size.center, + shadow = Shadow( + color = Color.Black.copy(alpha = 0.4f), + blurRadius = 4.dp.toPx(), + offset = Offset(x = 0f, y = 1.dp.toPx()) + ) + ) + } else Spacer( + modifier = Modifier + .size(18.dp) + .border( + width = 1.dp, + color = colorPalette.textDisabled, + shape = CircleShape + ) ) - .padding(horizontal = 24.dp, vertical = 16.dp), - content = content + + BasicText( + text = valueText(value), + style = typography.xs.medium + ) + } + } + } + + Box( + modifier = Modifier + .align(Alignment.End) + .padding(end = 24.dp) + ) { + DialogTextButton( + text = stringResource(R.string.cancel), + onClick = onDismiss ) } } @Composable -inline fun ValueSelectorDialog( - noinline onDismiss: () -> Unit, +fun SliderDialog( + onDismiss: () -> Unit, title: String, - selectedValue: T, - values: List, - crossinline onValueSelected: (T) -> Unit, + provideState: @Composable () -> MutableState, + onSlideCompleted: (newState: Float) -> Unit, + min: Float, + max: Float, modifier: Modifier = Modifier, - crossinline valueText: (T) -> String = { it.toString() } -) { + toDisplay: @Composable (Float) -> String = { it.toString() }, + @IntRange(from = 0) steps: Int = 0, + content: @Composable () -> Unit = { } +) = Dialog(onDismissRequest = onDismiss) { val (colorPalette, typography) = LocalAppearance.current + var state by provideState() - Dialog(onDismissRequest = onDismiss) { - Column( - modifier = modifier - .padding(all = 48.dp) - .background(color = colorPalette.background1, shape = RoundedCornerShape(8.dp)) - .padding(vertical = 16.dp), + Column( + modifier = modifier + .padding(all = 48.dp) + .background(color = colorPalette.background1, shape = RoundedCornerShape(8.dp)) + .padding(vertical = 16.dp) + ) { + BasicText( + text = title, + style = typography.s.semiBold, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp) + ) + + Slider( + state = state, + setState = { state = it }, + onSlideCompleted = { onSlideCompleted(state) }, + range = min..max, + steps = steps, + modifier = Modifier + .height(36.dp) + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) + + BasicText( + text = toDisplay(state), + style = typography.s.semiBold, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(vertical = 8.dp) + ) + + content() + + Box( + modifier = Modifier + .align(Alignment.End) + .padding(end = 24.dp) ) { - BasicText( - text = title, - style = typography.s.semiBold, + DialogTextButton( + text = stringResource(R.string.confirm), + onClick = onDismiss, modifier = Modifier - .padding(vertical = 8.dp, horizontal = 24.dp) ) - - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - ) { - values.forEach { value -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - modifier = Modifier - .clickable( - onClick = { - onDismiss() - onValueSelected(value) - } - ) - .padding(vertical = 12.dp, horizontal = 24.dp) - .fillMaxWidth() - ) { - if (selectedValue == value) { - Canvas( - modifier = Modifier - .size(18.dp) - .background( - color = colorPalette.accent, - shape = CircleShape - ) - ) { - drawCircle( - color = colorPalette.onAccent, - radius = 4.dp.toPx(), - center = size.center, - shadow = Shadow( - color = Color.Black.copy(alpha = 0.4f), - blurRadius = 4.dp.toPx(), - offset = Offset(x = 0f, y = 1.dp.toPx()) - ) - ) - } - } else { - Spacer( - modifier = Modifier - .size(18.dp) - .border( - width = 1.dp, - color = colorPalette.textDisabled, - shape = CircleShape - ) - ) - } - - BasicText( - text = valueText(value), - style = typography.xs.medium - ) - } - } - } - - Box( - modifier = Modifier - .align(Alignment.End) - .padding(end = 24.dp) - ) { - DialogTextButton( - text = "Отмена", - onClick = onDismiss, - modifier = Modifier - ) - } } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/DialogTextButton.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/DialogTextButton.kt index 8423634..398197e 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/DialogTextButton.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/DialogTextButton.kt @@ -20,7 +20,7 @@ fun DialogTextButton( onClick: () -> Unit, modifier: Modifier = Modifier, enabled: Boolean = true, - primary: Boolean = false, + primary: Boolean = false ) { val (colorPalette, typography) = LocalAppearance.current diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Divider.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Divider.kt new file mode 100644 index 0000000..f81fd1b --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Divider.kt @@ -0,0 +1,72 @@ +package it.hamy.muza.ui.components.themed + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import it.hamy.muza.ui.styling.LocalAppearance + +/** + * A simple horizontal divider, derived from Material Design + */ +@Composable +fun HorizontalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = LocalAppearance.current.colorPalette.textDisabled +) = Canvas( + modifier = modifier + .fillMaxWidth() + .height(thickness) +) { + val stroke = thickness.toPx() + + drawLine( + color = color, + strokeWidth = stroke, + start = Offset( + x = 0f, + y = stroke / 2 + ), + end = Offset( + x = size.width, + y = stroke / 2 + ) + ) +} + +/** + * A simple vertical divider, derived from Material Design + */ +@Composable +fun VerticalDivider( + modifier: Modifier = Modifier, + thickness: Dp = 1.dp, + color: Color = LocalAppearance.current.colorPalette.textDisabled +) = Canvas( + modifier = modifier + .fillMaxHeight() + .width(thickness) +) { + val stroke = thickness.toPx() + + drawLine( + color = color, + strokeWidth = stroke, + start = Offset( + x = stroke / 2, + y = 0f + ), + end = Offset( + x = stroke / 2, + y = size.height + ) + ) +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/FloatingActionsContainer.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/FloatingActionsContainer.kt index 9bc3425..399abbf 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/FloatingActionsContainer.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/FloatingActionsContainer.kt @@ -31,7 +31,6 @@ import it.hamy.muza.utils.scrollingInfo import it.hamy.muza.utils.smoothScrollToTop import kotlinx.coroutines.launch -@ExperimentalAnimationApi @Composable fun BoxScope.FloatingActionsContainerWithScrollToTop( lazyGridState: LazyGridState, @@ -55,7 +54,6 @@ fun BoxScope.FloatingActionsContainerWithScrollToTop( ) } -@ExperimentalAnimationApi @Composable fun BoxScope.FloatingActionsContainerWithScrollToTop( lazyListState: LazyListState, @@ -79,7 +77,6 @@ fun BoxScope.FloatingActionsContainerWithScrollToTop( ) } -@ExperimentalAnimationApi @Composable fun BoxScope.FloatingActionsContainerWithScrollToTop( scrollState: ScrollState, @@ -102,7 +99,7 @@ fun BoxScope.FloatingActionsContainerWithScrollToTop( ) } -@ExperimentalAnimationApi +@OptIn(ExperimentalAnimationApi::class) @Composable fun BoxScope.FloatingActions( transitionState: MutableTransitionState, @@ -122,13 +119,17 @@ fun BoxScope.FloatingActions( modifier = modifier .align(Alignment.BottomEnd) .padding(end = 16.dp) - .padding(windowInsets.only(WindowInsetsSides.End).asPaddingValues()) + .padding( + windowInsets + .only(WindowInsetsSides.End) + .asPaddingValues() + ) ) { onScrollToTop?.let { transition.AnimatedVisibility( visible = { it?.isScrollingDown == false && it.isFar }, enter = slideInVertically(tween(500, if (iconId == null) 0 else 100)) { it }, - exit = slideOutVertically(tween(500, 0)) { it }, + exit = slideOutVertically(tween(500, 0)) { it } ) { val coroutineScope = rememberCoroutineScope() @@ -151,8 +152,14 @@ fun BoxScope.FloatingActions( onClick?.let { transition.AnimatedVisibility( visible = { it?.isScrollingDown == false }, - enter = slideInVertically(tween(500, 0)) { it }, - exit = slideOutVertically(tween(500, 100)) { it }, + enter = slideInVertically( + animationSpec = tween(durationMillis = 500, delayMillis = 0), + initialOffsetY = { it } + ), + exit = slideOutVertically( + animationSpec = tween(durationMillis = 500, delayMillis = 100), + targetOffsetY = { it } + ) ) { PrimaryButton( iconId = iconId, diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Header.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Header.kt index f98ddfd..fa48a7b 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Header.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Header.kt @@ -26,74 +26,64 @@ import kotlin.random.Random fun Header( title: String, modifier: Modifier = Modifier, - actionsContent: @Composable RowScope.() -> Unit = {}, -) { - val typography = LocalAppearance.current.typography + actionsContent: @Composable RowScope.() -> Unit = {} +) = Header( + modifier = modifier, + titleContent = { + BasicText( + text = title, + style = LocalAppearance.current.typography.xxl.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, + actionsContent = actionsContent +) - Header( - modifier = modifier, - titleContent = { - BasicText( - text = title, - style = typography.xxl.medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - actionsContent = actionsContent +@Composable +fun Header( + titleContent: @Composable () -> Unit, + actionsContent: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier +) = Box( + contentAlignment = Alignment.CenterEnd, + modifier = modifier + .padding(horizontal = 16.dp) + .height(Dimensions.items.headerHeight) + .fillMaxWidth() +) { + titleContent() + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .align(Alignment.BottomEnd) + .heightIn(min = 48.dp), + content = actionsContent ) } @Composable -fun Header( - modifier: Modifier = Modifier, - titleContent: @Composable () -> Unit, - actionsContent: @Composable RowScope.() -> Unit, -) { - Box( - contentAlignment = Alignment.CenterEnd, - modifier = modifier - .padding(horizontal = 16.dp) - .height(Dimensions.headerHeight) - .fillMaxWidth() - ) { - titleContent() - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .align(Alignment.BottomEnd) - .heightIn(min = 48.dp), - content = actionsContent, - ) - } -} - -@Composable -fun HeaderPlaceholder( - modifier: Modifier = Modifier, +fun HeaderPlaceholder(modifier: Modifier = Modifier) = Box( + contentAlignment = Alignment.CenterEnd, + modifier = modifier + .padding(horizontal = 16.dp) + .height(Dimensions.items.headerHeight) + .fillMaxWidth() ) { val (colorPalette, typography) = LocalAppearance.current Box( - contentAlignment = Alignment.CenterEnd, - modifier = modifier - .padding(horizontal = 16.dp) - .height(Dimensions.headerHeight) - .fillMaxWidth() + modifier = Modifier + .background(colorPalette.shimmer) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) ) { - Box( - modifier = Modifier - .background(colorPalette.shimmer) - .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) - ) { - BasicText( - text = "", - style = typography.xxl.medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + BasicText( + text = " ", + style = typography.xxl.medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/IconButton.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/IconButton.kt index 1500dd5..c91ba4c 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/IconButton.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/IconButton.kt @@ -23,19 +23,17 @@ fun HeaderIconButton( color: Color, modifier: Modifier = Modifier, enabled: Boolean = true, - indication: Indication? = null -) { - IconButton( - icon = icon, - color = color, - onClick = onClick, - enabled = enabled, - indication = indication, - modifier = modifier - .padding(all = 4.dp) - .size(18.dp) - ) -} + indication: Indication? = rememberRipple(bounded = false) +) = IconButton( + icon = icon, + color = color, + onClick = onClick, + enabled = enabled, + indication = indication, + modifier = modifier + .padding(all = 4.dp) + .size(18.dp) +) @Composable fun IconButton( @@ -44,19 +42,17 @@ fun IconButton( color: Color, modifier: Modifier = Modifier, enabled: Boolean = true, - indication: Indication? = null -) { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = ColorFilter.tint(color), - modifier = Modifier - .clickable( - indication = indication ?: rememberRipple(bounded = false), - interactionSource = remember { MutableInteractionSource() }, - enabled = enabled, - onClick = onClick - ) - .then(modifier) - ) -} + indication: Indication? = rememberRipple(bounded = false) +) = Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier + .clickable( + indication = indication, + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClick = onClick + ) + .then(modifier) +) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/LayoutWithAdaptiveThumbnail.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/LayoutWithAdaptiveThumbnail.kt index 773d571..529f6e6 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/LayoutWithAdaptiveThumbnail.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/LayoutWithAdaptiveThumbnail.kt @@ -1,6 +1,7 @@ package it.hamy.muza.ui.components.themed import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -15,56 +16,48 @@ import androidx.compose.ui.unit.dp import coil.compose.AsyncImage import com.valentinilk.shimmer.shimmer import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px import it.hamy.muza.ui.styling.shimmer import it.hamy.muza.utils.isLandscape +import it.hamy.muza.utils.px import it.hamy.muza.utils.thumbnail @Composable inline fun LayoutWithAdaptiveThumbnail( thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier, content: @Composable () -> Unit +) = if (isLandscape) Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier ) { - val isLandscape = isLandscape - - if (isLandscape) { - Row(verticalAlignment = Alignment.CenterVertically) { - thumbnailContent() - content() - } - } else { - content() - } -} + thumbnailContent() + content() +} else Box(modifier = modifier) { content() } fun adaptiveThumbnailContent( isLoading: Boolean, url: String?, shape: Shape? = null ): @Composable () -> Unit = { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current + val colorPalette = LocalAppearance.current.colorPalette + val thumbnailShape = LocalAppearance.current.thumbnailShape BoxWithConstraints(contentAlignment = Alignment.Center) { - val thumbnailSizeDp = if (isLandscape) (maxHeight - 128.dp) else (maxWidth - 64.dp) - val thumbnailSizePx = thumbnailSizeDp.px + val thumbnailSize = if (isLandscape) (maxHeight - 128.dp) else (maxWidth - 64.dp) val modifier = Modifier .padding(all = 16.dp) .clip(shape ?: thumbnailShape) - .size(thumbnailSizeDp) + .size(thumbnailSize) - if (isLoading) { - Spacer( - modifier = modifier - .shimmer() - .background(colorPalette.shimmer) - ) - } else { - AsyncImage( - model = url?.thumbnail(thumbnailSizePx), - contentDescription = null, - modifier = modifier - ) - } + if (isLoading) Spacer( + modifier = modifier + .shimmer() + .background(colorPalette.shimmer) + ) else AsyncImage( + model = url?.thumbnail(thumbnailSize.px), + contentDescription = null, + modifier = modifier.background(colorPalette.background1) + ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/MediaItemMenu.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/MediaItemMenu.kt index 99db8cf..d17950f 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/MediaItemMenu.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/MediaItemMenu.kt @@ -2,12 +2,12 @@ package it.hamy.muza.ui.components.themed import android.content.Intent import androidx.activity.compose.BackHandler +import androidx.annotation.OptIn import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.tween -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -27,7 +27,9 @@ import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -39,10 +41,14 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi import it.hamy.innertube.models.NavigationEndpoint import it.hamy.muza.Database import it.hamy.muza.LocalPlayerServiceBinder @@ -54,6 +60,8 @@ import it.hamy.muza.models.Playlist import it.hamy.muza.models.Song import it.hamy.muza.models.SongPlaylistMap import it.hamy.muza.query +import it.hamy.muza.service.PrecacheService +import it.hamy.muza.service.isLocal import it.hamy.muza.transaction import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.screens.albumRoute @@ -61,20 +69,24 @@ import it.hamy.muza.ui.screens.artistRoute import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.favoritesIcon -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.addNext import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.enqueue import it.hamy.muza.utils.forcePlay import it.hamy.muza.utils.formatAsDuration +import it.hamy.muza.utils.isCached +import it.hamy.muza.utils.launchYouTubeMusic import it.hamy.muza.utils.medium +import it.hamy.muza.utils.px import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.thumbnail +import it.hamy.muza.utils.toast import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@ExperimentalAnimationApi +@OptIn(UnstableApi::class) @Composable fun InHistoryMediaItemMenu( onDismiss: () -> Unit, @@ -82,25 +94,21 @@ fun InHistoryMediaItemMenu( modifier: Modifier = Modifier ) { val binder = LocalPlayerServiceBinder.current + var isHiding by remember { mutableStateOf(false) } - var isHiding by remember { - mutableStateOf(false) - } - - if (isHiding) { - ConfirmationDialog( - text = "Вы действительно хотите скрыть эту песню? Время воспроизведения и кэш будут удалены.\n" + "Это действие необратимо", - onDismiss = { isHiding = false }, - onConfirm = { - onDismiss() - query { - // Not sure we can to this here + if (isHiding) ConfirmationDialog( + text = stringResource(R.string.confirm_hide_song), + onDismiss = { isHiding = false }, + onConfirm = { + onDismiss() + query { + runCatching { binder?.cache?.removeResource(song.id) - Database.incrementTotalPlayTimeMs(song.id, -song.totalPlayTimeMs) + Database.delete(song) } } - ) - } + } + ) NonQueuedMediaItemMenu( mediaItem = song.asMediaItem, @@ -110,7 +118,6 @@ fun InHistoryMediaItemMenu( ) } -@ExperimentalAnimationApi @Composable fun InPlaylistMediaItemMenu( onDismiss: () -> Unit, @@ -118,21 +125,18 @@ fun InPlaylistMediaItemMenu( positionInPlaylist: Int, song: Song, modifier: Modifier = Modifier -) { - NonQueuedMediaItemMenu( - mediaItem = song.asMediaItem, - onDismiss = onDismiss, - onRemoveFromPlaylist = { - transaction { - Database.move(playlistId, positionInPlaylist, Int.MAX_VALUE) - Database.delete(SongPlaylistMap(song.id, playlistId, Int.MAX_VALUE)) - } - }, - modifier = modifier - ) -} +) = NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = onDismiss, + onRemoveFromPlaylist = { + transaction { + Database.move(playlistId, positionInPlaylist, Int.MAX_VALUE) + Database.delete(SongPlaylistMap(song.id, playlistId, Int.MAX_VALUE)) + } + }, + modifier = modifier +) -@ExperimentalAnimationApi @Composable fun NonQueuedMediaItemMenu( onDismiss: () -> Unit, @@ -140,7 +144,7 @@ fun NonQueuedMediaItemMenu( modifier: Modifier = Modifier, onRemoveFromPlaylist: (() -> Unit)? = null, onHideFromDatabase: (() -> Unit)? = null, - onRemoveFromQuickPicks: (() -> Unit)? = null, + onRemoveFromQuickPicks: (() -> Unit)? = null ) { val binder = LocalPlayerServiceBinder.current @@ -166,7 +170,6 @@ fun NonQueuedMediaItemMenu( ) } -@ExperimentalAnimationApi @Composable fun QueuedMediaItemMenu( onDismiss: () -> Unit, @@ -179,14 +182,11 @@ fun QueuedMediaItemMenu( BaseMediaItemMenu( mediaItem = mediaItem, onDismiss = onDismiss, - onRemoveFromQueue = if (indexInQueue != null) ({ - binder?.player?.removeMediaItem(indexInQueue) - }) else null, + onRemoveFromQueue = indexInQueue?.let { index -> { binder?.player?.removeMediaItem(index) } }, modifier = modifier ) } -@ExperimentalAnimationApi @Composable fun BaseMediaItemMenu( onDismiss: () -> Unit, @@ -201,6 +201,8 @@ fun BaseMediaItemMenu( onRemoveFromPlaylist: (() -> Unit)? = null, onHideFromDatabase: (() -> Unit)? = null, onRemoveFromQuickPicks: (() -> Unit)? = null, + onShowSpeedDialog: (() -> Unit)? = null, + onShowNormalizationDialog: (() -> Unit)? = null ) { val context = LocalContext.current @@ -242,15 +244,17 @@ fun BaseMediaItemMenu( context.startActivity(Intent.createChooser(sendIntent, null)) }, onRemoveFromQuickPicks = onRemoveFromQuickPicks, + onShowSpeedDialog = onShowSpeedDialog, + onShowNormalizationDialog = onShowNormalizationDialog, modifier = modifier ) } -@ExperimentalAnimationApi @Composable fun MediaItemMenu( - onDismiss: () -> Unit, mediaItem: MediaItem, + onDismiss: () -> Unit, + onShare: () -> Unit, modifier: Modifier = Modifier, onGoToEqualizer: (() -> Unit)? = null, onShowSleepTimer: (() -> Unit)? = null, @@ -264,23 +268,28 @@ fun MediaItemMenu( onGoToAlbum: ((String) -> Unit)? = null, onGoToArtist: ((String) -> Unit)? = null, onRemoveFromQuickPicks: (() -> Unit)? = null, - onShare: () -> Unit + onShowSpeedDialog: (() -> Unit)? = null, + onShowNormalizationDialog: (() -> Unit)? = null ) { val (colorPalette) = LocalAppearance.current val density = LocalDensity.current + val uriHandler = LocalUriHandler.current + val playerServiceBinder = LocalPlayerServiceBinder.current + val context = LocalContext.current - var isViewingPlaylists by remember { - mutableStateOf(false) - } + val isLocal by remember { derivedStateOf { mediaItem.isLocal } } - var height by remember { - mutableStateOf(0.dp) - } + var isViewingPlaylists by remember { mutableStateOf(false) } + var height by remember { mutableStateOf(0.dp) } + var likedAt by remember { mutableStateOf(null) } + var isBlacklisted by remember { mutableStateOf(false) } var albumInfo by remember { - mutableStateOf(mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> - Info(albumId, null) - }) + mutableStateOf( + mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> + Info(albumId, null) + } + ) } var artistsInfo by remember { @@ -295,16 +304,13 @@ fun MediaItemMenu( ) } - var likedAt by remember { - mutableStateOf(null) - } - LaunchedEffect(Unit) { withContext(Dispatchers.IO) { if (albumInfo == null) albumInfo = Database.songAlbumInfo(mediaItem.mediaId) if (artistsInfo == null) artistsInfo = Database.songArtistInfo(mediaItem.mediaId) - Database.likedAt(mediaItem.mediaId).collect { likedAt = it } + launch { Database.likedAt(mediaItem.mediaId).collect { likedAt = it } } + launch { Database.blacklisted(mediaItem.mediaId).collect { isBlacklisted = it } } } } @@ -312,41 +318,33 @@ fun MediaItemMenu( targetState = isViewingPlaylists, transitionSpec = { val animationSpec = tween(400) - val slideDirection = - if (targetState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right + val slideDirection = if (targetState) AnimatedContentTransitionScope.SlideDirection.Left + else AnimatedContentTransitionScope.SlideDirection.Right - slideIntoContainer(slideDirection, animationSpec) with + slideIntoContainer(slideDirection, animationSpec) togetherWith slideOutOfContainer(slideDirection, animationSpec) - } + }, + label = "" ) { currentIsViewingPlaylists -> if (currentIsViewingPlaylists) { val playlistPreviews by remember { Database.playlistPreviews(PlaylistSortBy.DateAdded, SortOrder.Descending) }.collectAsState(initial = emptyList(), context = Dispatchers.IO) - var isCreatingNewPlaylist by rememberSaveable { - mutableStateOf(false) - } + var isCreatingNewPlaylist by rememberSaveable { mutableStateOf(false) } - if (isCreatingNewPlaylist && onAddToPlaylist != null) { - TextFieldDialog( - hintText = "Введите название плейлиста", - onDismiss = { isCreatingNewPlaylist = false }, - onDone = { text -> - onDismiss() - onAddToPlaylist(Playlist(name = text), 0) - } - ) - } + if (isCreatingNewPlaylist && onAddToPlaylist != null) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + onDismiss = { isCreatingNewPlaylist = false }, + onDone = { text -> + onDismiss() + onAddToPlaylist(Playlist(name = text), 0) + } + ) - BackHandler { - isViewingPlaylists = false - } + BackHandler { isViewingPlaylists = false } - Menu( - modifier = modifier - .requiredHeight(height) - ) { + Menu(modifier = modifier.requiredHeight(height)) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, @@ -363,13 +361,11 @@ fun MediaItemMenu( .size(20.dp) ) - if (onAddToPlaylist != null) { - SecondaryTextButton( - text = "Новый плейлист", - onClick = { isCreatingNewPlaylist = true }, - alternative = true - ) - } + if (onAddToPlaylist != null) SecondaryTextButton( + text = stringResource(R.string.new_playlist), + onClick = { isCreatingNewPlaylist = true }, + alternative = true + ) } onAddToPlaylist?.let { onAddToPlaylist -> @@ -377,7 +373,11 @@ fun MediaItemMenu( MenuEntry( icon = R.drawable.playlist, text = playlistPreview.playlist.name, - secondaryText = "${playlistPreview.songCount} песен", + secondaryText = pluralStringResource( + id = R.plurals.song_count_plural, + count = playlistPreview.songCount, + playlistPreview.songCount + ), onClick = { onDismiss() onAddToPlaylist(playlistPreview.playlist, playlistPreview.songCount) @@ -386,300 +386,321 @@ fun MediaItemMenu( } } } - } else { - Menu( - modifier = modifier - .onPlaced { height = with(density) { it.size.height.toDp() } } + } else Menu( + modifier = modifier.onPlaced { + height = with(density) { it.size.height.toDp() } + } + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(end = 12.dp) ) { - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px + SongItem( + modifier = Modifier.weight(1f), + thumbnailUrl = mediaItem.mediaMetadata.artworkUri + .thumbnail(Dimensions.thumbnails.song.px)?.toString(), + title = mediaItem.mediaMetadata.title?.toString().orEmpty(), + authors = mediaItem.mediaMetadata.artist?.toString().orEmpty(), + duration = null, + thumbnailSize = Dimensions.thumbnails.song + ) - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(end = 12.dp) - ) { - SongItem( - thumbnailUrl = mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx) - ?.toString(), - title = mediaItem.mediaMetadata.title.toString(), - authors = mediaItem.mediaMetadata.artist.toString(), - duration = null, - thumbnailSizeDp = thumbnailSizeDp, + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + IconButton( + icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, + color = colorPalette.favoritesIcon, + onClick = { + query { + if (Database.like( + mediaItem.mediaId, + if (likedAt == null) System.currentTimeMillis() else null + ) == 0 + ) { + Database.insert(mediaItem, Song::toggleLike) + } + } + }, modifier = Modifier - .weight(1f) + .padding(all = 4.dp) + .size(18.dp) ) - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - IconButton( - icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, - color = colorPalette.favoritesIcon, - onClick = { - query { - if (Database.like( - mediaItem.mediaId, - if (likedAt == null) System.currentTimeMillis() else null - ) == 0 - ) { - Database.insert(mediaItem, Song::toggleLike) - } - } - }, - modifier = Modifier - .padding(all = 4.dp) - .size(18.dp) + if (!isLocal) IconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = onShare, + modifier = Modifier + .padding(all = 4.dp) + .size(17.dp) + ) + } + } + + Spacer(Modifier.height(8.dp)) + + Spacer( + modifier = Modifier + .alpha(0.5f) + .align(Alignment.CenterHorizontally) + .background(colorPalette.textDisabled) + .height(1.dp) + .fillMaxWidth(1f) + ) + + Spacer(Modifier.height(8.dp)) + + if (!isLocal && !isCached(mediaItem.mediaId)) MenuEntry( + icon = R.drawable.download, + text = stringResource(R.string.pre_cache), + onClick = { + onDismiss() + PrecacheService.scheduleCache(context.applicationContext, mediaItem) + } + ) + + if (!isLocal) onStartRadio?.let { onStartRadio -> + MenuEntry( + icon = R.drawable.radio, + text = stringResource(R.string.start_radio), + onClick = { + onDismiss() + onStartRadio() + } + ) + } + + onShowSpeedDialog?.let { onShowSpeedDialog -> + MenuEntry( + icon = R.drawable.speed, + text = stringResource(R.string.playback_speed), + onClick = { + onDismiss() + onShowSpeedDialog() + } + ) + } + + onShowNormalizationDialog?.let { onShowNormalizationDialog -> + MenuEntry( + icon = R.drawable.volume_up, + text = stringResource(R.string.song_volume_boost), + onClick = { + onDismiss() + onShowNormalizationDialog() + } + ) + } + + onPlayNext?.let { onPlayNext -> + MenuEntry( + icon = R.drawable.play_skip_forward, + text = stringResource(R.string.play_next), + onClick = { + onDismiss() + onPlayNext() + } + ) + } + + onEnqueue?.let { onEnqueue -> + MenuEntry( + icon = R.drawable.enqueue, + text = stringResource(R.string.enqueue), + onClick = { + onDismiss() + onEnqueue() + } + ) + } + + if (!mediaItem.isLocal) MenuEntry( + icon = R.drawable.remove_circle_outline, + text = if (isBlacklisted) stringResource(R.string.remove_from_blacklist) + else stringResource(R.string.add_to_blacklist), + onClick = { + transaction { + Database.insert(mediaItem) + Database.toggleBlacklist(mediaItem.mediaId) + } + } + ) + + onGoToEqualizer?.let { onGoToEqualizer -> + MenuEntry( + icon = R.drawable.equalizer, + text = stringResource(R.string.equalizer), + onClick = { + onDismiss() + onGoToEqualizer() + } + ) + } + + onShowSleepTimer?.let { + val binder = LocalPlayerServiceBinder.current + val (_, typography) = LocalAppearance.current + + var isShowingSleepTimerDialog by remember { mutableStateOf(false) } + + val sleepTimerMillisLeft by (binder?.sleepTimerMillisLeft ?: flowOf(null)) + .collectAsState(initial = null) + + if (isShowingSleepTimerDialog) { + if (sleepTimerMillisLeft != null) ConfirmationDialog( + text = stringResource(R.string.stop_sleep_timer_prompt), + cancelText = stringResource(R.string.no), + confirmText = stringResource(R.string.stop), + onDismiss = { isShowingSleepTimerDialog = false }, + onConfirm = { + binder?.cancelSleepTimer() + onDismiss() + } + ) else DefaultDialog(onDismiss = { isShowingSleepTimerDialog = false }) { + var amount by remember { mutableIntStateOf(1) } + + BasicText( + text = stringResource(R.string.set_sleep_timer), + style = typography.s.semiBold, + modifier = Modifier.padding(vertical = 8.dp, horizontal = 24.dp) ) - IconButton( - icon = R.drawable.share_social, - color = colorPalette.text, - onClick = onShare, - modifier = Modifier - .padding(all = 4.dp) - .size(17.dp) - ) - } - } - - Spacer( - modifier = Modifier - .height(8.dp) - ) - - Spacer( - modifier = Modifier - .alpha(0.5f) - .align(Alignment.CenterHorizontally) - .background(colorPalette.textDisabled) - .height(1.dp) - .fillMaxWidth(1f) - ) - - Spacer( - modifier = Modifier - .height(8.dp) - ) - - onStartRadio?.let { onStartRadio -> - MenuEntry( - icon = R.drawable.radio, - text = "Включить радио", - onClick = { - onDismiss() - onStartRadio() - } - ) - } - - onPlayNext?.let { onPlayNext -> - MenuEntry( - icon = R.drawable.play_skip_forward, - text = "Следующая", - onClick = { - onDismiss() - onPlayNext() - } - ) - } - - onEnqueue?.let { onEnqueue -> - MenuEntry( - icon = R.drawable.enqueue, - text = "В очередь", - onClick = { - onDismiss() - onEnqueue() - } - ) - } - - onGoToEqualizer?.let { onGoToEqualizer -> - MenuEntry( - icon = R.drawable.equalizer, - text = "Эквалайзер", - onClick = { - onDismiss() - onGoToEqualizer() - } - ) - } - - // TODO: find solution to this shit - onShowSleepTimer?.let { - val binder = LocalPlayerServiceBinder.current - val (_, typography) = LocalAppearance.current - - var isShowingSleepTimerDialog by remember { - mutableStateOf(false) - } - - val sleepTimerMillisLeft by (binder?.sleepTimerMillisLeft - ?: flowOf(null)) - .collectAsState(initial = null) - - if (isShowingSleepTimerDialog) { - if (sleepTimerMillisLeft != null) { - ConfirmationDialog( - text = "Вы хотите отключить таймер сна?", - cancelText = "нет", - confirmText = "отключить", - onDismiss = { isShowingSleepTimerDialog = false }, - onConfirm = { - binder?.cancelSleepTimer() - onDismiss() - } - ) - } else { - DefaultDialog( - onDismiss = { isShowingSleepTimerDialog = false } - ) { - var amount by remember { - mutableStateOf(1) - } - - BasicText( - text = "Установить таймер сна", - style = typography.s.semiBold, - modifier = Modifier - .padding(vertical = 8.dp, horizontal = 24.dp) - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy( - space = 16.dp, - alignment = Alignment.CenterHorizontally - ), - modifier = Modifier - .padding(vertical = 16.dp) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .alpha(if (amount <= 1) 0.5f else 1f) - .clip(CircleShape) - .clickable(enabled = amount > 1) { amount-- } - .size(48.dp) - .background(colorPalette.background0) - ) { - BasicText( - text = "-", - style = typography.xs.semiBold - ) - } - - Box(contentAlignment = Alignment.Center) { - BasicText( - text = "88ч 88м", - style = typography.s.semiBold, - modifier = Modifier - .alpha(0f) - ) - BasicText( - text = "${amount / 6}ч ${(amount % 6) * 10}м", - style = typography.s.semiBold - ) - } - - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .alpha(if (amount >= 60) 0.5f else 1f) - .clip(CircleShape) - .clickable(enabled = amount < 60) { amount++ } - .size(48.dp) - .background(colorPalette.background0) - ) { - BasicText( - text = "+", - style = typography.xs.semiBold - ) - } - } - - Row( - horizontalArrangement = Arrangement.SpaceEvenly, - modifier = Modifier - .fillMaxWidth() - ) { - DialogTextButton( - text = "Отмена", - onClick = { isShowingSleepTimerDialog = false } - ) - - DialogTextButton( - text = "Установить", - enabled = amount > 0, - primary = true, - onClick = { - binder?.startSleepTimer(amount * 10 * 60 * 1000L) - isShowingSleepTimerDialog = false - } - ) - } - } - } - } - - MenuEntry( - icon = R.drawable.alarm, - text = "Таймер сна", - onClick = { isShowingSleepTimerDialog = true }, - trailingContent = sleepTimerMillisLeft?.let { - { - BasicText( - text = "Осталось ${formatAsDuration(it)}", - style = typography.xxs.medium, - modifier = modifier - .background( - color = colorPalette.background0, - shape = RoundedCornerShape(16.dp) - ) - .padding(horizontal = 16.dp, vertical = 8.dp) - .animateContentSize() - ) - } - } - ) - } - - if (onAddToPlaylist != null) { - MenuEntry( - icon = R.drawable.playlist, - text = "Добавить в плейлист", - onClick = { isViewingPlaylists = true }, - trailingContent = { - Image( - painter = painterResource(R.drawable.chevron_forward), - contentDescription = null, - colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( - colorPalette.textSecondary - ), + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy( + space = 16.dp, + alignment = Alignment.CenterHorizontally + ), + modifier = Modifier.padding(vertical = 16.dp) + ) { + Box( + contentAlignment = Alignment.Center, modifier = Modifier - .size(16.dp) + .alpha(if (amount <= 1) 0.5f else 1f) + .clip(CircleShape) + .clickable(enabled = amount > 1) { amount-- } + .size(48.dp) + .background(colorPalette.background0) + ) { + BasicText( + text = "-", + style = typography.xs.semiBold + ) + } + + Box(contentAlignment = Alignment.Center) { + BasicText( + text = "88h 88m", // invisible placeholder, no need to localize + style = typography.s.semiBold, + modifier = Modifier.alpha(0f) + ) + BasicText( + text = "${stringResource(R.string.format_hours, amount / 6)} " + + stringResource( + R.string.format_minutes, + (amount % 6) * 10 + ), + style = typography.s.semiBold + ) + } + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .alpha(if (amount >= 60) 0.5f else 1f) + .clip(CircleShape) + .clickable(enabled = amount < 60) { amount++ } + .size(48.dp) + .background(colorPalette.background0) + ) { + BasicText( + text = "+", + style = typography.xs.semiBold + ) + } + } + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier.fillMaxWidth() + ) { + DialogTextButton( + text = stringResource(R.string.cancel), + onClick = { isShowingSleepTimerDialog = false } + ) + + DialogTextButton( + text = stringResource(R.string.set), + enabled = amount > 0, + primary = true, + onClick = { + binder?.startSleepTimer(amount * 10 * 60 * 1000L) + isShowingSleepTimerDialog = false + } ) } - ) - } - - onGoToAlbum?.let { onGoToAlbum -> - albumInfo?.let { (albumId) -> - MenuEntry( - icon = R.drawable.disc, - text = "Перейти в альбом", - onClick = { - onDismiss() - onGoToAlbum(albumId) - } - ) } } - onGoToArtist?.let { onGoToArtist -> - artistsInfo?.forEach { (authorId, authorName) -> + MenuEntry( + icon = R.drawable.alarm, + text = stringResource(R.string.sleep_timer), + onClick = { isShowingSleepTimerDialog = true }, + trailingContent = sleepTimerMillisLeft?.let { + { + BasicText( + text = stringResource( + R.string.format_time_left, + formatAsDuration(it) + ), + style = typography.xxs.medium, + modifier = Modifier + .background( + color = colorPalette.background0, + shape = RoundedCornerShape(16.dp) + ) + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize() + ) + } + } + ) + } + + if (onAddToPlaylist != null) MenuEntry( + icon = R.drawable.playlist, + text = stringResource(R.string.add_to_playlist), + onClick = { isViewingPlaylists = true }, + trailingContent = { + Image( + painter = painterResource(R.drawable.chevron_forward), + contentDescription = null, + colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( + colorPalette.textSecondary + ), + modifier = Modifier.size(16.dp) + ) + } + ) + + if (!isLocal) onGoToAlbum?.let { onGoToAlbum -> + albumInfo?.let { (albumId) -> + MenuEntry( + icon = R.drawable.disc, + text = stringResource(R.string.go_to_album), + onClick = { + onDismiss() + onGoToAlbum(albumId) + } + ) + } + } + + if (!isLocal) onGoToArtist?.let { onGoToArtist -> + artistsInfo?.forEach { (authorId, authorName) -> + authorName?.let { name -> MenuEntry( icon = R.drawable.person, - text = "Больше от $authorName", + text = stringResource(R.string.format_go_to_artist, name), onClick = { onDismiss() onGoToArtist(authorId) @@ -687,47 +708,68 @@ fun MediaItemMenu( ) } } + } - onRemoveFromQueue?.let { onRemoveFromQueue -> - MenuEntry( - icon = R.drawable.trash, - text = "Убрать из очереди", - onClick = { - onDismiss() - onRemoveFromQueue() - } - ) + if (!isLocal) MenuEntry( + icon = R.drawable.play, + text = stringResource(R.string.watch_on_youtube), + onClick = { + onDismiss() + playerServiceBinder?.player?.pause() + uriHandler.openUri("https://youtube.com/watch?v=${mediaItem.mediaId}") } + ) - onRemoveFromPlaylist?.let { onRemoveFromPlaylist -> - MenuEntry( - icon = R.drawable.trash, - text = "Удалить из плейлиста", - onClick = { - onDismiss() - onRemoveFromPlaylist() - } - ) + if (!isLocal) MenuEntry( + icon = R.drawable.musical_notes, + text = stringResource(R.string.open_in_youtube_music), + onClick = { + onDismiss() + playerServiceBinder?.player?.pause() + if (!launchYouTubeMusic(context, "watch?v=${mediaItem.mediaId}")) + context.toast(context.getString(R.string.youtube_music_not_installed)) } + ) - onHideFromDatabase?.let { onHideFromDatabase -> - MenuEntry( - icon = R.drawable.trash, - text = "Скрыть", - onClick = onHideFromDatabase - ) - } + onRemoveFromQueue?.let { onRemoveFromQueue -> + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.remove_from_queue), + onClick = { + onDismiss() + onRemoveFromQueue() + } + ) + } - onRemoveFromQuickPicks?.let { - MenuEntry( - icon = R.drawable.trash, - text = "Скрыть из \"Обзора\"", - onClick = { - onDismiss() - onRemoveFromQuickPicks() - } - ) - } + onRemoveFromPlaylist?.let { onRemoveFromPlaylist -> + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.remove_from_playlist), + onClick = { + onDismiss() + onRemoveFromPlaylist() + } + ) + } + + if (!isLocal) onHideFromDatabase?.let { onHideFromDatabase -> + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.hide), + onClick = onHideFromDatabase + ) + } + + if (!isLocal) onRemoveFromQuickPicks?.let { + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.hide_from_quick_picks), + onClick = { + onDismiss() + onRemoveFromQuickPicks() + } + ) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Menu.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Menu.kt index 1c28bf9..bf71e7e 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Menu.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Menu.kt @@ -13,13 +13,16 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Shape import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import it.hamy.muza.ui.styling.LocalAppearance @@ -29,28 +32,26 @@ import it.hamy.muza.utils.secondary @Composable inline fun Menu( modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp), content: @Composable ColumnScope.() -> Unit -) { - val (colorPalette) = LocalAppearance.current - - Column( - modifier = modifier - .padding(top = 48.dp) - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .background(colorPalette.background1) - .padding(top = 2.dp) - .padding(vertical = 8.dp) - .navigationBarsPadding(), - content = content - ) -} +) = Column( + modifier = modifier + .fillMaxWidth() + .clip(shape) + .verticalScroll(rememberScrollState()) + .background(LocalAppearance.current.colorPalette.background1) + .padding(top = 2.dp) + .padding(vertical = 8.dp) + .navigationBarsPadding(), + content = content +) @Composable fun MenuEntry( @DrawableRes icon: Int, text: String, onClick: () -> Unit, + modifier: Modifier = Modifier, secondaryText: String? = null, enabled: Boolean = true, trailingContent: (@Composable () -> Unit)? = null @@ -60,7 +61,7 @@ fun MenuEntry( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier + modifier = modifier .clickable(enabled = enabled, onClick = onClick) .fillMaxWidth() .alpha(if (enabled) 1f else 0.4f) @@ -70,8 +71,7 @@ fun MenuEntry( painter = painterResource(icon), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .size(15.dp) + modifier = Modifier.size(15.dp) ) Column( diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/NavigationRail.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/NavigationRail.kt index f4377ae..28590ae 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/NavigationRail.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/NavigationRail.kt @@ -54,7 +54,8 @@ inline fun NavigationRail( val isLandscape = isLandscape val paddingValues = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.Start).asPaddingValues() + .only(WindowInsetsSides.Vertical + WindowInsetsSides.Start) + .asPaddingValues() Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -66,8 +67,9 @@ inline fun NavigationRail( contentAlignment = Alignment.TopCenter, modifier = Modifier .size( - width = if (isLandscape) Dimensions.navigationRailWidthLandscape else Dimensions.navigationRailWidth, - height = Dimensions.headerHeight + width = if (isLandscape) Dimensions.navigationRail.widthLandscape + else Dimensions.navigationRail.width, + height = Dimensions.items.headerHeight ) ) { Image( @@ -76,7 +78,7 @@ inline fun NavigationRail( colorFilter = ColorFilter.tint(colorPalette.textSecondary), modifier = Modifier .offset( - x = if (isLandscape) 0.dp else Dimensions.navigationRailIconOffset, + x = if (isLandscape) 0.dp else Dimensions.navigationRail.iconOffset, y = 48.dp ) .clip(CircleShape) @@ -89,7 +91,7 @@ inline fun NavigationRail( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .width(if (isLandscape) Dimensions.navigationRailWidthLandscape else Dimensions.navigationRailWidth) + .width(if (isLandscape) Dimensions.navigationRail.widthLandscape else Dimensions.navigationRail.width) ) { val transition = updateTransition(targetState = tabIndex, label = null) @@ -114,7 +116,7 @@ inline fun NavigationRail( translationX = (1f - dothAlpha) * -48.dp.toPx() rotationZ = if (isLandscape) 0f else -90f } - .size(Dimensions.navigationRailIconOffset * 2) + .size(Dimensions.navigationRail.iconOffset * 2) ) } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/PlaylistInfo.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/PlaylistInfo.kt new file mode 100644 index 0000000..edd2165 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/PlaylistInfo.kt @@ -0,0 +1,75 @@ +package it.hamy.muza.ui.components.themed + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import it.hamy.innertube.Innertube +import it.hamy.muza.models.Album +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.semiBold + +@Composable +fun PlaylistInfo( + description: String?, + year: String?, + otherInfo: String?, + modifier: Modifier = Modifier +) { + val (_, typography) = LocalAppearance.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.padding(horizontal = 8.dp) + ) { + otherInfo?.let { info -> + BasicText( + text = info, + style = typography.s.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + } + + year?.let { year -> + BasicText( + text = year, + style = typography.s.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false + ) + } + + description?.let { description -> + Attribution(text = description) + } + } +} + +@Composable +fun PlaylistInfo( + playlist: Innertube.PlaylistOrAlbumPage?, + modifier: Modifier = Modifier +) = PlaylistInfo( + description = playlist?.description, + year = playlist?.year, + otherInfo = playlist?.otherInfo, + modifier = modifier +) + +@Composable +fun PlaylistInfo( + playlist: Album?, + modifier: Modifier = Modifier +) = PlaylistInfo( + description = playlist?.description, + year = playlist?.year, + otherInfo = playlist?.otherInfo, + modifier = modifier +) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/PrimaryButton.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/PrimaryButton.kt index 6bc493c..e99365b 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/PrimaryButton.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/PrimaryButton.kt @@ -22,7 +22,7 @@ fun PrimaryButton( onClick: () -> Unit, @DrawableRes iconId: Int, modifier: Modifier = Modifier, - enabled: Boolean = true, + enabled: Boolean = true ) { val (colorPalette) = LocalAppearance.current diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/ProgressIndicator.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/ProgressIndicator.kt new file mode 100644 index 0000000..3ad9e91 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/ProgressIndicator.kt @@ -0,0 +1,49 @@ +package it.hamy.muza.ui.components.themed + +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import it.hamy.muza.ui.styling.LocalAppearance + +@Composable +fun CircularProgressIndicator( + modifier: Modifier = Modifier, + progress: Float? = null, + strokeCap: StrokeCap? = null +) { + val (colorPalette) = LocalAppearance.current + + if (progress == null) androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + color = colorPalette.accent, + strokeCap = strokeCap ?: ProgressIndicatorDefaults.CircularIndeterminateStrokeCap + ) else androidx.compose.material3.CircularProgressIndicator( + modifier = modifier, + color = colorPalette.accent, + strokeCap = strokeCap ?: ProgressIndicatorDefaults.CircularDeterminateStrokeCap, + progress = { progress } + ) +} + +@Composable +fun LinearProgressIndicator( + modifier: Modifier = Modifier, + progress: Float? = null, + strokeCap: StrokeCap = ProgressIndicatorDefaults.LinearStrokeCap +) { + val (colorPalette) = LocalAppearance.current + + if (progress == null) androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + color = colorPalette.accent, + trackColor = colorPalette.background1, + strokeCap = strokeCap + ) else androidx.compose.material3.LinearProgressIndicator( + modifier = modifier, + color = colorPalette.accent, + trackColor = colorPalette.background1, + strokeCap = strokeCap, + progress = { progress } + ) +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/ReorderHandle.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/ReorderHandle.kt new file mode 100644 index 0000000..34b54c3 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/ReorderHandle.kt @@ -0,0 +1,28 @@ +package it.hamy.muza.ui.components.themed + +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.hamy.compose.reordering.ReorderingState +import it.hamy.compose.reordering.reorder +import it.hamy.muza.R +import it.hamy.muza.ui.styling.LocalAppearance + +@Composable +fun ReorderHandle( + reorderingState: ReorderingState, + index: Int, + modifier: Modifier = Modifier +) = IconButton( + icon = R.drawable.reorder, + color = LocalAppearance.current.colorPalette.textDisabled, + indication = null, + onClick = {}, + modifier = modifier + .reorder( + reorderingState = reorderingState, + index = index + ) + .size(18.dp) +) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Scaffold.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Scaffold.kt index 0944c05..eea43de 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Scaffold.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Scaffold.kt @@ -1,13 +1,12 @@ package it.hamy.muza.ui.components.themed import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.AnimatedVisibilityScope -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring -import androidx.compose.animation.with +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -17,7 +16,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.IntOffset import it.hamy.muza.ui.styling.LocalAppearance -@ExperimentalAnimationApi @Composable fun Scaffold( topIconButtonId: Int, @@ -47,8 +45,8 @@ fun Scaffold( targetState = tabIndex, transitionSpec = { val slideDirection = when (targetState > initialState) { - true -> AnimatedContentScope.SlideDirection.Up - false -> AnimatedContentScope.SlideDirection.Down + true -> AnimatedContentTransitionScope.SlideDirection.Up + false -> AnimatedContentTransitionScope.SlideDirection.Down } val animationSpec = spring( @@ -57,10 +55,11 @@ fun Scaffold( visibilityThreshold = IntOffset.VisibilityThreshold ) - slideIntoContainer(slideDirection, animationSpec) with + slideIntoContainer(slideDirection, animationSpec) togetherWith slideOutOfContainer(slideDirection, animationSpec) }, - content = content + content = content, + label = "" ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/SecondaryButton.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/SecondaryButton.kt index 9d9cb73..430feba 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/SecondaryButton.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/SecondaryButton.kt @@ -22,7 +22,7 @@ fun SecondaryButton( onClick: () -> Unit, @DrawableRes iconId: Int, modifier: Modifier = Modifier, - enabled: Boolean = true, + enabled: Boolean = true ) { val (colorPalette) = LocalAppearance.current diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/SecondaryTextButton.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/SecondaryTextButton.kt index 36ce6fe..a2cc141 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/SecondaryTextButton.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/SecondaryTextButton.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.primaryButton @@ -25,7 +26,7 @@ fun SecondaryTextButton( BasicText( text = text, - style = typography.xxs.medium, + style = typography.xxs.medium.copy(textAlign = TextAlign.Center), modifier = modifier .clip(RoundedCornerShape(16.dp)) .clickable(enabled = enabled, onClick = onClick) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Slider.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Slider.kt new file mode 100644 index 0000000..aa00c44 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Slider.kt @@ -0,0 +1,33 @@ +package it.hamy.muza.ui.components.themed + +import androidx.annotation.IntRange +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import it.hamy.muza.ui.styling.LocalAppearance + +@Composable +fun Slider( + state: Float, + setState: (Float) -> Unit, + onSlideCompleted: () -> Unit, + range: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + @IntRange(from = 0) steps: Int = 0 +) { + val (colorPalette) = LocalAppearance.current + + androidx.compose.material3.Slider( + value = state, + onValueChange = setState, + onValueChangeFinished = onSlideCompleted, + valueRange = range, + modifier = modifier, + steps = steps, + colors = SliderDefaults.colors( + thumbColor = colorPalette.onAccent, + activeTrackColor = colorPalette.accent, + inactiveTrackColor = colorPalette.text.copy(alpha = 0.75f) + ) + ) +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Switch.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Switch.kt index 131dfc7..664991a 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Switch.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/Switch.kt @@ -20,7 +20,7 @@ import it.hamy.muza.utils.drawCircle @Composable fun Switch( isChecked: Boolean, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { val (colorPalette) = LocalAppearance.current @@ -38,13 +38,10 @@ fun Switch( if (it) 36.dp else 12.dp } - Canvas( - modifier = modifier - .size(width = 48.dp, height = 24.dp) - ) { + Canvas(modifier = modifier.size(width = 48.dp, height = 24.dp)) { drawRoundRect( color = backgroundColor, - cornerRadius = CornerRadius(x = 12.dp.toPx(), y = 12.dp.toPx()), + cornerRadius = CornerRadius(x = 12.dp.toPx(), y = 12.dp.toPx()) ) drawCircle( diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextField.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextField.kt new file mode 100644 index 0000000..e4bf387 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextField.kt @@ -0,0 +1,143 @@ +package it.hamy.muza.ui.components.themed + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextLayoutResult +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import it.hamy.muza.ui.styling.Appearance +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.secondary +import it.hamy.muza.utils.semiBold + +@Composable +fun ColumnScope.TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + appearance: Appearance = LocalAppearance.current, + textStyle: TextStyle = appearance.typography.xs.semiBold, + singleLine: Boolean = false, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions( + imeAction = if (singleLine) ImeAction.Done else ImeAction.None + ), + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (TextLayoutResult) -> Unit = { }, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + hintText: String? = null +) = BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = textStyle, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + cursorBrush = SolidColor(appearance.colorPalette.text), + decorationBox = { innerTextField -> + hintText?.let { text -> + Box(modifier = Modifier.weight(1f)) { + this@TextField.AnimatedVisibility( + visible = value.isEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)) + ) { + BasicText( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle.secondary + ) + } + } + } + + innerTextField() + } +) + +@Composable +fun RowScope.TextField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + appearance: Appearance = LocalAppearance.current, + textStyle: TextStyle = appearance.typography.xs.semiBold, + singleLine: Boolean = false, + keyboardActions: KeyboardActions = KeyboardActions.Default, + keyboardOptions: KeyboardOptions = KeyboardOptions( + imeAction = if (singleLine) ImeAction.Done else ImeAction.None + ), + minLines: Int = 1, + maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE, + visualTransformation: VisualTransformation = VisualTransformation.None, + onTextLayout: (TextLayoutResult) -> Unit = { }, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + hintText: String? = null +) = BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + textStyle = textStyle, + singleLine = singleLine, + maxLines = maxLines, + minLines = minLines, + visualTransformation = visualTransformation, + onTextLayout = onTextLayout, + interactionSource = interactionSource, + cursorBrush = SolidColor(appearance.colorPalette.text), + decorationBox = { innerTextField -> + hintText?.let { text -> + Box(modifier = Modifier.weight(1f)) { + this@TextField.AnimatedVisibility( + visible = value.isEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)) + ) { + BasicText( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = textStyle.secondary + ) + } + } + } + + innerTextField() + } +) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextPlaceholder.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextPlaceholder.kt index c86a2be..5a81369 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextPlaceholder.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextPlaceholder.kt @@ -18,12 +18,10 @@ import kotlin.random.Random fun TextPlaceholder( modifier: Modifier = Modifier, color: Color = LocalAppearance.current.colorPalette.shimmer -) { - Spacer( - modifier = modifier - .padding(vertical = 4.dp) - .background(color) - .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) - .height(16.dp) - ) -} +) = Spacer( + modifier = modifier + .padding(vertical = 4.dp) + .background(color) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) + .height(16.dp) +) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextToggle.kt b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextToggle.kt new file mode 100644 index 0000000..00219ad --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/components/themed/TextToggle.kt @@ -0,0 +1,68 @@ +package it.hamy.muza.ui.components.themed + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import it.hamy.muza.R +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.medium + +@Composable +fun TextToggle( + state: Boolean, + toggleState: () -> Unit, + name: String, + modifier: Modifier = Modifier, + onLabel: String = stringResource(R.string.on_label), + offLabel: String = stringResource(R.string.off_label) +) { + val (colorPalette, typography) = LocalAppearance.current + + Row( + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .clickable { toggleState() } + .background(colorPalette.background1) + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize() + ) { + BasicText( + text = "$name ", + style = typography.xxs.medium + ) + + AnimatedContent( + targetState = state, + transitionSpec = { + val slideDirection = + if (targetState) AnimatedContentTransitionScope.SlideDirection.Up + else AnimatedContentTransitionScope.SlideDirection.Down + + ContentTransform( + targetContentEnter = slideIntoContainer(slideDirection) + fadeIn(), + initialContentExit = slideOutOfContainer(slideDirection) + fadeOut() + ) + }, + label = "" + ) { + BasicText( + text = if (it) onLabel else offLabel, + style = typography.xxs.medium + ) + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/items/AlbumItem.kt b/app/src/main/kotlin/it/hamy/muza/ui/items/AlbumItem.kt index 56d60cb..d9a36df 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/items/AlbumItem.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/items/AlbumItem.kt @@ -13,54 +13,47 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import it.hamy.innertube.Innertube import it.hamy.muza.models.Album import it.hamy.muza.ui.components.themed.TextPlaceholder import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.shimmer +import it.hamy.muza.utils.px import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.thumbnail -import it.hamy.innertube.Innertube @Composable fun AlbumItem( album: Album, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, alternative: Boolean = false -) { - AlbumItem( - thumbnailUrl = album.thumbnailUrl, - title = album.title, - authors = album.authorsText, - year = album.year, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - alternative = alternative, - modifier = modifier - ) -} +) = AlbumItem( + thumbnailUrl = album.thumbnailUrl, + title = album.title, + authors = album.authorsText, + year = album.year, + thumbnailSize = thumbnailSize, + alternative = alternative, + modifier = modifier +) @Composable fun AlbumItem( album: Innertube.AlbumItem, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, alternative: Boolean = false -) { - AlbumItem( - thumbnailUrl = album.thumbnail?.url, - title = album.info?.name, - authors = album.authors?.joinToString("") { it.name ?: "" }, - year = album.year, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - alternative = alternative, - modifier = modifier - ) -} +) = AlbumItem( + thumbnailUrl = album.thumbnail?.url, + title = album.info?.name, + authors = album.authors?.joinToString("") { it.name.orEmpty() }, + year = album.year, + thumbnailSize = thumbnailSize, + alternative = alternative, + modifier = modifier +) @Composable fun AlbumItem( @@ -68,88 +61,75 @@ fun AlbumItem( title: String?, authors: String?, year: String?, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + modifier = modifier ) { - val (_, typography, thumbnailShape) = LocalAppearance.current + val typography = LocalAppearance.current.typography + val thumbnailShape = LocalAppearance.current.thumbnailShape - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier - ) { - AsyncImage( - model = thumbnailUrl?.thumbnail(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(thumbnailShape) - .size(thumbnailSizeDp) + AsyncImage( + model = thumbnailUrl?.thumbnail(thumbnailSize.px), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(thumbnailSize) + ) + + ItemInfoContainer { + BasicText( + text = title.orEmpty(), + style = typography.xs.semiBold, + maxLines = if (alternative) 1 else 2, + overflow = TextOverflow.Ellipsis ) - ItemInfoContainer { + if (!alternative) authors?.let { BasicText( - text = title ?: "", - style = typography.xs.semiBold, - maxLines = if (alternative) 1 else 2, - overflow = TextOverflow.Ellipsis, - ) - - if (!alternative) { - authors?.let { - BasicText( - text = authors, - style = typography.xs.semiBold.secondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - - BasicText( - text = year ?: "", - style = typography.xxs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 4.dp) + text = authors, + style = typography.xs.semiBold.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis ) } + + BasicText( + text = year.orEmpty(), + style = typography.xxs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) } } @Composable fun AlbumItemPlaceholder( - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + modifier = modifier ) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current + val colorPalette = LocalAppearance.current.colorPalette + val thumbnailShape = LocalAppearance.current.thumbnailShape - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(thumbnailSizeDp) - ) + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSize) + ) - ItemInfoContainer { - TextPlaceholder() - - if (!alternative) { - TextPlaceholder() - } - - TextPlaceholder( - modifier = Modifier - .padding(top = 4.dp) - ) - } + ItemInfoContainer { + TextPlaceholder() + if (!alternative) TextPlaceholder() + TextPlaceholder(modifier = Modifier.padding(top = 4.dp)) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/items/ArtistItem.kt b/app/src/main/kotlin/it/hamy/muza/ui/items/ArtistItem.kt index f11f950..82ffb69 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/items/ArtistItem.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/items/ArtistItem.kt @@ -15,84 +15,76 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import it.hamy.innertube.Innertube import it.hamy.muza.models.Artist import it.hamy.muza.ui.components.themed.TextPlaceholder import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.shimmer +import it.hamy.muza.utils.px import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.thumbnail -import it.hamy.innertube.Innertube @Composable fun ArtistItem( artist: Artist, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - ArtistItem( - thumbnailUrl = artist.thumbnailUrl, - name = artist.name, - subscribersCount = null, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} + alternative: Boolean = false +) = ArtistItem( + thumbnailUrl = artist.thumbnailUrl, + name = artist.name, + subscribersCount = null, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) @Composable fun ArtistItem( artist: Innertube.ArtistItem, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - ArtistItem( - thumbnailUrl = artist.thumbnail?.url, - name = artist.info?.name, - subscribersCount = artist.subscribersCountText, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} + alternative: Boolean = false +) = ArtistItem( + thumbnailUrl = artist.thumbnail?.url, + name = artist.info?.name, + subscribersCount = artist.subscribersCountText, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) @Composable fun ArtistItem( thumbnailUrl: String?, name: String?, subscribersCount: String?, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, + alternative: Boolean = false ) { val (_, typography) = LocalAppearance.current ItemContainer( alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = thumbnailSize, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier ) { AsyncImage( - model = thumbnailUrl?.thumbnail(thumbnailSizePx), + model = thumbnailUrl?.thumbnail(thumbnailSize.px), contentDescription = null, modifier = Modifier .clip(CircleShape) - .requiredSize(thumbnailSizeDp) + .requiredSize(thumbnailSize) ) ItemInfoContainer( - horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start ) { BasicText( - text = name ?: "", + text = name.orEmpty(), style = typography.xs.semiBold, maxLines = if (alternative) 1 else 2, overflow = TextOverflow.Ellipsis @@ -104,8 +96,7 @@ fun ArtistItem( style = typography.xxs.semiBold.secondary, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 4.dp) + modifier = Modifier.padding(top = 4.dp) ) } } @@ -114,32 +105,29 @@ fun ArtistItem( @Composable fun ArtistItemPlaceholder( - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, + alternative: Boolean = false ) { val (colorPalette) = LocalAppearance.current ItemContainer( alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = thumbnailSize, horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier ) { Spacer( modifier = Modifier .background(color = colorPalette.shimmer, shape = CircleShape) - .size(thumbnailSizeDp) + .size(thumbnailSize) ) ItemInfoContainer( - horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start ) { TextPlaceholder() - TextPlaceholder( - modifier = Modifier - .padding(top = 4.dp) - ) + TextPlaceholder(modifier = Modifier.padding(top = 4.dp)) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/items/ItemContainer.kt b/app/src/main/kotlin/it/hamy/muza/ui/items/ItemContainer.kt index 4c65e05..35e1c17 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/items/ItemContainer.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/items/ItemContainer.kt @@ -17,51 +17,40 @@ import it.hamy.muza.ui.styling.Dimensions @Composable inline fun ItemContainer( alternative: Boolean, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, content: @Composable (centeredModifier: Modifier) -> Unit -) { - if (alternative) { - Column( - horizontalAlignment = horizontalAlignment, - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - .width(thumbnailSizeDp) - ) { - content( - centeredModifier = Modifier - .align(Alignment.CenterHorizontally) - ) - } - } else { - Row( - verticalAlignment = verticalAlignment, - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = modifier - .padding(vertical = Dimensions.itemsVerticalPadding, horizontal = 16.dp) - .fillMaxWidth() - ) { - content( - centeredModifier = Modifier - .align(Alignment.CenterVertically) - ) - } - } -} +) = if (alternative) Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding( + vertical = Dimensions.items.verticalPadding, + horizontal = Dimensions.items.horizontalPadding + ) + .width(thumbnailSize) +) { content(Modifier.align(Alignment.CenterHorizontally)) } +else Row( + verticalAlignment = verticalAlignment, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = modifier + .padding( + vertical = Dimensions.items.verticalPadding, + horizontal = Dimensions.items.horizontalPadding + ) + .fillMaxWidth() +) { content(Modifier.align(Alignment.CenterVertically)) } @Composable inline fun ItemInfoContainer( modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable ColumnScope.() -> Unit -) { - Column( - horizontalAlignment = horizontalAlignment, - verticalArrangement = Arrangement.spacedBy(4.dp), - modifier = modifier, - content = content - ) -} +) = Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + content = content +) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/items/PlaylistItem.kt b/app/src/main/kotlin/it/hamy/muza/ui/items/PlaylistItem.kt index 5d7eeb5..8788651 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/items/PlaylistItem.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/items/PlaylistItem.kt @@ -27,6 +27,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import it.hamy.innertube.Innertube import it.hamy.muza.Database import it.hamy.muza.models.PlaylistPreview import it.hamy.muza.ui.components.themed.TextPlaceholder @@ -36,10 +37,10 @@ import it.hamy.muza.ui.styling.overlay import it.hamy.muza.ui.styling.shimmer import it.hamy.muza.utils.color import it.hamy.muza.utils.medium +import it.hamy.muza.utils.px import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.thumbnail -import it.hamy.innertube.Innertube import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -50,82 +51,74 @@ fun PlaylistItem( colorTint: Color, name: String?, songCount: Int?, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - PlaylistItem( - thumbnailContent = { - Image( - painter = painterResource(icon), - contentDescription = null, - colorFilter = ColorFilter.tint(colorTint), - modifier = Modifier - .align(Alignment.Center) - .size(24.dp) - ) - }, - songCount = songCount, - name = name, - channelName = null, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} + alternative: Boolean = false +) = PlaylistItem( + thumbnailContent = { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(colorTint), + modifier = Modifier + .align(Alignment.Center) + .size(24.dp) + ) + }, + songCount = songCount, + name = name, + channelName = null, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) @Composable fun PlaylistItem( playlist: PlaylistPreview, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, + alternative: Boolean = false ) { + val thumbnailSizePx = thumbnailSize.px val thumbnails by remember { - Database.playlistThumbnailUrls(playlist.playlist.id).distinctUntilChanged().map { - it.map { url -> - url.thumbnail(thumbnailSizePx / 2) + Database + .playlistThumbnailUrls(playlist.playlist.id) + .distinctUntilChanged() + .map { urls -> + urls.map { it.thumbnail(thumbnailSizePx / 2) } } - } }.collectAsState(initial = emptyList(), context = Dispatchers.IO) PlaylistItem( thumbnailContent = { - if (thumbnails.toSet().size == 1) { - AsyncImage( - model = thumbnails.first().thumbnail(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = it - ) - } else { - Box( - modifier = it - .fillMaxSize() - ) { - listOf( - Alignment.TopStart, - Alignment.TopEnd, - Alignment.BottomStart, - Alignment.BottomEnd - ).forEachIndexed { index, alignment -> - AsyncImage( - model = thumbnails.getOrNull(index), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .align(alignment) - .size(thumbnailSizeDp / 2) - ) - } + if (thumbnails.toSet().size == 1) AsyncImage( + model = thumbnails.first().thumbnail(thumbnailSizePx), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = it + ) else Box(modifier = it.fillMaxSize()) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).forEachIndexed { index, alignment -> + AsyncImage( + model = thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(thumbnailSize / 2) + ) } } }, songCount = playlist.songCount, name = playlist.playlist.name, channelName = null, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = thumbnailSize, modifier = modifier, alternative = alternative ) @@ -134,22 +127,18 @@ fun PlaylistItem( @Composable fun PlaylistItem( playlist: Innertube.PlaylistItem, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - PlaylistItem( - thumbnailUrl = playlist.thumbnail?.url, - songCount = playlist.songCount, - name = playlist.info?.name, - channelName = playlist.channel?.name, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative - ) -} + alternative: Boolean = false +) = PlaylistItem( + thumbnailUrl = playlist.thumbnail?.url, + songCount = playlist.songCount, + name = playlist.info?.name, + channelName = playlist.channel?.name, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) @Composable fun PlaylistItem( @@ -157,28 +146,25 @@ fun PlaylistItem( songCount: Int?, name: String?, channelName: String?, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - PlaylistItem( - thumbnailContent = { - AsyncImage( - model = thumbnailUrl?.thumbnail(thumbnailSizePx), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = it - ) - }, - songCount = songCount, - name = name, - channelName = channelName, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - alternative = alternative, - ) -} + alternative: Boolean = false +) = PlaylistItem( + thumbnailContent = { + AsyncImage( + model = thumbnailUrl?.thumbnail(thumbnailSize.px), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = it + ) + }, + songCount = songCount, + name = name, + channelName = channelName, + thumbnailSize = thumbnailSize, + modifier = modifier, + alternative = alternative +) @Composable fun PlaylistItem( @@ -186,89 +172,86 @@ fun PlaylistItem( songCount: Int?, name: String?, channelName: String?, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, -) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + modifier = modifier +) { centeredModifier -> + val colorPalette = LocalAppearance.current.colorPalette + val typography = LocalAppearance.current.typography + val thumbnailShape = LocalAppearance.current.thumbnailShape - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier - ) { centeredModifier -> - Box( - modifier = centeredModifier - .clip(thumbnailShape) - .background(color = colorPalette.background1) - .requiredSize(thumbnailSizeDp) - ) { - thumbnailContent( - modifier = Modifier - .fillMaxSize() - ) + Box( + modifier = centeredModifier + .clip(thumbnailShape) + .background(color = colorPalette.background1) + .requiredSize(thumbnailSize) + ) { + thumbnailContent(Modifier.fillMaxSize()) - songCount?.let { - BasicText( - text = "$songCount", - style = typography.xxs.medium.color(colorPalette.onOverlay), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(all = 4.dp) - .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) - .padding(horizontal = 4.dp, vertical = 2.dp) - .align(Alignment.BottomEnd) - ) - } - } - - ItemInfoContainer( - horizontalAlignment = if (alternative && channelName == null) Alignment.CenterHorizontally else Alignment.Start, - ) { + songCount?.let { BasicText( - text = name ?: "", - style = typography.xs.semiBold, - maxLines = 2, + text = "$songCount", + style = typography.xxs.medium.color(colorPalette.onOverlay), + maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(all = 4.dp) + .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + .align(Alignment.BottomEnd) ) + } + } - channelName?.let { - BasicText( - text = channelName, - style = typography.xs.semiBold.secondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } + ItemInfoContainer( + horizontalAlignment = if (alternative && channelName == null) Alignment.CenterHorizontally + else Alignment.Start + ) { + BasicText( + text = name.orEmpty(), + style = typography.xs.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + channelName?.let { + BasicText( + text = channelName, + style = typography.xs.semiBold.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) } } } @Composable fun PlaylistItemPlaceholder( - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - alternative: Boolean = false, + alternative: Boolean = false +) = ItemContainer( + alternative = alternative, + thumbnailSize = thumbnailSize, + modifier = modifier ) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current + val colorPalette = LocalAppearance.current.colorPalette + val thumbnailShape = LocalAppearance.current.thumbnailShape - ItemContainer( - alternative = alternative, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSize) + ) + + ItemInfoContainer( + horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(thumbnailSizeDp) - ) - - ItemInfoContainer( - horizontalAlignment = if (alternative) Alignment.CenterHorizontally else Alignment.Start, - ) { - TextPlaceholder() - TextPlaceholder() - } + TextPlaceholder() + TextPlaceholder() } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/items/SongItem.kt b/app/src/main/kotlin/it/hamy/muza/ui/items/SongItem.kt index 426c846..ee9d2f2 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/items/SongItem.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/items/SongItem.kt @@ -13,80 +13,75 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import coil.compose.AsyncImage +import it.hamy.innertube.Innertube +import it.hamy.muza.models.Song import it.hamy.muza.ui.components.themed.TextPlaceholder import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.shimmer import it.hamy.muza.utils.medium +import it.hamy.muza.utils.px import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.thumbnail -import it.hamy.innertube.Innertube -import it.hamy.muza.models.Song @Composable fun SongItem( song: Innertube.SongItem, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier -) { - SongItem( - thumbnailUrl = song.thumbnail?.size(thumbnailSizePx), - title = song.info?.name, - authors = song.authors?.joinToString("") { it.name ?: "" }, - duration = song.durationText, - thumbnailSizeDp = thumbnailSizeDp, - modifier = modifier, - ) -} +) = SongItem( + modifier = modifier, + thumbnailUrl = song.thumbnail?.size(thumbnailSize.px), + title = song.info?.name, + authors = song.authors?.joinToString("") { it.name.orEmpty() }, + duration = song.durationText, + thumbnailSize = thumbnailSize +) @Composable fun SongItem( song: MediaItem, - thumbnailSizeDp: Dp, - thumbnailSizePx: Int, + thumbnailSize: Dp, modifier: Modifier = Modifier, onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null -) { - SongItem( - thumbnailUrl = song.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx)?.toString(), - title = song.mediaMetadata.title.toString(), - authors = song.mediaMetadata.artist.toString(), - duration = song.mediaMetadata.extras?.getString("durationText"), - thumbnailSizeDp = thumbnailSizeDp, - onThumbnailContent = onThumbnailContent, - trailingContent = trailingContent, - modifier = modifier, - ) -} +) = SongItem( + modifier = modifier, + thumbnailUrl = song.mediaMetadata.artworkUri.thumbnail(thumbnailSize.px)?.toString(), + title = song.mediaMetadata.title?.toString(), + authors = song.mediaMetadata.artist?.toString(), + duration = song.mediaMetadata.extras?.getString("durationText"), + thumbnailSize = thumbnailSize, + onThumbnailContent = onThumbnailContent, + trailingContent = trailingContent +) @Composable fun SongItem( song: Song, - thumbnailSizePx: Int, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null -) { - SongItem( - thumbnailUrl = song.thumbnailUrl?.thumbnail(thumbnailSizePx), - title = song.title, - authors = song.artistsText, - duration = song.durationText, - thumbnailSizeDp = thumbnailSizeDp, - onThumbnailContent = onThumbnailContent, - trailingContent = trailingContent, - modifier = modifier, - ) -} + index: Int? = null, + onThumbnailContent: @Composable (BoxScope.() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null +) = SongItem( + modifier = modifier, + index = index, + thumbnailUrl = song.thumbnailUrl?.thumbnail(thumbnailSize.px), + title = song.title, + authors = song.artistsText, + duration = song.durationText, + thumbnailSize = thumbnailSize, + onThumbnailContent = onThumbnailContent, + trailingContent = trailingContent +) @Composable fun SongItem( @@ -94,25 +89,46 @@ fun SongItem( title: String?, authors: String?, duration: String?, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier, - onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, - trailingContent: (@Composable () -> Unit)? = null + index: Int? = null, + onThumbnailContent: @Composable (BoxScope.() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null ) { + val (colorPalette, typography) = LocalAppearance.current + SongItem( title = title, authors = authors, duration = duration, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = thumbnailSize, thumbnailContent = { - AsyncImage( - model = thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, + Box( modifier = Modifier .clip(LocalAppearance.current.thumbnailShape) + .background(colorPalette.background1) .fillMaxSize() - ) + ) { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + + if (index != null) { + Box( + modifier = Modifier + .background(color = Color.Black.copy(alpha = 0.75f)) + .fillMaxSize() + ) + BasicText( + text = "${index + 1}", + style = typography.xs.semiBold.copy(color = Color.White), + modifier = Modifier.align(Alignment.Center) + ) + } + } onThumbnailContent?.invoke(this) }, @@ -123,59 +139,56 @@ fun SongItem( @Composable fun SongItem( - thumbnailContent: @Composable BoxScope.() -> Unit, title: String?, authors: String?, duration: String?, - thumbnailSizeDp: Dp, + thumbnailSize: Dp, + thumbnailContent: @Composable BoxScope.() -> Unit, modifier: Modifier = Modifier, - trailingContent: @Composable (() -> Unit)? = null, + trailingContent: @Composable (() -> Unit)? = null ) { val (_, typography) = LocalAppearance.current ItemContainer( alternative = false, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = thumbnailSize, modifier = modifier ) { Box( - modifier = Modifier - .size(thumbnailSizeDp) - ) { - thumbnailContent() - } + modifier = Modifier.size(thumbnailSize), + content = thumbnailContent + ) ItemInfoContainer { trailingContent?.let { Row(verticalAlignment = Alignment.CenterVertically) { BasicText( - text = title ?: "", + text = title.orEmpty(), style = typography.xs.semiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) + modifier = Modifier.weight(1f) ) it() } } ?: BasicText( - text = title ?: "", + text = title.orEmpty(), style = typography.xs.semiBold, maxLines = 1, - overflow = TextOverflow.Ellipsis, + overflow = TextOverflow.Ellipsis ) - Row(verticalAlignment = Alignment.CenterVertically) { - BasicText( - text = authors ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Clip, - modifier = Modifier - .weight(1f) - ) + authors?.let { + BasicText( + text = authors, + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } duration?.let { BasicText( @@ -183,8 +196,7 @@ fun SongItem( style = typography.xxs.secondary.medium, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 4.dp) + modifier = Modifier.padding(top = 4.dp) ) } } @@ -194,25 +206,24 @@ fun SongItem( @Composable fun SongItemPlaceholder( - thumbnailSizeDp: Dp, + thumbnailSize: Dp, modifier: Modifier = Modifier +) = ItemContainer( + alternative = false, + thumbnailSize = thumbnailSize, + modifier = modifier ) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current + val colorPalette = LocalAppearance.current.colorPalette + val thumbnailShape = LocalAppearance.current.thumbnailShape - ItemContainer( - alternative = false, - thumbnailSizeDp =thumbnailSizeDp, - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(thumbnailSizeDp) - ) + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(thumbnailSize) + ) - ItemInfoContainer { - TextPlaceholder() - TextPlaceholder() - } + ItemInfoContainer { + TextPlaceholder() + TextPlaceholder() } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/items/VideoItem.kt b/app/src/main/kotlin/it/hamy/muza/ui/items/VideoItem.kt index 8ac4272..f8989db 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/items/VideoItem.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/items/VideoItem.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil.compose.AsyncImage +import it.hamy.innertube.Innertube import it.hamy.muza.ui.components.themed.TextPlaceholder import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.onOverlay @@ -25,26 +26,23 @@ import it.hamy.muza.utils.color import it.hamy.muza.utils.medium import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold -import it.hamy.innertube.Innertube @Composable fun VideoItem( video: Innertube.VideoItem, - thumbnailHeightDp: Dp, - thumbnailWidthDp: Dp, + thumbnailWidth: Dp, + thumbnailHeight: Dp, modifier: Modifier = Modifier -) { - VideoItem( - thumbnailUrl = video.thumbnail?.url, - duration = video.durationText, - title = video.info?.name, - uploader = video.authors?.joinToString("") { it.name ?: "" }, - views = video.viewsText, - thumbnailHeightDp = thumbnailHeightDp, - thumbnailWidthDp = thumbnailWidthDp, - modifier = modifier - ) -} +) = VideoItem( + thumbnailUrl = video.thumbnail?.url, + duration = video.durationText, + title = video.info?.name, + uploader = video.authors?.joinToString("") { it.name.orEmpty() }, + views = video.viewsText, + thumbnailWidth = thumbnailWidth, + thumbnailHeight = thumbnailHeight, + modifier = modifier +) @Composable fun VideoItem( @@ -53,97 +51,92 @@ fun VideoItem( title: String?, uploader: String?, views: String?, - thumbnailHeightDp: Dp, - thumbnailWidthDp: Dp, + thumbnailWidth: Dp, + thumbnailHeight: Dp, modifier: Modifier = Modifier +) = ItemContainer( + alternative = false, + thumbnailSize = 0.dp, + modifier = modifier ) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val colorPalette = LocalAppearance.current.colorPalette + val typography = LocalAppearance.current.typography + val thumbnailShape = LocalAppearance.current.thumbnailShape - ItemContainer( - alternative = false, - thumbnailSizeDp = 0.dp, - modifier = modifier - ) { - Box { - AsyncImage( - model = thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .clip(thumbnailShape) - .size(width = thumbnailWidthDp, height = thumbnailHeightDp) - ) + Box { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .clip(thumbnailShape) + .size(width = thumbnailWidth, height = thumbnailHeight) + ) - duration?.let { - BasicText( - text = duration, - style = typography.xxs.medium.color(colorPalette.onOverlay), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(all = 4.dp) - .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) - .padding(horizontal = 4.dp, vertical = 2.dp) - .align(Alignment.BottomEnd) - ) - } - } - - ItemInfoContainer { + duration?.let { BasicText( - text = title ?: "", - style = typography.xs.semiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - - BasicText( - text = uploader ?: "", - style = typography.xs.semiBold.secondary, + text = duration, + style = typography.xxs.medium.color(colorPalette.onOverlay), maxLines = 1, overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(all = 4.dp) + .background(color = colorPalette.overlay, shape = RoundedCornerShape(2.dp)) + .padding(horizontal = 4.dp, vertical = 2.dp) + .align(Alignment.BottomEnd) ) + } + } - views?.let { - BasicText( - text = views, - style = typography.xxs.medium.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 4.dp) - ) - } + ItemInfoContainer { + BasicText( + text = title.orEmpty(), + style = typography.xs.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + BasicText( + text = uploader.orEmpty(), + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + views?.let { + BasicText( + text = views, + style = typography.xxs.medium.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 4.dp) + ) } } } @Composable fun VideoItemPlaceholder( - thumbnailHeightDp: Dp, - thumbnailWidthDp: Dp, + thumbnailWidth: Dp, + thumbnailHeight: Dp, modifier: Modifier = Modifier +) = ItemContainer( + alternative = false, + thumbnailSize = 0.dp, + modifier = modifier ) { - val (colorPalette, _, thumbnailShape) = LocalAppearance.current + val colorPalette = LocalAppearance.current.colorPalette + val thumbnailShape = LocalAppearance.current.thumbnailShape - ItemContainer( - alternative = false, - thumbnailSizeDp = 0.dp, - modifier = modifier - ) { - Spacer( - modifier = Modifier - .background(color = colorPalette.shimmer, shape = thumbnailShape) - .size(width = thumbnailWidthDp, height = thumbnailHeightDp) - ) + Spacer( + modifier = Modifier + .background(color = colorPalette.shimmer, shape = thumbnailShape) + .size(width = thumbnailWidth, height = thumbnailHeight) + ) - ItemInfoContainer { - TextPlaceholder() - TextPlaceholder() - TextPlaceholder( - modifier = Modifier - .padding(top = 8.dp) - ) - } + ItemInfoContainer { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder(modifier = Modifier.padding(top = 8.dp)) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/modifiers/FadingEdge.kt b/app/src/main/kotlin/it/hamy/muza/ui/modifiers/FadingEdge.kt new file mode 100644 index 0000000..c1b67bc --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/modifiers/FadingEdge.kt @@ -0,0 +1,46 @@ +package it.hamy.muza.ui.modifiers + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer + +private fun Modifier.fadingEdge( + start: Boolean, + middle: Int, + end: Boolean, + alpha: Float, + isHorizontal: Boolean +) = this + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + val gradient = buildList { + val transparentColor = Color(red = 0f, green = 0f, blue = 0f, alpha = 1f - alpha) + + add(if (start) transparentColor else Color.Black) + repeat(middle) { add(Color.Black) } + add(if (end) transparentColor else Color.Black) + } + drawRect( + brush = if (isHorizontal) Brush.horizontalGradient(gradient) + else Brush.verticalGradient(gradient), + blendMode = BlendMode.DstIn + ) + } + +fun Modifier.verticalFadingEdge( + top: Boolean = true, + middle: Int = 3, + bottom: Boolean = true, + alpha: Float = 1f +) = fadingEdge(start = top, middle = middle, end = bottom, alpha = alpha, isHorizontal = false) + +fun Modifier.horizontalFadingEdge( + left: Boolean = true, + middle: Int = 3, + right: Boolean = true, + alpha: Float = 1f +) = fadingEdge(start = left, middle = middle, end = right, alpha = alpha, isHorizontal = true) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/modifiers/PinchToggle.kt b/app/src/main/kotlin/it/hamy/muza/ui/modifiers/PinchToggle.kt new file mode 100644 index 0000000..c6037e2 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/modifiers/PinchToggle.kt @@ -0,0 +1,71 @@ +package it.hamy.muza.ui.modifiers + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.calculateCentroidSize +import androidx.compose.foundation.gestures.calculateZoom +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChanged +import androidx.compose.ui.util.fastAny +import androidx.compose.ui.util.fastForEach +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.isActive +import kotlin.math.abs + +@JvmInline +value class PinchDirection private constructor(private val out: Boolean) { + companion object { + val Out = PinchDirection(out = true) + val In = PinchDirection(out = false) + } + + fun reachedThreshold( + value: Float, + threshold: Float + ) = when (this) { + Out -> value >= threshold + In -> value <= threshold + else -> error("Unreachable") + } +} + +fun Modifier.pinchToToggle( + direction: PinchDirection, + threshold: Float, + key: Any? = Unit, + onPinch: (scale: Float) -> Unit +) = this.pointerInput(key) { + coroutineScope { + awaitEachGesture { + val touchSlop = viewConfiguration.touchSlop / 2 + var scale = 1f + var touchSlopReached = false + + awaitFirstDown(requireUnconsumed = false) + while (isActive) { + val event = awaitPointerEvent() + if (event.changes.fastAny { it.isConsumed }) break + if (!event.changes.fastAny { it.pressed }) continue + + scale *= event.calculateZoom() + if (!touchSlopReached) { + val centroidSize = event.calculateCentroidSize(useCurrent = false) + if (abs(1 - scale) * centroidSize > touchSlop) touchSlopReached = true + } + + if (touchSlopReached) event.changes.fastForEach { if (it.positionChanged()) it.consume() } + + if ( + direction.reachedThreshold( + value = scale, + threshold = threshold + ) + ) { + onPinch(scale) + break + } + } + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/modifiers/Pressable.kt b/app/src/main/kotlin/it/hamy/muza/ui/modifiers/Pressable.kt new file mode 100644 index 0000000..7c5003f --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/modifiers/Pressable.kt @@ -0,0 +1,22 @@ +package it.hamy.muza.ui.modifiers + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed + +fun Modifier.pressable(onPress: () -> Unit, onCancel: () -> Unit = {}, onRelease: () -> Unit) = + this.composed { + val interactionSource = remember { MutableInteractionSource() } + val isPressed = interactionSource.collectIsPressedAsState() + LaunchedEffect(isPressed.value) { + if (isPressed.value) onPress() + else onCancel() + } + clickable(interactionSource = interactionSource, indication = null) { + onRelease() + } + } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/modifiers/Swiping.kt b/app/src/main/kotlin/it/hamy/muza/ui/modifiers/Swiping.kt new file mode 100644 index 0000000..4902142 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/modifiers/Swiping.kt @@ -0,0 +1,241 @@ +package it.hamy.muza.ui.modifiers + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.DecayAnimationSpec +import androidx.compose.animation.core.calculateTargetValue +import androidx.compose.animation.core.spring +import androidx.compose.animation.splineBasedDecay +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitHorizontalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.awaitVerticalTouchSlopOrCancellation +import androidx.compose.foundation.gestures.horizontalDrag +import androidx.compose.foundation.gestures.verticalDrag +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import it.hamy.muza.utils.px +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Duration + +@Stable +@JvmInline +value class SwipeState internal constructor( + private val offsetLazy: Lazy> = lazy { acquire() } +) { + internal val offset get() = offsetLazy.value + + private companion object { + private val animatables = mutableListOf>() + private val coroutineScope = CoroutineScope(Dispatchers.IO) + + fun acquire() = animatables.removeFirstOrNull() ?: Animatable(0f) + fun recycle(animatable: Animatable) { + coroutineScope.launch { + animatable.snapTo(0f) + animatables += animatable + } + } + } + + @Composable + fun calculateOffset(bounds: ClosedRange? = null) = + offset.value.px.dp.let { if (bounds == null) it else it.coerceIn(bounds) } + + internal fun recycle() = recycle(offset) +} + +@Composable +fun rememberSwipeState(key: Any?): SwipeState { + val state = remember(key) { SwipeState() } + DisposableEffect(Unit) { + onDispose { + state.recycle() + } + } + return state +} + +fun Modifier.onSwipe( + state: SwipeState? = null, + key: Any = Unit, + animateOffset: Boolean = false, + orientation: Orientation = Orientation.Horizontal, + delay: Duration = Duration.ZERO, + decay: Density.() -> DecayAnimationSpec = { splineBasedDecay(this) }, + animationSpec: AnimationSpec = spring(), + bounds: ClosedRange? = null, + disposable: Boolean = false, + onSwipeOut: suspend (animationJob: Job) -> Unit +) = onSwipe( + state = state, + key = key, + animateOffset = animateOffset, + onSwipeLeft = onSwipeOut, + onSwipeRight = onSwipeOut, + orientation = orientation, + delay = delay, + decay = decay, + animationSpec = animationSpec, + bounds = bounds, + disposable = disposable +) + +@Suppress("CyclomaticComplexMethod") +fun Modifier.onSwipe( + state: SwipeState? = null, + key: Any = Unit, + animateOffset: Boolean = false, + onSwipeLeft: suspend (animationJob: Job) -> Unit = { }, + onSwipeRight: suspend (animationJob: Job) -> Unit = { }, + orientation: Orientation = Orientation.Horizontal, + delay: Duration = Duration.ZERO, + decay: Density.() -> DecayAnimationSpec = { splineBasedDecay(this) }, + animationSpec: AnimationSpec = spring(), + bounds: ClosedRange? = null, + disposable: Boolean = false +) = this.composed { + val swipeState = state ?: rememberSwipeState(key) + + pointerInput(key) { + coroutineScope { + // fling loop, doesn't really offset anything but simulates the animation beforehand + while (isActive) { + val velocityTracker = VelocityTracker() + + awaitPointerEventScope { + val pointer = awaitFirstDown(requireUnconsumed = false).id + launch { swipeState.offset.snapTo(0f) } + + val onDrag: (PointerInputChange) -> Unit = { + val change = + if (orientation == Orientation.Horizontal) it.positionChange().x + else it.positionChange().y + + launch { swipeState.offset.snapTo(swipeState.offset.value + change) } + + velocityTracker.addPosition(it.uptimeMillis, it.position) + if (change != 0f) it.consume() + } + + if (orientation == Orientation.Horizontal) { + awaitHorizontalTouchSlopOrCancellation(pointer) { change, _ -> onDrag(change) } + ?: return@awaitPointerEventScope + horizontalDrag(pointer, onDrag) + } else { + awaitVerticalTouchSlopOrCancellation(pointer) { change, _ -> onDrag(change) } + ?: return@awaitPointerEventScope + verticalDrag(pointer, onDrag) + } + } + + // drag completed, calculate velocity + val targetOffset = decay().calculateTargetValue( + initialValue = swipeState.offset.value, + initialVelocity = velocityTracker.calculateVelocity() + .let { if (orientation == Orientation.Horizontal) it.x else it.y } + ) + val size = if (orientation == Orientation.Horizontal) size.width else size.height + + launch animationEnd@{ + when { + targetOffset >= size / 2 -> { + val animationJob = launch { + swipeState.offset.animateTo( + targetValue = size.toFloat(), + animationSpec = animationSpec + ) + } + delay(delay) + onSwipeRight(animationJob) + if (disposable) return@animationEnd swipeState.recycle() + } + + targetOffset <= -size / 2 -> { + val animationJob = launch { + swipeState.offset.animateTo( + targetValue = -size.toFloat(), + animationSpec = animationSpec + ) + } + delay(delay) + onSwipeLeft(animationJob) + if (disposable) return@animationEnd swipeState.recycle() + } + } + swipeState.offset.animateTo( + targetValue = 0f, + animationSpec = animationSpec + ) + } + } + } + }.let { modifier -> + when { + animateOffset && orientation == Orientation.Horizontal -> + modifier.offset(x = swipeState.calculateOffset(bounds = bounds)) + + animateOffset && orientation == Orientation.Vertical -> + modifier.offset(y = swipeState.calculateOffset(bounds = bounds)) + + else -> modifier + } + } +} + +fun Modifier.swipeToClose( + state: SwipeState? = null, + key: Any = Unit, + delay: Duration = Duration.ZERO, + decay: Density.() -> DecayAnimationSpec = { splineBasedDecay(this) }, + onClose: suspend (animationJob: Job) -> Unit +) = this.composed { + val swipeState = state ?: rememberSwipeState(key) + + val density = LocalDensity.current + + var currentWidth by remember { mutableIntStateOf(0) } + val currentWidthDp by remember { derivedStateOf { currentWidth.px.dp(density) } } + val bounds by remember { derivedStateOf { -currentWidthDp..0.dp } } + + this + .onSizeChanged { currentWidth = it.width } + .alpha((currentWidthDp + swipeState.calculateOffset(bounds = bounds)) / currentWidthDp) + .onSwipe( + state = swipeState, + key = key, + animateOffset = true, + disposable = true, + onSwipeLeft = onClose, + orientation = Orientation.Horizontal, + delay = delay, + decay = decay, + bounds = bounds + ) +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/Routes.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/Routes.kt index c10814c..d5e2a7e 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/Routes.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/Routes.kt @@ -1,32 +1,41 @@ package it.hamy.muza.ui.screens -import android.annotation.SuppressLint -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable +import io.ktor.http.Url import it.hamy.compose.routing.Route0 import it.hamy.compose.routing.Route1 +import it.hamy.compose.routing.Route3 import it.hamy.compose.routing.RouteHandlerScope import it.hamy.muza.enums.BuiltInPlaylist +import it.hamy.muza.models.Mood import it.hamy.muza.ui.screens.album.AlbumScreen import it.hamy.muza.ui.screens.artist.ArtistScreen +import it.hamy.muza.ui.screens.mood.MoodScreen +import it.hamy.muza.ui.screens.pipedplaylist.PipedPlaylistScreen import it.hamy.muza.ui.screens.playlist.PlaylistScreen +import java.util.UUID + +/** + * Marker class for linters that a composable is a route and should not be handled like a regular + * composable, but rather as an entrypoint. + */ +@Retention(AnnotationRetention.SOURCE) +@Target(AnnotationTarget.FUNCTION) +annotation class Route val albumRoute = Route1("albumRoute") val artistRoute = Route1("artistRoute") val builtInPlaylistRoute = Route1("builtInPlaylistRoute") val localPlaylistRoute = Route1("localPlaylistRoute") -val playlistRoute = Route1("playlistRoute") +val pipedPlaylistRoute = Route3("pipedPlaylistRoute") +val playlistRoute = Route3("playlistRoute") +val moodRoute = Route1("moodRoute") val searchResultRoute = Route1("searchResultRoute") val searchRoute = Route1("searchRoute") val settingsRoute = Route0("settingsRoute") -@SuppressLint("ComposableNaming") -@Suppress("NOTHING_TO_INLINE") -@ExperimentalAnimationApi -@ExperimentalFoundationApi @Composable -inline fun RouteHandlerScope.globalRoutes() { +fun RouteHandlerScope.GlobalRoutes() { albumRoute { browseId -> AlbumScreen( browseId = browseId ?: error("browseId cannot be null") @@ -39,9 +48,27 @@ inline fun RouteHandlerScope.globalRoutes() { ) } - playlistRoute { browseId -> - PlaylistScreen( - browseId = browseId ?: error("browseId cannot be null") + pipedPlaylistRoute { apiBaseUrl, sessionToken, playlistId -> + PipedPlaylistScreen( + apiBaseUrl = apiBaseUrl?.let { + runCatching { Url(it) }.getOrNull() + } ?: error("apiBaseUrl cannot be null"), + sessionToken = sessionToken ?: error("sessionToken cannot be null"), + playlistId = runCatching { + UUID.fromString(playlistId) + }.getOrNull() ?: error("playlistId cannot be null") ) } + + playlistRoute { browseId, params, maxDepth -> + PlaylistScreen( + browseId = browseId ?: error("browseId cannot be null"), + params = params, + maxDepth = maxDepth + ) + } + + moodRoute { mood -> + MoodScreen(mood = mood) + } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/album/AlbumScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/album/AlbumScreen.kt index 0aec2f9..1aeadc8 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/album/AlbumScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/album/AlbumScreen.kt @@ -1,28 +1,25 @@ package it.hamy.muza.ui.screens.album import android.content.Intent -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Spacer import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource import com.valentinilk.shimmer.shimmer import it.hamy.compose.persist.PersistMapCleanup import it.hamy.compose.persist.persist +import it.hamy.compose.routing.RouteHandler import it.hamy.innertube.Innertube import it.hamy.innertube.models.bodies.BrowseBody import it.hamy.innertube.requests.albumPage -import it.hamy.compose.routing.RouteHandler import it.hamy.muza.Database import it.hamy.muza.R import it.hamy.muza.models.Album @@ -31,43 +28,46 @@ import it.hamy.muza.query import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.HeaderIconButton import it.hamy.muza.ui.components.themed.HeaderPlaceholder +import it.hamy.muza.ui.components.themed.PlaylistInfo import it.hamy.muza.ui.components.themed.Scaffold import it.hamy.muza.ui.components.themed.adaptiveThumbnailContent import it.hamy.muza.ui.items.AlbumItem import it.hamy.muza.ui.items.AlbumItemPlaceholder +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.screens.albumRoute -import it.hamy.muza.ui.screens.globalRoutes import it.hamy.muza.ui.screens.searchresult.ItemsPage +import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.asMediaItem +import it.hamy.muza.utils.stateFlowSaver import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable fun AlbumScreen(browseId: String) { val saveableStateHolder = rememberSaveableStateHolder() - var tabIndex by rememberSaveable { - mutableStateOf(0) - } + val tabIndexState = rememberSaveable(saver = stateFlowSaver()) { MutableStateFlow(0) } + val tabIndex by tabIndexState.collectAsState() var album by persist("album/$browseId/album") var albumPage by persist("album/$browseId/albumPage") - PersistMapCleanup(tagPrefix = "album/$browseId/") + PersistMapCleanup(prefix = "album/$browseId/") LaunchedEffect(Unit) { Database .album(browseId) - .combine(snapshotFlow { tabIndex }) { album, tabIndex -> album to tabIndex } + .combine(tabIndexState) { album, tabIndex -> album to tabIndex } .collect { (currentAlbum, tabIndex) -> album = currentAlbum - if (albumPage == null && (currentAlbum?.timestamp == null || tabIndex == 1)) { + if (albumPage == null && (currentAlbum?.timestamp == null || tabIndex == 1)) withContext(Dispatchers.IO) { Innertube.albumPage(BrowseBody(browseId = browseId)) ?.onSuccess { currentAlbumPage -> @@ -76,18 +76,20 @@ fun AlbumScreen(browseId: String) { Database.clearAlbum(browseId) Database.upsert( - Album( + album = Album( id = browseId, title = currentAlbumPage.title, + description = currentAlbumPage.description, thumbnailUrl = currentAlbumPage.thumbnail?.url, year = currentAlbumPage.year, authorsText = currentAlbumPage.authors - ?.joinToString("") { it.name ?: "" }, + ?.joinToString("") { it.name.orEmpty() }, shareUrl = currentAlbumPage.url, timestamp = System.currentTimeMillis(), - bookmarkedAt = album?.bookmarkedAt + bookmarkedAt = album?.bookmarkedAt, + otherInfo = currentAlbumPage.otherInfo ), - currentAlbumPage + songAlbumMaps = currentAlbumPage .songsPage ?.items ?.map(Innertube.SongItem::asMediaItem) @@ -102,88 +104,79 @@ fun AlbumScreen(browseId: String) { ) } } - - } } } RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + GlobalRoutes() - host { - val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = - { textButton -> - if (album?.timestamp == null) { - HeaderPlaceholder( - modifier = Modifier - .shimmer() + NavHost { + val headerContent: @Composable ( + beforeContent: (@Composable () -> Unit)?, + afterContent: (@Composable () -> Unit)? + ) -> Unit = { beforeContent, afterContent -> + if (album?.timestamp == null) HeaderPlaceholder(modifier = Modifier.shimmer()) + else { + val (colorPalette) = LocalAppearance.current + val context = LocalContext.current + + Header(title = album?.title ?: stringResource(R.string.unknown)) { + beforeContent?.invoke() + + Spacer(modifier = Modifier.weight(1f)) + + afterContent?.invoke() + + HeaderIconButton( + icon = if (album?.bookmarkedAt == null) R.drawable.bookmark_outline + else R.drawable.bookmark, + color = colorPalette.accent, + onClick = { + val bookmarkedAt = + if (album?.bookmarkedAt == null) System.currentTimeMillis() else null + + query { + album + ?.copy(bookmarkedAt = bookmarkedAt) + ?.let(Database::update) + } + } ) - } else { - val (colorPalette) = LocalAppearance.current - val context = LocalContext.current - Header(title = album?.title ?: "Неизвестный") { - textButton?.invoke() - - Spacer( - modifier = Modifier - .weight(1f) - ) - - HeaderIconButton( - icon = if (album?.bookmarkedAt == null) { - R.drawable.bookmark_outline - } else { - R.drawable.bookmark - }, - color = colorPalette.accent, - onClick = { - val bookmarkedAt = - if (album?.bookmarkedAt == null) System.currentTimeMillis() else null - - query { - album - ?.copy(bookmarkedAt = bookmarkedAt) - ?.let(Database::update) + HeaderIconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + album?.shareUrl?.let { url -> + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, url) } - } - ) - HeaderIconButton( - icon = R.drawable.share_social, - color = colorPalette.text, - onClick = { - album?.shareUrl?.let { url -> - val sendIntent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, url) - } - - context.startActivity( - Intent.createChooser( - sendIntent, - null - ) - ) - } + context.startActivity( + Intent.createChooser(sendIntent, null) + ) } - ) - } + } + ) } } + } - val thumbnailContent = - adaptiveThumbnailContent(album?.timestamp == null, album?.thumbnailUrl) + val thumbnailContent = adaptiveThumbnailContent( + isLoading = album?.timestamp == null, + url = album?.thumbnailUrl + ) Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, - onTabChanged = { tabIndex = it }, - tabColumnContent = { Item -> - Item(0, "Песни", R.drawable.musical_notes) - Item(1, "Другие версии", R.drawable.disc) + onTabChanged = { newTab -> tabIndexState.update { newTab } }, + tabColumnContent = { item -> + item(0, stringResource(R.string.songs), R.drawable.musical_notes) + item(1, stringResource(R.string.other_versions), R.drawable.disc) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { @@ -192,39 +185,38 @@ fun AlbumScreen(browseId: String) { browseId = browseId, headerContent = headerContent, thumbnailContent = thumbnailContent, + afterHeaderContent = { + if (album == null) PlaylistInfo(playlist = albumPage) + else PlaylistInfo(playlist = album) + } ) 1 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - ItemsPage( tag = "album/$browseId/alternatives", - headerContent = headerContent, + header = headerContent, initialPlaceholderCount = 1, continuationPlaceholderCount = 1, - emptyItemsText = "Этот альбом не имеет других версий", - itemsPageProvider = albumPage?.let { - ({ + emptyItemsText = stringResource(R.string.no_alternative_version), + provider = albumPage?.let { + { Result.success( Innertube.ItemsPage( items = albumPage?.otherVersions, continuation = null ) ) - }) + } }, itemContent = { album -> AlbumItem( album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable { albumRoute(album.key) } + thumbnailSize = Dimensions.thumbnails.album, + modifier = Modifier.clickable { albumRoute(album.key) } ) }, itemPlaceholderContent = { - AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) } ) } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/album/AlbumSongs.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/album/AlbumSongs.kt index a4dff33..0d6fe76 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/album/AlbumSongs.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/album/AlbumSongs.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.album -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -21,6 +20,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import it.hamy.compose.persist.persistList import it.hamy.muza.Database @@ -47,13 +47,17 @@ import it.hamy.muza.utils.forcePlayFromBeginning import it.hamy.muza.utils.isLandscape import it.hamy.muza.utils.semiBold -@ExperimentalAnimationApi -@ExperimentalFoundationApi +@OptIn(ExperimentalFoundationApi::class) @Composable fun AlbumSongs( browseId: String, - headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + headerContent: @Composable ( + beforeContent: (@Composable () -> Unit)?, + afterContent: (@Composable () -> Unit)? + ) -> Unit, thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + afterHeaderContent: (@Composable () -> Unit)? = null ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current @@ -65,16 +69,18 @@ fun AlbumSongs( Database.albumSongs(browseId).collect { songs = it } } - val thumbnailSizeDp = Dimensions.thumbnails.song - val lazyListState = rememberLazyListState() - LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier + ) { Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() @@ -84,19 +90,21 @@ fun AlbumSongs( contentType = 0 ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - headerContent { - SecondaryTextButton( - text = "В очередь", - enabled = songs.isNotEmpty(), - onClick = { - binder?.player?.enqueue(songs.map(Song::asMediaItem)) - } - ) - } + headerContent( + { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = songs.isNotEmpty(), + onClick = { + binder?.player?.enqueue(songs.map(Song::asMediaItem)) + } + ) + }, + null + ) - if (!isLandscape) { - thumbnailContent() - } + if (!isLandscape) thumbnailContent() + afterHeaderContent?.invoke() } } @@ -108,7 +116,7 @@ fun AlbumSongs( title = song.title, authors = song.artistsText, duration = song.durationText, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.song, thumbnailContent = { BasicText( text = "${index + 1}", @@ -116,40 +124,34 @@ fun AlbumSongs( maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier - .width(thumbnailSizeDp) + .width(Dimensions.thumbnails.song) .align(Alignment.Center) ) }, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(Song::asMediaItem), - index + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem ) } - ) + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items = songs.map(Song::asMediaItem), + index = index + ) + } + ) ) } - if (songs.isEmpty()) { - item(key = "loading") { - ShimmerHost( - modifier = Modifier - .fillParentMaxSize() - ) { - repeat(4) { - SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) - } + if (songs.isEmpty()) item(key = "loading") { + ShimmerHost(modifier = Modifier.fillParentMaxSize()) { + repeat(4) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistLocalSongs.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistLocalSongs.kt index 65dc964..ef802ab 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistLocalSongs.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistLocalSongs.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.artist -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -19,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import it.hamy.compose.persist.persist import it.hamy.muza.Database import it.hamy.muza.LocalPlayerAwareWindowInsets @@ -35,19 +35,18 @@ import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.items.SongItemPlaceholder import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.enqueue import it.hamy.muza.utils.forcePlayAtIndex import it.hamy.muza.utils.forcePlayFromBeginning -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) @Composable fun ArtistLocalSongs( browseId: String, headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier ) { val binder = LocalPlayerServiceBinder.current val (colorPalette) = LocalAppearance.current @@ -59,17 +58,17 @@ fun ArtistLocalSongs( Database.artistSongs(browseId).collect { songs = it } } - val songThumbnailSizeDp = Dimensions.thumbnails.song - val songThumbnailSizePx = songThumbnailSizeDp.px - val lazyListState = rememberLazyListState() - LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier + ) { Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() @@ -81,7 +80,7 @@ fun ArtistLocalSongs( Column(horizontalAlignment = Alignment.CenterHorizontally) { headerContent { SecondaryTextButton( - text = "В очередь", + text = stringResource(R.string.enqueue), enabled = !songs.isNullOrEmpty(), onClick = { binder?.player?.enqueue(songs!!.map(Song::asMediaItem)) @@ -99,33 +98,31 @@ fun ArtistLocalSongs( key = { _, song -> song.id } ) { index, song -> SongItem( - song = song, - thumbnailSizeDp = songThumbnailSizeDp, - thumbnailSizePx = songThumbnailSizePx, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(Song::asMediaItem), - index + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem ) } - ) + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items = songs.map(Song::asMediaItem), + index = index + ) + } + ), + song = song, + thumbnailSize = Dimensions.thumbnails.song ) } } ?: item(key = "loading") { ShimmerHost { repeat(4) { - SongItemPlaceholder(thumbnailSizeDp = Dimensions.thumbnails.song) + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistOverview.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistOverview.kt index 9a17ce2..9a7b21d 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistOverview.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistOverview.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.artist -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -13,7 +12,6 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyRow @@ -24,7 +22,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import it.hamy.innertube.Innertube import it.hamy.innertube.models.NavigationEndpoint @@ -33,6 +31,7 @@ import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.ShimmerHost +import it.hamy.muza.ui.components.themed.Attribution import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.LayoutWithAdaptiveThumbnail import it.hamy.muza.ui.components.themed.NonQueuedMediaItemMenu @@ -44,16 +43,12 @@ import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.items.SongItemPlaceholder import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px -import it.hamy.muza.utils.align import it.hamy.muza.utils.asMediaItem -import it.hamy.muza.utils.color import it.hamy.muza.utils.forcePlay import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) @Composable fun ArtistOverview( youtubeArtistPage: Innertube.ArtistPage?, @@ -63,17 +58,13 @@ fun ArtistOverview( onAlbumClick: (String) -> Unit, thumbnailContent: @Composable () -> Unit, headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + modifier: Modifier = Modifier ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current val windowInsets = LocalPlayerAwareWindowInsets.current - val songThumbnailSizeDp = Dimensions.thumbnails.song - val songThumbnailSizePx = songThumbnailSizeDp.px - val albumThumbnailSizeDp = 108.dp - val albumThumbnailSizePx = albumThumbnailSizeDp.px - val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() val sectionTextModifier = Modifier @@ -82,7 +73,10 @@ fun ArtistOverview( val scrollState = rememberScrollState() - LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier + ) { Box { Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -96,14 +90,11 @@ fun ArtistOverview( .asPaddingValues() ) ) { - Box( - modifier = Modifier - .padding(endPaddingValues) - ) { + Box(modifier = Modifier.padding(endPaddingValues)) { headerContent { youtubeArtistPage?.shuffleEndpoint?.let { endpoint -> SecondaryTextButton( - text = "Перемешать", + text = stringResource(R.string.shuffle), onClick = { binder?.stopRadio() binder?.playRadio(endpoint) @@ -115,7 +106,7 @@ fun ArtistOverview( thumbnailContent() - if (youtubeArtistPage != null) { + youtubeArtistPage?.let { youtubeArtistPage.songs?.let { songs -> Row( verticalAlignment = Alignment.Bottom, @@ -125,17 +116,16 @@ fun ArtistOverview( .padding(endPaddingValues) ) { BasicText( - text = "Песни", + text = stringResource(R.string.songs), style = typography.m.semiBold, modifier = sectionTextModifier ) youtubeArtistPage.songsEndpoint?.let { BasicText( - text = "Все", + text = stringResource(R.string.view_all), style = typography.xs.secondary, - modifier = sectionTextModifier - .clickable(onClick = onViewAllSongsClick), + modifier = sectionTextModifier.clickable(onClick = onViewAllSongsClick) ) } } @@ -143,15 +133,14 @@ fun ArtistOverview( songs.forEach { song -> SongItem( song = song, - thumbnailSizeDp = songThumbnailSizeDp, - thumbnailSizePx = songThumbnailSizePx, + thumbnailSize = Dimensions.thumbnails.song, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, - mediaItem = song.asMediaItem, + mediaItem = song.asMediaItem ) } }, @@ -178,25 +167,23 @@ fun ArtistOverview( .padding(endPaddingValues) ) { BasicText( - text = "Альбомы", + text = stringResource(R.string.albums), style = typography.m.semiBold, modifier = sectionTextModifier ) youtubeArtistPage.albumsEndpoint?.let { BasicText( - text = "Все", + text = stringResource(R.string.view_all), style = typography.xs.secondary, - modifier = sectionTextModifier - .clickable(onClick = onViewAllAlbumsClick), + modifier = sectionTextModifier.clickable(onClick = onViewAllAlbumsClick) ) } } LazyRow( contentPadding = endPaddingValues, - modifier = Modifier - .fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { items( items = albums, @@ -204,11 +191,11 @@ fun ArtistOverview( ) { album -> AlbumItem( album = album, - thumbnailSizePx = albumThumbnailSizePx, - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true, - modifier = Modifier - .clickable(onClick = { onAlbumClick(album.key) }) + modifier = Modifier.clickable(onClick = { + onAlbumClick(album.key) + }) ) } } @@ -223,25 +210,23 @@ fun ArtistOverview( .padding(endPaddingValues) ) { BasicText( - text = "Синглы", + text = stringResource(R.string.singles), style = typography.m.semiBold, modifier = sectionTextModifier ) youtubeArtistPage.singlesEndpoint?.let { BasicText( - text = "Все", + text = stringResource(R.string.view_all), style = typography.xs.secondary, - modifier = sectionTextModifier - .clickable(onClick = onViewAllSinglesClick), + modifier = sectionTextModifier.clickable(onClick = onViewAllSinglesClick) ) } } LazyRow( contentPadding = endPaddingValues, - modifier = Modifier - .fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { items( items = singles, @@ -249,85 +234,38 @@ fun ArtistOverview( ) { album -> AlbumItem( album = album, - thumbnailSizePx = albumThumbnailSizePx, - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true, - modifier = Modifier - .clickable(onClick = { onAlbumClick(album.key) }) + modifier = Modifier.clickable(onClick = { onAlbumClick(album.key) }) ) } } } youtubeArtistPage.description?.let { description -> - val attributionsIndex = description.lastIndexOf("\n\nFrom Wikipedia") - - Row( + Attribution( + text = description, modifier = Modifier .padding(top = 16.dp) .padding(vertical = 16.dp, horizontal = 8.dp) - .padding(endPaddingValues) - ) { - BasicText( - text = "“", - style = typography.xxl.semiBold, - modifier = Modifier - .offset(y = (-8).dp) - .align(Alignment.Top) - ) - - BasicText( - text = if (attributionsIndex == -1) { - description - } else { - description.substring(0, attributionsIndex) - }, - style = typography.xxs.secondary, - modifier = Modifier - .padding(horizontal = 8.dp) - .weight(1f) - ) - - BasicText( - text = "„", - style = typography.xxl.semiBold, - modifier = Modifier - .offset(y = 4.dp) - .align(Alignment.Bottom) - ) - } - - if (attributionsIndex != -1) { - BasicText( - text = "From Wikipedia under Creative Commons Attribution CC-BY-SA 3.0", - style = typography.xxs.color(colorPalette.textDisabled).align(TextAlign.End), - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(bottom = 16.dp) - .padding(endPaddingValues) - ) - } + ) } - } else { - ShimmerHost { + } ?: ShimmerHost { + TextPlaceholder(modifier = sectionTextModifier) + + repeat(5) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + + repeat(2) { TextPlaceholder(modifier = sectionTextModifier) - repeat(5) { - SongItemPlaceholder( - thumbnailSizeDp = songThumbnailSizeDp, - ) - } - - repeat(2) { - TextPlaceholder(modifier = sectionTextModifier) - - Row { - repeat(2) { - AlbumItemPlaceholder( - thumbnailSizeDp = albumThumbnailSizeDp, - alternative = true - ) - } + Row { + repeat(2) { + AlbumItemPlaceholder( + thumbnailSize = Dimensions.thumbnails.album, + alternative = true + ) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistScreen.kt index f58fd34..4de6f75 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/artist/ArtistScreen.kt @@ -1,7 +1,6 @@ package it.hamy.muza.ui.screens.artist import android.content.Intent -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -12,24 +11,25 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource import com.valentinilk.shimmer.shimmer import it.hamy.compose.persist.PersistMapCleanup import it.hamy.compose.persist.persist +import it.hamy.compose.routing.RouteHandler import it.hamy.innertube.Innertube import it.hamy.innertube.models.bodies.BrowseBody import it.hamy.innertube.models.bodies.ContinuationBody import it.hamy.innertube.requests.artistPage import it.hamy.innertube.requests.itemsPage import it.hamy.innertube.utils.from -import it.hamy.compose.routing.RouteHandler import it.hamy.muza.Database import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R import it.hamy.muza.models.Artist +import it.hamy.muza.preferences.UIStatePreferences +import it.hamy.muza.preferences.UIStatePreferences.artistScreenTabIndexProperty import it.hamy.muza.query import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.themed.Header @@ -42,31 +42,30 @@ import it.hamy.muza.ui.items.AlbumItem import it.hamy.muza.ui.items.AlbumItemPlaceholder import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.items.SongItemPlaceholder +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.screens.albumRoute -import it.hamy.muza.ui.screens.globalRoutes import it.hamy.muza.ui.screens.searchresult.ItemsPage import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px -import it.hamy.muza.utils.artistScreenTabIndexKey import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.forcePlay -import it.hamy.muza.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) +@Route @Composable fun ArtistScreen(browseId: String) { + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + val saveableStateHolder = rememberSaveableStateHolder() - var tabIndex by rememberPreference(artistScreenTabIndexKey, defaultValue = 0) - - PersistMapCleanup(tagPrefix = "artist/$browseId/") + PersistMapCleanup(prefix = "artist/$browseId/") var artist by persist("artist/$browseId/artist") @@ -75,12 +74,15 @@ fun ArtistScreen(browseId: String) { LaunchedEffect(Unit) { Database .artist(browseId) - .combine(snapshotFlow { tabIndex }.map { it != 4 }) { artist, mustFetch -> artist to mustFetch } + .combine( + flow = artistScreenTabIndexProperty.stateFlow.map { it != 4 }, + transform = ::Pair + ) .distinctUntilChanged() .collect { (currentArtist, mustFetch) -> artist = currentArtist - if (artistPage == null && (currentArtist?.timestamp == null || mustFetch)) { + if (artistPage == null && (currentArtist?.timestamp == null || mustFetch)) withContext(Dispatchers.IO) { Innertube.artistPage(BrowseBody(browseId = browseId)) ?.onSuccess { currentArtistPage -> @@ -97,46 +99,33 @@ fun ArtistScreen(browseId: String) { ) } } - } } } RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + GlobalRoutes() - host { - val thumbnailContent = - adaptiveThumbnailContent( - artist?.timestamp == null, - artist?.thumbnailUrl, - CircleShape - ) + NavHost { + val thumbnailContent = adaptiveThumbnailContent( + isLoading = artist?.timestamp == null, + url = artist?.thumbnailUrl, + shape = CircleShape + ) val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { textButton -> - if (artist?.timestamp == null) { - HeaderPlaceholder( - modifier = Modifier - .shimmer() - ) - } else { + if (artist?.timestamp == null) HeaderPlaceholder(modifier = Modifier.shimmer()) else { val (colorPalette) = LocalAppearance.current val context = LocalContext.current - Header(title = artist?.name ?: "Unknown") { + Header(title = artist?.name ?: stringResource(R.string.unknown)) { textButton?.invoke() - Spacer( - modifier = Modifier - .weight(1f) - ) + Spacer(modifier = Modifier.weight(1f)) HeaderIconButton( - icon = if (artist?.bookmarkedAt == null) { - R.drawable.bookmark_outline - } else { - R.drawable.bookmark - }, + icon = if (artist?.bookmarkedAt == null) R.drawable.bookmark_outline + else R.drawable.bookmark, color = colorPalette.accent, onClick = { val bookmarkedAt = @@ -173,15 +162,15 @@ fun ArtistScreen(browseId: String) { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, - tabIndex = tabIndex, - onTabChanged = { tabIndex = it }, - tabColumnContent = { Item -> - Item(0, "Обзор", R.drawable.sparkles) - Item(1, "Песни", R.drawable.musical_notes) - Item(2, "Альбомы", R.drawable.disc) - Item(3, "Синглы", R.drawable.disc) - Item(4, "Библиотека", R.drawable.library) - }, + tabIndex = UIStatePreferences.artistScreenTabIndex, + onTabChanged = { UIStatePreferences.artistScreenTabIndex = it }, + tabColumnContent = { item -> + item(0, stringResource(R.string.overview), R.drawable.sparkles) + item(1, stringResource(R.string.songs), R.drawable.musical_notes) + item(2, stringResource(R.string.albums), R.drawable.disc) + item(3, stringResource(R.string.singles), R.drawable.disc) + item(4, stringResource(R.string.library), R.drawable.library) + } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { when (currentTabIndex) { @@ -190,180 +179,160 @@ fun ArtistScreen(browseId: String) { thumbnailContent = thumbnailContent, headerContent = headerContent, onAlbumClick = { albumRoute(it) }, - onViewAllSongsClick = { tabIndex = 1 }, - onViewAllAlbumsClick = { tabIndex = 2 }, - onViewAllSinglesClick = { tabIndex = 3 }, + onViewAllSongsClick = { UIStatePreferences.artistScreenTabIndex = 1 }, + onViewAllAlbumsClick = { UIStatePreferences.artistScreenTabIndex = 2 }, + onViewAllSinglesClick = { UIStatePreferences.artistScreenTabIndex = 3 } ) - 1 -> { - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "artist/$browseId/songs", - headerContent = headerContent, - itemsPageProvider = artistPage?.let { - ({ continuation -> - continuation?.let { + 1 -> ItemsPage( + tag = "artist/$browseId/songs", + header = headerContent, + provider = artistPage?.let { + @Suppress("SpacingAroundCurly") + { continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicResponsiveListItemRenderer = Innertube.SongItem::from + ) + } ?: artistPage + ?.songsEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> Innertube.itemsPage( - body = ContinuationBody(continuation = continuation), - fromMusicResponsiveListItemRenderer = Innertube.SongItem::from, + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params + ), + fromMusicResponsiveListItemRenderer = Innertube.SongItem::from ) - } ?: artistPage - ?.songsEndpoint - ?.takeIf { it.browseId != null } - ?.let { endpoint -> - Innertube.itemsPage( - body = BrowseBody( - browseId = endpoint.browseId!!, - params = endpoint.params, - ), - fromMusicResponsiveListItemRenderer = Innertube.SongItem::from, + } + ?: Result.success( + Innertube.ItemsPage( + items = artistPage?.songs, + continuation = null + ) + ) + } + }, + itemContent = { song -> + SongItem( + song = song, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem ) } - ?: Result.success( - Innertube.ItemsPage( - items = artistPage?.songs, - continuation = null - ) - ) - }) - }, - itemContent = { song -> - SongItem( - song = song, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlay(song.asMediaItem) - binder?.setupRadio(song.info?.endpoint) - } - ) + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(song.asMediaItem) + binder?.setupRadio(song.info?.endpoint) + } ) - }, - itemPlaceholderContent = { - SongItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } + ) + }, + itemPlaceholderContent = { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + ) - 2 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "artist/$browseId/albums", - headerContent = headerContent, - emptyItemsText = "Исполнитель не выпустил ни одного альбома", - itemsPageProvider = artistPage?.let { - ({ continuation -> - continuation?.let { + 2 -> ItemsPage( + tag = "artist/$browseId/albums", + header = headerContent, + emptyItemsText = stringResource(R.string.artist_has_no_albums), + provider = artistPage?.let { + @Suppress("SpacingAroundCurly") + { continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from + ) + } ?: artistPage + ?.albumsEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> Innertube.itemsPage( - body = ContinuationBody(continuation = continuation), - fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, - ) - } ?: artistPage - ?.albumsEndpoint - ?.takeIf { it.browseId != null } - ?.let { endpoint -> - Innertube.itemsPage( - body = BrowseBody( - browseId = endpoint.browseId!!, - params = endpoint.params, - ), - fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, - ) - } - ?: Result.success( - Innertube.ItemsPage( - items = artistPage?.albums, - continuation = null + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params + ), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from ) + } + ?: Result.success( + Innertube.ItemsPage( + items = artistPage?.albums, + continuation = null ) - }) - }, - itemContent = { album -> - AlbumItem( - album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { albumRoute(album.key) }) ) - }, - itemPlaceholderContent = { - AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } - ) - } + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + modifier = Modifier.clickable(onClick = { albumRoute(album.key) }) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) + } + ) - 3 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "artist/$browseId/singles", - headerContent = headerContent, - emptyItemsText = "Исполнитель не выпустил ни одного сингла", - itemsPageProvider = artistPage?.let { - ({ continuation -> - continuation?.let { + 3 -> ItemsPage( + tag = "artist/$browseId/singles", + header = headerContent, + emptyItemsText = stringResource(R.string.artist_has_no_singles), + provider = artistPage?.let { + @Suppress("SpacingAroundCurly") + { continuation -> + continuation?.let { + Innertube.itemsPage( + body = ContinuationBody(continuation = continuation), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from + ) + } ?: artistPage + ?.singlesEndpoint + ?.takeIf { it.browseId != null } + ?.let { endpoint -> Innertube.itemsPage( - body = ContinuationBody(continuation = continuation), - fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, - ) - } ?: artistPage - ?.singlesEndpoint - ?.takeIf { it.browseId != null } - ?.let { endpoint -> - Innertube.itemsPage( - body = BrowseBody( - browseId = endpoint.browseId!!, - params = endpoint.params, - ), - fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from, - ) - } - ?: Result.success( - Innertube.ItemsPage( - items = artistPage?.singles, - continuation = null + body = BrowseBody( + browseId = endpoint.browseId!!, + params = endpoint.params + ), + fromMusicTwoRowItemRenderer = Innertube.AlbumItem::from ) + } + ?: Result.success( + Innertube.ItemsPage( + items = artistPage?.singles, + continuation = null ) - }) - }, - itemContent = { album -> - AlbumItem( - album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { albumRoute(album.key) }) ) - }, - itemPlaceholderContent = { - AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) } - ) - } + }, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + modifier = Modifier.clickable(onClick = { albumRoute(album.key) }) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) + } + ) 4 -> ArtistLocalSongs( browseId = browseId, headerContent = headerContent, - thumbnailContent = thumbnailContent, + thumbnailContent = thumbnailContent ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt index 8e86543..9eabd06 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/builtinplaylist/BuiltInPlaylistScreen.kt @@ -1,51 +1,60 @@ package it.hamy.muza.ui.screens.builtinplaylist -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.res.stringResource import it.hamy.compose.persist.PersistMapCleanup import it.hamy.compose.routing.RouteHandler import it.hamy.muza.R import it.hamy.muza.enums.BuiltInPlaylist +import it.hamy.muza.preferences.DataPreferences import it.hamy.muza.ui.components.themed.Scaffold -import it.hamy.muza.ui.screens.globalRoutes +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable fun BuiltInPlaylistScreen(builtInPlaylist: BuiltInPlaylist) { val saveableStateHolder = rememberSaveableStateHolder() val (tabIndex, onTabIndexChanged) = rememberSaveable { - mutableStateOf(when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> 0 - BuiltInPlaylist.Offline -> 1 - }) + mutableIntStateOf( + when (builtInPlaylist) { + BuiltInPlaylist.Favorites -> 0 + BuiltInPlaylist.Offline -> 1 + BuiltInPlaylist.Top -> 2 + } + ) } - PersistMapCleanup(tagPrefix = "${builtInPlaylist.name}/") + PersistMapCleanup(prefix = "${builtInPlaylist.name}/") RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + GlobalRoutes() - host { + NavHost { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = onTabIndexChanged, - tabColumnContent = { Item -> - Item(0, "Любимые", R.drawable.heart) - Item(1, "Сохранённые", R.drawable.airplane) + tabColumnContent = { item -> + item(0, stringResource(R.string.favorites), R.drawable.heart) + item(1, stringResource(R.string.offline), R.drawable.airplane) + item( + 2, + stringResource(R.string.format_top_playlist, DataPreferences.topListLength), + R.drawable.trending_up + ) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { when (currentTabIndex) { 0 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Favorites) 1 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Offline) + 2 -> BuiltInPlaylistSongs(builtInPlaylist = BuiltInPlaylist.Top) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt index cdbcae5..8f1338c 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/builtinplaylist/BuiltInPlaylistSongs.kt @@ -1,10 +1,10 @@ package it.hamy.muza.ui.screens.builtinplaylist -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues @@ -17,8 +17,11 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import it.hamy.compose.persist.persistList import it.hamy.muza.Database @@ -27,59 +30,74 @@ import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R import it.hamy.muza.enums.BuiltInPlaylist import it.hamy.muza.models.Song -import it.hamy.muza.models.SongWithContentLength +import it.hamy.muza.preferences.DataPreferences import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.InHistoryMediaItemMenu import it.hamy.muza.ui.components.themed.NonQueuedMediaItemMenu import it.hamy.muza.ui.components.themed.SecondaryTextButton +import it.hamy.muza.ui.components.themed.ValueSelectorDialog import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.enqueue import it.hamy.muza.utils.forcePlayAtIndex import it.hamy.muza.utils.forcePlayFromBeginning -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.flowOn +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.cancellable +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class, ExperimentalCoroutinesApi::class) @Composable -fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) { +fun BuiltInPlaylistSongs( + builtInPlaylist: BuiltInPlaylist, + modifier: Modifier = Modifier +) = with(DataPreferences) { val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current var songs by persistList("${builtInPlaylist.name}/songs") - LaunchedEffect(Unit) { + LaunchedEffect(binder) { when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> Database - .favorites() + BuiltInPlaylist.Favorites -> Database.favorites() - BuiltInPlaylist.Offline -> Database - .songsWithContentLength() - .flowOn(Dispatchers.IO) - .map { songs -> - songs.filter { song -> - song.contentLength?.let { - binder?.cache?.isCached(song.song.id, 0, song.contentLength) - } ?: false - }.map(SongWithContentLength::song) - } + BuiltInPlaylist.Offline -> + Database + .songsWithContentLength() + .map { songs -> + songs.filter { binder?.isCached(it) ?: false }.map { it.song } + } + + BuiltInPlaylist.Top -> combine( + flow = topListPeriodProperty.stateFlow, + flow2 = topListLengthProperty.stateFlow + ) { period, length -> period to length }.flatMapLatest { (period, length) -> + if (period.duration == null) Database + .songsByPlayTimeDesc(limit = length) + .distinctUntilChanged() + .cancellable() + else Database + .trending( + limit = length, + period = period.duration.inWholeMilliseconds + ) + .distinctUntilChanged() + .cancellable() + } }.collect { songs = it } } - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSize = thumbnailSizeDp.px - val lazyListState = rememberLazyListState() - Box { + Box(modifier = modifier) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current @@ -88,69 +106,94 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) { .background(colorPalette.background0) .fillMaxSize() ) { - item( - key = "header", - contentType = 0 - ) { + item(key = "header", contentType = 0) { Header( title = when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> "Любимые" - BuiltInPlaylist.Offline -> "Сохранённые" + BuiltInPlaylist.Favorites -> stringResource(R.string.favorites) + BuiltInPlaylist.Offline -> stringResource(R.string.offline) + BuiltInPlaylist.Top -> stringResource( + R.string.format_my_top_playlist, + topListLength + ) }, - modifier = Modifier - .padding(bottom = 8.dp) + modifier = Modifier.padding(bottom = 8.dp) ) { SecondaryTextButton( - text = "В очередь", + text = stringResource(R.string.enqueue), enabled = songs.isNotEmpty(), onClick = { binder?.player?.enqueue(songs.map(Song::asMediaItem)) } ) - Spacer( - modifier = Modifier - .weight(1f) - ) + Spacer(modifier = Modifier.weight(1f)) + + if (builtInPlaylist == BuiltInPlaylist.Top) { + var dialogShowing by rememberSaveable { mutableStateOf(false) } + + SecondaryTextButton( + text = topListPeriod.displayName(), + onClick = { dialogShowing = true } + ) + + if (dialogShowing) ValueSelectorDialog( + onDismiss = { dialogShowing = false }, + title = stringResource( + R.string.format_view_top_of_header, + topListLength + ), + selectedValue = topListPeriod, + values = DataPreferences.TopListPeriod.entries.toImmutableList(), + onValueSelected = { topListPeriod = it }, + valueText = { it.displayName() } + ) + } } } itemsIndexed( items = songs, key = { _, song -> song.id }, - contentType = { _, song -> song }, + contentType = { _, song -> song } ) { index, song -> - SongItem( - song = song, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSize, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - when (builtInPlaylist) { - BuiltInPlaylist.Favorites -> NonQueuedMediaItemMenu( - mediaItem = song.asMediaItem, - onDismiss = menuState::hide - ) + Row { + SongItem( + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + when (builtInPlaylist) { + BuiltInPlaylist.Favorites -> NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = menuState::hide + ) - BuiltInPlaylist.Offline -> InHistoryMediaItemMenu( - song = song, - onDismiss = menuState::hide - ) + BuiltInPlaylist.Offline -> InHistoryMediaItemMenu( + song = song, + onDismiss = menuState::hide + ) + + BuiltInPlaylist.Top -> NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = menuState::hide + ) + } } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlayAtIndex( + items = songs.map(Song::asMediaItem), + index = index + ) } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlayAtIndex( - songs.map(Song::asMediaItem), - index - ) - } - ) - .animateItemPlacement() - ) + ) + .animateItemPlacement(), + song = song, + index = if (builtInPlaylist == BuiltInPlaylist.Top) index else null, + thumbnailSize = Dimensions.thumbnails.song + ) + } } } @@ -158,12 +201,11 @@ fun BuiltInPlaylistSongs(builtInPlaylist: BuiltInPlaylist) { lazyListState = lazyListState, iconId = R.drawable.shuffle, onClick = { - if (songs.isNotEmpty()) { - binder?.stopRadio() - binder?.player?.forcePlayFromBeginning( - songs.shuffled().map(Song::asMediaItem) - ) - } + if (songs.isEmpty()) return@FloatingActionsContainerWithScrollToTop + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().map(Song::asMediaItem) + ) } ) } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeAlbums.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeAlbums.kt index 41b6ac8..fd527c3 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeAlbums.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeAlbums.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.home -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -23,6 +22,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import it.hamy.compose.persist.persist import it.hamy.muza.Database @@ -31,41 +31,34 @@ import it.hamy.muza.R import it.hamy.muza.enums.AlbumSortBy import it.hamy.muza.enums.SortOrder import it.hamy.muza.models.Album +import it.hamy.muza.preferences.OrderPreferences import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.HeaderIconButton import it.hamy.muza.ui.items.AlbumItem +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px -import it.hamy.muza.utils.albumSortByKey -import it.hamy.muza.utils.albumSortOrderKey -import it.hamy.muza.utils.rememberPreference -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) +@Route @Composable fun HomeAlbums( onAlbumClick: (Album) -> Unit, - onSearchClick: () -> Unit, -) { + onSearchClick: () -> Unit +) = with(OrderPreferences) { val (colorPalette) = LocalAppearance.current - var sortBy by rememberPreference(albumSortByKey, AlbumSortBy.DateAdded) - var sortOrder by rememberPreference(albumSortOrderKey, SortOrder.Descending) - var items by persist>(tag = "home/albums", emptyList()) - LaunchedEffect(sortBy, sortOrder) { - Database.albums(sortBy, sortOrder).collect { items = it } + LaunchedEffect(albumSortBy, albumSortOrder) { + Database.albums(albumSortBy, albumSortOrder).collect { items = it } } - val thumbnailSizeDp = Dimensions.thumbnails.song * 2 - val thumbnailSizePx = thumbnailSizeDp.px - val sortOrderIconRotation by animateFloatAsState( - targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, - animationSpec = tween(durationMillis = 400, easing = LinearEasing) + targetValue = if (albumSortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing), + label = "" ) val lazyListState = rememberLazyListState() @@ -83,36 +76,33 @@ fun HomeAlbums( key = "header", contentType = 0 ) { - Header(title = "Альбомы") { + Header(title = stringResource(R.string.albums)) { HeaderIconButton( icon = R.drawable.calendar, - color = if (sortBy == AlbumSortBy.Year) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = AlbumSortBy.Year } + color = if (albumSortBy == AlbumSortBy.Year) colorPalette.text else colorPalette.textDisabled, + onClick = { albumSortBy = AlbumSortBy.Year } ) HeaderIconButton( icon = R.drawable.text, - color = if (sortBy == AlbumSortBy.Title) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = AlbumSortBy.Title } + color = if (albumSortBy == AlbumSortBy.Title) colorPalette.text else colorPalette.textDisabled, + onClick = { albumSortBy = AlbumSortBy.Title } ) HeaderIconButton( icon = R.drawable.time, - color = if (sortBy == AlbumSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = AlbumSortBy.DateAdded } + color = if (albumSortBy == AlbumSortBy.DateAdded) colorPalette.text + else colorPalette.textDisabled, + onClick = { albumSortBy = AlbumSortBy.DateAdded } ) - Spacer( - modifier = Modifier - .width(2.dp) - ) + Spacer(modifier = Modifier.width(2.dp)) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, - onClick = { sortOrder = !sortOrder }, - modifier = Modifier - .graphicsLayer { rotationZ = sortOrderIconRotation } + onClick = { albumSortOrder = !albumSortOrder }, + modifier = Modifier.graphicsLayer { rotationZ = sortOrderIconRotation } ) } } @@ -123,8 +113,7 @@ fun HomeAlbums( ) { album -> AlbumItem( album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, modifier = Modifier .clickable(onClick = { onAlbumClick(album) }) .animateItemPlacement() diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeArtists.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeArtists.kt index 16ec07d..169e87e 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeArtists.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeArtists.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.home -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -27,6 +26,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import it.hamy.compose.persist.persistList import it.hamy.muza.Database @@ -35,41 +35,39 @@ import it.hamy.muza.R import it.hamy.muza.enums.ArtistSortBy import it.hamy.muza.enums.SortOrder import it.hamy.muza.models.Artist +import it.hamy.muza.preferences.OrderPreferences import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.HeaderIconButton import it.hamy.muza.ui.items.ArtistItem +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px -import it.hamy.muza.utils.artistSortByKey -import it.hamy.muza.utils.artistSortOrderKey -import it.hamy.muza.utils.rememberPreference -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) +@Route @Composable fun HomeArtistList( onArtistClick: (Artist) -> Unit, - onSearchClick: () -> Unit, -) { + onSearchClick: () -> Unit +) = with(OrderPreferences) { val (colorPalette) = LocalAppearance.current - var sortBy by rememberPreference(artistSortByKey, ArtistSortBy.DateAdded) - var sortOrder by rememberPreference(artistSortOrderKey, SortOrder.Descending) - var items by persistList("home/artists") - LaunchedEffect(sortBy, sortOrder) { - Database.artists(sortBy, sortOrder).collect { items = it } + LaunchedEffect(artistSortBy, artistSortOrder) { + Database + .artists(artistSortBy, artistSortOrder) + .collect { items = it } } - val thumbnailSizeDp = Dimensions.thumbnails.song * 2 - val thumbnailSizePx = thumbnailSizeDp.px - val sortOrderIconRotation by animateFloatAsState( - targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, - animationSpec = tween(durationMillis = 400, easing = LinearEasing) + targetValue = if (artistSortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween( + durationMillis = 400, + easing = LinearEasing + ), + label = "" ) val lazyGridState = rememberLazyGridState() @@ -77,12 +75,13 @@ fun HomeArtistList( Box { LazyVerticalGrid( state = lazyGridState, - columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2), + columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.items.verticalPadding * 2), contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2), + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + verticalArrangement = Arrangement.spacedBy(Dimensions.items.verticalPadding * 2), horizontalArrangement = Arrangement.spacedBy( - space = Dimensions.itemsVerticalPadding * 2, + space = Dimensions.items.verticalPadding * 2, alignment = Alignment.CenterHorizontally ), modifier = Modifier @@ -94,30 +93,28 @@ fun HomeArtistList( contentType = 0, span = { GridItemSpan(maxLineSpan) } ) { - Header(title = "Исполнители") { + Header(title = stringResource(R.string.artists)) { HeaderIconButton( icon = R.drawable.text, - color = if (sortBy == ArtistSortBy.Name) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = ArtistSortBy.Name } + color = if (artistSortBy == ArtistSortBy.Name) colorPalette.text + else colorPalette.textDisabled, + onClick = { artistSortBy = ArtistSortBy.Name } ) HeaderIconButton( icon = R.drawable.time, - color = if (sortBy == ArtistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = ArtistSortBy.DateAdded } + color = if (artistSortBy == ArtistSortBy.DateAdded) colorPalette.text + else colorPalette.textDisabled, + onClick = { artistSortBy = ArtistSortBy.DateAdded } ) - Spacer( - modifier = Modifier - .width(2.dp) - ) + Spacer(modifier = Modifier.width(2.dp)) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, - onClick = { sortOrder = !sortOrder }, - modifier = Modifier - .graphicsLayer { rotationZ = sortOrderIconRotation } + onClick = { artistSortOrder = !artistSortOrder }, + modifier = Modifier.graphicsLayer { rotationZ = sortOrderIconRotation } ) } } @@ -125,8 +122,7 @@ fun HomeArtistList( items(items = items, key = Artist::id) { artist -> ArtistItem( artist = artist, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.song * 2, alternative = true, modifier = Modifier .clickable(onClick = { onArtistClick(artist) }) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeDiscovery.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeDiscovery.kt new file mode 100644 index 0000000..74da549 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeDiscovery.kt @@ -0,0 +1,276 @@ +package it.hamy.muza.ui.screens.home + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ElevatedCard +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import it.hamy.compose.persist.persist +import it.hamy.innertube.Innertube +import it.hamy.innertube.requests.discoverPage +import it.hamy.muza.LocalPlayerAwareWindowInsets +import it.hamy.muza.R +import it.hamy.muza.ui.components.NavigationAd +import it.hamy.muza.ui.components.ShimmerHost +import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.hamy.muza.ui.components.themed.Header +import it.hamy.muza.ui.components.themed.TextPlaceholder +import it.hamy.muza.ui.items.AlbumItem +import it.hamy.muza.ui.items.AlbumItemPlaceholder +import it.hamy.muza.ui.screens.Route +import it.hamy.muza.ui.styling.Dimensions +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.ui.styling.shimmer +import it.hamy.muza.utils.center +import it.hamy.muza.utils.color +import it.hamy.muza.utils.isLandscape +import it.hamy.muza.utils.rememberSnapLayoutInfoProvider +import it.hamy.muza.utils.secondary +import it.hamy.muza.utils.semiBold + +@OptIn(ExperimentalFoundationApi::class) +@Route +@Composable +fun HomeDiscovery( + onMoodClick: (mood: Innertube.Mood.Item) -> Unit, + onNewReleaseAlbumClick: (String) -> Unit, + onSearchClick: () -> Unit +) { + val (colorPalette, typography) = LocalAppearance.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val scrollState = rememberScrollState() + val lazyGridState = rememberLazyGridState() + + val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() + + val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + .padding(endPaddingValues) + + var discoverPage by persist>("home/discovery") + + LaunchedEffect(Unit) { + if (discoverPage?.isSuccess != true) + discoverPage = Innertube.discoverPage() + } + + BoxWithConstraints { + val moodItemWidthFactor = if (isLandscape && maxWidth * 0.475f >= 320.dp) 0.475f else 0.75f + + val snapLayoutInfoProvider = rememberSnapLayoutInfoProvider( + lazyGridState = lazyGridState, + positionInLayout = { layoutSize, itemSize -> + layoutSize * moodItemWidthFactor / 2f - itemSize / 2f + } + ) + + val itemWidth = maxWidth * moodItemWidthFactor + + Column( + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + .verticalScroll(scrollState) + .padding( + windowInsets + .only(WindowInsetsSides.Vertical) + .asPaddingValues() + ) + ) { + Header( + title = stringResource(R.string.discover), + modifier = Modifier.padding(endPaddingValues) + ) + + discoverPage?.getOrNull()?.let { page -> + if (page.moods.isNotEmpty()) { + BasicText( + text = stringResource(R.string.moods_and_genres), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + LazyHorizontalGrid( + state = lazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + contentPadding = endPaddingValues, + modifier = Modifier + .fillMaxWidth() + .height((4 * (64 + 4)).dp) + ) { + items( + items = page.moods.sortedBy { it.title }, + key = { it.endpoint.params ?: it.title } + ) { + MoodItem( + mood = it, + onClick = { it.endpoint.browseId?.let { _ -> onMoodClick(it) } }, + modifier = Modifier + .width(itemWidth) + .padding(4.dp) + ) + } + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(start = 5.dp, end = 5.dp, top = 5.dp), + contentAlignment = Alignment.Center, + ) { + NavigationAd(id = "R-M-5961316-3") + } + } + + if (page.newReleaseAlbums.isNotEmpty()) { + BasicText( + text = stringResource(R.string.new_released_albums), + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + + LazyRow(contentPadding = endPaddingValues) { + items(items = page.newReleaseAlbums, key = { it.key }) { + AlbumItem( + album = it, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable(onClick = { onNewReleaseAlbumClick(it.key) }) + ) + } + } + } + } ?: discoverPage?.exceptionOrNull()?.let { + BasicText( + text = stringResource(R.string.error_message), + style = typography.s.secondary.center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + ) + } ?: ShimmerHost { + TextPlaceholder(modifier = sectionTextModifier) + LazyHorizontalGrid( + state = lazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + contentPadding = endPaddingValues, + modifier = Modifier + .fillMaxWidth() + .height(4 * (Dimensions.items.moodHeight + 4.dp)) + ) { + items(16) { + MoodItemPlaceholder( + width = itemWidth, + modifier = Modifier.padding(4.dp) + ) + } + } + TextPlaceholder(modifier = sectionTextModifier) + Row { + repeat(2) { + AlbumItemPlaceholder( + thumbnailSize = Dimensions.thumbnails.album, + alternative = true + ) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + scrollState = scrollState, + iconId = R.drawable.search, + onClick = onSearchClick + ) + } +} + +@Composable +fun MoodItem( + mood: Innertube.Mood.Item, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val typography = LocalAppearance.current.typography + val thumbnailShape = LocalAppearance.current.thumbnailShape + + val color by remember { derivedStateOf { Color(mood.stripeColor) } } + + ElevatedCard( + modifier = modifier.height(Dimensions.items.moodHeight), + shape = thumbnailShape, + colors = CardDefaults.elevatedCardColors(containerColor = color) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable { onClick() }, + contentAlignment = Alignment.CenterStart + ) { + BasicText( + text = mood.title, + style = typography.xs.semiBold.color( + if (color.luminance() >= 0.5f) Color.Black else Color.White + ), + modifier = Modifier.padding(start = 24.dp) + ) + } + } +} + +@Composable +fun MoodItemPlaceholder( + width: Dp, + modifier: Modifier = Modifier +) = Spacer( + modifier = modifier + .background( + color = LocalAppearance.current.colorPalette.shimmer, + shape = LocalAppearance.current.thumbnailShape + ) + .size( + width = width, + height = Dimensions.items.moodHeight + ) +) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeLocalSongs.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeLocalSongs.kt new file mode 100644 index 0000000..07fa93d --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeLocalSongs.kt @@ -0,0 +1,185 @@ +package it.hamy.muza.ui.screens.home + +import android.Manifest +import android.content.ContentUris +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.provider.MediaStore +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import it.hamy.muza.Database +import it.hamy.muza.R +import it.hamy.muza.models.Song +import it.hamy.muza.preferences.OrderPreferences +import it.hamy.muza.service.LOCAL_KEY_PREFIX +import it.hamy.muza.transaction +import it.hamy.muza.ui.components.themed.SecondaryTextButton +import it.hamy.muza.ui.screens.Route +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.get +import it.hamy.muza.utils.hasPermission +import it.hamy.muza.utils.isAtLeastAndroid10 +import it.hamy.muza.utils.isAtLeastAndroid13 +import it.hamy.muza.utils.isCompositionLaunched +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private val permission = if (isAtLeastAndroid13) Manifest.permission.READ_MEDIA_AUDIO +else Manifest.permission.READ_EXTERNAL_STORAGE + +@Route +@Composable +fun HomeLocalSongs(onSearchClick: () -> Unit) = with(OrderPreferences) { + val context = LocalContext.current + val (_, typography) = LocalAppearance.current + + var hasPermission by remember(isCompositionLaunched()) { + mutableStateOf(context.applicationContext.hasPermission(permission)) + } + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { hasPermission = it } + ) + + LaunchedEffect(hasPermission) { + context.musicFilesAsFlow().collect() + } + + if (hasPermission) HomeSongs( + onSearchClick = onSearchClick, + songProvider = { + Database.songs( + sortBy = localSongSortBy, + sortOrder = localSongSortOrder, + isLocal = true + ).map { songs -> songs.filter { it.durationText != "0:00" } } + }, + sortBy = localSongSortBy, + setSortBy = { localSongSortBy = it }, + sortOrder = localSongSortOrder, + setSortOrder = { localSongSortOrder = it }, + title = stringResource(R.string.local) + ) else { + LaunchedEffect(Unit) { launcher.launch(permission) } + + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(2.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally + ) { + BasicText( + text = stringResource(R.string.media_permission_declined), + modifier = Modifier.fillMaxWidth(0.5f), + style = typography.s + ) + SecondaryTextButton( + text = stringResource(R.string.open_settings), + onClick = { + context.startActivity( + Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply { + setData(Uri.fromParts("package", context.packageName, null)) + } + ) + } + ) + } + } +} + +private val mediaScope = CoroutineScope(Dispatchers.IO + CoroutineName("MediaStore worker")) +fun Context.musicFilesAsFlow(): StateFlow> = flow { + var version: String? = null + + while (currentCoroutineContext().isActive) { + val newVersion = MediaStore.getVersion(applicationContext) + if (version != newVersion) { + version = newVersion + val collection = + if (isAtLeastAndroid10) MediaStore.Audio.Media.getContentUri(MediaStore.VOLUME_EXTERNAL) + else MediaStore.Audio.Media.EXTERNAL_CONTENT_URI + val projection = arrayOf( + MediaStore.Audio.Media.IS_MUSIC, + MediaStore.Audio.Media._ID, + MediaStore.Audio.Media.DISPLAY_NAME, + MediaStore.Audio.Media.DURATION, + MediaStore.Audio.Media.ARTIST, + MediaStore.Audio.Media.ALBUM_ID + ) + val sortOrder = "${MediaStore.Audio.Media.DISPLAY_NAME} ASC" + val albumUriBase = Uri.parse("content://media/external/audio/albumart") + + contentResolver.query(collection, projection, null, null, sortOrder) + ?.use { cursor -> + val isMusicIdx = cursor[MediaStore.Audio.Media.IS_MUSIC] + val idIdx = cursor[MediaStore.Audio.Media._ID] + val nameIdx = cursor[MediaStore.Audio.Media.DISPLAY_NAME] + val durationIdx = cursor[MediaStore.Audio.Media.DURATION] + val artistIdx = cursor[MediaStore.Audio.Media.ARTIST] + val albumIdIdx = cursor[MediaStore.Audio.Media.ALBUM_ID] + + buildList { + while (cursor.moveToNext()) { + if (cursor.getInt(isMusicIdx) == 0) continue + val id = cursor.getLong(idIdx) + val name = cursor.getString(nameIdx) + val duration = cursor.getInt(durationIdx) + if (duration == 0) continue + val artist = cursor.getString(artistIdx) + val albumId = cursor.getLong(albumIdIdx) + + val albumUri = ContentUris.withAppendedId(albumUriBase, albumId) + val durationText = + duration.milliseconds.toComponents { minutes, seconds, _ -> + "$minutes:${seconds.toString().padStart(2, '0')}" + } + + add( + Song( + id = "$LOCAL_KEY_PREFIX$id", + title = name, + artistsText = artist, + durationText = durationText, + thumbnailUrl = albumUri.toString() + ) + ) + } + } + }?.let { emit(it) } + } + delay(5.seconds) + } +}.distinctUntilChanged() + .onEach { songs -> transaction { songs.forEach(Database::insert) } } + .stateIn(mediaScope, SharingStarted.Eagerly, listOf()) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomePlaylists.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomePlaylists.kt index 8cf70e9..fd47099 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomePlaylists.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomePlaylists.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.home -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -9,14 +8,11 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan @@ -32,207 +28,257 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import it.hamy.compose.persist.persist import it.hamy.compose.persist.persistList +import it.hamy.piped.Piped +import it.hamy.piped.models.Session import it.hamy.muza.Database import it.hamy.muza.LocalPlayerAwareWindowInsets import it.hamy.muza.R import it.hamy.muza.enums.BuiltInPlaylist import it.hamy.muza.enums.PlaylistSortBy import it.hamy.muza.enums.SortOrder +import it.hamy.muza.models.PipedSession import it.hamy.muza.models.Playlist import it.hamy.muza.models.PlaylistPreview +import it.hamy.muza.preferences.DataPreferences +import it.hamy.muza.preferences.OrderPreferences import it.hamy.muza.query -import it.hamy.muza.ui.components.YandexAdsBanner import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.HeaderIconButton import it.hamy.muza.ui.components.themed.SecondaryTextButton import it.hamy.muza.ui.components.themed.TextFieldDialog import it.hamy.muza.ui.items.PlaylistItem +import it.hamy.muza.ui.screens.Route +import it.hamy.muza.ui.screens.settings.SettingsEntryGroupText +import it.hamy.muza.ui.screens.settings.SettingsGroupSpacer import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px -import it.hamy.muza.utils.playlistSortByKey -import it.hamy.muza.utils.playlistSortOrderKey -import it.hamy.muza.utils.rememberPreference +import kotlinx.coroutines.async +import it.hamy.piped.models.PlaylistPreview as PipedPlaylistPreview +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import it.hamy.muza.ui.components.PlaylistAd - -@ExperimentalAnimationApi -@ExperimentalFoundationApi +@OptIn(ExperimentalFoundationApi::class) +@Route @Composable - fun HomePlaylists( onBuiltInPlaylist: (BuiltInPlaylist) -> Unit, onPlaylistClick: (Playlist) -> Unit, - onSearchClick: () -> Unit, -) { + onPipedPlaylistClick: (Session, PipedPlaylistPreview) -> Unit, + onSearchClick: () -> Unit +) = with(OrderPreferences) { val (colorPalette) = LocalAppearance.current - var isCreatingANewPlaylist by rememberSaveable { - mutableStateOf(false) - } + var isCreatingANewPlaylist by rememberSaveable { mutableStateOf(false) } - - - if (isCreatingANewPlaylist) { - TextFieldDialog( - hintText = "Введите название плейлиста", - onDismiss = { - isCreatingANewPlaylist = false - }, - onDone = { text -> - query { - Database.insert(Playlist(name = text)) - } + if (isCreatingANewPlaylist) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + onDismiss = { isCreatingANewPlaylist = false }, + onDone = { text -> + query { + Database.insert(Playlist(name = text)) } - ) + } + ) + var items by persistList("home/playlists") + var pipedSessions by persist?>>("home/piped") + + LaunchedEffect(playlistSortBy, playlistSortOrder) { + Database.playlistPreviews(playlistSortBy, playlistSortOrder).collect { items = it } } - var sortBy by rememberPreference(playlistSortByKey, PlaylistSortBy.DateAdded) - var sortOrder by rememberPreference(playlistSortOrderKey, SortOrder.Descending) - - var items by persistList("home/playlists") - - LaunchedEffect(sortBy, sortOrder) { - Database.playlistPreviews(sortBy, sortOrder).collect { items = it } + LaunchedEffect(Unit) { + Database.pipedSessions().collect { sessions -> + pipedSessions = sessions.associateWith { session -> + async { + Piped.playlist.list(session = session.toApiSession())?.getOrNull() + } + }.mapValues { (_, value) -> value.await() } + } } val sortOrderIconRotation by animateFloatAsState( - targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, - animationSpec = tween(durationMillis = 400, easing = LinearEasing) + targetValue = if (playlistSortOrder == SortOrder.Ascending) 0f else 180f, + animationSpec = tween(durationMillis = 400, easing = LinearEasing), + label = "" ) - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - val lazyGridState = rememberLazyGridState() - Box(modifier = Modifier.fillMaxSize()) { - Column( + Box { + LazyVerticalGrid( + state = lazyGridState, + columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.items.verticalPadding * 2), + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + verticalArrangement = Arrangement.spacedBy(Dimensions.items.verticalPadding * 2), + horizontalArrangement = Arrangement.spacedBy( + space = Dimensions.items.verticalPadding * 2, + alignment = Alignment.CenterHorizontally + ), modifier = Modifier .fillMaxSize() + .background(colorPalette.background0) ) { - LazyVerticalGrid( - state = lazyGridState, - columns = GridCells.Adaptive(Dimensions.thumbnails.song * 2 + Dimensions.itemsVerticalPadding * 2), - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - verticalArrangement = Arrangement.spacedBy(Dimensions.itemsVerticalPadding * 2), - horizontalArrangement = Arrangement.spacedBy( - space = Dimensions.itemsVerticalPadding * 2, - alignment = Alignment.CenterHorizontally - ), - modifier = Modifier - .fillMaxWidth() - .background(colorPalette.background0) - ) { - item(key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) }) { - Header(title = "Плейлисты") { - SecondaryTextButton( - text = "Новый плейлист", - onClick = { isCreatingANewPlaylist = true } - ) + item(key = "header", contentType = 0, span = { GridItemSpan(maxLineSpan) }) { + Header(title = stringResource(R.string.playlists)) { + SecondaryTextButton( + text = stringResource(R.string.new_playlist), + onClick = { isCreatingANewPlaylist = true } + ) - Spacer( - modifier = Modifier - .weight(1f) - ) + Spacer(modifier = Modifier.weight(1f)) - HeaderIconButton( - icon = R.drawable.medical, - color = if (sortBy == PlaylistSortBy.SongCount) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = PlaylistSortBy.SongCount } - ) + HeaderIconButton( + icon = R.drawable.medical, + color = if (playlistSortBy == PlaylistSortBy.SongCount) colorPalette.text + else colorPalette.textDisabled, + onClick = { playlistSortBy = PlaylistSortBy.SongCount } + ) - HeaderIconButton( - icon = R.drawable.text, - color = if (sortBy == PlaylistSortBy.Name) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = PlaylistSortBy.Name } - ) + HeaderIconButton( + icon = R.drawable.text, + color = if (playlistSortBy == PlaylistSortBy.Name) colorPalette.text + else colorPalette.textDisabled, + onClick = { playlistSortBy = PlaylistSortBy.Name } + ) - HeaderIconButton( - icon = R.drawable.time, - color = if (sortBy == PlaylistSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = PlaylistSortBy.DateAdded } - ) + HeaderIconButton( + icon = R.drawable.time, + color = if (playlistSortBy == PlaylistSortBy.DateAdded) colorPalette.text + else colorPalette.textDisabled, + onClick = { playlistSortBy = PlaylistSortBy.DateAdded } + ) - Spacer( - modifier = Modifier - .width(2.dp) - ) + Spacer(modifier = Modifier.width(2.dp)) - HeaderIconButton( - icon = R.drawable.arrow_up, - color = colorPalette.text, - onClick = { sortOrder = !sortOrder }, - modifier = Modifier - .graphicsLayer { rotationZ = sortOrderIconRotation } - ) - } - } - - - - - - item(key = "favorites") { - PlaylistItem( - icon = R.drawable.heart, - colorTint = colorPalette.red, - name = "Любимые", - songCount = null, - thumbnailSizeDp = thumbnailSizeDp, - alternative = true, - modifier = Modifier - .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) }) - .animateItemPlacement() + HeaderIconButton( + icon = R.drawable.arrow_up, + color = colorPalette.text, + onClick = { playlistSortOrder = !playlistSortOrder }, + modifier = Modifier.graphicsLayer { rotationZ = sortOrderIconRotation } ) } - - item(key = "offline") { - PlaylistItem( - icon = R.drawable.airplane, - colorTint = colorPalette.blue, - name = "Сохранённые", - songCount = null, - thumbnailSizeDp = thumbnailSizeDp, - alternative = true, - modifier = Modifier - .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) }) - .animateItemPlacement() - ) - } - - items(items = items, key = { it.playlist.id }) { playlistPreview -> - PlaylistItem( - playlist = playlistPreview, - thumbnailSizeDp = thumbnailSizeDp, - thumbnailSizePx = thumbnailSizePx, - alternative = true, - modifier = Modifier - .clickable(onClick = { onPlaylistClick(playlistPreview.playlist) }) - .animateItemPlacement() - ) - } - - item { - Box( - modifier = Modifier - .fillMaxSize() - .padding(start = 14.dp, end = 10.dp, top = 20.dp) - .align(Alignment.CenterHorizontally), - contentAlignment = Alignment.Center, - ) { - YandexAdsBanner(id = "R-M-5961316-1") - } - } - } + item(key = "favorites") { + PlaylistItem( + icon = R.drawable.heart, + colorTint = colorPalette.red, + name = stringResource(R.string.favorites), + songCount = null, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier + .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Favorites) }) + .animateItemPlacement() + ) + } + item(key = "offline") { + PlaylistItem( + icon = R.drawable.airplane, + colorTint = colorPalette.blue, + name = stringResource(R.string.offline), + songCount = null, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier + .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Offline) }) + .animateItemPlacement() + ) + } + item(key = "top") { + PlaylistItem( + icon = R.drawable.trending, + colorTint = colorPalette.red, + name = stringResource( + R.string.format_my_top_playlist, + DataPreferences.topListLength + ), + songCount = null, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier + .clickable(onClick = { onBuiltInPlaylist(BuiltInPlaylist.Top) }) + .animateItemPlacement() + ) + } + + items( + items = items, + key = { it.playlist.id } + ) { playlistPreview -> + PlaylistItem( + playlist = playlistPreview, + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier + .clickable(onClick = { onPlaylistClick(playlistPreview.playlist) }) + .animateItemPlacement() + ) + } + + item { + Box( + modifier = Modifier + .fillMaxSize() + .padding(start = 5.dp, end = 5.dp, top = 5.dp), + contentAlignment = Alignment.Center, + ) { + PlaylistAd(id = "R-M-5961316-1") + } + } + + pipedSessions + ?.ifEmpty { null } + ?.filter { it.value?.isNotEmpty() == true } + ?.forEach { (session, playlists) -> + item( + key = "piped-header-${session.username}", + contentType = 0, + span = { GridItemSpan(maxLineSpan) } + ) { + SettingsGroupSpacer() + SettingsEntryGroupText(title = session.username) + } + + playlists?.let { + items( + items = playlists, + key = { "piped-${session.username}-${it.id}" } + ) { playlist -> + PlaylistItem( + name = playlist.name, + songCount = playlist.videoCount, + channelName = null, + thumbnailUrl = playlist.thumbnailUrl.toString(), + thumbnailSize = Dimensions.thumbnails.playlist, + alternative = true, + modifier = Modifier + .clickable(onClick = { + onPipedPlaylistClick( + session.toApiSession(), + playlist + ) + }) + .animateItemPlacement() + ) + } + } + } } + FloatingActionsContainerWithScrollToTop( lazyGridState = lazyGridState, iconId = R.drawable.search, diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/QuickPicks.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeQuickPicks.kt similarity index 74% rename from app/src/main/kotlin/it/hamy/muza/ui/screens/home/QuickPicks.kt rename to app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeQuickPicks.kt index e11104e..044fbb9 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/QuickPicks.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeQuickPicks.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.home -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -32,12 +31,14 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import it.hamy.compose.persist.persist import it.hamy.innertube.Innertube @@ -49,10 +50,11 @@ import it.hamy.muza.LocalPlayerAwareWindowInsets import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R import it.hamy.muza.models.Song +import it.hamy.muza.preferences.DataPreferences import it.hamy.muza.query import it.hamy.muza.ui.components.LocalMenuState +import it.hamy.muza.ui.components.QuickpicksAd import it.hamy.muza.ui.components.ShimmerHost -import it.hamy.muza.ui.components.YandexAdsBannerQuickPicksCenter import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.NonQueuedMediaItemMenu @@ -65,27 +67,26 @@ import it.hamy.muza.ui.items.PlaylistItem import it.hamy.muza.ui.items.PlaylistItemPlaceholder import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.items.SongItemPlaceholder +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px -import it.hamy.muza.utils.SnapLayoutInfoProvider import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.center import it.hamy.muza.utils.forcePlay import it.hamy.muza.utils.isLandscape +import it.hamy.muza.utils.rememberSnapLayoutInfoProvider import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold import kotlinx.coroutines.flow.distinctUntilChanged -import it.hamy.muza.preferences.DataPreferences -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) +@Route @Composable fun QuickPicks( onAlbumClick: (String) -> Unit, onArtistClick: (String) -> Unit, onPlaylistClick: (String) -> Unit, - onSearchClick: () -> Unit, + onSearchClick: () -> Unit ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current @@ -96,33 +97,21 @@ fun QuickPicks( var relatedPageResult by persist?>(tag = "home/relatedPageResult") - LaunchedEffect(Unit) { - Database.trending().distinctUntilChanged().collect { song -> - if ((song == null && relatedPageResult == null) || trending?.id != song?.id) { - relatedPageResult = - Innertube.relatedPage(NextBody(videoId = (song?.id ?: "J7p4bzqLvCw"))) - } - trending = song - } - } - - LaunchedEffect(DataPreferences.quickPicksSource) { suspend fun handleSong(song: Song?) { if (relatedPageResult == null || trending?.id != song?.id) relatedPageResult = Innertube.relatedPage( - NextBody( - videoId = (song?.id ?: "J7p4bzqLvCw") - ) + body = NextBody(videoId = (song?.id ?: "J7p4bzqLvCw")) ) trending = song } + when (DataPreferences.quickPicksSource) { DataPreferences.QuickPicksSource.Trending -> Database .trending() .distinctUntilChanged() - .collect { handleSong(it) } + .collect { handleSong(it.firstOrNull()) } DataPreferences.QuickPicksSource.LastInteraction -> Database @@ -132,15 +121,6 @@ fun QuickPicks( } } - val songThumbnailSizeDp = Dimensions.thumbnails.song - val songThumbnailSizePx = songThumbnailSizeDp.px - val albumThumbnailSizeDp = 108.dp - val albumThumbnailSizePx = albumThumbnailSizeDp.px - val artistThumbnailSizeDp = 92.dp - val artistThumbnailSizePx = artistThumbnailSizeDp.px - val playlistThumbnailSizeDp = 108.dp - val playlistThumbnailSizePx = playlistThumbnailSizeDp.px - val scrollState = rememberScrollState() val quickPicksLazyGridState = rememberLazyGridState() @@ -152,20 +132,16 @@ fun QuickPicks( .padding(endPaddingValues) BoxWithConstraints { - val quickPicksLazyGridItemWidthFactor = if (isLandscape && maxWidth * 0.475f >= 320.dp) { - 0.475f - } else { - 0.9f - } - val snapLayoutInfoProvider = remember(quickPicksLazyGridState) { - SnapLayoutInfoProvider( - lazyGridState = quickPicksLazyGridState, - positionInLayout = { layoutSize, itemSize -> - (layoutSize * quickPicksLazyGridItemWidthFactor / 2f - itemSize / 2f) - } - ) - } + val quickPicksLazyGridItemWidthFactor = + if (isLandscape && maxWidth * 0.475f >= 320.dp) 0.475f else 0.85f + + val snapLayoutInfoProvider = rememberSnapLayoutInfoProvider( + lazyGridState = quickPicksLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * quickPicksLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) val itemInHorizontalGridWidth = maxWidth * quickPicksLazyGridItemWidthFactor @@ -181,9 +157,8 @@ fun QuickPicks( ) ) { Header( - title = "Обзор", - modifier = Modifier - .padding(endPaddingValues) + title = stringResource(R.string.quick_picks), + modifier = Modifier.padding(endPaddingValues) ) relatedPageResult?.getOrNull()?.let { related -> @@ -194,23 +169,11 @@ fun QuickPicks( contentPadding = endPaddingValues, modifier = Modifier .fillMaxWidth() - .height((songThumbnailSizeDp + Dimensions.itemsVerticalPadding * 2) * 4) + .height((Dimensions.thumbnails.song + Dimensions.items.verticalPadding * 2) * 4) ) { trending?.let { song -> item { SongItem( - song = song, - thumbnailSizePx = songThumbnailSizePx, - thumbnailSizeDp = songThumbnailSizeDp, - trailingContent = { - Image( - painter = painterResource(R.drawable.star), - contentDescription = null, - colorFilter = ColorFilter.tint(colorPalette.accent), - modifier = Modifier - .size(16.dp) - ) - }, modifier = Modifier .combinedClickable( onLongClick = { @@ -236,8 +199,17 @@ fun QuickPicks( } ) .animateItemPlacement() - .width(itemInHorizontalGridWidth) - ) + .width(itemInHorizontalGridWidth), + song = song, + thumbnailSize = Dimensions.thumbnails.song + ) { + Image( + painter = painterResource(R.drawable.star), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.accent), + modifier = Modifier.size(16.dp) + ) + } } } @@ -248,8 +220,7 @@ fun QuickPicks( ) { song -> SongItem( song = song, - thumbnailSizePx = songThumbnailSizePx, - thumbnailSizeDp = songThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.song, modifier = Modifier .combinedClickable( onLongClick = { @@ -273,21 +244,21 @@ fun QuickPicks( .width(itemInHorizontalGridWidth) ) } - } + } Box( modifier = Modifier .fillMaxSize() - .padding(start = 0.dp, end = 0.dp, top = 15.dp) - .align(Alignment.CenterHorizontally), + .padding(start = 5.dp, end = 5.dp, top = 15.dp), contentAlignment = Alignment.Center, ) { - YandexAdsBannerQuickPicksCenter(id = "R-M-5961316-5") + QuickpicksAd(id = "R-M-5961316-5") } + related.albums?.let { albums -> BasicText( - text = "Похожие альбомы", + text = stringResource(R.string.related_albums), style = typography.m.semiBold, modifier = sectionTextModifier ) @@ -299,19 +270,18 @@ fun QuickPicks( ) { album -> AlbumItem( album = album, - thumbnailSizePx = albumThumbnailSizePx, - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true, - modifier = Modifier - .clickable(onClick = { onAlbumClick(album.key) }) + modifier = Modifier.clickable(onClick = { onAlbumClick(album.key) }) ) } } } + related.artists?.let { artists -> BasicText( - text = "Похожие исполнители", + text = stringResource(R.string.similar_artists), style = typography.m.semiBold, modifier = sectionTextModifier ) @@ -319,15 +289,13 @@ fun QuickPicks( LazyRow(contentPadding = endPaddingValues) { items( items = artists, - key = Innertube.ArtistItem::key, + key = Innertube.ArtistItem::key ) { artist -> ArtistItem( artist = artist, - thumbnailSizePx = artistThumbnailSizePx, - thumbnailSizeDp = artistThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.artist, alternative = true, - modifier = Modifier - .clickable(onClick = { onArtistClick(artist.key) }) + modifier = Modifier.clickable(onClick = { onArtistClick(artist.key) }) ) } } @@ -335,7 +303,7 @@ fun QuickPicks( related.playlists?.let { playlists -> BasicText( - text = "Плейлисты, которые вам понравятся", + text = stringResource(R.string.recommended_playlists), style = typography.m.semiBold, modifier = Modifier .padding(horizontal = 16.dp) @@ -345,15 +313,13 @@ fun QuickPicks( LazyRow(contentPadding = endPaddingValues) { items( items = playlists, - key = Innertube.PlaylistItem::key, + key = Innertube.PlaylistItem::key ) { playlist -> PlaylistItem( playlist = playlist, - thumbnailSizePx = playlistThumbnailSizePx, - thumbnailSizeDp = playlistThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.playlist, alternative = true, - modifier = Modifier - .clickable(onClick = { onPlaylistClick(playlist.key) }) + modifier = Modifier.clickable(onClick = { onPlaylistClick(playlist.key) }) ) } } @@ -362,7 +328,7 @@ fun QuickPicks( Unit } ?: relatedPageResult?.exceptionOrNull()?.let { BasicText( - text = "Упс, тут какая-то ошЫбочка...", + text = stringResource(R.string.error_message), style = typography.s.secondary.center, modifier = Modifier .align(Alignment.CenterHorizontally) @@ -370,9 +336,7 @@ fun QuickPicks( ) } ?: ShimmerHost { repeat(4) { - SongItemPlaceholder( - thumbnailSizeDp = songThumbnailSizeDp, - ) + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) } TextPlaceholder(modifier = sectionTextModifier) @@ -380,7 +344,7 @@ fun QuickPicks( Row { repeat(2) { AlbumItemPlaceholder( - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true ) } @@ -391,7 +355,7 @@ fun QuickPicks( Row { repeat(2) { ArtistItemPlaceholder( - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true ) } @@ -402,7 +366,7 @@ fun QuickPicks( Row { repeat(2) { PlaylistItemPlaceholder( - thumbnailSizeDp = albumThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.album, alternative = true ) } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeScreen.kt index 92aa3a6..64f45c0 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeScreen.kt @@ -1,10 +1,9 @@ package it.hamy.muza.ui.screens.home import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import it.hamy.compose.persist.PersistMapCleanup import it.hamy.compose.routing.RouteHandler import it.hamy.compose.routing.defaultStacking @@ -16,15 +15,21 @@ import it.hamy.compose.routing.isUnstacking import it.hamy.muza.Database import it.hamy.muza.R import it.hamy.muza.models.SearchQuery +import it.hamy.muza.models.toUiMood +import it.hamy.muza.preferences.DataPreferences +import it.hamy.muza.preferences.UIStatePreferences import it.hamy.muza.query import it.hamy.muza.ui.components.themed.Scaffold +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.screens.albumRoute import it.hamy.muza.ui.screens.artistRoute import it.hamy.muza.ui.screens.builtInPlaylistRoute import it.hamy.muza.ui.screens.builtinplaylist.BuiltInPlaylistScreen -import it.hamy.muza.ui.screens.globalRoutes import it.hamy.muza.ui.screens.localPlaylistRoute import it.hamy.muza.ui.screens.localplaylist.LocalPlaylistScreen +import it.hamy.muza.ui.screens.moodRoute +import it.hamy.muza.ui.screens.pipedPlaylistRoute import it.hamy.muza.ui.screens.playlistRoute import it.hamy.muza.ui.screens.search.SearchScreen import it.hamy.muza.ui.screens.searchResultRoute @@ -32,13 +37,9 @@ import it.hamy.muza.ui.screens.searchRoute import it.hamy.muza.ui.screens.searchresult.SearchResultScreen import it.hamy.muza.ui.screens.settings.SettingsScreen import it.hamy.muza.ui.screens.settingsRoute -import it.hamy.muza.utils.homeScreenTabIndexKey -import it.hamy.muza.utils.pauseSearchHistoryKey -import it.hamy.muza.utils.preferences -import it.hamy.muza.utils.rememberPreference -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalAnimationApi::class) +@Route @Composable fun HomeScreen(onPlaylistUrl: (String) -> Unit) { val saveableStateHolder = rememberSaveableStateHolder() @@ -61,7 +62,7 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) { } } ) { - globalRoutes() + GlobalRoutes() settingsRoute { SettingsScreen() @@ -82,77 +83,86 @@ fun HomeScreen(onPlaylistUrl: (String) -> Unit) { searchResultRoute { query -> SearchResultScreen( query = query, - onSearchAgain = { - searchRoute(query) - } + onSearchAgain = { searchRoute(query) } ) } searchRoute { initialTextInput -> - val context = LocalContext.current - SearchScreen( initialTextInput = initialTextInput, onSearch = { query -> pop() searchResultRoute(query) - if (!context.preferences.getBoolean(pauseSearchHistoryKey, false)) { - query { - Database.insert(SearchQuery(query = query)) - } + if (!DataPreferences.pauseSearchHistory) query { + Database.insert(SearchQuery(query = query)) } }, onViewPlaylist = onPlaylistUrl ) } - host { - val (tabIndex, onTabChanged) = rememberPreference( - homeScreenTabIndexKey, - defaultValue = 0 - ) - + NavHost { Scaffold( - topIconButtonId = R.drawable.equalizer, + topIconButtonId = R.drawable.settings, onTopIconButtonClick = { settingsRoute() }, - tabIndex = tabIndex, - onTabChanged = onTabChanged, - tabColumnContent = { Item -> - Item(0, "Обзор", R.drawable.sparkles) - Item(1, "Песни", R.drawable.musical_notes) - Item(2, "Плейлисты", R.drawable.playlist) - Item(3, "Исполнители", R.drawable.person) - Item(4, "Альбомы", R.drawable.disc) + tabIndex = UIStatePreferences.homeScreenTabIndex, + onTabChanged = { UIStatePreferences.homeScreenTabIndex = it }, + tabColumnContent = { item -> + item(0, stringResource(R.string.quick_picks), R.drawable.sparkles) + item(1, stringResource(R.string.discover), R.drawable.globe) + item(2, stringResource(R.string.songs), R.drawable.musical_notes) + item(3, stringResource(R.string.playlists), R.drawable.playlist) + item(4, stringResource(R.string.artists), R.drawable.person) + item(5, stringResource(R.string.albums), R.drawable.disc) + item(6, stringResource(R.string.local), R.drawable.download) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + val onSearchClick = { searchRoute("") } when (currentTabIndex) { 0 -> QuickPicks( onAlbumClick = { albumRoute(it) }, onArtistClick = { artistRoute(it) }, onPlaylistClick = { playlistRoute(it) }, - onSearchClick = { searchRoute("") } + onSearchClick = onSearchClick ) - 1 -> HomeSongs( - onSearchClick = { searchRoute("") } + 1 -> HomeDiscovery( + onMoodClick = { mood -> moodRoute(mood.toUiMood()) }, + onNewReleaseAlbumClick = { albumRoute(it) }, + onSearchClick = onSearchClick ) - 2 -> HomePlaylists( + 2 -> HomeSongs( + onSearchClick = onSearchClick + ) + + 3 -> HomePlaylists( onBuiltInPlaylist = { builtInPlaylistRoute(it) }, onPlaylistClick = { localPlaylistRoute(it.id) }, - onSearchClick = { searchRoute("") } + onPipedPlaylistClick = { session, playlist -> + pipedPlaylistRoute( + p0 = session.apiBaseUrl.toString(), + p1 = session.token, + p2 = playlist.id.toString() + ) + }, + onSearchClick = onSearchClick ) - 3 -> HomeArtistList( + 4 -> HomeArtistList( onArtistClick = { artistRoute(it.id) }, - onSearchClick = { searchRoute("") } + onSearchClick = onSearchClick ) - 4 -> HomeAlbums( + 5 -> HomeAlbums( onAlbumClick = { albumRoute(it.id) }, - onSearchClick = { searchRoute("") } + onSearchClick = onSearchClick + ) + + 6 -> HomeLocalSongs( + onSearchClick = onSearchClick ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeSongs.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeSongs.kt index fb1913f..867b51c 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeSongs.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/home/HomeSongs.kt @@ -1,6 +1,7 @@ package it.hamy.muza.ui.screens.home -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween @@ -8,6 +9,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues @@ -17,20 +19,36 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi import it.hamy.compose.persist.persistList import it.hamy.muza.Database import it.hamy.muza.LocalPlayerAwareWindowInsets @@ -39,51 +57,105 @@ import it.hamy.muza.R import it.hamy.muza.enums.SongSortBy import it.hamy.muza.enums.SortOrder import it.hamy.muza.models.Song +import it.hamy.muza.preferences.AppearancePreferences +import it.hamy.muza.preferences.OrderPreferences +import it.hamy.muza.service.isLocal +import it.hamy.muza.transaction import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.HeaderIconButton import it.hamy.muza.ui.components.themed.InHistoryMediaItemMenu +import it.hamy.muza.ui.components.themed.TextField import it.hamy.muza.ui.items.SongItem +import it.hamy.muza.ui.modifiers.swipeToClose +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.onOverlay import it.hamy.muza.ui.styling.overlay -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.center import it.hamy.muza.utils.color import it.hamy.muza.utils.forcePlayAtIndex -import it.hamy.muza.utils.rememberPreference +import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold -import it.hamy.muza.utils.songSortByKey -import it.hamy.muza.utils.songSortOrderKey +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +private val Song.formattedTotalPlayTime: String + @Composable get() { + val seconds = totalPlayTimeMs / 1000 + + val hours = seconds / 3600 + + return when { + hours == 0L -> stringResource(id = R.string.format_minutes, seconds / 60) + hours < 24L -> stringResource(id = R.string.format_hours, hours) + else -> stringResource(id = R.string.format_days, hours / 24) + } + } -@ExperimentalFoundationApi -@ExperimentalAnimationApi @Composable fun HomeSongs( onSearchClick: () -> Unit +) = with(OrderPreferences) { + HomeSongs( + onSearchClick = onSearchClick, + songProvider = { + Database.songs(songSortBy, songSortOrder) + .map { songs -> songs.filter { it.totalPlayTimeMs > 0L } } + }, + sortBy = songSortBy, + setSortBy = { songSortBy = it }, + sortOrder = songSortOrder, + setSortOrder = { songSortOrder = it }, + title = stringResource(R.string.songs) + ) +} + +@kotlin.OptIn(ExperimentalFoundationApi::class) +@OptIn(UnstableApi::class) +@Route +@Composable +fun HomeSongs( + onSearchClick: () -> Unit, + songProvider: () -> Flow>, + sortBy: SongSortBy, + setSortBy: (SongSortBy) -> Unit, + sortOrder: SortOrder, + setSortOrder: (SortOrder) -> Unit, + title: String ) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val colorPalette = LocalAppearance.current.colorPalette + val typography = LocalAppearance.current.typography + val thumbnailShape = LocalAppearance.current.thumbnailShape + val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current + val focusManager = LocalFocusManager.current + val keyboardController = LocalSoftwareKeyboardController.current - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - var sortBy by rememberPreference(songSortByKey, SongSortBy.DateAdded) - var sortOrder by rememberPreference(songSortOrderKey, SortOrder.Descending) - + var filter: String? by rememberSaveable { mutableStateOf(null) } var items by persistList("home/songs") + val filteredItems by remember { + derivedStateOf { + filter?.lowercase()?.ifBlank { null }?.let { f -> + items.filter { + f in it.title.lowercase() || f in it.artistsText?.lowercase().orEmpty() + }.sortedBy { it.title } + } ?: items + } + } LaunchedEffect(sortBy, sortOrder) { - Database.songs(sortBy, sortOrder).collect { items = it } + songProvider().collect { items = it } } val sortOrderIconRotation by animateFloatAsState( targetValue = if (sortOrder == SortOrder.Ascending) 0f else 180f, - animationSpec = tween(durationMillis = 400, easing = LinearEasing) + animationSpec = tween(durationMillis = 400, easing = LinearEasing), + label = "" ) val lazyListState = rememberLazyListState() @@ -96,75 +168,110 @@ fun HomeSongs( LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() ) { item( key = "header", contentType = 0 ) { - Header(title = "Песни") { + Header(title = title) { + var searching by rememberSaveable { mutableStateOf(false) } + + AnimatedContent( + targetState = searching, + label = "" + ) { state -> + if (state) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + TextField( + value = filter.orEmpty(), + onValueChange = { filter = it }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { + if (filter.isNullOrBlank()) filter = "" + focusManager.clearFocus() + searching = false + }), + hintText = stringResource(R.string.filter_placeholder), + modifier = Modifier + .focusRequester(focusRequester) + .onFocusChanged { + if (!it.hasFocus) { + keyboardController?.hide() + if (filter?.isBlank() == true) { + filter = null + searching = false + } + } + } + ) + } else Row(verticalAlignment = Alignment.CenterVertically) { + HeaderIconButton( + onClick = { searching = true }, + icon = R.drawable.search, + color = colorPalette.text + ) + + Spacer(modifier = Modifier.width(8.dp)) + + if (items.isNotEmpty()) BasicText( + text = pluralStringResource( + R.plurals.song_count_plural, + items.size, + items.size + ), + style = typography.xs.secondary.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + HeaderIconButton( icon = R.drawable.trending, color = if (sortBy == SongSortBy.PlayTime) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = SongSortBy.PlayTime } + onClick = { setSortBy(SongSortBy.PlayTime) } ) HeaderIconButton( icon = R.drawable.text, color = if (sortBy == SongSortBy.Title) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = SongSortBy.Title } + onClick = { setSortBy(SongSortBy.Title) } ) HeaderIconButton( icon = R.drawable.time, color = if (sortBy == SongSortBy.DateAdded) colorPalette.text else colorPalette.textDisabled, - onClick = { sortBy = SongSortBy.DateAdded } - ) - - Spacer( - modifier = Modifier - .width(2.dp) + onClick = { setSortBy(SongSortBy.DateAdded) } ) HeaderIconButton( icon = R.drawable.arrow_up, color = colorPalette.text, - onClick = { sortOrder = !sortOrder }, - modifier = Modifier - .graphicsLayer { rotationZ = sortOrderIconRotation } + onClick = { setSortOrder(!sortOrder) }, + modifier = Modifier.graphicsLayer { rotationZ = sortOrderIconRotation } ) } } - itemsIndexed( - items = items, - key = { _, song -> song.id } - ) { index, song -> + items( + items = filteredItems, + key = { song -> song.id } + ) { song -> SongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - onThumbnailContent = if (sortBy == SongSortBy.PlayTime) ({ - BasicText( - text = song.formattedTotalPlayTime, - style = typography.xxs.semiBold.center.color(colorPalette.onOverlay), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .background( - brush = Brush.verticalGradient( - colors = listOf(Color.Transparent, colorPalette.overlay) - ), - shape = thumbnailShape - ) - .padding(horizontal = 8.dp, vertical = 4.dp) - .align(Alignment.BottomCenter) - ) - }) else null, modifier = Modifier .combinedClickable( onLongClick = { + keyboardController?.hide() menuState.display { InHistoryMediaItemMenu( song = song, @@ -173,14 +280,52 @@ fun HomeSongs( } }, onClick = { + keyboardController?.hide() binder?.stopRadio() binder?.player?.forcePlayAtIndex( items.map(Song::asMediaItem), - index + items.indexOf(song) ) } ) .animateItemPlacement() + .let { + if (!song.isLocal && AppearancePreferences.swipeToHideSong) it.swipeToClose( + key = filteredItems, + onClose = { animationJob -> + binder?.cache?.removeResource(song.id) + transaction { + Database.delete(song) + } + animationJob.join() + } + ) else it + }, + song = song, + thumbnailSize = Dimensions.thumbnails.song, + onThumbnailContent = if (sortBy == SongSortBy.PlayTime) { + { + BasicText( + text = song.formattedTotalPlayTime, + style = typography.xxs.semiBold.center.color(colorPalette.onOverlay), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background( + brush = Brush.verticalGradient( + colors = listOf(Color.Transparent, colorPalette.overlay) + ), + shape = thumbnailShape.copy( + topStart = CornerSize(0.dp), + topEnd = CornerSize(0.dp) + ) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + .align(Alignment.BottomCenter) + ) + } + } else null ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/localplaylist/LocalPlaylistScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/localplaylist/LocalPlaylistScreen.kt index 11118a0..a057edd 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/localplaylist/LocalPlaylistScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/localplaylist/LocalPlaylistScreen.kt @@ -1,34 +1,33 @@ package it.hamy.muza.ui.screens.localplaylist -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.res.stringResource import it.hamy.compose.persist.PersistMapCleanup import it.hamy.compose.routing.RouteHandler import it.hamy.muza.R import it.hamy.muza.ui.components.themed.Scaffold -import it.hamy.muza.ui.screens.globalRoutes +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable fun LocalPlaylistScreen(playlistId: Long) { val saveableStateHolder = rememberSaveableStateHolder() - PersistMapCleanup(tagPrefix = "localPlaylist/$playlistId/") + PersistMapCleanup(prefix = "localPlaylist/$playlistId/") RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + GlobalRoutes() - host { + NavHost { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = 0, onTabChanged = { }, - tabColumnContent = { Item -> - Item(0, "Песни", R.drawable.musical_notes) + tabColumnContent = { item -> + item(0, stringResource(R.string.songs), R.drawable.musical_notes) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(currentTabIndex) { diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/localplaylist/LocalPlaylistSongs.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/localplaylist/LocalPlaylistSongs.kt index 943e63f..e41a411 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/localplaylist/LocalPlaylistSongs.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/localplaylist/LocalPlaylistSongs.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.localplaylist -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -11,10 +10,9 @@ import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -22,21 +20,23 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import it.hamy.compose.persist.persist -import it.hamy.innertube.Innertube -import it.hamy.innertube.models.bodies.BrowseBody -import it.hamy.innertube.requests.playlistPage -import it.hamy.compose.reordering.ReorderingLazyColumn import it.hamy.compose.reordering.animateItemPlacement import it.hamy.compose.reordering.draggedItem import it.hamy.compose.reordering.rememberReorderingState -import it.hamy.compose.reordering.reorder +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.bodies.BrowseBody +import it.hamy.innertube.requests.playlistPage import it.hamy.muza.Database import it.hamy.muza.LocalPlayerAwareWindowInsets import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R -import it.hamy.muza.models.PlaylistWithSongs +import it.hamy.muza.models.Playlist import it.hamy.muza.models.Song import it.hamy.muza.models.SongPlaylistMap import it.hamy.muza.query @@ -46,48 +46,66 @@ import it.hamy.muza.ui.components.themed.ConfirmationDialog import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.HeaderIconButton -import it.hamy.muza.ui.components.themed.IconButton import it.hamy.muza.ui.components.themed.InPlaylistMediaItemMenu import it.hamy.muza.ui.components.themed.Menu import it.hamy.muza.ui.components.themed.MenuEntry +import it.hamy.muza.ui.components.themed.ReorderHandle import it.hamy.muza.ui.components.themed.SecondaryTextButton import it.hamy.muza.ui.components.themed.TextFieldDialog import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px +import it.hamy.muza.utils.PlaylistDownloadIcon import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.completed import it.hamy.muza.utils.enqueue import it.hamy.muza.utils.forcePlayAtIndex import it.hamy.muza.utils.forcePlayFromBeginning +import it.hamy.muza.utils.launchYouTubeMusic +import it.hamy.muza.utils.toast +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -@ExperimentalAnimationApi -@ExperimentalFoundationApi +@OptIn(ExperimentalFoundationApi::class) @Composable fun LocalPlaylistSongs( playlistId: Long, onDelete: () -> Unit, + modifier: Modifier = Modifier ) { val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current val menuState = LocalMenuState.current + val uriHandler = LocalUriHandler.current + val context = LocalContext.current - var playlistWithSongs by persist("localPlaylist/$playlistId/playlistWithSongs") + var playlist by persist("localPlaylist/$playlistId/playlist") + var songs by persist?>("localPlaylist/$playlistId/Songs") LaunchedEffect(Unit) { - Database.playlistWithSongs(playlistId).filterNotNull().collect { playlistWithSongs = it } + Database + .playlist(playlistId) + .filterNotNull() + .distinctUntilChanged() + .collect { playlist = it } + } + + LaunchedEffect(Unit) { + Database + .playlistSongs(playlistId) + .filterNotNull() + .distinctUntilChanged() + .collect { songs = it } } val lazyListState = rememberLazyListState() val reorderingState = rememberReorderingState( lazyListState = lazyListState, - key = playlistWithSongs?.songs ?: emptyList(), + key = songs ?: emptyList(), onDragEnd = { fromIndex, toIndex -> query { Database.move(playlistId, fromIndex, toIndex) @@ -96,186 +114,210 @@ fun LocalPlaylistSongs( extraItemCount = 1 ) - var isRenaming by rememberSaveable { - mutableStateOf(false) - } + var isRenaming by rememberSaveable { mutableStateOf(false) } - if (isRenaming) { - TextFieldDialog( - hintText = "Введите название плейлиста", - initialTextInput = playlistWithSongs?.playlist?.name ?: "", - onDismiss = { isRenaming = false }, - onDone = { text -> - query { - playlistWithSongs?.playlist?.copy(name = text)?.let(Database::update) - } + if (isRenaming) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + initialTextInput = playlist?.name.orEmpty(), + onDismiss = { isRenaming = false }, + onDone = { text -> + query { + playlist?.copy(name = text)?.let(Database::update) } - ) - } + } + ) - var isDeleting by rememberSaveable { - mutableStateOf(false) - } + var isDeleting by rememberSaveable { mutableStateOf(false) } - if (isDeleting) { - ConfirmationDialog( - text = "Вы реально хотите удалить этот плейлист?", - onDismiss = { isDeleting = false }, - onConfirm = { - query { - playlistWithSongs?.playlist?.let(Database::delete) - } - onDelete() + if (isDeleting) ConfirmationDialog( + text = stringResource(R.string.confirm_delete_playlist), + onDismiss = { isDeleting = false }, + onConfirm = { + query { + playlist?.let(Database::delete) } - ) - } + onDelete() + } + ) - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - val rippleIndication = rememberRipple(bounded = false) - - Box { - ReorderingLazyColumn( - reorderingState = reorderingState, - contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - ) { - item( - key = "header", - contentType = 0 + Box(modifier = modifier) { + LookaheadScope { + LazyColumn( + state = reorderingState.lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() ) { - Header( - title = playlistWithSongs?.playlist?.name ?: "Неизвестный", - modifier = Modifier - .padding(bottom = 8.dp) + item( + key = "header", + contentType = 0 ) { - SecondaryTextButton( - text = "Enqueue", - enabled = playlistWithSongs?.songs?.isNotEmpty() == true, - onClick = { - playlistWithSongs?.songs - ?.map(Song::asMediaItem) - ?.let { mediaItems -> - binder?.player?.enqueue(mediaItems) - } - } - ) + Header( + title = playlist?.name + ?: stringResource(R.string.unknown), + modifier = Modifier.padding(bottom = 8.dp) + ) { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = songs?.isNotEmpty() == true, + onClick = { + songs?.map(Song::asMediaItem) + ?.let { mediaItems -> + binder?.player?.enqueue(mediaItems) + } + } + ) - Spacer( - modifier = Modifier - .weight(1f) - ) + Spacer(modifier = Modifier.weight(1f)) - HeaderIconButton( - icon = R.drawable.ellipsis_horizontal, - color = colorPalette.text, - onClick = { - menuState.display { - Menu { - playlistWithSongs?.playlist?.browseId?.let { browseId -> - MenuEntry( - icon = R.drawable.sync, - text = "Синхр.", - onClick = { - menuState.hide() - transaction { - runBlocking(Dispatchers.IO) { - withContext(Dispatchers.IO) { - Innertube.playlistPage(BrowseBody(browseId = browseId)) - ?.completed() - } - }?.getOrNull()?.let { remotePlaylist -> - Database.clearPlaylist(playlistId) + songs?.map(Song::asMediaItem) + ?.let { PlaylistDownloadIcon(songs = it.toImmutableList()) } - remotePlaylist.songsPage - ?.items - ?.map(Innertube.SongItem::asMediaItem) - ?.onEach(Database::insert) - ?.mapIndexed { position, mediaItem -> - SongPlaylistMap( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = position + HeaderIconButton( + icon = R.drawable.ellipsis_horizontal, + color = colorPalette.text, + onClick = { + menuState.display { + Menu { + playlist?.browseId?.let { browseId -> + MenuEntry( + icon = R.drawable.sync, + text = stringResource(R.string.sync), + onClick = { + menuState.hide() + transaction { + runBlocking(Dispatchers.IO) { + Innertube.playlistPage( + BrowseBody( + browseId = browseId ) - }?.let(Database::insertSongPlaylistMaps) + )?.completed() + }?.getOrNull()?.let { remotePlaylist -> + Database.clearPlaylist(playlistId) + + remotePlaylist.songsPage + ?.items + ?.map(Innertube.SongItem::asMediaItem) + ?.onEach(Database::insert) + ?.mapIndexed { position, mediaItem -> + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = position + ) + } + ?.let(Database::insertSongPlaylistMaps) + } } } + ) + + songs?.firstOrNull()?.id?.let { firstSongId -> + MenuEntry( + icon = R.drawable.play, + text = stringResource(R.string.watch_playlist_on_youtube), + onClick = { + menuState.hide() + binder?.player?.pause() + uriHandler.openUri( + "https://youtube.com/watch?v=$firstSongId&list=${ + playlist?.browseId + ?.drop(2) + }" + ) + } + ) + + MenuEntry( + icon = R.drawable.musical_notes, + text = stringResource(R.string.open_in_youtube_music), + onClick = { + menuState.hide() + binder?.player?.pause() + if ( + !launchYouTubeMusic( + context = context, + endpoint = "watch?v=$firstSongId&list=${ + playlist?.browseId + ?.drop(2) + }" + ) + ) context.toast( + context.getString(R.string.youtube_music_not_installed) + ) + } + ) + } + } + + MenuEntry( + icon = R.drawable.pencil, + text = stringResource(R.string.rename), + onClick = { + menuState.hide() + isRenaming = true + } + ) + + MenuEntry( + icon = R.drawable.trash, + text = stringResource(R.string.delete), + onClick = { + menuState.hide() + isDeleting = true } ) } - - MenuEntry( - icon = R.drawable.pencil, - text = "Переименовать", - onClick = { - menuState.hide() - isRenaming = true - } - ) - - MenuEntry( - icon = R.drawable.trash, - text = "Удалить", - onClick = { - menuState.hide() - isDeleting = true - } - ) } } - } - ) + ) + } } - } - itemsIndexed( - items = playlistWithSongs?.songs ?: emptyList(), - key = { _, song -> song.id }, - contentType = { _, song -> song }, - ) { index, song -> - SongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - trailingContent = { - IconButton( - icon = R.drawable.reorder, - color = colorPalette.textDisabled, - indication = rippleIndication, - onClick = {}, - modifier = Modifier - .reorder(reorderingState = reorderingState, index = index) - .size(18.dp) - ) - }, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - InPlaylistMediaItemMenu( - playlistId = playlistId, - positionInPlaylist = index, - song = song, - onDismiss = menuState::hide - ) - } - }, - onClick = { - playlistWithSongs?.songs - ?.map(Song::asMediaItem) - ?.let { mediaItems -> - binder?.stopRadio() - binder?.player?.forcePlayAtIndex(mediaItems, index) + itemsIndexed( + items = songs ?: emptyList(), + key = { _, song -> song.id }, + contentType = { _, song -> song } + ) { index, song -> + SongItem( + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + InPlaylistMediaItemMenu( + playlistId = playlistId, + positionInPlaylist = index, + song = song, + onDismiss = menuState::hide + ) } - } + }, + onClick = { + songs + ?.map(Song::asMediaItem) + ?.let { mediaItems -> + binder?.stopRadio() + binder?.player?.forcePlayAtIndex(mediaItems, index) + } + } + ) + .animateItemPlacement(reorderingState) + .draggedItem( + reorderingState = reorderingState, + index = index + ) + .background(colorPalette.background0), + song = song, + thumbnailSize = Dimensions.thumbnails.song + ) { + ReorderHandle( + reorderingState = reorderingState, + index = index ) - .animateItemPlacement(reorderingState = reorderingState) - .draggedItem(reorderingState = reorderingState, index = index) - ) + } + } } } @@ -284,7 +326,7 @@ fun LocalPlaylistSongs( iconId = R.drawable.shuffle, visible = !reorderingState.isDragging, onClick = { - playlistWithSongs?.songs?.let { songs -> + songs?.let { songs -> if (songs.isNotEmpty()) { binder?.stopRadio() binder?.player?.forcePlayFromBeginning( diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/mood/MoodList.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/mood/MoodList.kt new file mode 100644 index 0000000..06a592e --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/mood/MoodList.kt @@ -0,0 +1,177 @@ +package it.hamy.muza.ui.screens.mood + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import it.hamy.compose.persist.persist +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.bodies.BrowseBody +import it.hamy.innertube.requests.BrowseResult +import it.hamy.innertube.requests.browse +import it.hamy.muza.LocalPlayerAwareWindowInsets +import it.hamy.muza.R +import it.hamy.muza.models.Mood +import it.hamy.muza.ui.components.ShimmerHost +import it.hamy.muza.ui.components.themed.Header +import it.hamy.muza.ui.components.themed.HeaderPlaceholder +import it.hamy.muza.ui.components.themed.TextPlaceholder +import it.hamy.muza.ui.items.AlbumItem +import it.hamy.muza.ui.items.AlbumItemPlaceholder +import it.hamy.muza.ui.items.ArtistItem +import it.hamy.muza.ui.items.PlaylistItem +import it.hamy.muza.ui.screens.albumRoute +import it.hamy.muza.ui.screens.artistRoute +import it.hamy.muza.ui.screens.playlistRoute +import it.hamy.muza.ui.styling.Dimensions +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.center +import it.hamy.muza.utils.secondary +import it.hamy.muza.utils.semiBold + +internal const val DEFAULT_BROWSE_ID = "FEmusic_moods_and_genres_category" + +@Composable +fun MoodList( + mood: Mood, + modifier: Modifier = Modifier +) { + val (colorPalette, typography) = LocalAppearance.current + val windowInsets = LocalPlayerAwareWindowInsets.current + + val browseId = mood.browseId ?: DEFAULT_BROWSE_ID + var moodPage by persist>("playlist/$browseId${mood.params?.let { "/$it" }.orEmpty()}") + + LaunchedEffect(Unit) { + if (moodPage?.isSuccess != true) + moodPage = Innertube.browse(BrowseBody(browseId = browseId, params = mood.params)) + } + + val lazyListState = rememberLazyListState() + + val endPaddingValues = windowInsets.only(WindowInsetsSides.End).asPaddingValues() + + val sectionTextModifier = Modifier + .padding(horizontal = 16.dp) + .padding(top = 24.dp, bottom = 8.dp) + .padding(endPaddingValues) + + Column(modifier = modifier) { + moodPage?.getOrNull()?.let { moodResult -> + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Header(title = mood.name) + } + } + + moodResult.items.forEach { item -> + item { + BasicText( + text = item.title, + style = typography.m.semiBold, + modifier = sectionTextModifier + ) + } + item { + LazyRow { + items(items = item.items, key = { it.key }) { childItem -> + if (childItem.key == DEFAULT_BROWSE_ID) return@items + when (childItem) { + is Innertube.AlbumItem -> AlbumItem( + album = childItem, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable { + childItem.info?.endpoint?.browseId?.let { + albumRoute.global(it) + } + } + ) + + is Innertube.ArtistItem -> ArtistItem( + artist = childItem, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable { + childItem.info?.endpoint?.browseId?.let { + artistRoute.global(it) + } + } + ) + + is Innertube.PlaylistItem -> PlaylistItem( + playlist = childItem, + thumbnailSize = Dimensions.thumbnails.album, + alternative = true, + modifier = Modifier.clickable { + childItem.info?.endpoint?.let { endpoint -> + playlistRoute.global( + p0 = endpoint.browseId, + p1 = endpoint.params, + p2 = childItem.songCount?.let { it / 100 } + ) + } + } + ) + + else -> {} + } + } + } + } + } + } + } ?: moodPage?.exceptionOrNull()?.let { + BasicText( + text = stringResource(R.string.error_message), + style = typography.s.secondary.center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(all = 16.dp) + ) + } ?: ShimmerHost { + HeaderPlaceholder(modifier = Modifier.shimmer()) + repeat(4) { + TextPlaceholder(modifier = sectionTextModifier) + Row { + repeat(6) { + AlbumItemPlaceholder( + thumbnailSize = Dimensions.thumbnails.album, + alternative = true + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/mood/MoodScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/mood/MoodScreen.kt new file mode 100644 index 0000000..06dc955 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/mood/MoodScreen.kt @@ -0,0 +1,42 @@ +package it.hamy.muza.ui.screens.mood + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.res.stringResource +import it.hamy.compose.persist.PersistMapCleanup +import it.hamy.compose.routing.RouteHandler +import it.hamy.muza.R +import it.hamy.muza.models.Mood +import it.hamy.muza.ui.components.themed.Scaffold +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route + +@Route +@Composable +fun MoodScreen(mood: Mood) { + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup(prefix = "playlist/$DEFAULT_BROWSE_ID") + + RouteHandler(listenToGlobalEmitter = true) { + GlobalRoutes() + + NavHost { + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChanged = { }, + tabColumnContent = { item -> + item(0, stringResource(R.string.mood), R.drawable.disc) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> MoodList(mood = mood) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/pipedplaylist/PipedPlaylistScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/pipedplaylist/PipedPlaylistScreen.kt new file mode 100644 index 0000000..558e585 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/pipedplaylist/PipedPlaylistScreen.kt @@ -0,0 +1,55 @@ +package it.hamy.muza.ui.screens.pipedplaylist + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.res.stringResource +import io.ktor.http.Url +import it.hamy.compose.persist.PersistMapCleanup +import it.hamy.compose.routing.RouteHandler +import it.hamy.piped.models.authenticatedWith +import it.hamy.muza.R +import it.hamy.muza.ui.components.themed.Scaffold +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route +import java.util.UUID + +@Route +@Composable +fun PipedPlaylistScreen( + apiBaseUrl: Url, + sessionToken: String, + playlistId: UUID +) { + val saveableStateHolder = rememberSaveableStateHolder() + val session by remember { derivedStateOf { apiBaseUrl authenticatedWith sessionToken } } + + PersistMapCleanup(prefix = "pipedplaylist/$playlistId") + + RouteHandler(listenToGlobalEmitter = true) { + GlobalRoutes() + + NavHost { + Scaffold( + topIconButtonId = R.drawable.chevron_back, + onTopIconButtonClick = pop, + tabIndex = 0, + onTabChanged = { }, + tabColumnContent = { item -> + item(0, stringResource(R.string.songs), R.drawable.musical_notes) + } + ) { currentTabIndex -> + saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { + when (currentTabIndex) { + 0 -> PipedPlaylistSongList( + session = session, + playlistId = playlistId + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/pipedplaylist/PipedPlaylistSongList.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/pipedplaylist/PipedPlaylistSongList.kt new file mode 100644 index 0000000..0abcd44 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/pipedplaylist/PipedPlaylistSongList.kt @@ -0,0 +1,166 @@ +package it.hamy.muza.ui.screens.pipedplaylist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.valentinilk.shimmer.shimmer +import it.hamy.compose.persist.persist +import it.hamy.piped.Piped +import it.hamy.piped.models.Playlist +import it.hamy.piped.models.Session +import it.hamy.muza.LocalPlayerAwareWindowInsets +import it.hamy.muza.LocalPlayerServiceBinder +import it.hamy.muza.R +import it.hamy.muza.ui.components.LocalMenuState +import it.hamy.muza.ui.components.ShimmerHost +import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.hamy.muza.ui.components.themed.Header +import it.hamy.muza.ui.components.themed.HeaderPlaceholder +import it.hamy.muza.ui.components.themed.LayoutWithAdaptiveThumbnail +import it.hamy.muza.ui.components.themed.NonQueuedMediaItemMenu +import it.hamy.muza.ui.components.themed.SecondaryTextButton +import it.hamy.muza.ui.components.themed.adaptiveThumbnailContent +import it.hamy.muza.ui.items.SongItem +import it.hamy.muza.ui.items.SongItemPlaceholder +import it.hamy.muza.ui.styling.Dimensions +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.asMediaItem +import it.hamy.muza.utils.enqueue +import it.hamy.muza.utils.forcePlayAtIndex +import it.hamy.muza.utils.forcePlayFromBeginning +import it.hamy.muza.utils.isLandscape +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.util.UUID + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun PipedPlaylistSongList( + session: Session, + playlistId: UUID, + modifier: Modifier = Modifier +) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current + + var playlist by persist(tag = "pipedplaylist/$playlistId/playlistPage") + + LaunchedEffect(Unit) { + playlist = withContext(Dispatchers.IO) { + Piped.playlist.songs( + session = session, + id = playlistId + )?.getOrNull() + } + } + + val lazyListState = rememberLazyListState() + + val thumbnailContent = adaptiveThumbnailContent( + isLoading = playlist == null, + url = playlist?.thumbnailUrl?.toString() + ) + + LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier + ) { + Box { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + modifier = Modifier + .background(colorPalette.background0) + .fillMaxSize() + ) { + item( + key = "header", + contentType = 0 + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + if (playlist == null) HeaderPlaceholder(modifier = Modifier.shimmer()) + else Header(title = playlist?.name ?: stringResource(R.string.unknown)) { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = playlist?.videos?.isNotEmpty() == true, + onClick = { + playlist?.videos?.mapNotNull(Playlist.Video::asMediaItem) + ?.let { mediaItems -> binder?.player?.enqueue(mediaItems) } + } + ) + } + + if (!isLandscape) thumbnailContent() + } + } + + itemsIndexed(items = playlist?.videos ?: emptyList()) { index, song -> + song.asMediaItem?.let { mediaItem -> + SongItem( + song = mediaItem, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = mediaItem + ) + } + }, + onClick = { + playlist?.videos?.mapNotNull(Playlist.Video::asMediaItem) + ?.let { mediaItems -> + binder?.stopRadio() + binder?.player?.forcePlayAtIndex(mediaItems, index) + } + } + ) + ) + } + } + + if (playlist == null) item(key = "loading") { + ShimmerHost(modifier = Modifier.fillParentMaxSize()) { + repeat(4) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + } + } + } + + FloatingActionsContainerWithScrollToTop( + lazyListState = lazyListState, + iconId = R.drawable.shuffle, + onClick = { + playlist?.videos?.let { songs -> + if (songs.isNotEmpty()) { + binder?.stopRadio() + binder?.player?.forcePlayFromBeginning( + songs.shuffled().mapNotNull(Playlist.Video::asMediaItem) + ) + } + } + } + ) + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Controls.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Controls.kt index 2fb759c..b3ab6fa 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Controls.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Controls.kt @@ -1,214 +1,176 @@ package it.hamy.muza.ui.screens.player +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.media3.common.C +import androidx.compose.ui.util.fastForEachIndexed import androidx.media3.common.Player import it.hamy.muza.Database import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R +import it.hamy.muza.models.Info import it.hamy.muza.models.Song +import it.hamy.muza.models.ui.UiMedia +import it.hamy.muza.preferences.PlayerPreferences import it.hamy.muza.query +import it.hamy.muza.transaction import it.hamy.muza.ui.components.SeekBar +import it.hamy.muza.ui.components.themed.BigIconButton import it.hamy.muza.ui.components.themed.IconButton +import it.hamy.muza.ui.modifiers.horizontalFadingEdge +import it.hamy.muza.ui.screens.artistRoute import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.favoritesIcon import it.hamy.muza.utils.bold import it.hamy.muza.utils.forceSeekToNext import it.hamy.muza.utils.forceSeekToPrevious -import it.hamy.muza.utils.formatAsDuration -import it.hamy.muza.utils.rememberPreference +import it.hamy.muza.utils.px import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold -import it.hamy.muza.utils.trackLoopEnabledKey -import kotlinx.coroutines.flow.distinctUntilChanged -import it.hamy.muza.models.Info -import it.hamy.muza.ui.screens.artistRoute import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +private const val FORWARD_BACKWARD_OFFSET = 16f + @Composable fun Controls( - mediaId: String, - title: String?, - artist: String?, + media: UiMedia, shouldBePlaying: Boolean, position: Long, - duration: Long, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + layout: PlayerPreferences.PlayerLayout = PlayerPreferences.playerLayout ) { - val (colorPalette, typography) = LocalAppearance.current + var likedAt by remember { mutableStateOf(null) } - val binder = LocalPlayerServiceBinder.current - binder?.player ?: return - - var trackLoopEnabled by rememberPreference(trackLoopEnabledKey, defaultValue = false) - - var scrubbingPosition by remember(mediaId) { - mutableStateOf(null) - } - - var likedAt by rememberSaveable { - mutableStateOf(null) - } - - var artistsInfo: List? by remember { mutableStateOf(null) } - - LaunchedEffect(Unit, mediaId) { - withContext(Dispatchers.IO) { - if (artistsInfo == null) artistsInfo = Database.songArtistInfo(mediaId) - - Database.likedAt(mediaId).distinctUntilChanged().collect { likedAt = it } - } + LaunchedEffect(media) { + Database.likedAt(media.id).distinctUntilChanged().collect { likedAt = it } } val shouldBePlayingTransition = updateTransition(shouldBePlaying, label = "shouldBePlaying") - val playPauseRoundness by shouldBePlayingTransition.animateDp( + val playButtonRadius by shouldBePlayingTransition.animateDp( transitionSpec = { tween(durationMillis = 100, easing = LinearEasing) }, label = "playPauseRoundness", - targetValueByState = { if (it) 32.dp else 16.dp } + targetValueByState = { if (it) 16.dp else 32.dp } ) + when (layout) { + PlayerPreferences.PlayerLayout.Classic -> ClassicControls( + media = media, + shouldBePlaying = shouldBePlaying, + position = position, + likedAt = likedAt, + playButtonRadius = playButtonRadius, + modifier = modifier + ) + + PlayerPreferences.PlayerLayout.New -> ModernControls( + media = media, + shouldBePlaying = shouldBePlaying, + position = position, + likedAt = likedAt, + playButtonRadius = playButtonRadius, + modifier = modifier + ) + } +} + +@Composable +private fun ClassicControls( + media: UiMedia, + shouldBePlaying: Boolean, + position: Long, + likedAt: Long?, + playButtonRadius: Dp, + modifier: Modifier = Modifier +) = with(PlayerPreferences) { + val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current ?: return + Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = modifier .fillMaxWidth() .padding(horizontal = 32.dp) ) { - Spacer( - modifier = Modifier - .weight(1f) - ) - - BasicText( - text = title ?: "", - style = typography.l.bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - BasicText( - text = artist ?: "", - style = typography.s.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.clickable { - val goTo = artistRoute::global - goTo(artistsInfo?.get(0)?.id) - } - ) - - Spacer( - modifier = Modifier - .weight(0.5f) - ) - + Spacer(modifier = Modifier.weight(1f)) + MediaInfo(media) + Spacer(modifier = Modifier.weight(1f)) SeekBar( - value = scrubbingPosition ?: position, - minimumValue = 0, - maximumValue = duration, - onDragStart = { - scrubbingPosition = it - }, - onDrag = { delta -> - scrubbingPosition = if (duration != C.TIME_UNSET) { - scrubbingPosition?.plus(delta)?.coerceIn(0, duration) - } else { - null - } - }, - onDragEnd = { - scrubbingPosition?.let(binder.player::seekTo) - scrubbingPosition = null - }, - color = colorPalette.text, - backgroundColor = colorPalette.background2, - shape = RoundedCornerShape(8.dp) - ) - - Spacer( - modifier = Modifier - .height(8.dp) - ) - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - ) { - BasicText( - text = formatAsDuration(scrubbingPosition ?: position), - style = typography.xxs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - - if (duration != C.TIME_UNSET) { - BasicText( - text = formatAsDuration(duration), - style = typography.xxs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - - Spacer( - modifier = Modifier - .weight(1f) + binder = binder, + position = position, + media = media, + alwaysShowDuration = true ) + Spacer(modifier = Modifier.weight(1f)) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() + modifier = Modifier.fillMaxWidth() ) { IconButton( icon = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, color = colorPalette.favoritesIcon, onClick = { val currentMediaItem = binder.player.currentMediaItem + query { - if (Database.like( - mediaId, + if ( + Database.like( + media.id, if (likedAt == null) System.currentTimeMillis() else null ) == 0 ) { currentMediaItem - ?.takeIf { it.mediaId == mediaId } + ?.takeIf { it.mediaId == media.id } ?.let { Database.insert(currentMediaItem, Song::toggleLike) } @@ -229,21 +191,14 @@ fun Controls( .size(24.dp) ) - Spacer( - modifier = Modifier - .width(8.dp) - ) + Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier - .clip(RoundedCornerShape(playPauseRoundness)) + .clip(RoundedCornerShape(playButtonRadius)) .clickable { - if (shouldBePlaying) { - binder.player.pause() - } else { - if (binder.player.playbackState == Player.STATE_IDLE) { - binder.player.prepare() - } + if (shouldBePlaying) binder.player.pause() else { + if (binder.player.playbackState == Player.STATE_IDLE) binder.player.prepare() binder.player.play() } } @@ -260,10 +215,7 @@ fun Controls( ) } - Spacer( - modifier = Modifier - .width(8.dp) - ) + Spacer(modifier = Modifier.width(8.dp)) IconButton( icon = R.drawable.play_skip_forward, @@ -284,9 +236,227 @@ fun Controls( ) } - Spacer( + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun ModernControls( + media: UiMedia, + shouldBePlaying: Boolean, + position: Long, + likedAt: Long?, + playButtonRadius: Dp, + modifier: Modifier = Modifier, + controlHeight: Dp = 64.dp +) { + val binder = LocalPlayerServiceBinder.current ?: return + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + ) { + Spacer(modifier = Modifier.weight(1f)) + MediaInfo(media) + Spacer(modifier = Modifier.weight(1f)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(15.dp) + ) { + + SkipButton( + iconId = R.drawable.play_skip_back, + onClick = binder.player::forceSeekToPrevious, + modifier = Modifier.weight(1f), + offsetOnPress = -FORWARD_BACKWARD_OFFSET + ) + PlayButton( + radius = playButtonRadius, + shouldBePlaying = shouldBePlaying, + modifier = Modifier + .height(controlHeight) + .weight(2f) + ) + SkipButton( + iconId = R.drawable.play_skip_forward, + onClick = binder.player::forceSeekToNext, + modifier = Modifier.weight(1f) + ) + } + Spacer(modifier = Modifier.weight(1f)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + if (PlayerPreferences.showLike) BigIconButton( + iconId = if (likedAt == null) R.drawable.heart_outline else R.drawable.heart, + onClick = { + transaction { + Database.like( + songId = media.id, + likedAt = if (likedAt == null) System.currentTimeMillis() else null + ) + } + }, + modifier = Modifier.weight(1.2f) + ) + + Column(modifier = Modifier.weight(4f)) { + SeekBar( + binder = binder, + position = position, + media = media + ) + } + } + + Spacer(modifier = Modifier.weight(1f)) + } +} + +@Composable +private fun SkipButton( + @DrawableRes iconId: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + offsetOnPress: Float = FORWARD_BACKWARD_OFFSET +) { + val scope = rememberCoroutineScope() + val offsetDp = remember { Animatable(0f) } + val density = LocalDensity.current + + BigIconButton( + iconId = iconId, + onClick = { + onClick() + scope.launch { offsetDp.animateTo(offsetOnPress) } + }, + onPress = { scope.launch { offsetDp.animateTo(offsetOnPress) } }, + onCancel = { scope.launch { offsetDp.animateTo(0f) } }, + modifier = modifier.graphicsLayer { + with(density) { + translationX = offsetDp.value.dp.toPx() + } + } + ) +} + +@Composable +private fun PlayButton( + radius: Dp, + shouldBePlaying: Boolean, + modifier: Modifier = Modifier +) { + val colorPalette = LocalAppearance.current.colorPalette + val binder = LocalPlayerServiceBinder.current + + Box( + modifier = modifier + .clip(RoundedCornerShape(radius)) + .clickable { + if (shouldBePlaying) binder?.player?.pause() else { + if (binder?.player?.playbackState == Player.STATE_IDLE) binder.player.prepare() + binder?.player?.play() + } + } + .background(colorPalette.accent) + ) { + Image( + painter = painterResource(if (shouldBePlaying) R.drawable.pause else R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), modifier = Modifier - .weight(1f) + .align(Alignment.Center) + .size(28.dp) ) } } + +@Composable +private inline fun MediaInfoEntry( + maxHeight: Dp? = null, + content: @Composable RowScope.() -> Unit +) { + val scrollState = rememberScrollState() + val alphaLeft by animateFloatAsState( + targetValue = if (scrollState.canScrollBackward) 1f else 0f, + label = "" + ) + val alphaRight by animateFloatAsState( + targetValue = if (scrollState.canScrollForward) 1f else 0f, + label = "" + ) + + Row( + modifier = Modifier + .fillMaxWidth(0.75f) + .let { if (maxHeight == null) it else it.heightIn(max = maxHeight) } + .horizontalFadingEdge(right = false, alpha = alphaLeft, middle = 10) + .horizontalFadingEdge(left = false, alpha = alphaRight, middle = 10) + .horizontalScroll(scrollState), + horizontalArrangement = Arrangement.Center, + content = content + ) +} + +@Composable +private fun MediaInfo(media: UiMedia) { + val typography = LocalAppearance.current.typography + + var artistInfo: List? by remember { mutableStateOf(null) } + var maxHeight by rememberSaveable { mutableIntStateOf(0) } + + LaunchedEffect(media) { + artistInfo = withContext(Dispatchers.IO) { + Database.songArtistInfo(media.id).takeIf { it.isNotEmpty() } + } + } + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + MediaInfoEntry { + BasicText( + text = media.title, + style = typography.l.bold, + maxLines = 1 + ) + } + + AnimatedContent( + targetState = artistInfo, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "" + ) { state -> + state?.let { artists -> + MediaInfoEntry(maxHeight = maxHeight.px.dp) { + artists.fastForEachIndexed { i, artist -> + if (i == artists.lastIndex && artists.size > 1) BasicText( + text = " & ", + style = typography.s.semiBold.secondary + ) + BasicText( + text = artist.name.orEmpty(), + style = typography.s.semiBold.secondary, + modifier = Modifier.clickable { artistRoute.global(artist.id) } + ) + if (i != artists.lastIndex && i + 1 != artists.lastIndex) BasicText( + text = ", ", + style = typography.s.semiBold.secondary + ) + } + } + } ?: MediaInfoEntry { + BasicText( + text = media.artist, + style = typography.s.semiBold.secondary, + maxLines = 1, + modifier = Modifier.onGloballyPositioned { maxHeight = it.size.height } + ) + } + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Lyrics.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Lyrics.kt index 37b208c..fdc8e9c 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Lyrics.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Lyrics.kt @@ -13,11 +13,13 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn @@ -31,6 +33,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -44,6 +47,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.media3.common.C @@ -53,16 +57,24 @@ import it.hamy.innertube.Innertube import it.hamy.innertube.models.bodies.NextBody import it.hamy.innertube.requests.lyrics import it.hamy.kugou.KuGou +import it.hamy.lrclib.LrcLib +import it.hamy.lrclib.models.Track import it.hamy.muza.Database import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R import it.hamy.muza.models.Lyrics +import it.hamy.muza.preferences.PlayerPreferences import it.hamy.muza.query +import it.hamy.muza.transaction import it.hamy.muza.ui.components.LocalMenuState +import it.hamy.muza.ui.components.themed.CircularProgressIndicator +import it.hamy.muza.ui.components.themed.DefaultDialog import it.hamy.muza.ui.components.themed.Menu import it.hamy.muza.ui.components.themed.MenuEntry import it.hamy.muza.ui.components.themed.TextFieldDialog import it.hamy.muza.ui.components.themed.TextPlaceholder +import it.hamy.muza.ui.components.themed.ValueSelectorDialogBody +import it.hamy.muza.ui.modifiers.verticalFadingEdge import it.hamy.muza.ui.styling.DefaultDarkColorPalette import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.PureBlackColorPalette @@ -70,145 +82,214 @@ import it.hamy.muza.ui.styling.onOverlayShimmer import it.hamy.muza.utils.SynchronizedLyrics import it.hamy.muza.utils.center import it.hamy.muza.utils.color -import it.hamy.muza.utils.isShowingSynchronizedLyricsKey import it.hamy.muza.utils.medium -import it.hamy.muza.utils.rememberPreference +import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.toast -import it.hamy.muza.utils.verticalFadingEdge +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.withContext -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context -import androidx.core.content.ContextCompat -import androidx.core.content.ContextCompat.getSystemService +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds @Composable fun Lyrics( mediaId: String, isDisplayed: Boolean, onDismiss: () -> Unit, - size: Dp, + height: Dp, mediaMetadataProvider: () -> MediaMetadata, durationProvider: () -> Long, ensureSongInserted: () -> Unit, - modifier: Modifier = Modifier -) { - - - - + modifier: Modifier = Modifier, + onMenuLaunched: () -> Unit = { } +) = with(PlayerPreferences) { AnimatedVisibility( visible = isDisplayed, enter = fadeIn(), - exit = fadeOut(), + exit = fadeOut() ) { val (colorPalette, typography) = LocalAppearance.current val context = LocalContext.current val menuState = LocalMenuState.current val currentView = LocalView.current + val binder = LocalPlayerServiceBinder.current - var isShowingSynchronizedLyrics by rememberPreference(isShowingSynchronizedLyricsKey, false) - - var isEditing by remember(mediaId, isShowingSynchronizedLyrics) { - mutableStateOf(false) - } - - var lyrics by remember { - mutableStateOf(null) - } - + var isEditing by remember(mediaId, isShowingSynchronizedLyrics) { mutableStateOf(false) } + var isPicking by remember(mediaId, isShowingSynchronizedLyrics) { mutableStateOf(false) } + var lyrics by remember { mutableStateOf(null) } val text = if (isShowingSynchronizedLyrics) lyrics?.synced else lyrics?.fixed - - var isError by remember(mediaId, isShowingSynchronizedLyrics) { - mutableStateOf(false) - } - - - val clipboardManager = ContextCompat.getSystemService(context, ClipboardManager::class.java) + var isError by remember(mediaId, isShowingSynchronizedLyrics) { mutableStateOf(false) } + var invalidLrc by remember(mediaId, isShowingSynchronizedLyrics) { mutableStateOf(false) } LaunchedEffect(mediaId, isShowingSynchronizedLyrics) { - withContext(Dispatchers.IO) { - Database.lyrics(mediaId).collect { - if (isShowingSynchronizedLyrics && it?.synced == null) { - val mediaMetadata = mediaMetadataProvider() + runCatching { + withContext(Dispatchers.IO) { + Database.lyrics(mediaId).collect { currentLyrics -> + when { + isShowingSynchronizedLyrics && currentLyrics?.synced == null -> { + lyrics = null + val mediaMetadata = mediaMetadataProvider() + var duration = withContext(Dispatchers.Main) { durationProvider() } - var duration = withContext(Dispatchers.Main) { - durationProvider() - } + while (duration == C.TIME_UNSET) { + delay(100) + duration = withContext(Dispatchers.Main) { durationProvider() } + } - while (duration == C.TIME_UNSET) { - delay(100) - duration = withContext(Dispatchers.Main) { - durationProvider() + val album = mediaMetadata.albumTitle?.toString() + val artist = mediaMetadata.artist?.toString().orEmpty() + val title = mediaMetadata.title?.toString().orEmpty() + + LrcLib.lyrics( + artist = artist, + title = title, + duration = duration.milliseconds, + album = album + )?.onSuccess { + Database.upsert( + Lyrics( + songId = mediaId, + fixed = currentLyrics?.fixed, + synced = it?.text.orEmpty() + ) + ) + }?.onFailure { + KuGou.lyrics( + artist = artist, + title = title, + duration = duration / 1000 + )?.onSuccess { + Database.upsert( + Lyrics( + songId = mediaId, + fixed = currentLyrics?.fixed, + synced = it?.value.orEmpty() + ) + ) + }?.onFailure { + isError = true + } + } } - } - KuGou.lyrics( - artist = mediaMetadata.artist?.toString() ?: "", - title = mediaMetadata.title?.toString() ?: "", - duration = duration / 1000 - )?.onSuccess { syncedLyrics -> - Database.upsert( - Lyrics( - songId = mediaId, - fixed = it?.fixed, - synced = syncedLyrics?.value ?: "" - ) - ) - }?.onFailure { - isError = true + !isShowingSynchronizedLyrics && currentLyrics?.fixed == null -> { + lyrics = null + Innertube.lyrics(NextBody(videoId = mediaId))?.onSuccess { + Database.upsert( + Lyrics( + songId = mediaId, + fixed = it.orEmpty(), + synced = currentLyrics?.synced + ) + ) + }?.onFailure { + isError = true + } + } + + else -> lyrics = currentLyrics } - } else if (!isShowingSynchronizedLyrics && it?.fixed == null) { - Innertube.lyrics(NextBody(videoId = mediaId))?.onSuccess { fixedLyrics -> - Database.upsert( - Lyrics( - songId = mediaId, - fixed = fixedLyrics ?: "", - synced = it?.synced - ) - ) - }?.onFailure { - isError = true - } - } else { - lyrics = it } } + }.exceptionOrNull()?.let { if (it !is CancellationException) it.printStackTrace() } + } + + if (isEditing) TextFieldDialog( + hintText = stringResource(R.string.enter_lyrics), + initialTextInput = text.orEmpty(), + singleLine = false, + maxLines = 10, + isTextInputValid = { true }, + onDismiss = { isEditing = false }, + onDone = { + query { + ensureSongInserted() + Database.upsert( + Lyrics( + songId = mediaId, + fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else it, + synced = if (isShowingSynchronizedLyrics) it else lyrics?.synced + ) + ) + } + } + ) + + if (isPicking && isShowingSynchronizedLyrics) DefaultDialog( + onDismiss = { + isPicking = false + }, + horizontalPadding = 0.dp + ) { + val tracks = remember { mutableStateListOf() } + var loading by remember { mutableStateOf(true) } + var error by remember { mutableStateOf(false) } + + LaunchedEffect(Unit) { + val mediaMetadata = mediaMetadataProvider() + + LrcLib.lyrics( + artist = mediaMetadata.artist?.toString().orEmpty(), + title = mediaMetadata.title?.toString().orEmpty() + )?.onSuccess { + tracks.clear() + tracks.addAll(it) + loading = false + error = false + }?.onFailure { + loading = false + error = true + } ?: run { loading = false } + } + + when { + loading -> CircularProgressIndicator( + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + + error || tracks.isEmpty() -> BasicText( + text = stringResource(R.string.no_lyrics_found), + style = typography.s.semiBold.center, + modifier = Modifier + .padding(all = 24.dp) + .align(Alignment.CenterHorizontally) + ) + + else -> ValueSelectorDialogBody( + onDismiss = { isPicking = false }, + title = stringResource(R.string.choose_lyric_track), + selectedValue = null, + values = tracks.toImmutableList(), + onValueSelected = { + transaction { + Database.upsert( + Lyrics( + songId = mediaId, + fixed = lyrics?.fixed, + synced = it.syncedLyrics.orEmpty() + ) + ) + isPicking = false + } + }, + valueText = { + "${it.artistName} - ${it.trackName} (${ + it.duration.seconds.toComponents { minutes, seconds, _ -> + "$minutes:${seconds.toString().padStart(2, '0')}" + } + })" + } + ) } } - if (isEditing) { - TextFieldDialog( - hintText = "Введите текст песни", - initialTextInput = text ?: "", - singleLine = false, - maxLines = 10, - isTextInputValid = { true }, - onDismiss = { isEditing = false }, - onDone = { - query { - ensureSongInserted() - Database.upsert( - Lyrics( - songId = mediaId, - fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else it, - synced = if (isShowingSynchronizedLyrics) it else lyrics?.synced, - ) - ) - } - } - ) - } - - if (isShowingSynchronizedLyrics) { - DisposableEffect(Unit) { - currentView.keepScreenOn = true - onDispose { - currentView.keepScreenOn = false - } + if (isShowingSynchronizedLyrics) DisposableEffect(Unit) { + currentView.keepScreenOn = true + onDispose { + currentView.keepScreenOn = false } } @@ -216,9 +297,7 @@ fun Lyrics( contentAlignment = Alignment.Center, modifier = modifier .pointerInput(Unit) { - detectTapGestures( - onTap = { onDismiss() } - ) + detectTapGestures(onTap = { onDismiss() }) } .fillMaxSize() .background(Color.Black.copy(0.8f)) @@ -227,11 +306,11 @@ fun Lyrics( visible = isError && text == null, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, - modifier = Modifier - .align(Alignment.TopCenter) + modifier = Modifier.align(Alignment.TopCenter) ) { BasicText( - text = "Произошла ошибка при синхронизации ${if (isShowingSynchronizedLyrics) "синхронизирован " else ""}текст песни", + text = if (isShowingSynchronizedLyrics) stringResource(R.string.error_load_synchronized_lyrics) + else stringResource(R.string.error_load_lyrics), style = typography.xs.center.medium.color(PureBlackColorPalette.text), modifier = Modifier .background(Color.Black.copy(0.4f)) @@ -241,14 +320,30 @@ fun Lyrics( } AnimatedVisibility( - visible = text?.let(String::isEmpty) ?: false, + visible = text?.isEmpty() ?: false, enter = slideInVertically { -it }, exit = slideOutVertically { -it }, - modifier = Modifier - .align(Alignment.TopCenter) + modifier = Modifier.align(Alignment.TopCenter) ) { BasicText( - text = "${if (isShowingSynchronizedLyrics) "Синхронизированный т" else "Т"}екст не доступен", + text = if (isShowingSynchronizedLyrics) stringResource(R.string.synchronized_lyrics_not_available) + else stringResource(R.string.lyrics_not_available), + style = typography.xs.center.medium.color(PureBlackColorPalette.text), + modifier = Modifier + .background(Color.Black.copy(0.4f)) + .padding(all = 8.dp) + .fillMaxWidth() + ) + } + + AnimatedVisibility( + visible = invalidLrc && isShowingSynchronizedLyrics, + enter = slideInVertically { -it }, + exit = slideOutVertically { -it }, + modifier = Modifier.align(Alignment.TopCenter) + ) { + BasicText( + text = stringResource(R.string.invalid_synchronized_lyrics), style = typography.xs.center.medium.color(PureBlackColorPalette.text), modifier = Modifier .background(Color.Black.copy(0.4f)) @@ -260,76 +355,92 @@ fun Lyrics( if (text?.isNotEmpty() == true) { if (isShowingSynchronizedLyrics) { val density = LocalDensity.current - val player = LocalPlayerServiceBinder.current?.player - ?: return@AnimatedVisibility + val player = + LocalPlayerServiceBinder.current?.player ?: return@AnimatedVisibility val synchronizedLyrics = remember(text) { - SynchronizedLyrics(KuGou.Lyrics(text).sentences) { - player.currentPosition + 50 - } - } + val sentences = LrcLib.Lyrics(text).sentences - val lazyListState = rememberLazyListState( - synchronizedLyrics.index, - with(density) { size.roundToPx() } / 6) - - LaunchedEffect(synchronizedLyrics) { - val center = with(density) { size.roundToPx() } / 6 - - while (isActive) { - delay(50) - if (synchronizedLyrics.update()) { - lazyListState.animateScrollToItem( - synchronizedLyrics.index, - center - ) + if (sentences == null) { + invalidLrc = true + null + } else { + invalidLrc = false + SynchronizedLyrics(sentences) { + player.currentPosition + 50L - (lyrics?.startTime ?: 0L) } } } - LazyColumn( - state = lazyListState, - userScrollEnabled = false, - contentPadding = PaddingValues(vertical = size / 2), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .verticalFadingEdge() - ) { - itemsIndexed(items = synchronizedLyrics.sentences) { index, sentence -> - BasicText( - text = sentence.second, - style = typography.xs.center.medium.color(if (index == synchronizedLyrics.index) PureBlackColorPalette.text else PureBlackColorPalette.textDisabled), - modifier = Modifier - .padding(vertical = 4.dp, horizontal = 32.dp) + if (synchronizedLyrics != null) { + val lazyListState = rememberLazyListState() + + LaunchedEffect(synchronizedLyrics, density) { + val centerOffset = with(density) { (-height / 3).roundToPx() } + + lazyListState.animateScrollToItem( + index = synchronizedLyrics.index + 1, + scrollOffset = centerOffset ) + + while (isActive) { + delay(50) + if (!synchronizedLyrics.update()) continue + + lazyListState.animateScrollToItem( + index = synchronizedLyrics.index + 1, + scrollOffset = centerOffset + ) + } + } + + LazyColumn( + state = lazyListState, + userScrollEnabled = false, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + modifier = Modifier.verticalFadingEdge() + ) { + item(key = "header", contentType = 0) { + Spacer(modifier = Modifier.height(height)) + } + itemsIndexed( + items = synchronizedLyrics.sentences.values.toImmutableList() + ) { index, sentence -> + BasicText( + text = sentence, + style = typography.xs.center.medium.color( + if (index == synchronizedLyrics.index) PureBlackColorPalette.text + else PureBlackColorPalette.textDisabled + ), + modifier = Modifier.padding(vertical = 4.dp, horizontal = 32.dp) + ) + } + item(key = "footer", contentType = 0) { + Spacer(modifier = Modifier.height(height)) + } } } - } else { - BasicText( - text = text, - style = typography.xs.center.medium.color(PureBlackColorPalette.text), - modifier = Modifier - .verticalFadingEdge() - .verticalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(vertical = size / 4, horizontal = 32.dp) - ) - } + } else BasicText( + text = text, + style = typography.xs.center.medium.color(PureBlackColorPalette.text), + modifier = Modifier + .verticalFadingEdge() + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(vertical = height / 4, horizontal = 32.dp) + ) } - if (text == null && !isError) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .shimmer() - ) { - repeat(4) { - TextPlaceholder( - color = colorPalette.onOverlayShimmer, - modifier = Modifier - .alpha(1f - it * 0.2f) - ) - } + if (text == null && !isError) Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.shimmer() + ) { + repeat(4) { + TextPlaceholder( + color = colorPalette.onOverlayShimmer, + modifier = Modifier.alpha(1f - it * 0.2f) + ) } } @@ -343,12 +454,16 @@ fun Lyrics( indication = rememberRipple(bounded = false), interactionSource = remember { MutableInteractionSource() }, onClick = { + onMenuLaunched() menuState.display { Menu { MenuEntry( icon = R.drawable.time, - text = "Показать ${if (isShowingSynchronizedLyrics) "не" else ""}синхронизированный текст", - secondaryText = if (isShowingSynchronizedLyrics) null else "при помощи kugou.com", + text = if (isShowingSynchronizedLyrics) + stringResource(R.string.show_unsynchronized_lyrics) + else stringResource(R.string.show_synchronized_lyrics), + secondaryText = if (isShowingSynchronizedLyrics) null + else stringResource(R.string.provided_lyrics_by), onClick = { menuState.hide() isShowingSynchronizedLyrics = @@ -358,25 +473,16 @@ fun Lyrics( MenuEntry( icon = R.drawable.pencil, - text = "Изменить текст песни", + text = stringResource(R.string.edit_lyrics), onClick = { menuState.hide() isEditing = true } ) - MenuEntry( - icon = R.drawable.text, - text = "Скопировать текст", - onClick = { - menuState.hide() - clipboardManager?.setPrimaryClip(ClipData.newPlainText("Lyrics", lyrics?.fixed)) - } - ) - MenuEntry( icon = R.drawable.search, - text = "Искать текст в интернете", + text = stringResource(R.string.search_lyrics_online), onClick = { menuState.hide() val mediaMetadata = mediaMetadataProvider() @@ -386,33 +492,65 @@ fun Lyrics( Intent(Intent.ACTION_WEB_SEARCH).apply { putExtra( SearchManager.QUERY, - "${mediaMetadata.title} ${mediaMetadata.artist} текст песни" + "${mediaMetadata.title} ${mediaMetadata.artist} lyrics" ) } ) } catch (e: ActivityNotFoundException) { - context.toast("На вашем смартфоне не установлен поисковик!") + context.toast(context.getString(R.string.no_browser_installed)) } } ) MenuEntry( - icon = R.drawable.download, - text = "Обновить текст песни", + icon = R.drawable.sync, + text = stringResource(R.string.refetch_lyrics), enabled = lyrics != null, onClick = { menuState.hide() + val fixed = + if (isShowingSynchronizedLyrics) lyrics?.fixed else null + val synced = + if (isShowingSynchronizedLyrics) null else lyrics?.synced + query { Database.upsert( Lyrics( songId = mediaId, - fixed = if (isShowingSynchronizedLyrics) lyrics?.fixed else null, - synced = if (isShowingSynchronizedLyrics) null else lyrics?.synced, + fixed = fixed, + synced = synced ) ) } } ) + + if (isShowingSynchronizedLyrics) { + MenuEntry( + icon = R.drawable.download, + text = stringResource(R.string.pick_from_lrclib), + onClick = { + menuState.hide() + isPicking = true + } + ) + MenuEntry( + icon = R.drawable.play_skip_forward, + text = stringResource(R.string.set_lyrics_start_offset), + secondaryText = stringResource( + R.string.set_lyrics_start_offset_description + ), + onClick = { + menuState.hide() + lyrics?.let { + val startTime = binder?.player?.currentPosition + query { + Database.upsert(it.copy(startTime = startTime)) + } + } + } + ) + } } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/LyricsDialog.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/LyricsDialog.kt new file mode 100644 index 0000000..35892ce --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/LyricsDialog.kt @@ -0,0 +1,123 @@ +package it.hamy.muza.ui.screens.player + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentTransitionScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import coil.compose.AsyncImage +import it.hamy.muza.Database +import it.hamy.muza.LocalPlayerServiceBinder +import it.hamy.muza.service.isLocal +import it.hamy.muza.ui.modifiers.PinchDirection +import it.hamy.muza.ui.modifiers.onSwipe +import it.hamy.muza.ui.modifiers.pinchToToggle +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.forceSeekToNext +import it.hamy.muza.utils.forceSeekToPrevious +import it.hamy.muza.utils.px +import it.hamy.muza.utils.thumbnail + +@Composable +fun LyricsDialog( + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) = Dialog(onDismissRequest = onDismiss) { + val appearance = LocalAppearance.current + val (colorPalette) = appearance + val thumbnailShape = appearance.thumbnailShape + + val player = LocalPlayerServiceBinder.current?.player ?: return@Dialog + val (window, error) = currentWindow() + + LaunchedEffect(window, error) { + if (window == null || window.mediaItem.isLocal || error != null) onDismiss() + } + + window ?: return@Dialog + + AnimatedContent( + targetState = window, + transitionSpec = { + if (initialState.mediaItem.mediaId == targetState.mediaItem.mediaId) + return@AnimatedContent ContentTransform( + targetContentEnter = EnterTransition.None, + initialContentExit = ExitTransition.None + ) + + val direction = if (targetState.firstPeriodIndex > initialState.firstPeriodIndex) + AnimatedContentTransitionScope.SlideDirection.Left + else AnimatedContentTransitionScope.SlideDirection.Right + + ContentTransform( + targetContentEnter = slideIntoContainer( + towards = direction, + animationSpec = tween(500) + ), + initialContentExit = slideOutOfContainer( + towards = direction, + animationSpec = tween(500) + ), + sizeTransform = null + ) + }, + label = "" + ) { currentWindow -> + BoxWithConstraints( + modifier = modifier + .padding(all = 36.dp) + .padding(vertical = 32.dp) + .clip(thumbnailShape) + .fillMaxSize() + .background(colorPalette.background1) + .pinchToToggle( + direction = PinchDirection.In, + threshold = 0.9f, + onPinch = { onDismiss() } + ) + .onSwipe( + onSwipeLeft = { + player.forceSeekToNext() + }, + onSwipeRight = { + player.seekToDefaultPosition() + player.forceSeekToPrevious() + } + ) + ) { + val thumbnailHeight = maxHeight + + if (currentWindow.mediaItem.mediaMetadata.artworkUri != null) AsyncImage( + model = currentWindow.mediaItem.mediaMetadata.artworkUri.thumbnail((thumbnailHeight - 64.dp).px), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + .background(colorPalette.background0) + ) + + Lyrics( + mediaId = currentWindow.mediaItem.mediaId, + isDisplayed = true, + onDismiss = { }, + height = thumbnailHeight, + mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata, + durationProvider = player::getDuration, + ensureSongInserted = { Database.insert(currentWindow.mediaItem) }, + onMenuLaunched = onDismiss + ) + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/PlaybackError.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/PlaybackError.kt index d8e2c03..2df0246 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/PlaybackError.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/PlaybackError.kt @@ -1,6 +1,8 @@ package it.hamy.muza.ui.screens.player +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ContentTransform import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.slideInVertically @@ -14,14 +16,14 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp -import it.hamy.muza.ui.styling.PureBlackColorPalette import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.ui.styling.PureBlackColorPalette import it.hamy.muza.utils.center import it.hamy.muza.utils.color import it.hamy.muza.utils.medium @@ -29,47 +31,46 @@ import it.hamy.muza.utils.medium @Composable fun PlaybackError( isDisplayed: Boolean, - messageProvider: () -> String, + messageProvider: @Composable () -> String, onDismiss: () -> Unit, modifier: Modifier = Modifier -) { - val (_, typography) = LocalAppearance.current +) = Box(modifier = modifier) { + val message by rememberUpdatedState(newValue = messageProvider()) - Box { - AnimatedVisibility( - visible = isDisplayed, - enter = fadeIn(), - exit = fadeOut(), - ) { - Spacer( - modifier = modifier - .pointerInput(Unit) { - detectTapGestures( - onTap = { - onDismiss() - } - ) - } - .fillMaxSize() - .background(Color.Black.copy(0.8f)) - ) - } - - AnimatedVisibility( - visible = isDisplayed, - enter = slideInVertically { -it }, - exit = slideOutVertically { -it }, + AnimatedVisibility( + visible = isDisplayed, + enter = fadeIn(), + exit = fadeOut() + ) { + Spacer( modifier = Modifier - .align(Alignment.TopCenter) - ) { - BasicText( - text = remember { messageProvider() }, - style = typography.xs.center.medium.color(PureBlackColorPalette.text), - modifier = Modifier - .background(Color.Black.copy(0.4f)) - .padding(all = 8.dp) - .fillMaxWidth() + .pointerInput(Unit) { + detectTapGestures(onTap = { onDismiss() }) + } + .fillMaxSize() + .background(Color.Black.copy(0.8f)) + ) + } + + AnimatedContent( + targetState = message.takeIf { isDisplayed }, + transitionSpec = { + ContentTransform( + targetContentEnter = slideInVertically { -it }, + initialContentExit = slideOutVertically { -it }, + sizeTransform = null ) - } + }, + label = "", + modifier = Modifier.fillMaxWidth() + ) { currentMessage -> + if (currentMessage != null) BasicText( + text = currentMessage, + style = LocalAppearance.current.typography.xs.center.medium.color(PureBlackColorPalette.text), + modifier = Modifier + .background(Color.Black.copy(0.4f)) + .padding(all = 8.dp) + .fillMaxWidth() + ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Player.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Player.kt index 55bda0f..ec000a1 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Player.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Player.kt @@ -5,8 +5,12 @@ import android.content.Intent import android.media.audiofx.AudioEffect import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -25,9 +29,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.neverEqualPolicy import androidx.compose.runtime.remember @@ -41,48 +48,71 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.coerceAtMost import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi import coil.compose.AsyncImage -import it.hamy.innertube.models.NavigationEndpoint import it.hamy.compose.routing.OnGlobalRoute +import it.hamy.innertube.models.NavigationEndpoint +import it.hamy.muza.Database import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R +import it.hamy.muza.enums.ThumbnailRoundness +import it.hamy.muza.models.ui.toUiMedia +import it.hamy.muza.preferences.PlayerPreferences +import it.hamy.muza.roundedShape import it.hamy.muza.service.PlayerService +import it.hamy.muza.transaction import it.hamy.muza.ui.components.BottomSheet import it.hamy.muza.ui.components.BottomSheetState import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.rememberBottomSheetState import it.hamy.muza.ui.components.themed.BaseMediaItemMenu import it.hamy.muza.ui.components.themed.IconButton +import it.hamy.muza.ui.components.themed.SecondaryTextButton +import it.hamy.muza.ui.components.themed.SliderDialog +import it.hamy.muza.ui.modifiers.PinchDirection +import it.hamy.muza.ui.modifiers.onSwipe +import it.hamy.muza.ui.modifiers.pinchToToggle import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.collapsedPlayerProgressBar -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.DisposableListener import it.hamy.muza.utils.forceSeekToNext +import it.hamy.muza.utils.forceSeekToPrevious import it.hamy.muza.utils.isLandscape import it.hamy.muza.utils.positionAndDurationState +import it.hamy.muza.utils.px import it.hamy.muza.utils.seamlessPlay import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.shouldBePlaying import it.hamy.muza.utils.thumbnail import it.hamy.muza.utils.toast +import kotlinx.coroutines.flow.distinctUntilChanged import kotlin.math.absoluteValue -@ExperimentalFoundationApi -@ExperimentalAnimationApi +private fun onDismiss(binder: PlayerService.Binder) { + binder.stopRadio() + binder.player.clearMediaItems() +} + @Composable fun Player( layoutState: BottomSheetState, modifier: Modifier = Modifier, + shape: RoundedCornerShape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp + ) ) { val menuState = LocalMenuState.current - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val (colorPalette, typography, thumbnailCornerSize) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current binder?.player ?: return @@ -127,15 +157,24 @@ fun Player( BottomSheet( state = layoutState, modifier = modifier, - onDismiss = { - binder.stopRadio() - binder.player.clearMediaItems() - }, + onDismiss = { onDismiss(binder) }, + indication = null, collapsedContent = { Row( horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top, modifier = Modifier + .let { + if (PlayerPreferences.horizontalSwipeToClose) it.onSwipe( + animateOffset = true, + onSwipeOut = { animationJob -> + animationJob.join() + layoutState.dismiss() + onDismiss(binder) + } + ) else it + } + .clip(shape) .background(colorPalette.background1) .fillMaxSize() .padding(horizontalBottomPaddingValues) @@ -151,22 +190,19 @@ fun Player( ) } ) { - Spacer( - modifier = Modifier - .width(2.dp) - ) + Spacer(modifier = Modifier.width(2.dp)) Box( contentAlignment = Alignment.Center, - modifier = Modifier - .height(Dimensions.collapsedPlayer) + modifier = Modifier.height(Dimensions.items.collapsedPlayerHeight) ) { AsyncImage( model = mediaItem.mediaMetadata.artworkUri.thumbnail(Dimensions.thumbnails.song.px), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier - .clip(thumbnailShape) + .clip(thumbnailCornerSize.coerceAtMost(ThumbnailRoundness.Heavy.dp).roundedShape) + .background(colorPalette.background0) .size(48.dp) ) } @@ -174,44 +210,61 @@ fun Player( Column( verticalArrangement = Arrangement.Center, modifier = Modifier - .height(Dimensions.collapsedPlayer) + .height(Dimensions.items.collapsedPlayerHeight) .weight(1f) ) { - BasicText( - text = mediaItem.mediaMetadata.title?.toString() ?: "", - style = typography.xs.semiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - BasicText( - text = mediaItem.mediaMetadata.artist?.toString() ?: "", - style = typography.xs.semiBold.secondary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + AnimatedContent( + targetState = mediaItem.mediaMetadata.title?.toString().orEmpty(), + label = "", + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { text -> + BasicText( + text = text, + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + AnimatedVisibility(visible = mediaItem.mediaMetadata.artist != null) { + AnimatedContent( + targetState = mediaItem.mediaMetadata.artist?.toString().orEmpty(), + label = "", + transitionSpec = { fadeIn() togetherWith fadeOut() } + ) { text -> + BasicText( + text = text, + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } } - Spacer( - modifier = Modifier - .width(2.dp) - ) + Spacer(modifier = Modifier.width(2.dp)) Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(25.dp), verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .height(Dimensions.collapsedPlayer) + modifier = Modifier.height(Dimensions.items.collapsedPlayerHeight) ) { + AnimatedVisibility(visible = PlayerPreferences.isShowingPrevButtonCollapsed) { + IconButton( + icon = R.drawable.play_skip_back, + color = colorPalette.text, + onClick = binder.player::forceSeekToPrevious, + modifier = Modifier + .padding(horizontal = 4.dp, vertical = 8.dp) + .size(20.dp) + ) + } + IconButton( icon = if (shouldBePlaying) R.drawable.pause else R.drawable.play, color = colorPalette.text, onClick = { - if (shouldBePlaying) { - binder.player.pause() - } else { - if (binder.player.playbackState == Player.STATE_IDLE) { - binder.player.prepare() - } + if (shouldBePlaying) binder.player.pause() else { + if (binder.player.playbackState == Player.STATE_IDLE) binder.player.prepare() binder.player.play() } }, @@ -230,27 +283,22 @@ fun Player( ) } - Spacer( - modifier = Modifier - .width(2.dp) - ) + Spacer(modifier = Modifier.width(2.dp)) } } ) { - var isShowingLyrics by rememberSaveable { - mutableStateOf(false) - } + var isShowingStatsForNerds by rememberSaveable { mutableStateOf(false) } + var isShowingLyricsDialog by rememberSaveable { mutableStateOf(false) } - var isShowingStatsForNerds by rememberSaveable { - mutableStateOf(false) - } + if (isShowingLyricsDialog) LyricsDialog(onDismiss = { isShowingLyricsDialog = false }) val playerBottomSheetState = rememberBottomSheetState( - 64.dp + horizontalBottomPaddingValues.calculateBottomPadding(), - layoutState.expandedBound + dismissedBound = 64.dp + horizontalBottomPaddingValues.calculateBottomPadding(), + expandedBound = layoutState.expandedBound ) val containerModifier = Modifier + .clip(shape) .background(colorPalette.background1) .padding( windowInsets @@ -259,92 +307,163 @@ fun Player( ) .padding(bottom = playerBottomSheetState.collapsedBound) - val thumbnailContent: @Composable (modifier: Modifier) -> Unit = { modifier -> + val thumbnailContent: @Composable (modifier: Modifier) -> Unit = { innerModifier -> Thumbnail( - isShowingLyrics = isShowingLyrics, - onShowLyrics = { isShowingLyrics = it }, + isShowingLyrics = PlayerPreferences.isShowingLyrics, + onShowLyrics = { PlayerPreferences.isShowingLyrics = it }, isShowingStatsForNerds = isShowingStatsForNerds, onShowStatsForNerds = { isShowingStatsForNerds = it }, - modifier = modifier + modifier = innerModifier .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) + .pinchToToggle( + key = isShowingLyricsDialog, + direction = PinchDirection.Out, + threshold = 1.05f, + onPinch = { + if (PlayerPreferences.isShowingLyrics) isShowingLyricsDialog = true + } + ) ) } - val controlsContent: @Composable (modifier: Modifier) -> Unit = { modifier -> + val controlsContent: @Composable (modifier: Modifier) -> Unit = { innerModifier -> + val media = mediaItem.toUiMedia(positionAndDuration.second) + Controls( - mediaId = mediaItem.mediaId, - title = mediaItem.mediaMetadata.title?.toString(), - artist = mediaItem.mediaMetadata.artist?.toString(), + media = media, shouldBePlaying = shouldBePlaying, position = positionAndDuration.first, - duration = positionAndDuration.second, - modifier = modifier + modifier = innerModifier ) } - if (isLandscape) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = containerModifier - .padding(top = 32.dp) + if (isLandscape) Row( + verticalAlignment = Alignment.CenterVertically, + modifier = containerModifier.padding(top = 32.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(0.66f) + .padding(bottom = 16.dp) ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(0.66f) - .padding(bottom = 16.dp) - ) { - thumbnailContent( - modifier = Modifier - .padding(horizontal = 16.dp) - ) - } - - controlsContent( - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxHeight() - .weight(1f) - ) + thumbnailContent(Modifier.padding(horizontal = 16.dp)) } - } else { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = containerModifier - .padding(top = 54.dp) + + controlsContent( + Modifier + .padding(vertical = 8.dp) + .fillMaxHeight() + .weight(1f) + ) + } else Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = containerModifier.padding(top = 54.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1.25f) + ) { + thumbnailContent(Modifier.padding(horizontal = 32.dp, vertical = 8.dp)) + } + + controlsContent( + Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + .weight(1f) + ) + } + + var speedDialogOpen by rememberSaveable { mutableStateOf(false) } + + if (speedDialogOpen) { + SliderDialog( + onDismiss = { speedDialogOpen = false }, + title = stringResource(R.string.playback_speed), + provideState = { + remember(PlayerPreferences.speed) { + mutableFloatStateOf(PlayerPreferences.speed) + } + }, + onSlideCompleted = { PlayerPreferences.speed = it }, + min = 0f, + max = 2f, + toDisplay = { + if (it <= 0.01f) stringResource(R.string.minimum_speed_value) + else stringResource(R.string.format_speed_multiplier, "%.2f".format(it)) + } ) { Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .weight(1.25f) + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { - thumbnailContent( - modifier = Modifier - .padding(horizontal = 32.dp, vertical = 8.dp) + SecondaryTextButton( + text = stringResource(R.string.reset), + onClick = { + PlayerPreferences.speed = 1f + } ) } - - controlsContent( - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxWidth() - .weight(1f) - ) } } + var boostDialogOpen by rememberSaveable { mutableStateOf(false) } - Queue( - layoutState = playerBottomSheetState, - content = { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(horizontal = 8.dp) - .fillMaxHeight() + if (boostDialogOpen) { + fun submit(state: Float) = transaction { + Database.setLoudnessBoost( + songId = mediaItem.mediaId, + loudnessBoost = state.takeUnless { it == 0f } + ) + } + + SliderDialog( + onDismiss = { boostDialogOpen = false }, + title = stringResource(R.string.song_volume_boost), + provideState = { + val state = remember { mutableFloatStateOf(0f) } + + LaunchedEffect(mediaItem.mediaId) { + Database + .loudnessBoost(mediaItem.mediaId) + .distinctUntilChanged() + .collect { state.floatValue = it ?: 0f } + } + + state + }, + onSlideCompleted = { submit(it) }, + min = -20f, + max = 20f, + toDisplay = { stringResource(R.string.format_db, "%.2f".format(it)) } + ) { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center ) { + SecondaryTextButton( + text = stringResource(R.string.reset), + onClick = { submit(0f) } + ) + } + } + } + + with(PlayerPreferences) { + Queue( + layoutState = playerBottomSheetState, + beforeContent = { + if (playerLayout == PlayerPreferences.PlayerLayout.New) IconButton( + onClick = { trackLoopEnabled = !trackLoopEnabled }, + icon = R.drawable.infinite, + color = if (trackLoopEnabled) colorPalette.text else colorPalette.textDisabled, + modifier = Modifier + .padding(vertical = 8.dp) + .size(20.dp) + ) else Spacer(modifier = Modifier.width(20.dp)) + }, + afterContent = { IconButton( icon = R.drawable.ellipsis_horizontal, color = colorPalette.text, @@ -353,34 +472,34 @@ fun Player( PlayerMenu( onDismiss = menuState::hide, mediaItem = mediaItem, - binder = binder + binder = binder, + onShowSpeedDialog = { speedDialogOpen = true }, + onShowNormalizationDialog = + if (volumeNormalization) ({ boostDialogOpen = true }) else null ) } }, modifier = Modifier - .padding(horizontal = 4.dp, vertical = 8.dp) + .padding(vertical = 8.dp) .size(20.dp) ) - - Spacer( - modifier = Modifier - .width(4.dp) - ) - } - }, - backgroundColorProvider = { colorPalette.background2 }, - modifier = Modifier - .align(Alignment.BottomCenter) - ) + }, + backgroundColorProvider = { colorPalette.background2 }, + modifier = Modifier.align(Alignment.BottomCenter), + shape = shape + ) + } } } -@ExperimentalAnimationApi @Composable +@OptIn(UnstableApi::class) private fun PlayerMenu( binder: PlayerService.Binder, mediaItem: MediaItem, - onDismiss: () -> Unit + onDismiss: () -> Unit, + onShowSpeedDialog: (() -> Unit)? = null, + onShowNormalizationDialog: (() -> Unit)? = null ) { val context = LocalContext.current @@ -404,10 +523,12 @@ private fun PlayerMenu( } ) } catch (e: ActivityNotFoundException) { - context.toast("На вашем смартфоне не найдено эквалайзера") + context.toast(context.getString(R.string.no_equalizer_installed)) } }, onShowSleepTimer = {}, - onDismiss = onDismiss + onDismiss = onDismiss, + onShowSpeedDialog = onShowSpeedDialog, + onShowNormalizationDialog = onShowNormalizationDialog ) } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Queue.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Queue.kt index b997880..f7673ff 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Queue.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Queue.kt @@ -1,13 +1,8 @@ package it.hamy.muza.ui.screens.player -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope +import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ContentTransform import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.VectorConverter import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn @@ -17,145 +12,187 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicText -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.LookaheadScope import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline import com.valentinilk.shimmer.shimmer -import it.hamy.compose.reordering.ReorderingLazyColumn +import it.hamy.compose.persist.PersistMapCleanup +import it.hamy.compose.persist.persist import it.hamy.compose.reordering.animateItemPlacement import it.hamy.compose.reordering.draggedItem import it.hamy.compose.reordering.rememberReorderingState -import it.hamy.compose.reordering.reorder +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.bodies.NextBody +import it.hamy.innertube.requests.nextPage +import it.hamy.muza.Database import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R +import it.hamy.muza.enums.PlaylistSortBy +import it.hamy.muza.enums.SortOrder +import it.hamy.muza.models.Playlist +import it.hamy.muza.models.SongPlaylistMap +import it.hamy.muza.preferences.PlayerPreferences +import it.hamy.muza.transaction import it.hamy.muza.ui.components.BottomSheet import it.hamy.muza.ui.components.BottomSheetState import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.MusicBars +import it.hamy.muza.ui.components.themed.BaseMediaItemMenu import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop +import it.hamy.muza.ui.components.themed.HorizontalDivider import it.hamy.muza.ui.components.themed.IconButton +import it.hamy.muza.ui.components.themed.Menu +import it.hamy.muza.ui.components.themed.MenuEntry import it.hamy.muza.ui.components.themed.QueuedMediaItemMenu +import it.hamy.muza.ui.components.themed.ReorderHandle +import it.hamy.muza.ui.components.themed.SecondaryTextButton +import it.hamy.muza.ui.components.themed.TextFieldDialog +import it.hamy.muza.ui.components.themed.TextToggle import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.items.SongItemPlaceholder +import it.hamy.muza.ui.modifiers.swipeToClose import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.onOverlay -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.DisposableListener +import it.hamy.muza.utils.addNext +import it.hamy.muza.utils.asMediaItem +import it.hamy.muza.utils.enqueue import it.hamy.muza.utils.medium -import it.hamy.muza.utils.queueLoopEnabledKey -import it.hamy.muza.utils.rememberPreference +import it.hamy.muza.utils.onFirst +import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.shouldBePlaying import it.hamy.muza.utils.shuffleQueue import it.hamy.muza.utils.smoothScrollToTop import it.hamy.muza.utils.windows +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlin.math.roundToInt +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalAnimationApi::class, ExperimentalFoundationApi::class) @Composable fun Queue( backgroundColorProvider: () -> Color, layoutState: BottomSheetState, + beforeContent: @Composable RowScope.() -> Unit, + afterContent: @Composable RowScope.() -> Unit, modifier: Modifier = Modifier, - content: @Composable BoxScope.() -> Unit, + shape: RoundedCornerShape = RoundedCornerShape( + topStart = 12.dp, + topEnd = 12.dp + ) ) { - val (colorPalette, typography, thumbnailShape) = LocalAppearance.current + val colorPalette = LocalAppearance.current.colorPalette + val typography = LocalAppearance.current.typography + val thumbnailShape = LocalAppearance.current.thumbnailShape val windowInsets = WindowInsets.systemBars val horizontalBottomPaddingValues = windowInsets - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues() + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom) + .asPaddingValues() + + var suggestions by persist?>?>(tag = "queue/suggestions") + + PersistMapCleanup(prefix = "queue/suggestions") BottomSheet( state = layoutState, modifier = modifier, collapsedContent = { - Box( + Row( modifier = Modifier + .clip(shape) .drawBehind { drawRect(backgroundColorProvider()) } .fillMaxSize() - .padding(horizontalBottomPaddingValues) + .padding(horizontalBottomPaddingValues), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp) ) { + Spacer(modifier = Modifier.width(4.dp)) + beforeContent() + Spacer(modifier = Modifier.weight(1f)) Image( painter = painterResource(R.drawable.playlist), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(18.dp) + modifier = Modifier.size(18.dp) ) - - content() + Spacer(modifier = Modifier.weight(1f)) + afterContent() + Spacer(modifier = Modifier.width(4.dp)) } } ) { val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current binder?.player ?: return@BottomSheet val player = binder.player - var queueLoopEnabled by rememberPreference(queueLoopEnabledKey, defaultValue = true) - - val menuState = LocalMenuState.current - - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - var mediaItemIndex by remember { - mutableStateOf(if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex) + mutableIntStateOf(if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex) } - var windows by remember { - mutableStateOf(player.currentTimeline.windows) - } + var windows by remember { mutableStateOf(player.currentTimeline.windows) } + var shouldBePlaying by remember { mutableStateOf(player.shouldBePlaying) } - var shouldBePlaying by remember { - mutableStateOf(binder.player.shouldBePlaying) + val visibleSuggestions by remember(suggestions) { + derivedStateOf { + suggestions + ?.getOrNull() + .orEmpty() + .filter { windows.none { window -> window.mediaItem.mediaId == it.mediaId } } + } } player.DisposableListener { @@ -181,179 +218,195 @@ fun Queue( } } + LaunchedEffect(mediaItemIndex) { + withContext(Dispatchers.IO) { + suggestions = runCatching { + Innertube.nextPage( + NextBody( + videoId = windows[mediaItemIndex].mediaItem.mediaId + ) + )?.mapCatching { it.itemsPage?.items?.map(Innertube.SongItem::asMediaItem) } + }.getOrNull() + } + } + val reorderingState = rememberReorderingState( - lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = mediaItemIndex), + lazyListState = rememberLazyListState(), key = windows, onDragEnd = player::moveMediaItem, extraItemCount = 0 ) - val rippleIndication = rememberRipple(bounded = false) - - val musicBarsTransition = updateTransition(targetState = mediaItemIndex, label = "") - - val deleteHistory = remember { - mutableStateListOf() + LaunchedEffect(Unit) { + reorderingState.lazyListState.scrollToItem(index = mediaItemIndex.coerceAtLeast(0)) } + val scrollConnection = remember { + layoutState.preUpPostDownNestedScrollConnection + } + + val musicBarsTransition = updateTransition( + targetState = if (reorderingState.isDragging) -1L else mediaItemIndex, + label = "" + ) + Column { Box( modifier = Modifier + .clip(shape) .background(colorPalette.background1) .weight(1f) ) { - ReorderingLazyColumn( - reorderingState = reorderingState, - contentPadding = windowInsets - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) - .asPaddingValues(), - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .nestedScroll(layoutState.preUpPostDownNestedScrollConnection) + LookaheadScope { + LazyColumn( + state = reorderingState.lazyListState, + contentPadding = windowInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .asPaddingValues(), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.nestedScroll(scrollConnection) + ) { + itemsIndexed( + items = windows, + key = { _, window -> window.uid.hashCode() } + ) { i, window -> + val isPlayingThisMediaItem = mediaItemIndex == window.firstPeriodIndex - ) { - items( - items = windows, - key = { it.uid.hashCode() } - ) { window -> - val isPlayingThisMediaItem = mediaItemIndex == window.firstPeriodIndex - val offsetX = remember { Animatable(Offset(0f, 0f), Offset.VectorConverter) } - SongItem( - song = window.mediaItem, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - onThumbnailContent = { - musicBarsTransition.AnimatedVisibility( - visible = { it == window.firstPeriodIndex }, - enter = fadeIn(tween(800)), - exit = fadeOut(tween(800)), - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .background( - color = Color.Black.copy(alpha = 0.25f), - shape = thumbnailShape - ) - .size(Dimensions.thumbnails.song) + SongItem( + song = window.mediaItem, + thumbnailSize = Dimensions.thumbnails.song, + onThumbnailContent = { + musicBarsTransition.AnimatedVisibility( + visible = { it == window.firstPeriodIndex }, + enter = fadeIn(tween(800)), + exit = fadeOut(tween(800)) ) { - if (shouldBePlaying) { - MusicBars( + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + color = Color.Black.copy(alpha = 0.25f), + shape = thumbnailShape + ) + .size(Dimensions.thumbnails.song) + ) { + if (shouldBePlaying) MusicBars( color = colorPalette.onOverlay, - modifier = Modifier - .height(24.dp) - ) - } else { - Image( + modifier = Modifier.height(24.dp) + ) else Image( painter = painterResource(R.drawable.play), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.onOverlay), - modifier = Modifier - .size(24.dp) + modifier = Modifier.size(24.dp) ) } } + }, + trailingContent = { + ReorderHandle( + reorderingState = reorderingState, + index = i + ) + }, + modifier = Modifier + .combinedClickable( + onLongClick = { + menuState.display { + QueuedMediaItemMenu( + mediaItem = window.mediaItem, + indexInQueue = if (isPlayingThisMediaItem) null + else window.firstPeriodIndex, + onDismiss = menuState::hide + ) + } + }, + onClick = { + if (isPlayingThisMediaItem) { + if (shouldBePlaying) player.pause() else player.play() + } else { + player.seekToDefaultPosition(window.firstPeriodIndex) + player.playWhenReady = true + } + } + ) + .animateItemPlacement(reorderingState) + .draggedItem( + reorderingState = reorderingState, + index = i + ) + .background(colorPalette.background1) + .let { + if (PlayerPreferences.horizontalSwipeToRemoveItem && !isPlayingThisMediaItem) + it.swipeToClose( + key = windows, + delay = 100.milliseconds, + onClose = { player.removeMediaItem(window.firstPeriodIndex) } + ) + else it + } + ) + } + + item { + if (visibleSuggestions.isNotEmpty()) HorizontalDivider( + modifier = Modifier.padding(start = 28.dp + Dimensions.thumbnails.song) + ) + } + + items(visibleSuggestions) { + SongItem( + song = it, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.clickable { + menuState.display { + BaseMediaItemMenu( + onDismiss = { menuState.hide() }, + mediaItem = it, + onEnqueue = { binder.player.enqueue(it) }, + onPlayNext = { binder.player.addNext(it) } + ) + } } - }, - trailingContent = { - IconButton( - icon = R.drawable.reorder, - color = colorPalette.textDisabled, - indication = rippleIndication, - onClick = {}, - modifier = Modifier - .reorder( - reorderingState = reorderingState, - index = window.firstPeriodIndex - ) - .size(18.dp) - ) - }, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - QueuedMediaItemMenu( - mediaItem = window.mediaItem, - indexInQueue = if (isPlayingThisMediaItem) null else window.firstPeriodIndex, - onDismiss = menuState::hide - ) - } - }, - onClick = { - if (isPlayingThisMediaItem) { - if (shouldBePlaying) { - player.pause() - } else { - player.play() - } - } else { - player.seekToDefaultPosition(window.firstPeriodIndex) - player.playWhenReady = true - } - } - ) - .animateItemPlacement(reorderingState = reorderingState) - .draggedItem( - reorderingState = reorderingState, - index = window.firstPeriodIndex - ) - .draggable( - orientation = Orientation.Horizontal, - state = rememberDraggableState(onDelta = { delta -> - if (isPlayingThisMediaItem) return@rememberDraggableState - runBlocking { - offsetX.snapTo(offsetX.value + Offset(delta, 0f)) - } - }), - onDragStopped = { _ -> - if (offsetX.value.x >= 200.0f || offsetX.value.x <= -200.0f) { - val currentIndex = window.firstPeriodIndex - val mediaId = window.mediaItem.mediaId - - if (deleteHistory.indexOf(mediaId) != -1) return@draggable - deleteHistory.add(mediaId) - - var indexToDelete = currentIndex - for (i in 0 until currentIndex) { - if (deleteHistory.indexOf(windows.elementAt(i).mediaItem.mediaId) != -1) { - indexToDelete-- - } - } - - if (offsetX.value.x < 0) { - offsetX.animateTo(Offset(-1500.0f, offsetX.value.y)) - } else { - offsetX.animateTo(Offset(1500.0f, offsetX.value.y)) - } - - binder.player.removeMediaItem(indexToDelete) - deleteHistory.removeFirst() - } else { - offsetX.animateTo(Offset(0f, offsetX.value.y)) - } - } - ) - .offset { IntOffset(offsetX.value.x.roundToInt(), 0) } - ) - } - - item { - if (binder.isLoadingRadio) { - Column( - modifier = Modifier - .shimmer() ) { - repeat(3) { index -> - SongItemPlaceholder( - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .alpha(1f - index * 0.125f) - .fillMaxWidth() + Row( + horizontalArrangement = Arrangement.spacedBy( + space = 12.dp, + alignment = Alignment.End + ) + ) { + IconButton( + icon = R.drawable.play_skip_forward, + color = colorPalette.text, + onClick = { + binder.player.addNext(it) + }, + modifier = Modifier.size(18.dp) + ) + IconButton( + icon = R.drawable.enqueue, + color = colorPalette.text, + onClick = { + binder.player.enqueue(it) + }, + modifier = Modifier.size(18.dp) ) } } } + + item { + if (binder.isLoadingRadio || suggestions == null) + Column(modifier = Modifier.shimmer()) { + repeat(3) { index -> + SongItemPlaceholder( + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier + .alpha(1f - index * 0.125f) + .fillMaxWidth() + ) + } + } + } } } @@ -372,69 +425,139 @@ fun Queue( ) } - - Box( + Row( modifier = Modifier .clickable(onClick = layoutState::collapseSoft) .background(colorPalette.background2) .fillMaxWidth() .padding(horizontal = 12.dp) .padding(horizontalBottomPaddingValues) - .height(64.dp) + .height(64.dp), + verticalAlignment = Alignment.CenterVertically ) { - BasicText( - text = "${windows.size}", - style = typography.xxs.medium, - modifier = Modifier - .background( - color = colorPalette.background1, - shape = RoundedCornerShape(16.dp) - ) - .align(Alignment.CenterStart) - .padding(all = 8.dp) + TextToggle( + state = PlayerPreferences.queueLoopEnabled, + toggleState = { + PlayerPreferences.queueLoopEnabled = !PlayerPreferences.queueLoopEnabled + }, + name = stringResource(R.string.queue_loop) ) + Spacer(modifier = Modifier.weight(1f)) Image( painter = painterResource(R.drawable.chevron_down), contentDescription = null, colorFilter = ColorFilter.tint(colorPalette.text), - modifier = Modifier - .align(Alignment.Center) - .size(18.dp) + modifier = Modifier.size(18.dp) ) + Spacer(modifier = Modifier.weight(1f)) - Row( + BasicText( + text = pluralStringResource( + id = R.plurals.song_count_plural, + count = windows.size, + windows.size + ), + style = typography.xxs.medium, modifier = Modifier .clip(RoundedCornerShape(16.dp)) - .clickable { queueLoopEnabled = !queueLoopEnabled } + .clickable { + fun addToPlaylist(playlist: Playlist, index: Int) = transaction { + val playlistId = Database + .insert(playlist) + .takeIf { it != -1L } ?: playlist.id + + windows.forEachIndexed { i, window -> + val mediaItem = window.mediaItem + + Database.insert(mediaItem) + Database.insert( + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = index + i + ) + ) + } + } + + menuState.display { + var isCreatingNewPlaylist by rememberSaveable { mutableStateOf(false) } + + val playlistPreviews by remember { + Database + .playlistPreviews( + sortBy = PlaylistSortBy.DateAdded, + sortOrder = SortOrder.Descending + ) + .onFirst { isCreatingNewPlaylist = it.isEmpty() } + }.collectAsState(initial = null, context = Dispatchers.IO) + + if (isCreatingNewPlaylist) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + onDismiss = { isCreatingNewPlaylist = false }, + onDone = { text -> + menuState.hide() + addToPlaylist(Playlist(name = text), 0) + } + ) + + BackHandler { menuState.hide() } + + Menu { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 24.dp, vertical = 8.dp) + .fillMaxWidth() + ) { + BasicText( + text = stringResource(R.string.add_queue_to_playlist), + style = typography.m.semiBold, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = Modifier.weight(weight = 2f, fill = false) + ) + + Spacer(modifier = Modifier.width(8.dp)) + + SecondaryTextButton( + text = stringResource(R.string.new_playlist), + onClick = { isCreatingNewPlaylist = true }, + alternative = true, + modifier = Modifier.weight(weight = 1f, fill = false) + ) + } + + if (playlistPreviews?.isEmpty() == true) + Spacer(modifier = Modifier.height(160.dp)) + + playlistPreviews?.forEach { playlistPreview -> + MenuEntry( + icon = R.drawable.playlist, + text = playlistPreview.playlist.name, + secondaryText = pluralStringResource( + id = R.plurals.song_count_plural, + count = playlistPreview.songCount, + playlistPreview.songCount + ), + onClick = { + menuState.hide() + addToPlaylist( + playlistPreview.playlist, + playlistPreview.songCount + ) + } + ) + } + } + } + } .background(colorPalette.background1) .padding(horizontal = 16.dp, vertical = 8.dp) - .align(Alignment.CenterEnd) - .animateContentSize() - ) { - BasicText( - text = "Повтор ", - style = typography.xxs.medium, - ) - - AnimatedContent( - targetState = queueLoopEnabled, - transitionSpec = { - val slideDirection = if (targetState) AnimatedContentScope.SlideDirection.Up else AnimatedContentScope.SlideDirection.Down - - ContentTransform( - targetContentEnter = slideIntoContainer(slideDirection) + fadeIn(), - initialContentExit = slideOutOfContainer(slideDirection) + fadeOut(), - ) - } - ) { - BasicText( - text = if (it) "вкл." else "откл.", - style = typography.xxs.medium, - ) - } - } + ) } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/StatsForNerds.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/StatsForNerds.kt index 12881c1..2202178 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/StatsForNerds.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/StatsForNerds.kt @@ -1,6 +1,7 @@ package it.hamy.muza.ui.screens.player import android.text.format.Formatter +import androidx.annotation.OptIn import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -17,6 +18,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -24,7 +26,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi import androidx.media3.datasource.cache.Cache import androidx.media3.datasource.cache.CacheSpan import it.hamy.innertube.Innertube @@ -32,19 +36,21 @@ import it.hamy.innertube.models.bodies.PlayerBody import it.hamy.innertube.requests.player import it.hamy.muza.Database import it.hamy.muza.LocalPlayerServiceBinder +import it.hamy.muza.R import it.hamy.muza.models.Format import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.ui.styling.onOverlay import it.hamy.muza.ui.styling.overlay import it.hamy.muza.utils.color import it.hamy.muza.utils.medium -import kotlin.math.roundToInt import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.withContext +import kotlin.math.roundToInt +@OptIn(UnstableApi::class) @Composable fun StatsForNerds( mediaId: String, @@ -59,43 +65,40 @@ fun StatsForNerds( AnimatedVisibility( visible = isDisplayed, enter = fadeIn(), - exit = fadeOut(), + exit = fadeOut() ) { var cachedBytes by remember(mediaId) { - mutableStateOf(binder.cache.getCachedBytes(mediaId, 0, -1)) + mutableLongStateOf(binder.cache.getCachedBytes(mediaId, 0, -1)) } - var format by remember { - mutableStateOf(null) - } + var format by remember { mutableStateOf(null) } LaunchedEffect(mediaId) { Database.format(mediaId).distinctUntilChanged().collectLatest { currentFormat -> - if (currentFormat?.itag == null) { - binder.player.currentMediaItem?.takeIf { it.mediaId == mediaId }?.let { mediaItem -> + if (currentFormat?.itag == null) binder.player.currentMediaItem + ?.takeIf { it.mediaId == mediaId } + ?.let { mediaItem -> withContext(Dispatchers.IO) { delay(2000) - Innertube.player(PlayerBody(videoId = mediaId))?.onSuccess { response -> - response.streamingData?.highestQualityFormat?.let { format -> - Database.insert(mediaItem) - Database.insert( - Format( - songId = mediaId, - itag = format.itag, - mimeType = format.mimeType, - bitrate = format.bitrate, - loudnessDb = response.playerConfig?.audioConfig?.normalizedLoudnessDb, - contentLength = format.contentLength, - lastModified = format.lastModified + Innertube.player(PlayerBody(videoId = mediaId)) + ?.onSuccess { response -> + response.streamingData?.highestQualityFormat?.let { format -> + Database.insert(mediaItem) + Database.insert( + Format( + songId = mediaId, + itag = format.itag, + mimeType = format.mimeType, + bitrate = format.bitrate, + loudnessDb = response.playerConfig?.audioConfig?.normalizedLoudnessDb, + contentLength = format.contentLength, + lastModified = format.lastModified + ) ) - ) + } } - } } - } - } else { - format = currentFormat - } + } else format = currentFormat } } @@ -123,11 +126,7 @@ fun StatsForNerds( Box( modifier = modifier .pointerInput(Unit) { - detectTapGestures( - onTap = { - onDismiss() - } - ) + detectTapGestures(onTap = { onDismiss() }) } .background(colorPalette.overlay) .fillMaxSize() @@ -138,70 +137,54 @@ fun StatsForNerds( .align(Alignment.Center) .padding(all = 16.dp) ) { + @Composable + fun Text(text: String) = BasicText( + text = text, + maxLines = 1, + style = typography.xs.medium.color(colorPalette.onOverlay) + ) + Column(horizontalAlignment = Alignment.End) { - BasicText( - text = "Id", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Itag", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Bitrate", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Size", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Cached", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = "Loudness", - style = typography.xs.medium.color(colorPalette.onOverlay) - ) + Text(text = stringResource(R.string.id)) + Text(text = stringResource(R.string.itag)) + Text(text = stringResource(R.string.bitrate)) + Text(text = stringResource(R.string.size)) + Text(text = stringResource(R.string.cached)) + Text(text = stringResource(R.string.loudness)) } Column { - BasicText( - text = mediaId, - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) + Text(text = mediaId) + Text(text = format?.itag?.toString() ?: stringResource(R.string.unknown)) + Text( + text = format?.bitrate?.let { + stringResource( + R.string.format_kbps, + it / 1000 + ) + } ?: stringResource(R.string.unknown) ) - BasicText( - text = format?.itag?.toString() ?: "Unknown", - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( - text = format?.bitrate?.let { "${it / 1000} kbps" } ?: "Unknown", - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) - ) - BasicText( + Text( text = format?.contentLength - ?.let { Formatter.formatShortFileSize(context, it) } ?: "Unknown", - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) + ?.let { Formatter.formatShortFileSize(context, it) } + ?: stringResource(R.string.unknown) ) - BasicText( + Text( text = buildString { append(Formatter.formatShortFileSize(context, cachedBytes)) format?.contentLength?.let { append(" (${(cachedBytes.toFloat() / it * 100).roundToInt()}%)") } - }, - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) + } ) - BasicText( - text = format?.loudnessDb?.let { "%.2f dB".format(it) } ?: "Unknown", - maxLines = 1, - style = typography.xs.medium.color(colorPalette.onOverlay) + Text( + text = format?.loudnessDb?.let { + stringResource( + R.string.format_db, + "%.2f".format(it) + ) + } ?: stringResource(R.string.unknown) ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Thumbnail.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Thumbnail.kt index 914a963..d3e80a2 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Thumbnail.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/Thumbnail.kt @@ -1,51 +1,54 @@ package it.hamy.muza.ui.screens.player import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.SizeTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.foundation.background import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.media3.common.MediaItem -import androidx.media3.common.PlaybackException -import androidx.media3.common.Player import coil.compose.AsyncImage import it.hamy.muza.Database import it.hamy.muza.LocalPlayerServiceBinder +import it.hamy.muza.R import it.hamy.muza.service.LoginRequiredException import it.hamy.muza.service.PlayableFormatNotFoundException import it.hamy.muza.service.UnplayableException import it.hamy.muza.service.VideoIdMismatchException +import it.hamy.muza.service.isLocal +import it.hamy.muza.ui.modifiers.onSwipe import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px -import it.hamy.muza.utils.currentWindow -import it.hamy.muza.utils.DisposableListener +import it.hamy.muza.utils.forceSeekToNext +import it.hamy.muza.utils.forceSeekToPrevious +import it.hamy.muza.utils.px import it.hamy.muza.utils.thumbnail import java.net.UnknownHostException import java.nio.channels.UnresolvedAddressException -@ExperimentalAnimationApi @Composable fun Thumbnail( isShowingLyrics: Boolean, @@ -57,42 +60,26 @@ fun Thumbnail( val binder = LocalPlayerServiceBinder.current val player = binder?.player ?: return - val (thumbnailSizeDp, thumbnailSizePx) = Dimensions.thumbnails.player.song.let { - it to (it - 64.dp).px - } - - var nullableWindow by remember { - mutableStateOf(player.currentWindow) - } - - var error by remember { - mutableStateOf(player.playerError) - } - - player.DisposableListener { - object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - nullableWindow = player.currentWindow - } - - override fun onPlaybackStateChanged(playbackState: Int) { - error = player.playerError - } - - override fun onPlayerError(playbackException: PlaybackException) { - error = playbackException - } - } - } + val (colorPalette) = LocalAppearance.current + val thumbnailShape = LocalAppearance.current.thumbnailShape + val thumbnailSize = Dimensions.thumbnails.player.song + val (nullableWindow, error) = currentWindow() val window = nullableWindow ?: return AnimatedContent( targetState = window, transitionSpec = { + if (initialState.mediaItem.mediaId == targetState.mediaItem.mediaId) + return@AnimatedContent ContentTransform( + EnterTransition.None, + ExitTransition.None + ) + val duration = 500 - val slideDirection = - if (targetState.firstPeriodIndex > initialState.firstPeriodIndex) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right + val slideDirection = if (targetState.firstPeriodIndex > initialState.firstPeriodIndex) + AnimatedContentTransitionScope.SlideDirection.Left + else AnimatedContentTransitionScope.SlideDirection.Right ContentTransform( targetContentEnter = slideIntoContainer( @@ -113,19 +100,41 @@ fun Thumbnail( targetScale = 0.85f, animationSpec = tween(duration) ), - sizeTransform = SizeTransform(clip = false) + sizeTransform = null ) }, - contentAlignment = Alignment.Center - ) {currentWindow -> + modifier = modifier.onSwipe( + onSwipeLeft = { + binder.player.forceSeekToNext() + }, + onSwipeRight = { + binder.player.seekToDefaultPosition() + binder.player.forceSeekToPrevious() + } + ), + contentAlignment = Alignment.Center, + label = "" + ) { currentWindow -> + val shadowElevation by animateDpAsState( + targetValue = if (window == currentWindow) 8.dp else 0.dp, + animationSpec = tween(durationMillis = 1000, easing = LinearEasing), + label = "" + ) + Box( - modifier = modifier + modifier = Modifier .aspectRatio(1f) - .clip(LocalAppearance.current.thumbnailShape) - .size(thumbnailSizeDp) + .size(thumbnailSize) + .shadow( + elevation = shadowElevation, + shape = thumbnailShape, + clip = false + ) + .clip(thumbnailShape) ) { - AsyncImage( - model = currentWindow.mediaItem.mediaMetadata.artworkUri.thumbnail(thumbnailSizePx), + if (currentWindow.mediaItem.mediaMetadata.artworkUri != null) AsyncImage( + model = currentWindow.mediaItem.mediaMetadata.artworkUri.thumbnail((thumbnailSize - 64.dp).px), + error = painterResource(id = R.drawable.ic_launcher_foreground), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -136,16 +145,25 @@ fun Thumbnail( ) } .fillMaxSize() + .background(colorPalette.background0) + ) else Icon( + painter = painterResource(id = R.drawable.ic_launcher_foreground), + contentDescription = null, + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures(onLongPress = { onShowStatsForNerds(true) }) + } + .fillMaxSize() ) - Lyrics( + if (!currentWindow.mediaItem.isLocal) Lyrics( mediaId = currentWindow.mediaItem.mediaId, isDisplayed = isShowingLyrics && error == null, onDismiss = { onShowLyrics(false) }, ensureSongInserted = { Database.insert(currentWindow.mediaItem) }, - size = thumbnailSizeDp, + height = thumbnailSize, mediaMetadataProvider = currentWindow.mediaItem::mediaMetadata, - durationProvider = player::getDuration, + durationProvider = player::getDuration ) StatsForNerds( @@ -157,14 +175,17 @@ fun Thumbnail( PlaybackError( isDisplayed = error != null, messageProvider = { - when (error?.cause?.cause) { - is UnresolvedAddressException, is UnknownHostException -> "A network error has occurred" - is PlayableFormatNotFoundException -> "Couldn't find a playable audio format" - is UnplayableException -> "The original video source of this song has been deleted" - is LoginRequiredException -> "This song cannot be played due to server restrictions" - is VideoIdMismatchException -> "The returned video id doesn't match the requested one" - else -> "An unknown playback error has occurred" - } + if (currentWindow.mediaItem.isLocal) stringResource(R.string.error_local_music_deleted) else + when (error?.cause?.cause) { + is UnresolvedAddressException, is UnknownHostException -> + stringResource(R.string.error_network) + + is PlayableFormatNotFoundException -> stringResource(R.string.error_unplayable) + is UnplayableException -> stringResource(R.string.error_source_deleted) + is LoginRequiredException -> stringResource(R.string.error_server_restrictions) + is VideoIdMismatchException -> stringResource(R.string.error_id_mismatch) + else -> stringResource(R.string.error_unknown_playback) + } }, onDismiss = player::prepare ) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/player/WindowState.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/WindowState.kt new file mode 100644 index 0000000..077777e --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/player/WindowState.kt @@ -0,0 +1,39 @@ +package it.hamy.muza.ui.screens.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import it.hamy.muza.LocalPlayerServiceBinder +import it.hamy.muza.utils.DisposableListener +import it.hamy.muza.utils.currentWindow + +@Composable +fun currentWindow(): Pair { + val player = LocalPlayerServiceBinder.current?.player ?: return null to null + var window by remember { mutableStateOf(player.currentWindow) } + var error by remember { mutableStateOf(player.playerError) } + + player.DisposableListener { + object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + window = player.currentWindow + } + + override fun onPlaybackStateChanged(playbackState: Int) { + error = player.playerError + } + + override fun onPlayerError(playbackException: PlaybackException) { + error = playbackException + } + } + } + + return window to error +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/playlist/PlaylistScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/playlist/PlaylistScreen.kt index d9db75c..9589b45 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/playlist/PlaylistScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/playlist/PlaylistScreen.kt @@ -1,38 +1,45 @@ package it.hamy.muza.ui.screens.playlist -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.res.stringResource import it.hamy.compose.persist.PersistMapCleanup import it.hamy.compose.routing.RouteHandler import it.hamy.muza.R import it.hamy.muza.ui.components.themed.Scaffold -import it.hamy.muza.ui.screens.globalRoutes +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable -fun PlaylistScreen(browseId: String) { +fun PlaylistScreen( + browseId: String, + params: String?, + maxDepth: Int? = null +) { val saveableStateHolder = rememberSaveableStateHolder() - PersistMapCleanup(tagPrefix = "playlist/$browseId") + PersistMapCleanup(prefix = "playlist/$browseId") RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + GlobalRoutes() - host { + NavHost { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = 0, onTabChanged = { }, - tabColumnContent = { Item -> - Item(0, "Песни", R.drawable.musical_notes) + tabColumnContent = { item -> + item(0, stringResource(R.string.songs), R.drawable.musical_notes) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(key = currentTabIndex) { when (currentTabIndex) { - 0 -> PlaylistSongList(browseId = browseId) + 0 -> PlaylistSongList( + browseId = browseId, + params = params, + maxDepth = maxDepth + ) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/playlist/PlaylistSongList.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/playlist/PlaylistSongList.kt index e055e2a..b682108 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/playlist/PlaylistSongList.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/playlist/PlaylistSongList.kt @@ -1,7 +1,6 @@ package it.hamy.muza.ui.screens.playlist import android.content.Intent -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.combinedClickable @@ -24,6 +23,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import com.valentinilk.shimmer.shimmer import it.hamy.compose.persist.persist import it.hamy.innertube.Innertube @@ -45,6 +45,7 @@ import it.hamy.muza.ui.components.themed.HeaderIconButton import it.hamy.muza.ui.components.themed.HeaderPlaceholder import it.hamy.muza.ui.components.themed.LayoutWithAdaptiveThumbnail import it.hamy.muza.ui.components.themed.NonQueuedMediaItemMenu +import it.hamy.muza.ui.components.themed.PlaylistInfo import it.hamy.muza.ui.components.themed.SecondaryTextButton import it.hamy.muza.ui.components.themed.TextFieldDialog import it.hamy.muza.ui.components.themed.adaptiveThumbnailContent @@ -52,21 +53,24 @@ import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.items.SongItemPlaceholder import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px +import it.hamy.muza.utils.PlaylistDownloadIcon import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.completed import it.hamy.muza.utils.enqueue import it.hamy.muza.utils.forcePlayAtIndex import it.hamy.muza.utils.forcePlayFromBeginning import it.hamy.muza.utils.isLandscape +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) @Composable fun PlaylistSongList( browseId: String, + params: String?, + maxDepth: Int?, + modifier: Modifier = Modifier ) { val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current @@ -79,77 +83,74 @@ fun PlaylistSongList( if (playlistPage != null && playlistPage?.songsPage?.continuation == null) return@LaunchedEffect playlistPage = withContext(Dispatchers.IO) { - Innertube.playlistPage(BrowseBody(browseId = browseId))?.completed()?.getOrNull() + Innertube.playlistPage(BrowseBody(browseId = browseId, params = params)) + ?.completed(maxDepth = maxDepth ?: Int.MAX_VALUE)?.getOrNull() } } - val songThumbnailSizeDp = Dimensions.thumbnails.song - val songThumbnailSizePx = songThumbnailSizeDp.px + var isImportingPlaylist by rememberSaveable { mutableStateOf(false) } - var isImportingPlaylist by rememberSaveable { - mutableStateOf(false) - } + if (isImportingPlaylist) TextFieldDialog( + hintText = stringResource(R.string.enter_playlist_name_prompt), + initialTextInput = playlistPage?.title.orEmpty(), + onDismiss = { isImportingPlaylist = false }, + onDone = { text -> + query { + transaction { + val playlistId = Database.insert(Playlist(name = text, browseId = browseId)) - if (isImportingPlaylist) { - TextFieldDialog( - hintText = "Введите название плейлиста", - initialTextInput = playlistPage?.title ?: "", - onDismiss = { isImportingPlaylist = false }, - onDone = { text -> - query { - transaction { - val playlistId = Database.insert(Playlist(name = text, browseId = browseId)) - - playlistPage?.songsPage?.items - ?.map(Innertube.SongItem::asMediaItem) - ?.onEach(Database::insert) - ?.mapIndexed { index, mediaItem -> - SongPlaylistMap( - songId = mediaItem.mediaId, - playlistId = playlistId, - position = index - ) - }?.let(Database::insertSongPlaylistMaps) - } + playlistPage?.songsPage?.items + ?.map(Innertube.SongItem::asMediaItem) + ?.onEach(Database::insert) + ?.mapIndexed { index, mediaItem -> + SongPlaylistMap( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = index + ) + }?.let(Database::insertSongPlaylistMaps) } } - ) - } + } + ) val headerContent: @Composable () -> Unit = { - if (playlistPage == null) { - HeaderPlaceholder( - modifier = Modifier - .shimmer() - ) - } else { - Header(title = playlistPage?.title ?: "Неизвестный") { - SecondaryTextButton( - text = "В очередь", - enabled = playlistPage?.songsPage?.items?.isNotEmpty() == true, - onClick = { - playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems -> + if (playlistPage == null) HeaderPlaceholder(modifier = Modifier.shimmer()) + else Header(title = playlistPage?.title ?: stringResource(R.string.unknown)) { + SecondaryTextButton( + text = stringResource(R.string.enqueue), + enabled = playlistPage?.songsPage?.items?.isNotEmpty() == true, + onClick = { + playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem) + ?.let { mediaItems -> binder?.player?.enqueue(mediaItems) } - } - ) + } + ) - Spacer( - modifier = Modifier - .weight(1f) - ) + Spacer(modifier = Modifier.weight(1f)) - HeaderIconButton( - icon = R.drawable.add, - color = colorPalette.text, - onClick = { isImportingPlaylist = true } - ) + playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem) + ?.let { PlaylistDownloadIcon(songs = it.toImmutableList()) } - HeaderIconButton( - icon = R.drawable.share_social, - color = colorPalette.text, - onClick = { - (playlistPage?.url ?: "https://music.youtube.com/playlist?list=${browseId.removePrefix("VL")}").let { url -> + HeaderIconButton( + icon = R.drawable.add, + color = colorPalette.text, + onClick = { isImportingPlaylist = true } + ) + + HeaderIconButton( + icon = R.drawable.share_social, + color = colorPalette.text, + onClick = { + ( + playlistPage?.url + ?: "https://music.youtube.com/playlist?list=${ + browseId.removePrefix( + "VL" + ) + }" + ).let { url -> val sendIntent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" @@ -158,22 +159,28 @@ fun PlaylistSongList( context.startActivity(Intent.createChooser(sendIntent, null)) } - } - ) - } + } + ) } } - val thumbnailContent = adaptiveThumbnailContent(playlistPage == null, playlistPage?.thumbnail?.url) + val thumbnailContent = adaptiveThumbnailContent( + isLoading = playlistPage == null, + url = playlistPage?.thumbnail?.url + ) val lazyListState = rememberLazyListState() - LayoutWithAdaptiveThumbnail(thumbnailContent = thumbnailContent) { + LayoutWithAdaptiveThumbnail( + thumbnailContent = thumbnailContent, + modifier = modifier + ) { Box { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), modifier = Modifier .background(colorPalette.background0) .fillMaxSize() @@ -185,43 +192,39 @@ fun PlaylistSongList( Column(horizontalAlignment = Alignment.CenterHorizontally) { headerContent() if (!isLandscape) thumbnailContent() + PlaylistInfo(playlist = playlistPage) } } itemsIndexed(items = playlistPage?.songsPage?.items ?: emptyList()) { index, song -> SongItem( song = song, - thumbnailSizePx = songThumbnailSizePx, - thumbnailSizeDp = songThumbnailSizeDp, + thumbnailSize = Dimensions.thumbnails.song, modifier = Modifier .combinedClickable( onLongClick = { menuState.display { NonQueuedMediaItemMenu( onDismiss = menuState::hide, - mediaItem = song.asMediaItem, + mediaItem = song.asMediaItem ) } }, onClick = { - playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem)?.let { mediaItems -> - binder?.stopRadio() - binder?.player?.forcePlayAtIndex(mediaItems, index) - } + playlistPage?.songsPage?.items?.map(Innertube.SongItem::asMediaItem) + ?.let { mediaItems -> + binder?.stopRadio() + binder?.player?.forcePlayAtIndex(mediaItems, index) + } } ) ) } - if (playlistPage == null) { - item(key = "loading") { - ShimmerHost( - modifier = Modifier - .fillParentMaxSize() - ) { - repeat(4) { - SongItemPlaceholder(thumbnailSizeDp = songThumbnailSizeDp) - } + if (playlistPage == null) item(key = "loading") { + ShimmerHost(modifier = Modifier.fillParentMaxSize()) { + repeat(4) { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/search/LocalSongSearch.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/search/LocalSongSearch.kt index 3e9fe42..14daec1 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/search/LocalSongSearch.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/search/LocalSongSearch.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.search -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box @@ -19,6 +18,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign @@ -27,6 +27,7 @@ import it.hamy.innertube.models.NavigationEndpoint import it.hamy.muza.Database import it.hamy.muza.LocalPlayerAwareWindowInsets import it.hamy.muza.LocalPlayerServiceBinder +import it.hamy.muza.R import it.hamy.muza.models.Song import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop @@ -36,19 +37,18 @@ import it.hamy.muza.ui.components.themed.SecondaryTextButton import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.styling.Dimensions import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.align import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.forcePlay import it.hamy.muza.utils.medium -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) @Composable fun LocalSongSearch( textFieldValue: TextFieldValue, onTextFieldValueChanged: (TextFieldValue) -> Unit, - decorationBox: @Composable (@Composable () -> Unit) -> Unit + decorationBox: @Composable (@Composable () -> Unit) -> Unit, + modifier: Modifier = Modifier ) { val (colorPalette, typography) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current @@ -57,23 +57,18 @@ fun LocalSongSearch( var items by persistList("search/local/songs") LaunchedEffect(textFieldValue.text) { - if (textFieldValue.text.length > 1) { + if (textFieldValue.text.length > 1) Database.search("%${textFieldValue.text}%").collect { items = it } - } } - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - val lazyListState = rememberLazyListState() - Box { + Box(modifier = modifier) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .fillMaxSize() + modifier = Modifier.fillMaxSize() ) { item( key = "header", @@ -93,24 +88,19 @@ fun LocalSongSearch( ) }, actionsContent = { - if (textFieldValue.text.isNotEmpty()) { - SecondaryTextButton( - text = "Очистить", - onClick = { onTextFieldValueChanged(TextFieldValue()) } - ) - } + if (textFieldValue.text.isNotEmpty()) SecondaryTextButton( + text = stringResource(R.string.clear), + onClick = { onTextFieldValueChanged(TextFieldValue()) } + ) } ) } items( items = items, - key = Song::id, + key = Song::id ) { song -> SongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, modifier = Modifier .combinedClickable( onLongClick = { @@ -130,7 +120,9 @@ fun LocalSongSearch( ) } ) - .animateItemPlacement() + .animateItemPlacement(), + song = song, + thumbnailSize = Dimensions.thumbnails.song ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/search/OnlineSearch.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/search/OnlineSearch.kt index c26f477..4b14089 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/search/OnlineSearch.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/search/OnlineSearch.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.search -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource @@ -35,8 +34,8 @@ import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.TextFieldValue @@ -52,6 +51,7 @@ import it.hamy.muza.Database import it.hamy.muza.LocalPlayerAwareWindowInsets import it.hamy.muza.R import it.hamy.muza.models.SearchQuery +import it.hamy.muza.preferences.DataPreferences import it.hamy.muza.query import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.components.themed.Header @@ -60,43 +60,38 @@ import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.utils.align import it.hamy.muza.utils.center import it.hamy.muza.utils.medium -import it.hamy.muza.utils.pauseSearchHistoryKey -import it.hamy.muza.utils.preferences import it.hamy.muza.utils.secondary import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged -@ExperimentalAnimationApi @Composable fun OnlineSearch( textFieldValue: TextFieldValue, onTextFieldValueChanged: (TextFieldValue) -> Unit, onSearch: (String) -> Unit, onViewPlaylist: (String) -> Unit, - decorationBox: @Composable (@Composable () -> Unit) -> Unit + decorationBox: @Composable (@Composable () -> Unit) -> Unit, + modifier: Modifier = Modifier ) { - val context = LocalContext.current - val (colorPalette, typography) = LocalAppearance.current var history by persistList("search/online/history") LaunchedEffect(textFieldValue.text) { - if (!context.preferences.getBoolean(pauseSearchHistoryKey, false)) { - Database.queries("%${textFieldValue.text}%") - .distinctUntilChanged { old, new -> old.size == new.size } - .collect { history = it } - } + if (!DataPreferences.pauseSearchHistory) Database.queries("%${textFieldValue.text}%") + .distinctUntilChanged { old, new -> old.size == new.size } + .collect { history = it } } var suggestionsResult by persist?>?>("search/online/suggestionsResult") LaunchedEffect(textFieldValue.text) { - if (textFieldValue.text.isNotEmpty()) { - delay(200) - suggestionsResult = - Innertube.searchSuggestions(SearchSuggestionsBody(input = textFieldValue.text)) - } + if (textFieldValue.text.isEmpty()) return@LaunchedEffect + + delay(200) + suggestionsResult = Innertube.searchSuggestions( + body = SearchSuggestionsBody(input = textFieldValue.text) + ) } val playlistId = remember(textFieldValue.text) { @@ -115,19 +110,15 @@ fun OnlineSearch( val closeIconPainter = painterResource(R.drawable.close) val arrowForwardIconPainter = painterResource(R.drawable.arrow_forward) - val focusRequester = remember { - FocusRequester() - } - + val focusRequester = remember { FocusRequester() } val lazyListState = rememberLazyListState() - Box { + Box(modifier = modifier) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = Modifier - .fillMaxSize() + modifier = Modifier.fillMaxSize() ) { item( key = "header", @@ -144,15 +135,13 @@ fun OnlineSearch( keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), keyboardActions = KeyboardActions( onSearch = { - if (textFieldValue.text.isNotEmpty()) { + if (textFieldValue.text.isNotEmpty()) onSearch(textFieldValue.text) - } } ), cursorBrush = SolidColor(colorPalette.text), decorationBox = decorationBox, - modifier = Modifier - .focusRequester(focusRequester) + modifier = Modifier.focusRequester(focusRequester) ) }, actionsContent = { @@ -160,22 +149,18 @@ fun OnlineSearch( val isAlbum = playlistId.startsWith("OLAK5uy_") SecondaryTextButton( - text = "Посмотреть ${if (isAlbum) "альбом" else "плейлист"}", + text = if (isAlbum) stringResource(R.string.view_album) + else stringResource(R.string.view_playlist), onClick = { onViewPlaylist(textFieldValue.text) } ) } - Spacer( - modifier = Modifier - .weight(1f) - ) + Spacer(modifier = Modifier.weight(1f)) - if (textFieldValue.text.isNotEmpty()) { - SecondaryTextButton( - text = "Очистить", - onClick = { onTextFieldValueChanged(TextFieldValue()) } - ) - } + if (textFieldValue.text.isNotEmpty()) SecondaryTextButton( + text = stringResource(R.string.clear), + onClick = { onTextFieldValueChanged(TextFieldValue()) } + ) } ) } @@ -299,15 +284,11 @@ fun OnlineSearch( } } ?: suggestionsResult?.exceptionOrNull()?.let { item { - Box( - modifier = Modifier - .fillMaxSize() - ) { + Box(modifier = Modifier.fillMaxSize()) { BasicText( - text = "An error has occurred.", + text = stringResource(R.string.error_message), style = typography.s.secondary.center, - modifier = Modifier - .align(Alignment.Center) + modifier = Modifier.align(Alignment.Center) ) } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/search/SearchScreen.kt index cb0f86b..337b9e8 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/search/SearchScreen.kt @@ -1,31 +1,31 @@ package it.hamy.muza.ui.screens.search import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import it.hamy.compose.persist.PersistMapCleanup import it.hamy.compose.routing.RouteHandler import it.hamy.muza.R import it.hamy.muza.ui.components.themed.Scaffold -import it.hamy.muza.ui.screens.globalRoutes +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.utils.secondary -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable fun SearchScreen( initialTextInput: String, @@ -34,9 +34,7 @@ fun SearchScreen( ) { val saveableStateHolder = rememberSaveableStateHolder() - val (tabIndex, onTabChanged) = rememberSaveable { - mutableStateOf(0) - } + val (tabIndex, onTabChanged) = rememberSaveable { mutableIntStateOf(0) } val (textFieldValue, onTextFieldValueChanged) = rememberSaveable( initialTextInput, @@ -50,23 +48,22 @@ fun SearchScreen( ) } - PersistMapCleanup(tagPrefix = "search/") + PersistMapCleanup(prefix = "search/") RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + GlobalRoutes() - host { + NavHost { val decorationBox: @Composable (@Composable () -> Unit) -> Unit = { innerTextField -> Box { AnimatedVisibility( visible = textFieldValue.text.isEmpty(), enter = fadeIn(tween(300)), exit = fadeOut(tween(300)), - modifier = Modifier - .align(Alignment.CenterEnd) + modifier = Modifier.align(Alignment.CenterEnd) ) { BasicText( - text = "Найти...", + text = stringResource(R.string.search_placeholder), maxLines = 1, style = LocalAppearance.current.typography.xxl.secondary ) @@ -81,9 +78,9 @@ fun SearchScreen( onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = onTabChanged, - tabColumnContent = { Item -> - Item(0, "Онлайн", R.drawable.globe) - Item(1, "Библиотека", R.drawable.library) + tabColumnContent = { item -> + item(0, stringResource(R.string.online), R.drawable.globe) + item(1, stringResource(R.string.library), R.drawable.library) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(currentTabIndex) { diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/searchresult/ItemsPage.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/searchresult/ItemsPage.kt index 896416c..2b01672 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/searchresult/ItemsPage.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/searchresult/ItemsPage.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.searchresult -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues @@ -15,16 +14,19 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.BasicText import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import it.hamy.compose.persist.persist import it.hamy.innertube.Innertube import it.hamy.innertube.utils.plus import it.hamy.muza.LocalPlayerAwareWindowInsets +import it.hamy.muza.R import it.hamy.muza.ui.components.ShimmerHost import it.hamy.muza.ui.components.themed.FloatingActionsContainerWithScrollToTop import it.hamy.muza.ui.styling.LocalAppearance @@ -33,61 +35,83 @@ import it.hamy.muza.utils.secondary import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -@ExperimentalAnimationApi @Composable inline fun ItemsPage( tag: String, - crossinline headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, + crossinline header: @Composable (textButton: (@Composable () -> Unit)?) -> Unit, crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, noinline itemPlaceholderContent: @Composable () -> Unit, modifier: Modifier = Modifier, initialPlaceholderCount: Int = 8, continuationPlaceholderCount: Int = 3, - emptyItemsText: String = "Ничего не нашлось", - noinline itemsPageProvider: (suspend (String?) -> Result?>?)? = null, + emptyItemsText: String = stringResource(R.string.no_items_found), + noinline provider: (suspend (String?) -> Result?>?)? = null +) = ItemsPage( + tag = tag, + header = { before, _ -> header(before) }, + itemContent = itemContent, + itemPlaceholderContent = itemPlaceholderContent, + modifier = modifier, + initialPlaceholderCount = initialPlaceholderCount, + continuationPlaceholderCount = continuationPlaceholderCount, + emptyItemsText = emptyItemsText, + provider = provider +) + +@Composable +inline fun ItemsPage( + tag: String, + crossinline header: @Composable ( + beforeContent: (@Composable () -> Unit)?, + afterContent: (@Composable () -> Unit)? + ) -> Unit, + crossinline itemContent: @Composable LazyItemScope.(T) -> Unit, + noinline itemPlaceholderContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + initialPlaceholderCount: Int = 8, + continuationPlaceholderCount: Int = 3, + emptyItemsText: String = stringResource(R.string.no_items_found), + noinline provider: (suspend (String?) -> Result?>?)? = null ) { val (_, typography) = LocalAppearance.current - - val updatedItemsPageProvider by rememberUpdatedState(itemsPageProvider) - + val updatedProvider by rememberUpdatedState(provider) val lazyListState = rememberLazyListState() - var itemsPage by persist?>(tag) - LaunchedEffect(lazyListState, updatedItemsPageProvider) { - val currentItemsPageProvider = updatedItemsPageProvider ?: return@LaunchedEffect - - snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } } - .collect { shouldLoadMore -> - if (!shouldLoadMore) return@collect - - withContext(Dispatchers.IO) { - currentItemsPageProvider(itemsPage?.continuation) - }?.onSuccess { - if (it == null) { - if (itemsPage == null) { - itemsPage = Innertube.ItemsPage(null, null) - } - } else { - itemsPage += it - } - } - } + val shouldLoad by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + } } - Box { + LaunchedEffect(shouldLoad, updatedProvider) { + if (!shouldLoad) return@LaunchedEffect + val provideItems = updatedProvider ?: return@LaunchedEffect + + withContext(Dispatchers.IO) { + provideItems(itemsPage?.continuation) + }?.onSuccess { + if (it == null) { + if (itemsPage == null) itemsPage = Innertube.ItemsPage(null, null) + } else itemsPage += it + }?.onFailure { + itemsPage = itemsPage?.copy(continuation = null) + }?.exceptionOrNull()?.printStackTrace() + } + + Box(modifier = modifier) { LazyColumn( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues(), - modifier = modifier - .fillMaxSize() + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues(), + modifier = Modifier.fillMaxSize() ) { item( key = "header", - contentType = "header", + contentType = "header" ) { - headerContent(null) + header(null, null) } items( @@ -96,30 +120,24 @@ inline fun ItemsPage( itemContent = itemContent ) - if (itemsPage != null && itemsPage?.items.isNullOrEmpty()) { - item(key = "empty") { - BasicText( - text = emptyItemsText, - style = typography.xs.secondary.center, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 32.dp) - .fillMaxWidth() - ) - } + if (itemsPage != null && itemsPage?.items.isNullOrEmpty()) item(key = "empty") { + BasicText( + text = emptyItemsText, + style = typography.xs.secondary.center, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 32.dp) + .fillMaxWidth() + ) } - if (!(itemsPage != null && itemsPage?.continuation == null)) { - item(key = "loading") { - val isFirstLoad = itemsPage?.items.isNullOrEmpty() - ShimmerHost( - modifier = Modifier - .run { - if (isFirstLoad) fillParentMaxSize() else this - } - ) { - repeat(if (isFirstLoad) initialPlaceholderCount else continuationPlaceholderCount) { - itemPlaceholderContent() - } + if (!(itemsPage != null && itemsPage?.continuation == null)) item(key = "loading") { + val isFirstLoad = itemsPage?.items.isNullOrEmpty() + + ShimmerHost( + modifier = if (isFirstLoad) Modifier.fillParentMaxSize() else Modifier + ) { + repeat(if (isFirstLoad) initialPlaceholderCount else continuationPlaceholderCount) { + itemPlaceholderContent() } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/searchresult/SearchResultScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/searchresult/SearchResultScreen.kt index 7a14e87..cd18a3a 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/searchresult/SearchResultScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/searchresult/SearchResultScreen.kt @@ -1,6 +1,5 @@ package it.hamy.muza.ui.screens.searchresult -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -9,18 +8,19 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import it.hamy.compose.persist.LocalPersistMap import it.hamy.compose.persist.PersistMapCleanup -import it.hamy.compose.persist.persistMap +import it.hamy.compose.routing.RouteHandler import it.hamy.innertube.Innertube import it.hamy.innertube.models.bodies.ContinuationBody import it.hamy.innertube.models.bodies.SearchBody import it.hamy.innertube.requests.searchPage import it.hamy.innertube.utils.from -import it.hamy.compose.routing.RouteHandler import it.hamy.muza.LocalPlayerServiceBinder import it.hamy.muza.R +import it.hamy.muza.preferences.UIStatePreferences import it.hamy.muza.ui.components.LocalMenuState import it.hamy.muza.ui.components.themed.Header import it.hamy.muza.ui.components.themed.NonQueuedMediaItemMenu @@ -35,285 +35,240 @@ import it.hamy.muza.ui.items.SongItem import it.hamy.muza.ui.items.SongItemPlaceholder import it.hamy.muza.ui.items.VideoItem import it.hamy.muza.ui.items.VideoItemPlaceholder +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.screens.albumRoute import it.hamy.muza.ui.screens.artistRoute -import it.hamy.muza.ui.screens.globalRoutes import it.hamy.muza.ui.screens.playlistRoute import it.hamy.muza.ui.styling.Dimensions -import it.hamy.muza.ui.styling.px import it.hamy.muza.utils.asMediaItem import it.hamy.muza.utils.forcePlay -import it.hamy.muza.utils.rememberPreference -import it.hamy.muza.utils.searchResultScreenTabIndexKey -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@OptIn(ExperimentalFoundationApi::class) +@Route @Composable fun SearchResultScreen(query: String, onSearchAgain: () -> Unit) { - val context = LocalContext.current - val saveableStateHolder = rememberSaveableStateHolder() - val (tabIndex, onTabIndexChanges) = rememberPreference(searchResultScreenTabIndexKey, 0) + val persistMap = LocalPersistMap.current + val binder = LocalPlayerServiceBinder.current + val menuState = LocalMenuState.current - PersistMapCleanup(tagPrefix = "searchResults/$query/") + val saveableStateHolder = rememberSaveableStateHolder() + + PersistMapCleanup(prefix = "searchResults/$query/") RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + GlobalRoutes() - host { + NavHost { val headerContent: @Composable (textButton: (@Composable () -> Unit)?) -> Unit = { Header( title = query, - modifier = Modifier - .pointerInput(Unit) { - detectTapGestures { - context.persistMap?.keys?.removeAll { - it.startsWith("searchResults/$query/") - } - onSearchAgain() - } + modifier = Modifier.pointerInput(Unit) { + detectTapGestures { + persistMap?.clean("searchResults/$query/") + onSearchAgain() } + } ) } - val emptyItemsText = "Ничего не нашлось" - Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, - tabIndex = tabIndex, - onTabChanged = onTabIndexChanges, - tabColumnContent = { Item -> - Item(0, "Песни", R.drawable.musical_notes) - Item(1, "Альбомы", R.drawable.disc) - Item(2, "Исполнители", R.drawable.person) - Item(3, "Видео", R.drawable.film) - Item(4, "Плейлисты", R.drawable.playlist) - Item(5, "Рекомендации", R.drawable.playlist) + tabIndex = UIStatePreferences.searchResultScreenTabIndex, + onTabChanged = { UIStatePreferences.searchResultScreenTabIndex = it }, + tabColumnContent = { item -> + item(0, stringResource(R.string.songs), R.drawable.musical_notes) + item(1, stringResource(R.string.albums), R.drawable.disc) + item(2, stringResource(R.string.artists), R.drawable.person) + item(3, stringResource(R.string.videos), R.drawable.film) + item(4, stringResource(R.string.playlists), R.drawable.playlist) } ) { tabIndex -> saveableStateHolder.SaveableStateProvider(tabIndex) { when (tabIndex) { - 0 -> { - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - val thumbnailSizeDp = Dimensions.thumbnails.song - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "searchResults/$query/songs", - itemsPageProvider = { continuation -> - if (continuation == null) { - Innertube.searchPage( - body = SearchBody(query = query, params = Innertube.SearchFilter.Song.value), - fromMusicShelfRendererContent = Innertube.SongItem.Companion::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.SongItem.Companion::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { song -> - SongItem( - song = song, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - onDismiss = menuState::hide, - mediaItem = song.asMediaItem, - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlay(song.asMediaItem) - binder?.setupRadio(song.info?.endpoint) - } - ) - ) - }, - itemPlaceholderContent = { - SongItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 1 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "searchResults/$query/albums", - itemsPageProvider = { continuation -> - if (continuation == null) { - Innertube.searchPage( - body = SearchBody(query = query, params = Innertube.SearchFilter.Album.value), - fromMusicShelfRendererContent = Innertube.AlbumItem::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.AlbumItem::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { album -> - AlbumItem( - album = album, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { albumRoute(album.key) }) - ) - - }, - itemPlaceholderContent = { - AlbumItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 2 -> { - val thumbnailSizeDp = 64.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "searchResults/$query/artists", - itemsPageProvider = { continuation -> - if (continuation == null) { - Innertube.searchPage( - body = SearchBody(query = query, params = Innertube.SearchFilter.Artist.value), - fromMusicShelfRendererContent = Innertube.ArtistItem::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.ArtistItem::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { artist -> - ArtistItem( - artist = artist, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { artistRoute(artist.key) }) - ) - }, - itemPlaceholderContent = { - ArtistItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) - } - ) - } - - 3 -> { - val binder = LocalPlayerServiceBinder.current - val menuState = LocalMenuState.current - val thumbnailHeightDp = 72.dp - val thumbnailWidthDp = 128.dp - - ItemsPage( - tag = "searchResults/$query/videos", - itemsPageProvider = { continuation -> - if (continuation == null) { - Innertube.searchPage( - body = SearchBody(query = query, params = Innertube.SearchFilter.Video.value), - fromMusicShelfRendererContent = Innertube.VideoItem::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.VideoItem::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { video -> - VideoItem( - video = video, - thumbnailWidthDp = thumbnailWidthDp, - thumbnailHeightDp = thumbnailHeightDp, - modifier = Modifier - .combinedClickable( - onLongClick = { - menuState.display { - NonQueuedMediaItemMenu( - mediaItem = video.asMediaItem, - onDismiss = menuState::hide - ) - } - }, - onClick = { - binder?.stopRadio() - binder?.player?.forcePlay(video.asMediaItem) - binder?.setupRadio(video.info?.endpoint) - } - ) - ) - }, - itemPlaceholderContent = { - VideoItemPlaceholder( - thumbnailHeightDp = thumbnailHeightDp, - thumbnailWidthDp = thumbnailWidthDp - ) - } - ) - } - - 4, 5 -> { - val thumbnailSizeDp = 108.dp - val thumbnailSizePx = thumbnailSizeDp.px - - ItemsPage( - tag = "searchResults/$query/${if (tabIndex == 4) "playlists" else "featured"}", - itemsPageProvider = { continuation -> - if (continuation == null) { - val filter = if (tabIndex == 4) { - Innertube.SearchFilter.CommunityPlaylist - } else { - Innertube.SearchFilter.FeaturedPlaylist + 0 -> ItemsPage( + tag = "searchResults/$query/songs", + provider = { continuation -> + if (continuation == null) Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Song.value + ), + fromMusicShelfRendererContent = Innertube.SongItem.Companion::from + ) else Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.SongItem.Companion::from + ) + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { song -> + SongItem( + song = song, + thumbnailSize = Dimensions.thumbnails.song, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + onDismiss = menuState::hide, + mediaItem = song.asMediaItem + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(song.asMediaItem) + binder?.setupRadio(song.info?.endpoint) } - - Innertube.searchPage( - body = SearchBody(query = query, params = filter.value), - fromMusicShelfRendererContent = Innertube.PlaylistItem::from - ) - } else { - Innertube.searchPage( - body = ContinuationBody(continuation = continuation), - fromMusicShelfRendererContent = Innertube.PlaylistItem::from - ) - } - }, - emptyItemsText = emptyItemsText, - headerContent = headerContent, - itemContent = { playlist -> - PlaylistItem( - playlist = playlist, - thumbnailSizePx = thumbnailSizePx, - thumbnailSizeDp = thumbnailSizeDp, - modifier = Modifier - .clickable(onClick = { playlistRoute(playlist.key) }) ) - }, - itemPlaceholderContent = { - PlaylistItemPlaceholder(thumbnailSizeDp = thumbnailSizeDp) + ) + }, + itemPlaceholderContent = { + SongItemPlaceholder(thumbnailSize = Dimensions.thumbnails.song) + } + ) + + 1 -> ItemsPage( + tag = "searchResults/$query/albums", + provider = { continuation -> + if (continuation == null) { + Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Album.value + ), + fromMusicShelfRendererContent = Innertube.AlbumItem::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.AlbumItem::from + ) } - ) - } + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { album -> + AlbumItem( + album = album, + thumbnailSize = Dimensions.thumbnails.album, + modifier = Modifier.clickable(onClick = { albumRoute(album.key) }) + ) + }, + itemPlaceholderContent = { + AlbumItemPlaceholder(thumbnailSize = Dimensions.thumbnails.album) + } + ) + + 2 -> ItemsPage( + tag = "searchResults/$query/artists", + provider = { continuation -> + if (continuation == null) { + Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Artist.value + ), + fromMusicShelfRendererContent = Innertube.ArtistItem::from + ) + } else { + Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.ArtistItem::from + ) + } + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { artist -> + ArtistItem( + artist = artist, + thumbnailSize = 64.dp, + modifier = Modifier + .clickable(onClick = { artistRoute(artist.key) }) + ) + }, + itemPlaceholderContent = { + ArtistItemPlaceholder(thumbnailSize = 64.dp) + } + ) + + 3 -> ItemsPage( + tag = "searchResults/$query/videos", + provider = { continuation -> + if (continuation == null) Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.Video.value + ), + fromMusicShelfRendererContent = Innertube.VideoItem::from + ) else Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.VideoItem::from + ) + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { video -> + VideoItem( + video = video, + thumbnailWidth = 128.dp, + thumbnailHeight = 72.dp, + modifier = Modifier.combinedClickable( + onLongClick = { + menuState.display { + NonQueuedMediaItemMenu( + mediaItem = video.asMediaItem, + onDismiss = menuState::hide + ) + } + }, + onClick = { + binder?.stopRadio() + binder?.player?.forcePlay(video.asMediaItem) + binder?.setupRadio(video.info?.endpoint) + } + ) + ) + }, + itemPlaceholderContent = { + VideoItemPlaceholder( + thumbnailWidth = 128.dp, + thumbnailHeight = 72.dp + ) + } + ) + + 4 -> ItemsPage( + tag = "searchResults/$query/playlists", + provider = { continuation -> + if (continuation == null) Innertube.searchPage( + body = SearchBody( + query = query, + params = Innertube.SearchFilter.CommunityPlaylist.value + ), + fromMusicShelfRendererContent = Innertube.PlaylistItem::from + ) else Innertube.searchPage( + body = ContinuationBody(continuation = continuation), + fromMusicShelfRendererContent = Innertube.PlaylistItem::from + ) + }, + emptyItemsText = stringResource(R.string.no_search_results), + header = headerContent, + itemContent = { playlist -> + PlaylistItem( + playlist = playlist, + thumbnailSize = Dimensions.thumbnails.playlist, + modifier = Modifier.clickable(onClick = { + playlistRoute(playlist.key) + }) + ) + }, + itemPlaceholderContent = { + PlaylistItemPlaceholder(thumbnailSize = Dimensions.thumbnails.playlist) + } + ) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/About.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/About.kt index 077f674..53921da 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/About.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/About.kt @@ -1,77 +1,158 @@ package it.hamy.muza.ui.screens.settings -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import it.hamy.github.GitHub +import it.hamy.github.models.Release +import it.hamy.github.requests.releases import it.hamy.muza.BuildConfig -import it.hamy.muza.LocalPlayerAwareWindowInsets -import it.hamy.muza.ui.components.themed.Header +import it.hamy.muza.R +import it.hamy.muza.ui.components.themed.CircularProgressIndicator +import it.hamy.muza.ui.components.themed.DefaultDialog +import it.hamy.muza.ui.components.themed.SecondaryTextButton +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.utils.secondary +import it.hamy.muza.utils.bold +import it.hamy.muza.utils.center +import it.hamy.muza.utils.semiBold +import it.hamy.muza.utils.version +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext -@ExperimentalAnimationApi +private val VERSION_NAME = BuildConfig.VERSION_NAME.substringBeforeLast("-") +private const val REPO_OWNER = "hammsterr" +private const val REPO_NAME = "muza" + +@Route @Composable -fun About() { - val (colorPalette, typography) = LocalAppearance.current +fun About() = SettingsCategoryScreen( + title = stringResource(R.string.about), + description = stringResource( + R.string.format_version_credits, + VERSION_NAME + ) +) { + val (_, typography) = LocalAppearance.current + val uriHandler = LocalUriHandler.current - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) - ) { - Header(title = "Информация") { - BasicText( - text = "v${BuildConfig.VERSION_NAME} by Hamy", - style = typography.s.secondary - ) - } - - SettingsEntryGroupText(title = "СОЦИАЛЬНОЕ") - + SettingsGroup(title = stringResource(R.string.social)) { SettingsEntry( - title = "GitHub", - text = "Посмотреть исходный код", + title = stringResource(R.string.github), + text = stringResource(R.string.view_source), onClick = { - uriHandler.openUri("https://github.com/hammsterr/muza") - } - ) - - SettingsEntry( - title = "Новости", - text = "Следите за новостями в группе ВКонтакте", - onClick = { - uriHandler.openUri("https://vk.com/hamyack") - } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "ДИАГНОСТИКА") - - SettingsEntry( - title = "Тех. поддержка", - text = "Сообщайте об ошибках или пожеланиях", - onClick = { - uriHandler.openUri("https://hamyack.pages.dev") + uriHandler.openUri("https://github.com/$REPO_OWNER/$REPO_NAME") } ) } + + SettingsGroup(title = stringResource(R.string.contact)) { + SettingsEntry( + title = stringResource(R.string.report_bug), + text = stringResource(R.string.report_bug_description), + onClick = { + uriHandler.openUri( + "https://github.com/$REPO_OWNER/$REPO_NAME/issues/new?assignees=&labels=bug&template=bug_report.yaml" + ) + } + ) + + SettingsEntry( + title = stringResource(R.string.request_feature), + text = stringResource(R.string.request_feature_description), + onClick = { + uriHandler.openUri( + @Suppress("MaximumLineLength") + "https://github.com/hammsterr/muza/issues/" + ) + } + ) + } + + var newVersionDialogOpened by rememberSaveable { mutableStateOf(false) } + + SettingsGroup(title = stringResource(R.string.version)) { + SettingsEntry( + title = stringResource(R.string.check_new_version), + text = stringResource(R.string.current_version, VERSION_NAME), + onClick = { newVersionDialogOpened = true } + ) + } + + if (newVersionDialogOpened) DefaultDialog(onDismiss = { newVersionDialogOpened = false }) { + var newVersion: Result? by remember { mutableStateOf(null) } + + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + newVersion = GitHub.releases( + owner = REPO_OWNER, + repo = REPO_NAME + )?.mapCatching { releases -> + val currentVersion = VERSION_NAME.version + + releases + .sortedByDescending { it.publishedAt } + .firstOrNull { release -> + !release.draft && + !release.preRelease && + release.tag.removePrefix("v").version > currentVersion && + release.assets.any { + it.contentType == "application/vnd.android.package-archive" && + it.state == Release.Asset.State.Uploaded + } + } + }?.onFailure(Throwable::printStackTrace) + } + } + + newVersion?.getOrNull()?.let { + BasicText( + text = stringResource(R.string.new_version_available), + style = typography.xs.semiBold.center + ) + + Spacer(modifier = Modifier.height(12.dp)) + + BasicText( + text = it.name ?: it.tag, + style = typography.m.bold.center + ) + + Spacer(modifier = Modifier.height(16.dp)) + + SecondaryTextButton( + text = stringResource(R.string.rustore), + onClick = { uriHandler.openUri("https://apps.rustore.ru/app/it.hamy.muza") } + ) + Spacer(modifier = Modifier.height(16.dp)) + + SecondaryTextButton( + text = stringResource(R.string.github), + onClick = { it.frontendUrl.toString() } + ) + } ?: newVersion?.exceptionOrNull()?.let { + BasicText( + text = stringResource(R.string.error_github), + style = typography.xs.semiBold.center, + modifier = Modifier.padding(all = 24.dp) + ) + } ?: if (newVersion?.isSuccess == true) BasicText( + text = stringResource(R.string.up_to_date), + style = typography.xs.semiBold.center + ) else CircularProgressIndicator(modifier = Modifier.align(Alignment.CenterHorizontally)) + } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/AppearanceSettings.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/AppearanceSettings.kt index 8095698..afc8c0f 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/AppearanceSettings.kt @@ -1,147 +1,191 @@ package it.hamy.muza.ui.screens.settings -import android.app.AlertDialog -import android.content.ComponentName -import android.content.pm.PackageManager -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.core.content.ContextCompat -import it.hamy.muza.LocalPlayerAwareWindowInsets +import it.hamy.muza.R import it.hamy.muza.enums.ColorPaletteMode import it.hamy.muza.enums.ColorPaletteName import it.hamy.muza.enums.ThumbnailRoundness -import it.hamy.muza.ui.components.themed.Header +import it.hamy.muza.preferences.AppearancePreferences +import it.hamy.muza.preferences.PlayerPreferences +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.utils.applyFontPaddingKey -import it.hamy.muza.utils.colorPaletteModeKey -import it.hamy.muza.utils.colorPaletteNameKey import it.hamy.muza.utils.isAtLeastAndroid13 -import it.hamy.muza.utils.isShowingThumbnailInLockscreenKey -import it.hamy.muza.utils.rememberPreference -import it.hamy.muza.utils.thumbnailRoundnessKey -import it.hamy.muza.utils.useSystemFontKey -import it.hamy.muza.* - -@ExperimentalAnimationApi +@Route @Composable -fun AppearanceSettings() { +fun AppearanceSettings() = with(AppearancePreferences) { val (colorPalette) = LocalAppearance.current - var colorPaletteName by rememberPreference(colorPaletteNameKey, ColorPaletteName.Dynamic) - var colorPaletteMode by rememberPreference(colorPaletteModeKey, ColorPaletteMode.System) - var thumbnailRoundness by rememberPreference( - thumbnailRoundnessKey, - ThumbnailRoundness.Слабое - ) - var useSystemFont by rememberPreference(useSystemFontKey, false) - var applyFontPadding by rememberPreference(applyFontPaddingKey, false) - var isShowingThumbnailInLockscreen by rememberPreference( - isShowingThumbnailInLockscreenKey, - false - - ) - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() + SettingsCategoryScreen(title = stringResource(R.string.appearance)) { + SettingsGroup(title = stringResource(R.string.colors)) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.theme), + selectedValue = colorPaletteName, + onValueSelected = { colorPaletteName = it }, + valueText = { it.nameLocalized } ) - ) { - Header(title = "Внешний Вид") - SettingsEntryGroupText(title = "ЦВЕТА") + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.theme_mode), + selectedValue = colorPaletteMode, + isEnabled = colorPaletteName != ColorPaletteName.PureBlack && + colorPaletteName != ColorPaletteName.AMOLED, + onValueSelected = { colorPaletteMode = it }, + valueText = { it.nameLocalized } + ) + } + SettingsGroup(title = stringResource(R.string.shapes)) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.thumbnail_roundness), + selectedValue = thumbnailRoundness, + onValueSelected = { thumbnailRoundness = it }, + trailingContent = { + Spacer( + modifier = Modifier + .border( + width = 1.dp, + color = colorPalette.accent, + shape = thumbnailRoundness.shape + ) + .background( + color = colorPalette.background1, + shape = thumbnailRoundness.shape + ) + .size(36.dp) + ) + }, + valueText = { it.nameLocalized } + ) + } + SettingsGroup(title = stringResource(R.string.text)) { + SwitchSettingsEntry( + title = stringResource(R.string.use_system_font), + text = stringResource(R.string.use_system_font_description), + isChecked = useSystemFont, + onCheckedChange = { useSystemFont = it } + ) - EnumValueSelectorSettingsEntry( - title = "Цвет темы", - selectedValue = colorPaletteName, - onValueSelected = { colorPaletteName = it } - ) - - EnumValueSelectorSettingsEntry( - title = "Ночная тема", - selectedValue = colorPaletteMode, - isEnabled = colorPaletteName != ColorPaletteName.PureBlack, - onValueSelected = { colorPaletteMode = it } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "ФОРМЫ") - - EnumValueSelectorSettingsEntry( - title = "Скругление", - selectedValue = thumbnailRoundness, - onValueSelected = { thumbnailRoundness = it }, - trailingContent = { - Spacer( - modifier = Modifier - .border( - width = 1.dp, - color = colorPalette.accent, - shape = thumbnailRoundness.shape() - ) - .background( - color = colorPalette.background1, - shape = thumbnailRoundness.shape() - ) - .size(36.dp) - ) - } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "ТЕКСТ") - - SwitchSettingEntry( - title = "Системный шрифт", - text = "Использовать системный шрифт", - isChecked = useSystemFont, - onCheckedChange = { useSystemFont = it } - ) - - SwitchSettingEntry( - title = "Заполнение шрифта", - text = "Увеличить пробелы текста", - isChecked = applyFontPadding, - onCheckedChange = { applyFontPadding = it } - ) - - if (!isAtLeastAndroid13) { - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "ЭКРАН БЛОКИРОВКИ") - - SwitchSettingEntry( - title = "Показывать обложку", - text = "Использовать обложку в качестве обоев экрана блокировки", + SwitchSettingsEntry( + title = stringResource(R.string.apply_font_padding), + text = stringResource(R.string.apply_font_padding_description), + isChecked = applyFontPadding, + onCheckedChange = { applyFontPadding = it } + ) + } + if (!isAtLeastAndroid13) SettingsGroup(title = stringResource(R.string.lockscreen)) { + SwitchSettingsEntry( + title = stringResource(R.string.show_song_cover), + text = stringResource(R.string.show_song_cover_description), isChecked = isShowingThumbnailInLockscreen, onCheckedChange = { isShowingThumbnailInLockscreen = it } ) } + SettingsGroup(title = stringResource(R.string.player)) { + SwitchSettingsEntry( + title = stringResource(R.string.previous_button_while_collapsed), + text = stringResource(R.string.previous_button_while_collapsed_description), + isChecked = PlayerPreferences.isShowingPrevButtonCollapsed, + onCheckedChange = { PlayerPreferences.isShowingPrevButtonCollapsed = it } + ) + + SwitchSettingsEntry( + title = stringResource(R.string.swipe_horizontally_to_close), + text = stringResource(R.string.swipe_horizontally_to_close_description), + isChecked = PlayerPreferences.horizontalSwipeToClose, + onCheckedChange = { PlayerPreferences.horizontalSwipeToClose = it } + ) + + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.player_layout), + selectedValue = PlayerPreferences.playerLayout, + onValueSelected = { PlayerPreferences.playerLayout = it }, + valueText = { it.displayName() } + ) + + AnimatedVisibility( + visible = PlayerPreferences.playerLayout == PlayerPreferences.PlayerLayout.New, + label = "" + ) { + SwitchSettingsEntry( + title = stringResource(R.string.show_like_button), + text = stringResource(R.string.show_like_button_description), + isChecked = PlayerPreferences.showLike, + onCheckedChange = { PlayerPreferences.showLike = it } + ) + } + + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.seek_bar_style), + selectedValue = PlayerPreferences.seekBarStyle, + onValueSelected = { PlayerPreferences.seekBarStyle = it }, + valueText = { it.displayName() } + ) + + AnimatedVisibility( + visible = PlayerPreferences.seekBarStyle == PlayerPreferences.SeekBarStyle.Wavy, + label = "" + ) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.seek_bar_quality), + selectedValue = PlayerPreferences.wavySeekBarQuality, + onValueSelected = { PlayerPreferences.wavySeekBarQuality = it }, + valueText = { it.displayName() } + ) + } + + SwitchSettingsEntry( + title = stringResource(R.string.swipe_to_remove_item), + text = stringResource(R.string.swipe_to_remove_item_description), + isChecked = PlayerPreferences.horizontalSwipeToRemoveItem, + onCheckedChange = { PlayerPreferences.horizontalSwipeToRemoveItem = it } + ) + } + SettingsGroup(title = stringResource(R.string.songs)) { + SwitchSettingsEntry( + title = stringResource(R.string.swipe_to_hide_song), + text = stringResource(R.string.swipe_to_hide_song_description), + isChecked = swipeToHideSong, + onCheckedChange = { swipeToHideSong = it } + ) + } } } +val ColorPaletteName.nameLocalized + @Composable get() = stringResource( + when (this) { + ColorPaletteName.Default -> R.string.theme_name_default + ColorPaletteName.Dynamic -> R.string.theme_name_dynamic + ColorPaletteName.PureBlack -> R.string.theme_name_pureblack + ColorPaletteName.AMOLED -> R.string.theme_name_amoled + } + ) +val ColorPaletteMode.nameLocalized + @Composable get() = stringResource( + when (this) { + ColorPaletteMode.Light -> R.string.theme_mode_light + ColorPaletteMode.Dark -> R.string.theme_mode_dark + ColorPaletteMode.System -> R.string.theme_mode_system + } + ) + +val ThumbnailRoundness.nameLocalized + @Composable get() = stringResource( + when (this) { + ThumbnailRoundness.None -> R.string.thumbnail_roundness_none + ThumbnailRoundness.Light -> R.string.thumbnail_roundness_light + ThumbnailRoundness.Medium -> R.string.thumbnail_roundness_medium + ThumbnailRoundness.Heavy -> R.string.thumbnail_roundness_heavy + ThumbnailRoundness.Heavier -> R.string.thumbnail_roundness_heavier + ThumbnailRoundness.Heaviest -> R.string.thumbnail_roundness_heaviest + } + ) diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/CacheSettings.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/CacheSettings.kt index 4b950e8..8c78273 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/CacheSettings.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/CacheSettings.kt @@ -1,119 +1,71 @@ package it.hamy.muza.ui.screens.settings import android.text.format.Formatter -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.annotation.OptIn import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.media3.common.util.UnstableApi import coil.Coil import coil.annotation.ExperimentalCoilApi -import it.hamy.muza.LocalPlayerAwareWindowInsets import it.hamy.muza.LocalPlayerServiceBinder -import it.hamy.muza.enums.CoilDiskCacheMaxSize -import it.hamy.muza.enums.ExoPlayerDiskCacheMaxSize -import it.hamy.muza.ui.components.themed.Header -import it.hamy.muza.ui.styling.LocalAppearance -import it.hamy.muza.utils.coilDiskCacheMaxSizeKey -import it.hamy.muza.utils.exoPlayerDiskCacheMaxSizeKey -import it.hamy.muza.utils.rememberPreference +import it.hamy.muza.R +import it.hamy.muza.enums.ExoPlayerDiskCacheSize +import it.hamy.muza.preferences.DataPreferences +import it.hamy.muza.ui.screens.Route -@OptIn(ExperimentalCoilApi::class) -@ExperimentalAnimationApi +@kotlin.OptIn(ExperimentalCoilApi::class) +@OptIn(UnstableApi::class) +@Route @Composable -fun CacheSettings() { +fun CacheSettings() = with(DataPreferences) { val context = LocalContext.current - val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current - var coilDiskCacheMaxSize by rememberPreference( - coilDiskCacheMaxSizeKey, - CoilDiskCacheMaxSize.`128MB` - ) - var exoPlayerDiskCacheMaxSize by rememberPreference( - exoPlayerDiskCacheMaxSizeKey, - ExoPlayerDiskCacheMaxSize.`2GB` - ) - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) - ) { - Header(title = "Кэш") - - SettingsDescription(text = "Когда в кэше заканчивается свободное место, очищаются ресурсы, которые давно не используются.") + SettingsCategoryScreen(title = stringResource(R.string.cache)) { + SettingsDescription(text = stringResource(R.string.cache_description)) Coil.imageLoader(context).diskCache?.let { diskCache -> - val diskCacheSize = remember(diskCache) { - diskCache.size + val diskCacheSize = remember(diskCache) { diskCache.size } + + SettingsGroup( + title = stringResource(R.string.image_cache), + description = stringResource( + R.string.format_cache_space_used, + Formatter.formatShortFileSize(context, diskCacheSize), + diskCacheSize * 100 / coilDiskCacheMaxSize.bytes.coerceAtLeast(1) + ) + ) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.max_size), + selectedValue = coilDiskCacheMaxSize, + onValueSelected = { coilDiskCacheMaxSize = it } + ) } - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "КЭШ КАРТИНОК") - - SettingsDescription( - text = "${ - Formatter.formatShortFileSize( - context, - diskCacheSize - ) - } использовано (${diskCacheSize * 100 / coilDiskCacheMaxSize.bytes.coerceAtLeast(1)}%)" - ) - - EnumValueSelectorSettingsEntry( - title = "Максимальный размер", - selectedValue = coilDiskCacheMaxSize, - onValueSelected = { coilDiskCacheMaxSize = it } - ) } - binder?.cache?.let { cache -> - val diskCacheSize by remember { - derivedStateOf { - cache.cacheSpace - } - } + val diskCacheSize by remember { derivedStateOf { cache.cacheSpace } } - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "КЭШ ПЕСЕН") - - SettingsDescription( - text = buildString { + SettingsGroup( + title = stringResource(R.string.song_cache), + description = buildString { append(Formatter.formatShortFileSize(context, diskCacheSize)) - append(" использовано") + append(" ${stringResource(R.string.used_word)}") when (val size = exoPlayerDiskCacheMaxSize) { - ExoPlayerDiskCacheMaxSize.Unlimited -> {} + ExoPlayerDiskCacheSize.Unlimited -> {} else -> append(" (${diskCacheSize * 100 / size.bytes}%)") } } - ) - - EnumValueSelectorSettingsEntry( - title = "Максимальный размер", - selectedValue = exoPlayerDiskCacheMaxSize, - onValueSelected = { exoPlayerDiskCacheMaxSize = it } - ) + ) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.max_size), + selectedValue = exoPlayerDiskCacheMaxSize, + onValueSelected = { exoPlayerDiskCacheMaxSize = it } + ) + } } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/DatabaseSettings.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/DatabaseSettings.kt index 9f09014..5ca1c3f 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/DatabaseSettings.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/DatabaseSettings.kt @@ -1,157 +1,176 @@ package it.hamy.muza.ui.screens.settings -import android.annotation.SuppressLint import android.content.ActivityNotFoundException import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.animation.AnimatedVisibility import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import it.hamy.muza.Database -import it.hamy.muza.LocalPlayerAwareWindowInsets +import it.hamy.muza.R import it.hamy.muza.internal import it.hamy.muza.path +import it.hamy.muza.preferences.DataPreferences import it.hamy.muza.query import it.hamy.muza.service.PlayerService -import it.hamy.muza.ui.components.themed.Header -import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.transaction +import it.hamy.muza.ui.screens.Route import it.hamy.muza.utils.intent import it.hamy.muza.utils.toast +import kotlinx.coroutines.flow.distinctUntilChanged import java.io.FileInputStream import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date +import java.util.Locale import kotlin.system.exitProcess -import kotlinx.coroutines.flow.distinctUntilChanged -@ExperimentalAnimationApi +@Route @Composable -fun DatabaseSettings() { +fun DatabaseSettings() = with(DataPreferences) { val context = LocalContext.current - val (colorPalette) = LocalAppearance.current - val eventsCount by remember { - Database.eventsCount().distinctUntilChanged() - }.collectAsState(initial = 0) + val eventsCount by remember { Database.eventsCount().distinctUntilChanged() } + .collectAsState(initial = 0) - val backupLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/vnd.sqlite3")) { uri -> - if (uri == null) return@rememberLauncherForActivityResult + val blacklistLength by remember { Database.blacklistLength().distinctUntilChanged() } + .collectAsState(initial = 0) - query { - Database.checkpoint() + val backupLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument(mimeType = "application/vnd.sqlite3") + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult - context.applicationContext.contentResolver.openOutputStream(uri) - ?.use { outputStream -> - FileInputStream(Database.internal.path).use { inputStream -> - inputStream.copyTo(outputStream) - } - } + query { + Database.checkpoint() + + context.applicationContext.contentResolver.openOutputStream(uri)?.use { output -> + FileInputStream(Database.internal.path).use { input -> input.copyTo(output) } } } + } - val restoreLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult + val restoreLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + if (uri == null) return@rememberLauncherForActivityResult - query { - Database.checkpoint() - Database.internal.close() + query { + Database.checkpoint() + Database.internal.close() - context.applicationContext.contentResolver.openInputStream(uri) - ?.use { inputStream -> - FileOutputStream(Database.internal.path).use { outputStream -> - inputStream.copyTo(outputStream) - } + context.applicationContext.contentResolver.openInputStream(uri) + ?.use { inputStream -> + FileOutputStream(Database.internal.path).use { outputStream -> + inputStream.copyTo(outputStream) } + } - context.stopService(context.intent()) - exitProcess(0) - } + context.stopService(context.intent()) + exitProcess(0) } + } - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() + SettingsCategoryScreen(title = stringResource(R.string.database)) { + SettingsGroup(title = stringResource(R.string.cleanup)) { + SwitchSettingsEntry( + title = stringResource(R.string.pause_playback_history), + text = stringResource(R.string.pause_playback_history_description), + isChecked = pauseHistory, + onCheckedChange = { pauseHistory = !pauseHistory } ) - ) { - Header(title = "Данные") - SettingsEntryGroupText(title = "ОЧИСТИТЬ") - - SettingsEntry( - title = "Очистить воспроизведения", - text = if (eventsCount > 0) { - "Удалить $eventsCount событий воспроизведения" - } else { - "Воспроизведения были удалены!" - }, - isEnabled = eventsCount > 0, - onClick = { query(Database::clearEvents) } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "БЭКАП") - - SettingsDescription(text = "Личные настройки (ночная тема и т.д.) и кэш исключаются") - - SettingsEntry( - title = "Бэкап", - text = "Экспорт данных в локальное хранилище", - onClick = { - @SuppressLint("SimpleDateFormat") - val dateFormat = SimpleDateFormat("yyyyMMddHHmmss") - - try { - backupLauncher.launch("muza_${dateFormat.format(Date())}.db") - } catch (e: ActivityNotFoundException) { - context.toast("не найдено приложения для создания документов") - } + AnimatedVisibility(visible = pauseHistory) { + SettingsDescription( + text = stringResource(R.string.pause_playback_history_warning), + important = true + ) } - ) - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "ВОССТАНОВЛЕНИЕ") - - ImportantSettingsDescription(text = "Существующие настройки будут перезаписаны.\n${context.applicationInfo.nonLocalizedLabel} Будет перезапушена.") - - SettingsEntry( - title = "Восстановить", - text = "импорт данных из локального хранилища", - onClick = { - try { - restoreLauncher.launch( - arrayOf( - "application/vnd.sqlite3", - "application/x-sqlite3", - "application/octet-stream" - ) + AnimatedVisibility(visible = !(pauseHistory && eventsCount == 0)) { + SettingsEntry( + title = stringResource(R.string.reset_quick_picks), + text = if (eventsCount > 0) pluralStringResource( + R.plurals.format_reset_quick_picks_amount, + eventsCount, + eventsCount ) - } catch (e: ActivityNotFoundException) { - context.toast("не найдено приложения для открытия документов") - } + else stringResource(R.string.quick_picks_empty), + onClick = { query(Database::clearEvents) }, + isEnabled = eventsCount > 0 + ) } - ) + + SwitchSettingsEntry( + title = stringResource(R.string.pause_playback_time), + text = stringResource( + R.string.format_pause_playback_time_description, + topListLength + ), + isChecked = pausePlaytime, + onCheckedChange = { pausePlaytime = !pausePlaytime } + ) + + SettingsEntry( + title = stringResource(R.string.reset_blacklist), + text = if (blacklistLength > 0) pluralStringResource( + R.plurals.format_reset_blacklist_description, + blacklistLength, + blacklistLength + ) else stringResource(R.string.blacklist_empty), + isEnabled = blacklistLength > 0, + onClick = { + transaction { + Database.resetBlacklist() + } + } + ) + } + SettingsGroup( + title = stringResource(R.string.backup), + description = stringResource(R.string.backup_description) + ) { + SettingsEntry( + title = stringResource(R.string.backup), + text = stringResource(R.string.backup_action_description), + onClick = { + val dateFormat = SimpleDateFormat("yyyyMMddHHmmss", Locale.getDefault()) + + try { + backupLauncher.launch("vimusic_${dateFormat.format(Date())}.db") + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_file_chooser_installed)) + } + } + ) + } + SettingsGroup( + title = stringResource(R.string.restore), + description = stringResource(R.string.restore_warning), + important = true + ) { + SettingsEntry( + title = stringResource(R.string.restore), + text = stringResource(R.string.restore_description), + onClick = { + try { + restoreLauncher.launch( + arrayOf( + "application/vnd.sqlite3", + "application/x-sqlite3", + "application/octet-stream" + ) + ) + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_file_chooser_installed)) + } + } + ) + } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/OtherSettings.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/OtherSettings.kt index 9cc9fd6..fbddcef 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/OtherSettings.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/OtherSettings.kt @@ -6,59 +6,74 @@ import android.content.ComponentName import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.os.Handler +import android.os.Looper import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.SnapshotMutationPolicy import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import it.hamy.muza.Database -import it.hamy.muza.LocalPlayerAwareWindowInsets +import it.hamy.muza.DatabaseInitializer +import it.hamy.muza.LocalPlayerServiceBinder +import it.hamy.muza.R +import it.hamy.muza.preferences.AppearancePreferences import it.hamy.muza.preferences.DataPreferences +import it.hamy.muza.preferences.PlayerPreferences +import it.hamy.muza.preferences.isProxyEnabledKey +import it.hamy.muza.preferences.proxyHostNameKey +import it.hamy.muza.preferences.proxyModeKey +import it.hamy.muza.preferences.proxyPortKey +import it.hamy.muza.preferences.rememberPreference import it.hamy.muza.query import it.hamy.muza.service.PlayerMediaBrowserService -import it.hamy.muza.ui.components.themed.Header -import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.ui.components.themed.SecondaryTextButton +import it.hamy.muza.ui.components.themed.SliderDialog +import it.hamy.muza.ui.screens.Route +import it.hamy.muza.utils.findActivity import it.hamy.muza.utils.isAtLeastAndroid12 import it.hamy.muza.utils.isAtLeastAndroid6 import it.hamy.muza.utils.isIgnoringBatteryOptimizations -import it.hamy.muza.utils.isInvincibilityEnabledKey -import it.hamy.muza.utils.isProxyEnabledKey -import it.hamy.muza.utils.pauseSearchHistoryKey -import it.hamy.muza.utils.proxyHostNameKey -import it.hamy.muza.utils.proxyModeKey -import it.hamy.muza.utils.proxyPortKey -import it.hamy.muza.utils.rememberPreference +import it.hamy.muza.utils.smoothScrollToBottom import it.hamy.muza.utils.toast +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.distinctUntilChanged -import okhttp3.internal.toImmutableList +import kotlinx.coroutines.launch import java.net.Proxy - - +import kotlin.math.roundToInt +import kotlin.system.exitProcess @SuppressLint("BatteryLife") -@ExperimentalAnimationApi +@Route @Composable fun OtherSettings() { val context = LocalContext.current - val (colorPalette) = LocalAppearance.current + val binder = LocalPlayerServiceBinder.current + val uriHandler = LocalUriHandler.current + + val scrollState = rememberScrollState() + val coroutineScope = rememberCoroutineScope() var isAndroidAutoEnabled by remember { val component = ComponentName(context, PlayerMediaBrowserService::class.java) @@ -80,159 +95,259 @@ fun OtherSettings() { ) } - var isInvincibilityEnabled by rememberPreference(isInvincibilityEnabledKey, false) - - var isProxyEnabled by rememberPreference(isProxyEnabledKey, false) - - var proxyHost by rememberPreference(proxyHostNameKey, defaultValue = "") - - var proxyPort by rememberPreference(proxyPortKey, defaultValue = 1080) - - var proxyMode by rememberPreference(proxyModeKey, defaultValue = Proxy.Type.HTTP) - var isIgnoringBatteryOptimizations by remember { mutableStateOf(context.isIgnoringBatteryOptimizations) } - val activityResultLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { - isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations - } - - var pauseSearchHistory by rememberPreference(pauseSearchHistoryKey, false) + val activityResultLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult(), + onResult = { isIgnoringBatteryOptimizations = context.isIgnoringBatteryOptimizations } + ) val queriesCount by remember { Database.queriesCount().distinctUntilChanged() }.collectAsState(initial = 0) - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() - ) + var proxyMode by rememberPreference(proxyModeKey, defaultValue = Proxy.Type.HTTP) + + var isProxyEnabled by rememberPreference(isProxyEnabledKey, false) + + var proxyHost by rememberPreference(proxyHostNameKey, defaultValue = "") + + var proxyPort by rememberPreference(proxyPortKey, defaultValue = 3128) + + SettingsCategoryScreen( + title = stringResource(R.string.other), + scrollState = scrollState ) { - Header(title = "Другое") + SettingsGroup(title = stringResource(R.string.android_auto)) { + SwitchSettingsEntry( + title = stringResource(R.string.android_auto), + text = stringResource(R.string.android_auto_description), + isChecked = isAndroidAutoEnabled, + onCheckedChange = { isAndroidAutoEnabled = it } + ) - SettingsEntryGroupText(title = "ОБЗОР") + AnimatedVisibility(visible = isAndroidAutoEnabled) { + SettingsDescription(text = stringResource(R.string.android_auto_warning)) + } + } + SettingsGroup(title = stringResource(R.string.search_history)) { + SwitchSettingsEntry( + title = stringResource(R.string.pause_search_history), + text = stringResource(R.string.pause_search_history_description), + isChecked = DataPreferences.pauseSearchHistory, + onCheckedChange = { DataPreferences.pauseSearchHistory = it } + ) - ValueSelectorSettingsEntry( - title = "Режим отображения", - selectedValue = DataPreferences.quickPicksSource, - values = enumValues().toList().toImmutableList(), - onValueSelected = { DataPreferences.quickPicksSource = it } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "АНДРОИД АВТО") - - SettingsDescription(text = "Включите опцию \"неизвестные источники\" в настройках разработчика в Андроид Авто.") - - SwitchSettingEntry( - title = "Android Auto", - text = "Включить поддержку Андроид Авто", - isChecked = isAndroidAutoEnabled, - onCheckedChange = { isAndroidAutoEnabled = it } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "ИСТОРИЯ ПОИСКА") - - SwitchSettingEntry( - title = "Приостановить историю", - text = "Не сохранять историю поиска", - isChecked = pauseSearchHistory, - onCheckedChange = { pauseSearchHistory = it } - ) - - SettingsEntry( - title = "Очистить историю поиска", - text = if (queriesCount > 0) { - "Удалить $queriesCount поисковых запросов" - } else { - "История чиста" - }, - isEnabled = queriesCount > 0, - onClick = { query(Database::clearQueries) } - ) - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "ОПТИМИЗАЦИЯ БАТАРЕИ") - - ImportantSettingsDescription(text = "Если включена экономия батареи, воспроизведение может внезапно остановиться!") - - if (isAtLeastAndroid12) { - SettingsDescription(text = "Android 12+: Обязательно отключите экономию батареи, прежде чем включать опцию \"Invincible service\"!") + AnimatedVisibility(visible = !(DataPreferences.pauseSearchHistory && queriesCount == 0)) { + SettingsEntry( + title = stringResource(R.string.clear_search_history), + text = if (queriesCount > 0) stringResource( + R.string.format_clear_search_history_amount, + queriesCount + ) + else stringResource(R.string.empty_history), + onClick = { query(Database::clearQueries) }, + isEnabled = queriesCount > 0 + ) + } + } + SettingsGroup(title = stringResource(R.string.built_in_playlists)) { + IntSettingsEntry( + title = stringResource(R.string.top_list_length), + text = stringResource(R.string.top_list_length_description), + currentValue = DataPreferences.topListLength, + setValue = { DataPreferences.topListLength = it }, + defaultValue = 10, + range = 1..500 + ) + } + SettingsGroup(title = stringResource(R.string.quick_picks)) { + EnumValueSelectorSettingsEntry( + title = stringResource(R.string.quick_picks_source), + selectedValue = DataPreferences.quickPicksSource, + onValueSelected = { DataPreferences.quickPicksSource = it }, + valueText = { it.displayName() } + ) + } + SettingsGroup(title = stringResource(R.string.dynamic_thumbnails)) { + var selectingThumbnailSize by remember { mutableStateOf(false) } + SettingsEntry( + title = stringResource(R.string.max_dynamic_thumbnail_size), + text = stringResource(R.string.max_dynamic_thumbnail_size_description), + onClick = { + selectingThumbnailSize = true + } + ) + if (selectingThumbnailSize) SliderDialog( + onDismiss = { selectingThumbnailSize = false }, + title = stringResource(R.string.max_dynamic_thumbnail_size), + provideState = { + remember(AppearancePreferences.maxThumbnailSize) { + mutableFloatStateOf(AppearancePreferences.maxThumbnailSize.toFloat()) + } + }, + onSlideCompleted = { AppearancePreferences.maxThumbnailSize = it.roundToInt() }, + min = 16f, + max = 2160f, + toDisplay = { + stringResource( + R.string.format_px, + it.roundToInt() + ) + } + ) } - SettingsEntry( - title = "Игнор. экономии батареи ", - isEnabled = !isIgnoringBatteryOptimizations, - text = if (isIgnoringBatteryOptimizations) { - "Уже игнорируется" - } else { - "Отключить остановку приложения в фоне" - }, - onClick = { - if (!isAtLeastAndroid6) return@SettingsEntry - - try { - activityResultLauncher.launch( - Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { - data = Uri.parse("package:${context.packageName}") - } - ) - } catch (e: ActivityNotFoundException) { - try { - activityResultLauncher.launch( - Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS) - ) - } catch (e: ActivityNotFoundException) { - context.toast("не найдено настроек батареи! Добавьте приложение в белый список вручную") - } - } - } - ) - - SwitchSettingEntry( - title = "Invincible service", - text = "Обход экономии батареи", - isChecked = isInvincibilityEnabled, - onCheckedChange = { isInvincibilityEnabled = it } - ) + SettingsGroup(title = stringResource(R.string.proxy)) { - SettingsEntryGroupText(title = "ПРОКСИ") - - SwitchSettingEntry( - title = "Прокси", - text = "Включить прокси", - isChecked = isProxyEnabled, - onCheckedChange = { isProxyEnabled = it } - ) + SwitchSettingsEntry( + title = stringResource(R.string.proxy), + text = stringResource(R.string.proxy_desc), + isChecked = isProxyEnabled, + onCheckedChange = { isProxyEnabled = it } + ) AnimatedVisibility(visible = isProxyEnabled) { Column { - EnumValueSelectorSettingsEntry(title = "Прокси", + EnumValueSelectorSettingsEntry(title = "Proxy", selectedValue = proxyMode, onValueSelected = {proxyMode = it}) TextDialogSettingEntry( title = "Хост", text = "Введите хост", currentText = proxyHost, onTextSave = { proxyHost = it }) + + TextDialogSettingEntry( title = "Порт", text = "Введите порт", currentText = proxyPort.toString(), onTextSave = { proxyPort = it.toIntOrNull() ?: 1080 }) + } } } + + SettingsGroup(title = stringResource(R.string.service_lifetime)) { + AnimatedVisibility(visible = !isIgnoringBatteryOptimizations) { + SettingsDescription( + text = stringResource(R.string.service_lifetime_warning), + important = true + ) + } + + if (isAtLeastAndroid12) SettingsDescription( + text = stringResource(R.string.service_lifetime_warning_android_12) + ) + + SettingsEntry( + title = stringResource(R.string.ignore_battery_optimizations), + text = if (isIgnoringBatteryOptimizations) stringResource(R.string.ignoring_battery_optimizations) + else stringResource(R.string.ignore_battery_optimizations_action), + onClick = { + if (!isAtLeastAndroid6) return@SettingsEntry + + try { + activityResultLauncher.launch( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:${context.packageName}") + } + ) + } catch (e: ActivityNotFoundException) { + try { + activityResultLauncher.launch(Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)) + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_battery_optimization_settings_found)) + } + } + }, + isEnabled = !isIgnoringBatteryOptimizations + ) + + AnimatedVisibility(!isAtLeastAndroid12 || isIgnoringBatteryOptimizations) { + SwitchSettingsEntry( + title = stringResource(R.string.invincible_service), + text = stringResource(R.string.invincible_service_description), + isChecked = PlayerPreferences.isInvincibilityEnabled, + onCheckedChange = { PlayerPreferences.isInvincibilityEnabled = it } + ) + } + + SettingsEntry( + title = stringResource(R.string.need_help), + text = stringResource(R.string.need_help_description), + onClick = { + uriHandler.openUri("https://dontkillmyapp.com/") + } + ) + + SettingsDescription(text = stringResource(R.string.service_lifetime_report_issue)) + } + + var showTroubleshoot by rememberSaveable { mutableStateOf(false) } + + AnimatedContent(showTroubleshoot, label = "") { show -> + if (show) SettingsGroup( + title = stringResource(R.string.troubleshooting), + description = stringResource(R.string.troubleshooting_warning), + important = true + ) { + val troubleshootScope = rememberCoroutineScope() + var reloading by rememberSaveable { mutableStateOf(false) } + + SecondaryTextButton( + text = stringResource(R.string.reload_app_internals), + onClick = { + if (!reloading) troubleshootScope.launch { + reloading = true + binder?.restartForegroundOrStop() + DatabaseInitializer.reload() + reloading = false + } + }, + enabled = !reloading, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp) + .padding(horizontal = 16.dp) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + SecondaryTextButton( + text = stringResource(R.string.kill_app), + onClick = { + binder?.stopRadio() + binder?.invincible = false + context.findActivity().finishAndRemoveTask() + binder?.restartForegroundOrStop() + troubleshootScope.launch { + delay(500L) + Handler(Looper.getMainLooper()).postAtFrontOfQueue { exitProcess(0) } + } + }, + enabled = !reloading, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp) + .padding(horizontal = 16.dp) + ) + } else SecondaryTextButton( + text = stringResource(R.string.show_troubleshoot_section), + onClick = { + coroutineScope.launch { + delay(500) + scrollState.smoothScrollToBottom() + } + showTroubleshoot = true + }, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, bottom = 16.dp) + .padding(horizontal = 16.dp) + ) + } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/PlayerSettings.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/PlayerSettings.kt index a20f00e..3a82e8d 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/PlayerSettings.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/PlayerSettings.kt @@ -5,124 +5,180 @@ import android.content.Intent import android.media.audiofx.AudioEffect import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.background +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext -import it.hamy.muza.LocalPlayerAwareWindowInsets +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi import it.hamy.muza.LocalPlayerServiceBinder -import it.hamy.muza.ui.components.themed.Header -import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.R +import it.hamy.muza.preferences.PlayerPreferences +import it.hamy.muza.ui.components.themed.SecondaryTextButton +import it.hamy.muza.ui.screens.Route import it.hamy.muza.utils.isAtLeastAndroid6 -import it.hamy.muza.utils.persistentQueueKey -import it.hamy.muza.utils.rememberPreference -import it.hamy.muza.utils.resumePlaybackWhenDeviceConnectedKey -import it.hamy.muza.utils.skipSilenceKey import it.hamy.muza.utils.toast -import it.hamy.muza.utils.volumeNormalizationKey -@ExperimentalAnimationApi +@OptIn(UnstableApi::class) +@Route @Composable -fun PlayerSettings() { +fun PlayerSettings() = with(PlayerPreferences) { val context = LocalContext.current - val (colorPalette) = LocalAppearance.current val binder = LocalPlayerServiceBinder.current - var persistentQueue by rememberPreference(persistentQueueKey, false) - var resumePlaybackWhenDeviceConnected by rememberPreference( - resumePlaybackWhenDeviceConnectedKey, - false - ) - var skipSilence by rememberPreference(skipSilenceKey, false) - var volumeNormalization by rememberPreference(volumeNormalizationKey, false) + val activityResultLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { } - val activityResultLauncher = - rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - - Column( - modifier = Modifier - .background(colorPalette.background0) - .fillMaxSize() - .verticalScroll(rememberScrollState()) - .padding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) - .asPaddingValues() + SettingsCategoryScreen(title = stringResource(R.string.player)) { + SettingsGroup(title = stringResource(R.string.player)) { + SwitchSettingsEntry( + title = stringResource(R.string.persistent_queue), + text = stringResource(R.string.persistent_queue_description), + isChecked = persistentQueue, + onCheckedChange = { persistentQueue = it } ) - ) { - Header(title = "Плеер и Аудио") - SettingsEntryGroupText(title = "Плеер") - - SwitchSettingEntry( - title = "Постоянная очередь", - text = "Сохранение и восстановление воспроизводимых песен", - isChecked = persistentQueue, - onCheckedChange = { - persistentQueue = it - } - ) - - if (isAtLeastAndroid6) { - SwitchSettingEntry( - title = "Возобновление музыки", - text = "При подключении bluetooth устройств", + if (isAtLeastAndroid6) SwitchSettingsEntry( + title = stringResource(R.string.resume_playback), + text = stringResource(R.string.resume_playback_description), isChecked = resumePlaybackWhenDeviceConnected, onCheckedChange = { resumePlaybackWhenDeviceConnected = it } ) + + SwitchSettingsEntry( + title = stringResource(R.string.stop_when_closed), + text = stringResource(R.string.stop_when_closed_description), + isChecked = stopWhenClosed, + onCheckedChange = { stopWhenClosed = it } + ) } - - SettingsGroupSpacer() - - SettingsEntryGroupText(title = "АУДИО") - - SwitchSettingEntry( - title = "Пропускать тишину", - text = "Пропускать тихие фрагменты песен", - isChecked = skipSilence, - onCheckedChange = { - skipSilence = it - } - ) - - SwitchSettingEntry( - title = "Нормализация звука", - text = "Фиксированный уровень громкости", - isChecked = volumeNormalization, - onCheckedChange = { - volumeNormalization = it - } - ) - - SettingsEntry( - title = "Эквалайзер", - text = "Открыть системный эквалайзер", - onClick = { - val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder?.player?.audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + SettingsGroup(title = stringResource(R.string.audio)) { + SwitchSettingsEntry( + title = stringResource(R.string.skip_silence), + text = stringResource(R.string.skip_silence_description), + isChecked = skipSilence, + onCheckedChange = { + skipSilence = it } + ) - try { - activityResultLauncher.launch(intent) - } catch (e: ActivityNotFoundException) { - context.toast("не найден эквалайзер") + AnimatedVisibility(visible = skipSilence) { + val initialValue by remember { derivedStateOf { minimumSilence.toFloat() / 1000L } } + var newValue by remember(initialValue) { mutableFloatStateOf(initialValue) } + var changed by rememberSaveable { mutableStateOf(false) } + + Column { + SliderSettingsEntry( + title = stringResource(R.string.minimum_silence_length), + text = stringResource(R.string.minimum_silence_length_description), + state = newValue, + onSlide = { newValue = it }, + onSlideCompleted = { + minimumSilence = newValue.toLong() * 1000L + changed = true + }, + toDisplay = { stringResource(R.string.format_ms, it.toLong()) }, + range = 1.00f..2000.000f + ) + + AnimatedVisibility(visible = changed) { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + SettingsDescription( + text = stringResource(R.string.minimum_silence_length_warning), + important = true, + modifier = Modifier.weight(2f) + ) + SecondaryTextButton( + text = stringResource(R.string.restart_service), + onClick = { + binder?.restartForegroundOrStop()?.let { changed = false } + }, + modifier = Modifier + .weight(1f) + .padding(end = 24.dp) + ) + } + } } } - ) + + SwitchSettingsEntry( + title = stringResource(R.string.loudness_normalization), + text = stringResource(R.string.loudness_normalization_description), + isChecked = volumeNormalization, + onCheckedChange = { volumeNormalization = it } + ) + + AnimatedVisibility(visible = volumeNormalization) { + var newValue by remember(volumeNormalizationBaseGain) { + mutableFloatStateOf(volumeNormalizationBaseGain) + } + + SliderSettingsEntry( + title = stringResource(R.string.loudness_base_gain), + text = stringResource(R.string.loudness_base_gain_description), + state = newValue, + onSlide = { newValue = it }, + onSlideCompleted = { volumeNormalizationBaseGain = newValue }, + toDisplay = { stringResource(R.string.format_db, "%.2f".format(it)) }, + range = -20.00f..20.00f + ) + } + + SwitchSettingsEntry( + title = stringResource(R.string.bass_boost), + text = stringResource(R.string.bass_boost_description), + isChecked = bassBoost, + onCheckedChange = { bassBoost = it } + ) + + AnimatedVisibility(visible = bassBoost) { + var newValue by remember(bassBoostLevel) { mutableFloatStateOf(bassBoostLevel.toFloat()) } + + SliderSettingsEntry( + title = stringResource(R.string.bass_boost_level), + text = stringResource(R.string.bass_boost_level_description), + state = newValue, + onSlide = { newValue = it }, + onSlideCompleted = { bassBoostLevel = newValue.toInt() }, + toDisplay = { (it * 1000f).toInt().toString() }, + range = 0f..1f + ) + } + + SettingsEntry( + title = stringResource(R.string.equalizer), + text = stringResource(R.string.equalizer_description), + onClick = { + val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, binder?.player?.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + + try { + activityResultLauncher.launch(intent) + } catch (e: ActivityNotFoundException) { + context.toast(context.getString(R.string.no_equalizer_installed)) + } + } + ) + } } } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/SettingsScreen.kt index af37200..e7019c3 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/SettingsScreen.kt @@ -1,18 +1,28 @@ +@file:Suppress("TooManyFunctions") + package it.hamy.muza.ui.screens.settings -import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable @@ -22,46 +32,55 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.text +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import it.hamy.compose.routing.RouteHandler +import it.hamy.muza.LocalPlayerAwareWindowInsets import it.hamy.muza.R +import it.hamy.muza.ui.components.themed.Header +import it.hamy.muza.ui.components.themed.NumberFieldDialog import it.hamy.muza.ui.components.themed.Scaffold +import it.hamy.muza.ui.components.themed.Slider import it.hamy.muza.ui.components.themed.Switch import it.hamy.muza.ui.components.themed.TextFieldDialog import it.hamy.muza.ui.components.themed.ValueSelectorDialog -import it.hamy.muza.ui.screens.globalRoutes +import it.hamy.muza.ui.screens.GlobalRoutes +import it.hamy.muza.ui.screens.Route import it.hamy.muza.ui.styling.LocalAppearance import it.hamy.muza.utils.color import it.hamy.muza.utils.secondary import it.hamy.muza.utils.semiBold import it.hamy.muza.utils.toast +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList -@ExperimentalFoundationApi -@ExperimentalAnimationApi +@Route @Composable fun SettingsScreen() { val saveableStateHolder = rememberSaveableStateHolder() - val (tabIndex, onTabChanged) = rememberSaveable { - mutableStateOf(0) - } + val (tabIndex, onTabChanged) = rememberSaveable { mutableIntStateOf(0) } RouteHandler(listenToGlobalEmitter = true) { - globalRoutes() + GlobalRoutes() - host { + NavHost { Scaffold( topIconButtonId = R.drawable.chevron_back, onTopIconButtonClick = pop, tabIndex = tabIndex, onTabChanged = onTabChanged, - tabColumnContent = { Item -> - Item(0, "Вид", R.drawable.color_palette) - Item(1, "Плеер", R.drawable.play) - Item(2, "Кэш", R.drawable.server) - Item(3, "Данные", R.drawable.server) - Item(4, "Другое", R.drawable.shapes) - Item(5, "Инфо", R.drawable.information) + tabColumnContent = { item -> + item(0, stringResource(R.string.appearance), R.drawable.color_palette) + item(1, stringResource(R.string.player), R.drawable.play) + item(2, stringResource(R.string.cache), R.drawable.server) + item(3, stringResource(R.string.database), R.drawable.server) + item(4, stringResource(R.string.sync), R.drawable.sync) + item(5, stringResource(R.string.other), R.drawable.shapes) + item(6, stringResource(R.string.about), R.drawable.information) } ) { currentTabIndex -> saveableStateHolder.SaveableStateProvider(currentTabIndex) { @@ -70,8 +89,9 @@ fun SettingsScreen() { 1 -> PlayerSettings() 2 -> CacheSettings() 3 -> DatabaseSettings() - 4 -> OtherSettings() - 5 -> About() + 4 -> SyncSettings() + 5 -> OtherSettings() + 6 -> About() } } } @@ -83,119 +103,150 @@ fun SettingsScreen() { inline fun > EnumValueSelectorSettingsEntry( title: String, selectedValue: T, - crossinline onValueSelected: (T) -> Unit, + noinline onValueSelected: (T) -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true, - crossinline valueText: (T) -> String = Enum::name, + noinline valueText: @Composable (T) -> String = { it.name }, noinline trailingContent: (@Composable () -> Unit)? = null -) { - ValueSelectorSettingsEntry( - title = title, - selectedValue = selectedValue, - values = enumValues().toList(), - onValueSelected = onValueSelected, - modifier = modifier, - isEnabled = isEnabled, - valueText = valueText, - trailingContent = trailingContent, - ) -} +) = ValueSelectorSettingsEntry( + title = title, + selectedValue = selectedValue, + values = enumValues().toList().toImmutableList(), + onValueSelected = onValueSelected, + modifier = modifier, + isEnabled = isEnabled, + valueText = valueText, + trailingContent = trailingContent +) @Composable -inline fun ValueSelectorSettingsEntry( +fun ValueSelectorSettingsEntry( title: String, selectedValue: T, - values: List, - crossinline onValueSelected: (T) -> Unit, + values: ImmutableList, + onValueSelected: (T) -> Unit, modifier: Modifier = Modifier, isEnabled: Boolean = true, - crossinline valueText: (T) -> String = { it.toString() }, - noinline trailingContent: (@Composable () -> Unit)? = null + usePadding: Boolean = true, + valueText: @Composable (T) -> String = { it.toString() }, + trailingContent: (@Composable () -> Unit)? = null ) { - var isShowingDialog by remember { - mutableStateOf(false) - } + var isShowingDialog by remember { mutableStateOf(false) } - if (isShowingDialog) { - ValueSelectorDialog( - onDismiss = { isShowingDialog = false }, - title = title, - selectedValue = selectedValue, - values = values, - onValueSelected = onValueSelected, - valueText = valueText - ) - } + if (isShowingDialog) ValueSelectorDialog( + onDismiss = { isShowingDialog = false }, + title = title, + selectedValue = selectedValue, + values = values, + onValueSelected = onValueSelected, + valueText = valueText + ) SettingsEntry( + modifier = modifier, title = title, text = valueText(selectedValue), - modifier = modifier, - isEnabled = isEnabled, onClick = { isShowingDialog = true }, - trailingContent = trailingContent + isEnabled = isEnabled, + trailingContent = trailingContent, + usePadding = usePadding ) } @Composable -fun SwitchSettingEntry( +fun SwitchSettingsEntry( title: String, - text: String, + text: String?, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, modifier: Modifier = Modifier, - isEnabled: Boolean = true + isEnabled: Boolean = true, + usePadding: Boolean = true +) = SettingsEntry( + modifier = modifier, + title = title, + text = text, + onClick = { onCheckedChange(!isChecked) }, + isEnabled = isEnabled, + usePadding = usePadding ) { + Switch(isChecked = isChecked) +} + +@Composable +fun SliderSettingsEntry( + title: String, + text: String, + state: Float, + range: ClosedFloatingPointRange, + modifier: Modifier = Modifier, + onSlide: (Float) -> Unit = { }, + onSlideCompleted: () -> Unit = { }, + toDisplay: @Composable (Float) -> String = { it.toString() }, + steps: Int = 0, + isEnabled: Boolean = true, + usePadding: Boolean = true +) = Column(modifier = modifier) { SettingsEntry( title = title, - text = text, + text = "$text (${toDisplay(state)})", + onClick = {}, isEnabled = isEnabled, - onClick = { onCheckedChange(!isChecked) }, - trailingContent = { Switch(isChecked = isChecked) }, - modifier = modifier + usePadding = usePadding + ) + + Slider( + state = state, + setState = onSlide, + onSlideCompleted = onSlideCompleted, + range = range, + steps = steps, + modifier = Modifier + .height(36.dp) + .alpha(if (isEnabled) 1f else 0.5f) + .let { if (usePadding) it.padding(start = 32.dp, end = 16.dp) else it } + .padding(vertical = 16.dp) + .fillMaxWidth() ) } @Composable -fun SettingsEntry( +inline fun IntSettingsEntry( title: String, text: String, + currentValue: Int, + crossinline setValue: (Int) -> Unit, + range: IntRange, modifier: Modifier = Modifier, - onClick: () -> Unit, + defaultValue: Int = 0, isEnabled: Boolean = true, - trailingContent: (@Composable () -> Unit)? = null + usePadding: Boolean = true ) { - val (colorPalette, typography) = LocalAppearance.current + var isShowingDialog by remember { mutableStateOf(false) } - Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = modifier - .clickable(enabled = isEnabled, onClick = onClick) - .alpha(if (isEnabled) 1f else 0.5f) - .padding(start = 16.dp) - .padding(all = 16.dp) - .fillMaxWidth() - ) { - Column( - modifier = Modifier - .weight(1f) - ) { - BasicText( - text = title, - style = typography.xs.semiBold.copy(color = colorPalette.text), - ) + if (isShowingDialog) NumberFieldDialog( + onDismiss = { isShowingDialog = false }, + onDone = { + setValue(it) + isShowingDialog = false + }, + initialValue = currentValue, + defaultValue = defaultValue, + convert = { it.toIntOrNull() }, + range = range + ) - BasicText( - text = text, - style = typography.xs.semiBold.copy(color = colorPalette.textSecondary), - ) - } - - trailingContent?.invoke() - } + SettingsEntry( + modifier = modifier, + title = title, + text = text, + onClick = { isShowingDialog = true }, + isEnabled = isEnabled, + usePadding = usePadding + ) } + @Composable fun TextDialogSettingEntry( title: String, @@ -214,7 +265,7 @@ fun TextDialogSettingEntry( onDone ={value-> onTextSave(value) context.toast("Сохранено!") - } , doneText = "Save", initialTextInput = currentText) + } , doneText = "Сохранить", initialTextInput = currentText) } SettingsEntry( title = title, @@ -228,31 +279,53 @@ fun TextDialogSettingEntry( @Composable -fun SettingsDescription( - text: String, +fun SettingsEntry( + title: String, + onClick: () -> Unit, modifier: Modifier = Modifier, + text: String? = null, + isEnabled: Boolean = true, + usePadding: Boolean = true, + trailingContent: @Composable (() -> Unit)? = null +) = Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clickable(enabled = isEnabled, onClick = onClick) + .alpha(if (isEnabled) 1f else 0.5f) + .let { if (usePadding) it.padding(start = 32.dp, end = 16.dp) else it } + .padding(vertical = 16.dp) + .fillMaxWidth() ) { - val (_, typography) = LocalAppearance.current + val (colorPalette, typography) = LocalAppearance.current - BasicText( - text = text, - style = typography.xxs.secondary, - modifier = modifier - .padding(start = 16.dp) - .padding(horizontal = 16.dp, vertical = 8.dp) - ) + Column(modifier = Modifier.weight(1f)) { + BasicText( + text = title, + style = typography.xs.semiBold.copy(color = colorPalette.text) + ) + + if (text != null) BasicText( + text = text, + style = typography.xs.semiBold.copy(color = colorPalette.textSecondary) + ) + } + + trailingContent?.invoke() } @Composable -fun ImportantSettingsDescription( +fun SettingsDescription( text: String, modifier: Modifier = Modifier, + important: Boolean = false ) { val (colorPalette, typography) = LocalAppearance.current BasicText( text = text, - style = typography.xxs.semiBold.color(colorPalette.red), + style = if (important) typography.xxs.semiBold.color(colorPalette.red) + else typography.xxs.secondary, modifier = modifier .padding(start = 16.dp) .padding(horizontal = 16.dp, vertical = 8.dp) @@ -262,7 +335,7 @@ fun ImportantSettingsDescription( @Composable fun SettingsEntryGroupText( title: String, - modifier: Modifier = Modifier, + modifier: Modifier = Modifier ) { val (colorPalette, typography) = LocalAppearance.current @@ -272,15 +345,66 @@ fun SettingsEntryGroupText( modifier = modifier .padding(start = 16.dp) .padding(horizontal = 16.dp) + .semantics { text = AnnotatedString(text = title) } ) } @Composable -fun SettingsGroupSpacer( +fun SettingsGroupSpacer(modifier: Modifier = Modifier) = Spacer(modifier = modifier.height(24.dp)) + +@Composable +fun SettingsCategoryScreen( + title: String, modifier: Modifier = Modifier, + description: String? = null, + scrollState: ScrollState? = rememberScrollState(), + content: @Composable ColumnScope.() -> Unit ) { - Spacer( + val (colorPalette, typography) = LocalAppearance.current + + Column( modifier = modifier - .height(24.dp) - ) + .background(colorPalette.background0) + .fillMaxSize() + .let { if (scrollState != null) it.verticalScroll(state = scrollState) else it } + .padding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .asPaddingValues() + ) + ) { + Header(title = title) { + description?.let { description -> + BasicText( + text = description, + style = typography.s.secondary + ) + SettingsGroupSpacer() + } + } + + content() + } +} + +@Composable +fun SettingsGroup( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + important: Boolean = false, + content: @Composable ColumnScope.() -> Unit +) = Column(modifier = modifier) { + SettingsEntryGroupText(title = title) + + description?.let { description -> + SettingsDescription( + text = description, + important = important + ) + } + + content() + + SettingsGroupSpacer() } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/SyncSettings.kt b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/SyncSettings.kt new file mode 100644 index 0000000..0745661 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/ui/screens/settings/SyncSettings.kt @@ -0,0 +1,225 @@ +package it.hamy.muza.ui.screens.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import io.ktor.http.Url +import it.hamy.compose.persist.persistList +import it.hamy.piped.Piped +import it.hamy.piped.models.Instance +import it.hamy.muza.Database +import it.hamy.muza.R +import it.hamy.muza.models.PipedSession +import it.hamy.muza.transaction +import it.hamy.muza.ui.components.themed.CircularProgressIndicator +import it.hamy.muza.ui.components.themed.DefaultDialog +import it.hamy.muza.ui.components.themed.DialogTextButton +import it.hamy.muza.ui.components.themed.IconButton +import it.hamy.muza.ui.components.themed.TextField +import it.hamy.muza.ui.screens.Route +import it.hamy.muza.ui.styling.LocalAppearance +import it.hamy.muza.utils.center +import it.hamy.muza.utils.semiBold +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.launch + +@Route +@Composable +fun SyncSettings() { + val coroutineScope = rememberCoroutineScope() + + val (colorPalette, typography) = LocalAppearance.current + val uriHandler = LocalUriHandler.current + + val pipedSessions by Database.pipedSessions().collectAsState(initial = listOf()) + + var linkingPiped by remember { mutableStateOf(false) } + if (linkingPiped) DefaultDialog( + onDismiss = { linkingPiped = false }, + horizontalAlignment = Alignment.Start + ) { + var isLoading by rememberSaveable { mutableStateOf(false) } + var hasError by rememberSaveable { mutableStateOf(false) } + + when { + hasError -> BasicText( + text = stringResource(R.string.error_piped_link), + style = typography.xs.semiBold.center, + modifier = Modifier.padding(all = 24.dp) + ) + + isLoading -> CircularProgressIndicator(modifier = Modifier.padding(all = 8.dp)) + + else -> Column(modifier = Modifier.fillMaxWidth()) { + var instances: List by persistList(tag = "settings/sync/piped/instances") + var loadingInstances by rememberSaveable { mutableStateOf(true) } + var selectedInstance: Int? by rememberSaveable { mutableStateOf(null) } + var username by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var canSelect by rememberSaveable { mutableStateOf(false) } + var instancesUnavailable by rememberSaveable { mutableStateOf(false) } + var customInstance: String? by rememberSaveable { mutableStateOf(null) } + + LaunchedEffect(Unit) { + Piped.getInstances()?.getOrNull()?.let { + selectedInstance = null + instances = it + canSelect = true + } ?: run { instancesUnavailable = true } + loadingInstances = false + } + + BasicText( + text = stringResource(R.string.piped), + style = typography.m.semiBold + ) + + if (customInstance == null) ValueSelectorSettingsEntry( + title = stringResource(R.string.instance), + selectedValue = selectedInstance, + values = instances.indices.toImmutableList(), + onValueSelected = { selectedInstance = it }, + valueText = { idx -> + idx?.let { instances.getOrNull(it)?.name } + ?: if (instancesUnavailable) stringResource(R.string.error_piped_instances_unavailable) + else stringResource(R.string.click_to_select) + }, + isEnabled = !instancesUnavailable && canSelect, + usePadding = false, + trailingContent = if (loadingInstances) { + { CircularProgressIndicator() } + } else null + ) + SwitchSettingsEntry( + title = stringResource(R.string.custom_instance), + text = null, + isChecked = customInstance != null, + onCheckedChange = { customInstance = if (customInstance == null) "" else null }, + usePadding = false + ) + customInstance?.let { instance -> + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = instance, + onValueChange = { customInstance = it }, + hintText = stringResource(R.string.base_api_url), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth() + ) + } + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = username, + onValueChange = { username = it }, + hintText = stringResource(R.string.username), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + TextField( + value = password, + onValueChange = { password = it }, + hintText = stringResource(R.string.password), + visualTransformation = PasswordVisualTransformation(), + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + DialogTextButton( + text = stringResource(R.string.login), + primary = true, + enabled = (customInstance?.isNotBlank() == true || selectedInstance != null) && + username.isNotBlank() && password.isNotBlank(), + onClick = { + (customInstance?.let { + runCatching { + Url(it) + }.getOrNull() ?: runCatching { + Url("https://$it") + }.getOrNull() + } ?: selectedInstance?.let { instances[it].apiBaseUrl })?.let { url -> + coroutineScope.launch { + isLoading = true + val session = Piped.login( + apiBaseUrl = url, + username = username, + password = password + )?.getOrNull().run { + isLoading = false + if (this == null) { + hasError = true + return@launch + } + this + } + transaction { + Database.insert( + PipedSession( + apiBaseUrl = session.apiBaseUrl, + username = username, + token = session.token + ) + ) + } + linkingPiped = false + } + } + }, + modifier = Modifier.align(Alignment.CenterHorizontally) + ) + } + } + } + + SettingsCategoryScreen(title = stringResource(R.string.sync)) { + SettingsDescription(text = stringResource(R.string.sync_description)) + + SettingsGroup(title = stringResource(R.string.piped)) { + SettingsEntry( + title = stringResource(R.string.add_account), + text = stringResource(R.string.add_account_description), + onClick = { linkingPiped = true } + ) + SettingsEntry( + title = stringResource(R.string.learn_more), + text = stringResource(R.string.learn_more_description), + onClick = { uriHandler.openUri("https://github.com/TeamPiped/Piped/blob/master/README.md") } + ) + } + SettingsGroup(title = stringResource(R.string.piped_sessions)) { + pipedSessions.forEach { + SettingsEntry( + title = it.username, + text = it.apiBaseUrl.toString(), + onClick = { }, + trailingContent = { + IconButton( + onClick = { transaction { Database.delete(it) } }, + icon = R.drawable.delete, + color = colorPalette.text + ) + } + ) + } + } + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/styling/Appearance.kt b/app/src/main/kotlin/it/hamy/muza/ui/styling/Appearance.kt deleted file mode 100644 index 9d92dc1..0000000 --- a/app/src/main/kotlin/it/hamy/muza/ui/styling/Appearance.kt +++ /dev/null @@ -1,40 +0,0 @@ -package it.hamy.muza.ui.styling - -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.runtime.staticCompositionLocalOf -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.unit.dp - -data class Appearance( - val colorPalette: ColorPalette, - val typography: Typography, - val thumbnailShape: Shape, -) { - companion object : Saver> { - @Suppress("UNCHECKED_CAST") - override fun restore(value: List): Appearance { - return Appearance( - colorPalette = ColorPalette.restore(value[0] as List), - typography = Typography.restore(value[1] as List), - thumbnailShape = RoundedCornerShape((value[2] as Int).dp) - ) - } - - override fun SaverScope.save(value: Appearance) = - listOf( - with (ColorPalette.Companion) { save(value.colorPalette) }, - with (Typography.Companion) { save(value.typography) }, - when (value.thumbnailShape) { - RoundedCornerShape(2.dp) -> 2 - RoundedCornerShape(4.dp) -> 4 - RoundedCornerShape(8.dp) -> 8 - RoundedCornerShape(16.dp) -> 16 - else -> 0 - } - ) - } -} - -val LocalAppearance = staticCompositionLocalOf { TODO() } diff --git a/app/src/main/kotlin/it/hamy/muza/ui/styling/ColorPalette.kt b/app/src/main/kotlin/it/hamy/muza/ui/styling/ColorPalette.kt deleted file mode 100644 index 40b67d6..0000000 --- a/app/src/main/kotlin/it/hamy/muza/ui/styling/ColorPalette.kt +++ /dev/null @@ -1,177 +0,0 @@ -package it.hamy.muza.ui.styling - -import android.graphics.Bitmap -import androidx.compose.runtime.Immutable -import androidx.compose.runtime.saveable.Saver -import androidx.compose.runtime.saveable.SaverScope -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.core.graphics.ColorUtils -import androidx.palette.graphics.Palette -import it.hamy.muza.enums.ColorPaletteMode -import it.hamy.muza.enums.ColorPaletteName - -@Immutable -data class ColorPalette( - val background0: Color, - val background1: Color, - val background2: Color, - val accent: Color, - val onAccent: Color, - val red: Color = Color(0xffbf4040), - val blue: Color = Color(0xff4472cf), - val text: Color, - val textSecondary: Color, - val textDisabled: Color, - val isDark: Boolean -) { - companion object : Saver> { - override fun restore(value: List) = when (val accent = value[0] as Int) { - 0 -> DefaultDarkColorPalette - 1 -> DefaultLightColorPalette - 2 -> PureBlackColorPalette - else -> dynamicColorPaletteOf( - FloatArray(3).apply { ColorUtils.colorToHSL(accent, this) }, - value[1] as Boolean - ) - } - - override fun SaverScope.save(value: ColorPalette) = - listOf( - when { - value === DefaultDarkColorPalette -> 0 - value === DefaultLightColorPalette -> 1 - value === PureBlackColorPalette -> 2 - else -> value.accent.toArgb() - }, - value.isDark - ) - } -} - -val DefaultDarkColorPalette = ColorPalette( - background0 = Color(0xff16171d), - background1 = Color(0xff1f2029), - background2 = Color(0xff2b2d3b), - text = Color(0xffe1e1e2), - textSecondary = Color(0xffa3a4a6), - textDisabled = Color(0xff6f6f73), - accent = Color(0xff5055c0), - onAccent = Color.White, - isDark = true -) - -val DefaultLightColorPalette = ColorPalette( - background0 = Color(0xfffdfdfe), - background1 = Color(0xfff8f8fc), - background2 = Color(0xffeaeaf5), - text = Color(0xff212121), - textSecondary = Color(0xff656566), - textDisabled = Color(0xff9d9d9d), - accent = Color(0xff5055c0), - onAccent = Color.White, - isDark = false -) - -val PureBlackColorPalette = DefaultDarkColorPalette.copy( - background0 = Color.Black, - background1 = Color.Black, - background2 = Color.Black -) - -fun colorPaletteOf( - colorPaletteName: ColorPaletteName, - colorPaletteMode: ColorPaletteMode, - isSystemInDarkMode: Boolean -): ColorPalette { - return when (colorPaletteName) { - ColorPaletteName.Default, ColorPaletteName.Dynamic -> when (colorPaletteMode) { - ColorPaletteMode.Light -> DefaultLightColorPalette - ColorPaletteMode.Dark -> DefaultDarkColorPalette - ColorPaletteMode.System -> when (isSystemInDarkMode) { - true -> DefaultDarkColorPalette - false -> DefaultLightColorPalette - } - } - ColorPaletteName.PureBlack -> PureBlackColorPalette - } -} - -fun dynamicColorPaletteOf(bitmap: Bitmap, isDark: Boolean): ColorPalette? { - val palette = Palette - .from(bitmap) - .maximumColorCount(8) - .addFilter(if (isDark) ({ _, hsl -> hsl[0] !in 36f..100f }) else null) - .generate() - - val hsl = if (isDark) { - palette.dominantSwatch ?: Palette - .from(bitmap) - .maximumColorCount(8) - .generate() - .dominantSwatch - } else { - palette.dominantSwatch - }?.hsl ?: return null - - return if (hsl[1] < 0.08) { - val newHsl = palette.swatches - .map(Palette.Swatch::getHsl) - .sortedByDescending(FloatArray::component2) - .find { it[1] != 0f } - ?: hsl - - dynamicColorPaletteOf(newHsl, isDark) - } else { - dynamicColorPaletteOf(hsl, isDark) - } -} - -fun dynamicColorPaletteOf(hsl: FloatArray, isDark: Boolean): ColorPalette { - return colorPaletteOf(ColorPaletteName.Dynamic, if (isDark) ColorPaletteMode.Dark else ColorPaletteMode.Light, false).copy( - background0 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.1f), if (isDark) 0.10f else 0.925f), - background1 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.3f), if (isDark) 0.15f else 0.90f), - background2 = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.4f), if (isDark) 0.2f else 0.85f), - accent = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.5f), 0.5f), - text = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.02f), if (isDark) 0.88f else 0.12f), - textSecondary = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.1f), if (isDark) 0.65f else 0.40f), - textDisabled = Color.hsl(hsl[0], hsl[1].coerceAtMost(0.2f), if (isDark) 0.40f else 0.65f), - ) -} - -inline val ColorPalette.collapsedPlayerProgressBar: Color - get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { - text - } else { - accent - } - -inline val ColorPalette.favoritesIcon: Color - get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { - red - } else { - accent - } - -inline val ColorPalette.shimmer: Color - get() = if (this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette) { - Color(0xff838383) - } else { - accent - } - -inline val ColorPalette.primaryButton: Color - get() = if (this === PureBlackColorPalette) { - Color(0xFF272727) - } else { - background2 - } - -inline val ColorPalette.overlay: Color - get() = PureBlackColorPalette.background0.copy(alpha = 0.75f) - -inline val ColorPalette.onOverlay: Color - get() = PureBlackColorPalette.text - -inline val ColorPalette.onOverlayShimmer: Color - get() = PureBlackColorPalette.shimmer diff --git a/app/src/main/kotlin/it/hamy/muza/ui/styling/Dimensions.kt b/app/src/main/kotlin/it/hamy/muza/ui/styling/Dimensions.kt deleted file mode 100644 index 3186430..0000000 --- a/app/src/main/kotlin/it/hamy/muza/ui/styling/Dimensions.kt +++ /dev/null @@ -1,38 +0,0 @@ -package it.hamy.muza.ui.styling - -import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp - -@Suppress("ClassName") -object Dimensions { - val itemsVerticalPadding = 8.dp - - val navigationRailWidth = 64.dp - val navigationRailWidthLandscape = 128.dp - val navigationRailIconOffset = 6.dp - val headerHeight = 140.dp - - object thumbnails { - val album = 128.dp - val artist = 192.dp - val song = 54.dp - val playlist = album - - object player { - val song: Dp - @Composable - get() = with(LocalConfiguration.current) { - minOf(screenHeightDp, screenWidthDp) - }.dp - } - } - - val collapsedPlayer = 64.dp -} - -inline val Dp.px: Int - @Composable - inline get() = with(LocalDensity.current) { roundToPx() } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/CacheState.kt b/app/src/main/kotlin/it/hamy/muza/utils/CacheState.kt new file mode 100644 index 0000000..405fbcc --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/utils/CacheState.kt @@ -0,0 +1,73 @@ +package it.hamy.muza.utils + +import androidx.annotation.OptIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import it.hamy.muza.Database +import it.hamy.muza.LocalPlayerServiceBinder +import it.hamy.muza.R +import it.hamy.muza.models.Format +import it.hamy.muza.service.PrecacheService +import it.hamy.muza.service.downloadState +import it.hamy.muza.ui.components.themed.HeaderIconButton +import it.hamy.muza.ui.styling.LocalAppearance +import kotlinx.collections.immutable.ImmutableList +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +fun PlaylistDownloadIcon( + songs: ImmutableList, + modifier: Modifier = Modifier +) { + val context = LocalContext.current + val (colorPalette) = LocalAppearance.current + + val isDownloading by downloadState.collectAsState() + + if ( + !songs.all { + isCached( + mediaId = it.mediaId, + key = isDownloading + ) + } + ) HeaderIconButton( + icon = R.drawable.download, + color = colorPalette.text, + onClick = { + songs.forEach { + PrecacheService.scheduleCache(context.applicationContext, it) + } + }, + modifier = modifier + ) +} + +@OptIn(UnstableApi::class) +@Composable +fun isCached( + mediaId: String, + key: Any? = Unit +): Boolean { + val cache = LocalPlayerServiceBinder.current?.cache ?: return false + var format: Format? by remember { mutableStateOf(null) } + + LaunchedEffect(mediaId, key) { + Database.format(mediaId).distinctUntilChanged().collect { format = it } + } + + return remember(key) { + format?.contentLength?.let { len -> + cache.isCached(mediaId, 0, len) + } ?: false + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/utils/ConditionalCacheDataSourceFactory.kt b/app/src/main/kotlin/it/hamy/muza/utils/ConditionalCacheDataSourceFactory.kt new file mode 100644 index 0000000..6f0f7a0 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/utils/ConditionalCacheDataSourceFactory.kt @@ -0,0 +1,49 @@ +package it.hamy.muza.utils + +import androidx.annotation.OptIn +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import androidx.media3.datasource.cache.CacheDataSource + +@OptIn(UnstableApi::class) +class ConditionalCacheDataSourceFactory( + private val cacheDataSourceFactory: CacheDataSource.Factory, + private val upstreamDataSourceFactory: DataSource.Factory, + private val shouldCache: (DataSpec) -> Boolean +) : DataSource.Factory { + init { + cacheDataSourceFactory.setUpstreamDataSourceFactory(upstreamDataSourceFactory) + } + + override fun createDataSource() = object : DataSource { + private lateinit var selectedFactory: DataSource.Factory + private val transferListeners = mutableListOf() + + private val source by lazy { + selectedFactory.createDataSource().apply { + transferListeners.forEach { addTransferListener(it) } + transferListeners.clear() + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int) = + source.read(buffer, offset, length) + + override fun addTransferListener(transferListener: TransferListener) { + if (::selectedFactory.isInitialized) source.addTransferListener(transferListener) + else transferListeners += transferListener + } + + override fun open(dataSpec: DataSpec): Long { + selectedFactory = + if (shouldCache(dataSpec)) cacheDataSourceFactory else upstreamDataSourceFactory + + return source.open(dataSpec) + } + + override fun getUri() = source.uri + override fun close() = source.close() + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/utils/Configuration.kt b/app/src/main/kotlin/it/hamy/muza/utils/Configuration.kt index 50e5dfd..e006ed5 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/Configuration.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/Configuration.kt @@ -1,11 +1,44 @@ package it.hamy.muza.utils import android.content.res.Configuration +import android.os.Build import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalConfiguration val isLandscape @Composable @ReadOnlyComposable get() = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE + +inline val isAtLeastAndroid6 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + +inline val isAtLeastAndroid8 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + +inline val isAtLeastAndroid10 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + +inline val isAtLeastAndroid11 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R + +inline val isAtLeastAndroid12 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + +inline val isAtLeastAndroid13 + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + +@Composable +fun isCompositionLaunched(): Boolean { + var isLaunched by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + isLaunched = true + } + return isLaunched +} diff --git a/app/src/main/kotlin/it/hamy/muza/utils/Context.kt b/app/src/main/kotlin/it/hamy/muza/utils/Context.kt index a1356f2..f79c52c 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/Context.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/Context.kt @@ -2,39 +2,88 @@ package it.hamy.muza.utils import android.app.Activity import android.app.PendingIntent +import android.content.ActivityNotFoundException import android.content.BroadcastReceiver import android.content.Context +import android.content.ContextWrapper import android.content.Intent +import android.content.pm.PackageManager +import android.database.Cursor +import android.net.Uri import android.os.PowerManager import android.widget.Toast +import androidx.core.content.ContextCompat import androidx.core.content.getSystemService +import it.hamy.muza.BuildConfig -inline fun Context.intent(): Intent = - Intent(this@Context, T::class.java) +inline fun Context.intent(): Intent = Intent(this@Context, T::class.java) -inline fun Context.broadCastPendingIntent( +inline fun Context.broadcastPendingIntent( requestCode: Int = 0, - flags: Int = if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0, -): PendingIntent = - PendingIntent.getBroadcast(this, requestCode, intent(), flags) + flags: Int = if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0 +): PendingIntent = PendingIntent.getBroadcast(this, requestCode, intent(), flags) inline fun Context.activityPendingIntent( requestCode: Int = 0, flags: Int = 0, - block: Intent.() -> Unit = {}, -): PendingIntent = - PendingIntent.getActivity( - this, - requestCode, - intent().apply(block), - (if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) or flags - ) + block: Intent.() -> Unit = {} +): PendingIntent = PendingIntent.getActivity( + this, + requestCode, + intent().apply(block), + (if (isAtLeastAndroid6) PendingIntent.FLAG_IMMUTABLE else 0) or flags +) -val Context.isIgnoringBatteryOptimizations: Boolean - get() = if (isAtLeastAndroid6) { - getSystemService()?.isIgnoringBatteryOptimizations(packageName) ?: true - } else { - true - } +val Context.isIgnoringBatteryOptimizations + get() = !isAtLeastAndroid6 || + getSystemService()?.isIgnoringBatteryOptimizations(packageName) ?: true fun Context.toast(message: String) = Toast.makeText(this, message, Toast.LENGTH_SHORT).show() + +fun launchYouTubeMusic( + context: Context, + endpoint: String, + tryWithoutBrowser: Boolean = true +): Boolean { + return try { + val intent = Intent( + Intent.ACTION_VIEW, + Uri.parse("https://music.youtube.com/${endpoint.dropWhile { it == '/' }}") + ).apply { + if (tryWithoutBrowser && isAtLeastAndroid11) { + flags = Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER + } + } + intent.`package` = + context.applicationContext.packageManager.queryIntentActivities(intent, 0) + .firstOrNull { + it?.activityInfo?.packageName != null && + BuildConfig.APPLICATION_ID !in it.activityInfo.packageName + }?.activityInfo?.packageName + ?: return false + context.startActivity(intent) + true + } catch (e: ActivityNotFoundException) { + if (tryWithoutBrowser) launchYouTubeMusic( + context = context, + endpoint = endpoint, + tryWithoutBrowser = false + ) else false + } +} + +fun Context.findActivity(): Activity { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + error("Should be called in the context of an Activity") +} + +fun Context.hasPermission(permission: String) = ContextCompat.checkSelfPermission( + applicationContext, + permission +) == PackageManager.PERMISSION_GRANTED + +operator fun Cursor.get(column: String): Int = getColumnIndexOrThrow(column) diff --git a/app/src/main/kotlin/it/hamy/muza/utils/DrawScope.kt b/app/src/main/kotlin/it/hamy/muza/utils/DrawScope.kt index 440cdf8..34266b1 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/DrawScope.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/DrawScope.kt @@ -1,12 +1,19 @@ package it.hamy.muza.utils import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.* +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.PaintingStyle +import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb fun DrawScope.drawCircle( color: Color, - shadow: Shadow, + shadow: Shadow = Shadow.None, radius: Float = size.minDimension / 2.0f, center: Offset = this.center, alpha: Float = 1.0f, @@ -14,17 +21,17 @@ fun DrawScope.drawCircle( colorFilter: ColorFilter? = null, blendMode: BlendMode = DrawScope.DefaultBlendMode ) = drawContext.canvas.nativeCanvas.drawCircle( - center.x, - center.y, - radius, - Paint().also { + /* cx = */ center.x, + /* cy = */ center.y, + /* radius = */ radius, + /* paint = */ Paint().also { it.color = color it.alpha = alpha it.blendMode = blendMode it.colorFilter = colorFilter it.style = style }.asFrameworkPaint().also { - it.setShadowLayer( + if (shadow != Shadow.None) it.setShadowLayer( shadow.blurRadius, shadow.offset.x, shadow.offset.y, diff --git a/app/src/main/kotlin/it/hamy/muza/utils/FadingEdge.kt b/app/src/main/kotlin/it/hamy/muza/utils/FadingEdge.kt deleted file mode 100644 index 583399f..0000000 --- a/app/src/main/kotlin/it/hamy/muza/utils/FadingEdge.kt +++ /dev/null @@ -1,24 +0,0 @@ -package it.hamy.muza.utils - -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.BlendMode -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.graphicsLayer - -fun Modifier.verticalFadingEdge() = - graphicsLayer(alpha = 0.99f) - .drawWithContent { - drawContent() - drawRect( - brush = Brush.verticalGradient( - listOf( - Color.Transparent, - Color.Black, Color.Black, Color.Black, - Color.Transparent - ) - ), - blendMode = BlendMode.DstIn - ) - } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/InvincibleService.kt b/app/src/main/kotlin/it/hamy/muza/utils/InvincibleService.kt index 17e43c9..3382dfb 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/InvincibleService.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/InvincibleService.kt @@ -9,6 +9,7 @@ import android.content.IntentFilter import android.os.Binder import android.os.Handler import android.os.Looper +import androidx.core.content.ContextCompat // https://stackoverflow.com/q/53502244/16885569 // I found four ways to make the system not kill the stopped foreground service: e.g. when @@ -42,9 +43,8 @@ abstract class InvincibleService : Service() { } override fun onUnbind(intent: Intent?): Boolean { - if (isInvincibilityEnabled && isAllowedToStartForegroundServices) { + if (isInvincibilityEnabled && isAllowedToStartForegroundServices) invincibility = Invincibility() - } return true } @@ -55,11 +55,7 @@ abstract class InvincibleService : Service() { } protected fun makeInvincible(isInvincible: Boolean = true) { - if (isInvincible) { - invincibility?.start() - } else { - invincibility?.stop() - } + if (isInvincible) invincibility?.start() else invincibility?.stop() } protected abstract fun shouldBeInvincible(): Boolean @@ -82,33 +78,41 @@ abstract class InvincibleService : Service() { @Synchronized fun start() { - if (!isStarted) { - isStarted = true - handler.postDelayed(this, intervalMs) - registerReceiver(this, IntentFilter().apply { - addAction(Intent.ACTION_SCREEN_ON) - addAction(Intent.ACTION_SCREEN_OFF) - }) + if (isStarted) return + + isStarted = true + handler.postDelayed(this, intervalMs) + + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) } + ContextCompat.registerReceiver( + /* context = */ this@InvincibleService, + /* receiver = */ this, + /* filter = */ filter, + /* flags = */ ContextCompat.RECEIVER_NOT_EXPORTED + ) } @Synchronized fun stop() { - if (isStarted) { - handler.removeCallbacks(this) - unregisterReceiver(this) - isStarted = false - } + if (!isStarted) return + + handler.removeCallbacks(this) + unregisterReceiver(this) + isStarted = false } override fun run() { - if (shouldBeInvincible() && isAllowedToStartForegroundServices) { - notification()?.let { notification -> - startForeground(notificationId, notification) - stopForeground(false) - handler.postDelayed(this, intervalMs) - } - } + if (!shouldBeInvincible() || !isAllowedToStartForegroundServices) return + val notification = notification() ?: return + + startForeground(notificationId, notification) + @Suppress("DEPRECATION") + stopForeground(false) + + handler.postDelayed(this, intervalMs) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/Player.kt b/app/src/main/kotlin/it/hamy/muza/utils/Player.kt index 429d916..a1c356f 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/Player.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/Player.kt @@ -6,7 +6,10 @@ import androidx.media3.common.Player import androidx.media3.common.Timeline val Player.currentWindow: Timeline.Window? - get() = if (mediaItemCount == 0) null else currentTimeline.getWindow(currentMediaItemIndex, Timeline.Window()) + get() = if (mediaItemCount == 0) null else currentTimeline.getWindow( + currentMediaItemIndex, + Timeline.Window() + ) val Timeline.mediaItems: List get() = List(windowCount) { @@ -24,16 +27,21 @@ val Player.shouldBePlaying: Boolean fun Player.seamlessPlay(mediaItem: MediaItem) { if (mediaItem.mediaId == currentMediaItem?.mediaId) { if (currentMediaItemIndex > 0) removeMediaItems(0, currentMediaItemIndex) - if (currentMediaItemIndex < mediaItemCount - 1) removeMediaItems(currentMediaItemIndex + 1, mediaItemCount) - } else { - forcePlay(mediaItem) - } + if (currentMediaItemIndex < mediaItemCount - 1) removeMediaItems( + currentMediaItemIndex + 1, + mediaItemCount + ) + } else forcePlay(mediaItem) } fun Player.shuffleQueue() { - val mediaItems = currentTimeline.mediaItems.toMutableList().apply { removeAt(currentMediaItemIndex) } + val mediaItems = currentTimeline.mediaItems.toMutableList() + .apply { removeAt(currentMediaItemIndex) } if (currentMediaItemIndex > 0) removeMediaItems(0, currentMediaItemIndex) - if (currentMediaItemIndex < mediaItemCount - 1) removeMediaItems(currentMediaItemIndex + 1, mediaItemCount) + if (currentMediaItemIndex < mediaItemCount - 1) removeMediaItems( + currentMediaItemIndex + 1, + mediaItemCount + ) addMediaItems(mediaItems.shuffled()) } @@ -43,22 +51,21 @@ fun Player.forcePlay(mediaItem: MediaItem) { prepare() } -fun Player.forcePlayAtIndex(mediaItems: List, mediaItemIndex: Int) { - if (mediaItems.isEmpty()) return +fun Player.forcePlayAtIndex(items: List, index: Int) { + if (items.isEmpty()) return - setMediaItems(mediaItems, mediaItemIndex, C.TIME_UNSET) + setMediaItems(items, index, C.TIME_UNSET) playWhenReady = true prepare() } -fun Player.forcePlayFromBeginning(mediaItems: List) = - forcePlayAtIndex(mediaItems, 0) +fun Player.forcePlayFromBeginning(items: List) = + forcePlayAtIndex(items, 0) fun Player.forceSeekToPrevious() { - if (hasPreviousMediaItem() || currentPosition > maxSeekToPreviousPosition) { - seekToPrevious() - } else if (mediaItemCount > 0) { - seekTo(mediaItemCount - 1, C.TIME_UNSET) + when { + hasPreviousMediaItem() || currentPosition > maxSeekToPreviousPosition -> seekToPrevious() + mediaItemCount > 0 -> seekTo(mediaItemCount - 1, C.TIME_UNSET) } } @@ -66,34 +73,29 @@ fun Player.forceSeekToNext() = if (hasNextMediaItem()) seekToNext() else seekTo(0, C.TIME_UNSET) fun Player.addNext(mediaItem: MediaItem) { - if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { - forcePlay(mediaItem) - } else { - addMediaItem(currentMediaItemIndex + 1, mediaItem) - } + if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) forcePlay( + mediaItem + ) + else addMediaItem(currentMediaItemIndex + 1, mediaItem) } fun Player.enqueue(mediaItem: MediaItem) { - if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { - forcePlay(mediaItem) - } else { - addMediaItem(mediaItemCount, mediaItem) - } + if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) forcePlay( + mediaItem + ) + else addMediaItem(mediaItemCount, mediaItem) } fun Player.enqueue(mediaItems: List) { - if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) { - forcePlayFromBeginning(mediaItems) - } else { - addMediaItems(mediaItemCount, mediaItems) - } + if (playbackState == Player.STATE_IDLE || playbackState == Player.STATE_ENDED) forcePlayFromBeginning( + mediaItems + ) + else addMediaItems(mediaItemCount, mediaItems) } fun Player.findNextMediaItemById(mediaId: String): MediaItem? { for (i in currentMediaItemIndex until mediaItemCount) { - if (getMediaItemAt(i).mediaId == mediaId) { - return getMediaItemAt(i) - } + if (getMediaItemAt(i).mediaId == mediaId) return getMediaItemAt(i) } return null } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/PlayerState.kt b/app/src/main/kotlin/it/hamy/muza/utils/PlayerState.kt index 1880b8f..6e4df39 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/PlayerState.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/PlayerState.kt @@ -24,9 +24,7 @@ inline fun Player.DisposableListener(crossinline listenerProvider: () -> Player. @Composable fun Player.positionAndDurationState(): State> { - val state = remember { - mutableStateOf(currentPosition to duration) - } + val state = remember { mutableStateOf(currentPosition to duration) } LaunchedEffect(this) { var isSeeking = false @@ -59,9 +57,7 @@ fun Player.positionAndDurationState(): State> { val pollJob = launch { while (isActive) { delay(500) - if (!isSeeking) { - state.value = currentPosition to duration - } + if (!isSeeking) state.value = currentPosition to duration } } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/RingBuffer.kt b/app/src/main/kotlin/it/hamy/muza/utils/RingBuffer.kt index dac9c69..5103d4c 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/RingBuffer.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/RingBuffer.kt @@ -5,7 +5,6 @@ class RingBuffer(val size: Int, init: (index: Int) -> T) { private var index = 0 - fun getOrNull(index: Int): T? = list.getOrNull(index) - - fun append(element: T) = list.set(index++ % size, element) + operator fun get(index: Int) = list.getOrNull(index) + operator fun plusAssign(element: T) { list[index++ % size] = element } } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/ScrollingInfo.kt b/app/src/main/kotlin/it/hamy/muza/utils/ScrollingInfo.kt index 5a404ba..f13fc28 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/ScrollingInfo.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/ScrollingInfo.kt @@ -6,88 +6,74 @@ import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue data class ScrollingInfo( val isScrollingDown: Boolean = false, val isFar: Boolean = false -) { - fun and(condition: Boolean) = -// copy(isScrollingDown = isScrollingDown && condition, isFar = isFar && condition) - if (condition) this else copy(isScrollingDown = !isScrollingDown, isFar = !isFar) -} +) @Composable fun LazyListState.scrollingInfo(): ScrollingInfo { - var previousIndex by remember(this) { - mutableStateOf(firstVisibleItemIndex) - } - - var previousScrollOffset by remember(this) { - mutableStateOf(firstVisibleItemScrollOffset) - } + var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } return remember(this) { derivedStateOf { - val isScrollingDown = if (previousIndex == firstVisibleItemIndex) { - firstVisibleItemScrollOffset > previousScrollOffset - } else { - firstVisibleItemIndex > previousIndex - } - + val isScrollingDown = + if (previousIndex == firstVisibleItemIndex) firstVisibleItemScrollOffset > previousScrollOffset + else firstVisibleItemIndex > previousIndex val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size previousIndex = firstVisibleItemIndex previousScrollOffset = firstVisibleItemScrollOffset - ScrollingInfo(isScrollingDown, isFar) + ScrollingInfo( + isScrollingDown = isScrollingDown, + isFar = isFar + ) } }.value } @Composable fun LazyGridState.scrollingInfo(): ScrollingInfo { - var previousIndex by remember(this) { - mutableStateOf(firstVisibleItemIndex) - } - - var previousScrollOffset by remember(this) { - mutableStateOf(firstVisibleItemScrollOffset) - } + var previousIndex by remember(this) { mutableIntStateOf(firstVisibleItemIndex) } + var previousScrollOffset by remember(this) { mutableIntStateOf(firstVisibleItemScrollOffset) } return remember(this) { derivedStateOf { - val isScrollingDown = if (previousIndex == firstVisibleItemIndex) { - firstVisibleItemScrollOffset > previousScrollOffset - } else { - firstVisibleItemIndex > previousIndex - } - + val isScrollingDown = + if (previousIndex == firstVisibleItemIndex) firstVisibleItemScrollOffset > previousScrollOffset + else firstVisibleItemIndex > previousIndex val isFar = firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size previousIndex = firstVisibleItemIndex previousScrollOffset = firstVisibleItemScrollOffset - ScrollingInfo(isScrollingDown, isFar) + ScrollingInfo( + isScrollingDown = isScrollingDown, + isFar = isFar + ) } }.value } @Composable fun ScrollState.scrollingInfo(): ScrollingInfo { - var previousValue by remember(this) { - mutableStateOf(value) - } + var previousValue by remember(this) { mutableIntStateOf(value) } return remember(this) { derivedStateOf { val isScrollingDown = value > previousValue - previousValue = value - ScrollingInfo(isScrollingDown, false) + ScrollingInfo( + isScrollingDown = isScrollingDown, + isFar = false + ) } }.value } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/SmoothScrollToTop.kt b/app/src/main/kotlin/it/hamy/muza/utils/SmoothScroll.kt similarity index 80% rename from app/src/main/kotlin/it/hamy/muza/utils/SmoothScrollToTop.kt rename to app/src/main/kotlin/it/hamy/muza/utils/SmoothScroll.kt index 6b4fb16..3bc5346 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/SmoothScrollToTop.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/SmoothScroll.kt @@ -1,18 +1,21 @@ package it.hamy.muza.utils +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.grid.LazyGridState suspend fun LazyGridState.smoothScrollToTop() { - if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) { + if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) scrollToItem(layoutInfo.visibleItemsInfo.size) - } animateScrollToItem(0) } suspend fun LazyListState.smoothScrollToTop() { - if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) { + if (firstVisibleItemIndex > layoutInfo.visibleItemsInfo.size) scrollToItem(layoutInfo.visibleItemsInfo.size) - } animateScrollToItem(0) } + +suspend fun ScrollState.smoothScrollToBottom() { + animateScrollTo(maxValue) +} diff --git a/app/src/main/kotlin/it/hamy/muza/utils/LazyGridSnapLayoutInfoProvider.kt b/app/src/main/kotlin/it/hamy/muza/utils/SnapLayoutInfoProvider.kt similarity index 57% rename from app/src/main/kotlin/it/hamy/muza/utils/LazyGridSnapLayoutInfoProvider.kt rename to app/src/main/kotlin/it/hamy/muza/utils/SnapLayoutInfoProvider.kt index ef45f39..97d4c66 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/LazyGridSnapLayoutInfoProvider.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/SnapLayoutInfoProvider.kt @@ -6,11 +6,46 @@ import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastForEach -import androidx.compose.ui.util.fastSumBy -fun Density.calculateDistanceToDesiredSnapPosition( +private val LazyGridLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width + +context(Density) +@OptIn(ExperimentalFoundationApi::class) +private fun SnapLayoutInfoProvider( + lazyGridState: LazyGridState, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = + { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) } +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + private val layoutInfo: LazyGridLayoutInfo + get() = lazyGridState.layoutInfo + + // Single page snapping is the default + override fun calculateApproachOffset(initialVelocity: Float) = 0f + + // ignoring the velocity for now since there is no animation spec in this provider + override fun calculateSnappingOffset(currentVelocity: Float): Float { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) + // Find item that is closest to the center + if (offset <= 0 && offset > lowerBoundOffset) lowerBoundOffset = offset + // Find item that is closest to center, but after it + if (offset >= 0 && offset < upperBoundOffset) upperBoundOffset = offset + } + + return if ((lowerBoundOffset * -1f) > upperBoundOffset) upperBoundOffset else lowerBoundOffset + } +} + +private fun Density.calculateDistanceToDesiredSnapPosition( layoutInfo: LazyGridLayoutInfo, item: LazyGridItemInfo, positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float @@ -24,49 +59,18 @@ fun Density.calculateDistanceToDesiredSnapPosition( return itemCurrentPosition - desiredDistance } -private val LazyGridLayoutInfo.singleAxisViewportSize: Int - get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width - -@ExperimentalFoundationApi -fun SnapLayoutInfoProvider( +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun rememberSnapLayoutInfoProvider( lazyGridState: LazyGridState, positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) } -): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { +): SnapLayoutInfoProvider { + val density = LocalDensity.current - private val layoutInfo: LazyGridLayoutInfo - get() = lazyGridState.layoutInfo - - // Single page snapping is the default - override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f - - override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { - var lowerBoundOffset = Float.NEGATIVE_INFINITY - var upperBoundOffset = Float.POSITIVE_INFINITY - - layoutInfo.visibleItemsInfo.fastForEach { item -> - val offset = - calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) - - // Find item that is closest to the center - if (offset <= 0 && offset > lowerBoundOffset) { - lowerBoundOffset = offset - } - - // Find item that is closest to center, but after it - if (offset >= 0 && offset < upperBoundOffset) { - upperBoundOffset = offset - } - } - - return lowerBoundOffset.rangeTo(upperBoundOffset) - } - - override fun Density.snapStepSize(): Float = with(layoutInfo) { - if (visibleItemsInfo.isNotEmpty()) { - visibleItemsInfo.fastSumBy { it.size.width } / visibleItemsInfo.size.toFloat() - } else { - 0f + return remember(lazyGridState, density) { + with(density) { + SnapLayoutInfoProvider(lazyGridState, positionInLayout) } } } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/StateFlowSaver.kt b/app/src/main/kotlin/it/hamy/muza/utils/StateFlowSaver.kt new file mode 100644 index 0000000..79f8ab4 --- /dev/null +++ b/app/src/main/kotlin/it/hamy/muza/utils/StateFlowSaver.kt @@ -0,0 +1,18 @@ +package it.hamy.muza.utils + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import kotlinx.coroutines.flow.MutableStateFlow + +fun stateFlowSaver() = stateFlowSaverOf( + from = { it }, + to = { it } +) + +inline fun stateFlowSaverOf( + crossinline from: (Saveable) -> Type, + crossinline to: (Type) -> Saveable +) = object : Saver, Saveable> { + override fun restore(value: Saveable) = MutableStateFlow(from(value)) + override fun SaverScope.save(value: MutableStateFlow) = to(value.value) +} diff --git a/app/src/main/kotlin/it/hamy/muza/utils/SynchronizedLyrics.kt b/app/src/main/kotlin/it/hamy/muza/utils/SynchronizedLyrics.kt index 1ed8554..c2fd405 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/SynchronizedLyrics.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/SynchronizedLyrics.kt @@ -1,18 +1,21 @@ package it.hamy.muza.utils import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.setValue -class SynchronizedLyrics(val sentences: List>, private val positionProvider: () -> Long) { - var index by mutableStateOf(currentIndex) +class SynchronizedLyrics( + val sentences: Map, + private val positionProvider: () -> Long +) { + var index by mutableIntStateOf(currentIndex) private set private val currentIndex: Int get() { var index = -1 - for (item in sentences) { - if (item.first >= positionProvider()) break + for ((key) in sentences) { + if (key >= positionProvider()) break index++ } return if (index == -1) 0 else index @@ -23,8 +26,6 @@ class SynchronizedLyrics(val sentences: List>, private val po return if (newIndex != index) { index = newIndex true - } else { - false - } + } else false } } diff --git a/app/src/main/kotlin/it/hamy/muza/utils/TextStyle.kt b/app/src/main/kotlin/it/hamy/muza/utils/TextStyle.kt index 95abb15..7e56ece 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/TextStyle.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/TextStyle.kt @@ -10,24 +10,14 @@ import androidx.compose.ui.text.style.TextAlign import it.hamy.muza.ui.styling.LocalAppearance fun TextStyle.style(style: FontStyle) = copy(fontStyle = style) - fun TextStyle.weight(weight: FontWeight) = copy(fontWeight = weight) - fun TextStyle.align(align: TextAlign) = copy(textAlign = align) - fun TextStyle.color(color: Color) = copy(color = color) -inline val TextStyle.medium: TextStyle - get() = weight(FontWeight.Medium) - -inline val TextStyle.semiBold: TextStyle - get() = weight(FontWeight.SemiBold) - -inline val TextStyle.bold: TextStyle - get() = weight(FontWeight.Bold) - -inline val TextStyle.center: TextStyle - get() = align(TextAlign.Center) +inline val TextStyle.medium get() = weight(FontWeight.Medium) +inline val TextStyle.semiBold get() = weight(FontWeight.SemiBold) +inline val TextStyle.bold get() = weight(FontWeight.Bold) +inline val TextStyle.center get() = align(TextAlign.Center) inline val TextStyle.secondary: TextStyle @Composable diff --git a/app/src/main/kotlin/it/hamy/muza/utils/TimerJob.kt b/app/src/main/kotlin/it/hamy/muza/utils/TimerJob.kt index ce5d4bc..b3bbf69 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/TimerJob.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/TimerJob.kt @@ -14,22 +14,18 @@ interface TimerJob { } fun CoroutineScope.timer(delayMillis: Long, onCompletion: () -> Unit): TimerJob { + val end = System.currentTimeMillis() + delayMillis val millisLeft = MutableStateFlow(delayMillis) val job = launch { while (isActive && millisLeft.value != null) { delay(1000) - millisLeft.emit(millisLeft.value?.minus(1000)?.takeIf { it > 0 }) - } - } - val disposableHandle = job.invokeOnCompletion { - if (it == null) { - onCompletion() + millisLeft.emit((end - System.currentTimeMillis()).takeIf { it > 0 }) } } + val disposableHandle = job.invokeOnCompletion { if (it == null) onCompletion() } return object : TimerJob { - override val millisLeft: StateFlow - get() = millisLeft.asStateFlow() + override val millisLeft get() = millisLeft.asStateFlow() override fun cancel() { millisLeft.value = null diff --git a/app/src/main/kotlin/it/hamy/muza/utils/Utils.kt b/app/src/main/kotlin/it/hamy/muza/utils/Utils.kt index cf5beef..50136d6 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/Utils.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/Utils.kt @@ -1,17 +1,27 @@ +@file:OptIn(UnstableApi::class) + package it.hamy.muza.utils +import android.content.ContentUris import android.net.Uri -import android.os.Build +import android.provider.MediaStore import android.text.format.DateUtils +import androidx.annotation.OptIn import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import androidx.media3.common.util.UnstableApi import it.hamy.innertube.Innertube import it.hamy.innertube.models.bodies.ContinuationBody import it.hamy.innertube.requests.playlistPage -import it.hamy.innertube.utils.plus +import it.hamy.piped.models.Playlist import it.hamy.muza.models.Song +import it.hamy.muza.preferences.AppearancePreferences +import it.hamy.muza.service.LOCAL_KEY_PREFIX +import it.hamy.muza.service.isLocal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.onEach val Innertube.SongItem.asMediaItem: MediaItem get() = MediaItem.Builder() @@ -21,15 +31,16 @@ val Innertube.SongItem.asMediaItem: MediaItem .setMediaMetadata( MediaMetadata.Builder() .setTitle(info?.name) - .setArtist(authors?.joinToString("") { it.name ?: "" }) + .setArtist(authors?.joinToString("") { it.name.orEmpty() }) .setAlbumTitle(album?.name) .setArtworkUri(thumbnail?.url?.toUri()) .setExtras( bundleOf( "albumId" to album?.endpoint?.browseId, "durationText" to durationText, - "artistNames" to authors?.filter { it.endpoint != null }?.mapNotNull { it.name }, - "artistIds" to authors?.mapNotNull { it.endpoint?.browseId }, + "artistNames" to authors?.filter { it.endpoint != null } + ?.mapNotNull { it.name }, + "artistIds" to authors?.mapNotNull { it.endpoint?.browseId } ) ) .build() @@ -44,19 +55,48 @@ val Innertube.VideoItem.asMediaItem: MediaItem .setMediaMetadata( MediaMetadata.Builder() .setTitle(info?.name) - .setArtist(authors?.joinToString("") { it.name ?: "" }) + .setArtist(authors?.joinToString("") { it.name.orEmpty() }) .setArtworkUri(thumbnail?.url?.toUri()) .setExtras( bundleOf( "durationText" to durationText, - "artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null }?.mapNotNull { it.name } else null, - "artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } else null, + "artistNames" to if (isOfficialMusicVideo) authors?.filter { it.endpoint != null } + ?.mapNotNull { it.name } else null, + "artistIds" to if (isOfficialMusicVideo) authors?.mapNotNull { it.endpoint?.browseId } + else null ) ) .build() ) .build() +val Playlist.Video.asMediaItem: MediaItem? + get() { + val key = id ?: return null + + return MediaItem.Builder() + .setMediaId(key) + .setUri(key) + .setCustomCacheKey(key) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setArtist(uploaderName) + .setArtworkUri(Uri.parse(thumbnailUrl.toString())) + .setExtras( + bundleOf( + "durationText" to duration.toComponents { minutes, seconds, _ -> + "$minutes:${seconds.toString().padStart(2, '0')}" + }, + "artistNames" to listOf(uploaderName), + "artistIds" to uploaderId?.let { listOf(it) } + ) + ) + .build() + ) + .build() + } + val Song.asMediaItem: MediaItem get() = MediaItem.Builder() .setMediaMetadata( @@ -72,49 +112,66 @@ val Song.asMediaItem: MediaItem .build() ) .setMediaId(id) - .setUri(id) + .setUri( + if (isLocal) ContentUris.withAppendedId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + id.substringAfter(LOCAL_KEY_PREFIX).toLong() + ) else id.toUri() + ) .setCustomCacheKey(id) .build() -fun String?.thumbnail(size: Int): String? { +fun String?.thumbnail( + size: Int, + maxSize: Int = AppearancePreferences.maxThumbnailSize +): String? { + val actualSize = size.coerceAtMost(maxSize) return when { - this?.startsWith("https://lh3.googleusercontent.com") == true -> "$this-w$size-h$size" - this?.startsWith("https://yt3.ggpht.com") == true -> "$this-w$size-h$size-s$size" + this?.startsWith("https://lh3.googleusercontent.com") == true -> "$this-w$actualSize-h$actualSize" + this?.startsWith("https://yt3.ggpht.com") == true -> "$this-w$actualSize-h$actualSize-s$actualSize" else -> this } } -fun Uri?.thumbnail(size: Int): Uri? { - return toString().thumbnail(size)?.toUri() -} +fun Uri?.thumbnail(size: Int) = toString().thumbnail(size)?.toUri() fun formatAsDuration(millis: Long) = DateUtils.formatElapsedTime(millis / 1000).removePrefix("0") -suspend fun Result.completed(): Result? { - var playlistPage = getOrNull() ?: return null +suspend fun Result.completed( + maxDepth: Int = Int.MAX_VALUE +) = runCatching { + val page = getOrThrow() + val songs = page.songsPage?.items.orEmpty().toMutableSet() + var continuation = page.songsPage?.continuation - while (playlistPage.songsPage?.continuation != null) { - val continuation = playlistPage.songsPage?.continuation!! - val otherPlaylistPageResult = Innertube.playlistPage(ContinuationBody(continuation = continuation)) ?: break + var depth = 0 - if (otherPlaylistPageResult.isFailure) break + while (continuation != null && depth++ < maxDepth) { + val newSongs = Innertube.playlistPage( + body = ContinuationBody(continuation = continuation) + )?.getOrNull()?.takeUnless { it.items.isNullOrEmpty() } ?: break - otherPlaylistPageResult.getOrNull()?.let { otherSongsPage -> - playlistPage = playlistPage.copy(songsPage = playlistPage.songsPage + otherSongsPage) - } + if (newSongs.items?.any { it in songs } != false) break + + newSongs.items?.let { songs += it } + continuation = newSongs.continuation } - return Result.success(playlistPage) + page.copy( + songsPage = Innertube.ItemsPage( + items = songs.toList(), + continuation = null + ) + ) } -inline val isAtLeastAndroid6 - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M +fun Flow.onFirst(block: suspend (T) -> Unit): Flow { + var isFirst = true -inline val isAtLeastAndroid8 - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O + return onEach { + if (!isFirst) return@onEach -inline val isAtLeastAndroid12 - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S - -inline val isAtLeastAndroid13 - get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + block(it) + isFirst = false + } +} diff --git a/app/src/main/kotlin/it/hamy/muza/utils/YoutubeRadio.kt b/app/src/main/kotlin/it/hamy/muza/utils/YoutubeRadio.kt index 27707a2..7ff5b6e 100644 --- a/app/src/main/kotlin/it/hamy/muza/utils/YoutubeRadio.kt +++ b/app/src/main/kotlin/it/hamy/muza/utils/YoutubeRadio.kt @@ -23,7 +23,7 @@ data class YouTubeRadio( val continuation = nextContinuation if (continuation == null) { - Innertube.nextPage( + Innertube.nextPage( NextBody( videoId = videoId, playlistId = playlistId, @@ -43,7 +43,6 @@ data class YouTubeRadio( mediaItems = songsPage.items?.map(Innertube.SongItem::asMediaItem) songsPage.continuation?.takeUnless { nextContinuation == it } } - } return mediaItems ?: emptyList() diff --git a/app/src/main/logo.png b/app/src/main/logo.png deleted file mode 100644 index 7ffbb4a..0000000 Binary files a/app/src/main/logo.png and /dev/null differ diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml index a522620..300f401 100644 --- a/app/src/main/res/drawable/add.xml +++ b/app/src/main/res/drawable/add.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/airplane.xml b/app/src/main/res/drawable/airplane.xml index cee47b7..a4fe45d 100644 --- a/app/src/main/res/drawable/airplane.xml +++ b/app/src/main/res/drawable/airplane.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/alarm.xml b/app/src/main/res/drawable/alarm.xml index 1c4ad30..ba82249 100644 --- a/app/src/main/res/drawable/alarm.xml +++ b/app/src/main/res/drawable/alarm.xml @@ -3,13 +3,14 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/alert_circle.xml b/app/src/main/res/drawable/alert_circle.xml index 6300810..7f6ea33 100644 --- a/app/src/main/res/drawable/alert_circle.xml +++ b/app/src/main/res/drawable/alert_circle.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/app_icon.xml b/app/src/main/res/drawable/app_icon.xml index cbe7472..f59dc96 100644 --- a/app/src/main/res/drawable/app_icon.xml +++ b/app/src/main/res/drawable/app_icon.xml @@ -3,12 +3,13 @@ android:height="126.97dp" android:viewportWidth="122.98" android:viewportHeight="126.97"> - - + + + diff --git a/app/src/main/res/drawable/arrow_down.xml b/app/src/main/res/drawable/arrow_down.xml deleted file mode 100644 index 9d45330..0000000 --- a/app/src/main/res/drawable/arrow_down.xml +++ /dev/null @@ -1,20 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/arrow_forward.xml b/app/src/main/res/drawable/arrow_forward.xml index 386591b..591e29a 100644 --- a/app/src/main/res/drawable/arrow_forward.xml +++ b/app/src/main/res/drawable/arrow_forward.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/arrow_up.xml b/app/src/main/res/drawable/arrow_up.xml index 9de10de..e34a79e 100644 --- a/app/src/main/res/drawable/arrow_up.xml +++ b/app/src/main/res/drawable/arrow_up.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/bookmark.xml b/app/src/main/res/drawable/bookmark.xml index 416e06c..57bc7d9 100644 --- a/app/src/main/res/drawable/bookmark.xml +++ b/app/src/main/res/drawable/bookmark.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/bookmark_outline.xml b/app/src/main/res/drawable/bookmark_outline.xml index 1544145..a5cdd6c 100644 --- a/app/src/main/res/drawable/bookmark_outline.xml +++ b/app/src/main/res/drawable/bookmark_outline.xml @@ -3,11 +3,12 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/calendar.xml b/app/src/main/res/drawable/calendar.xml index 3eb788b..e610608 100644 --- a/app/src/main/res/drawable/calendar.xml +++ b/app/src/main/res/drawable/calendar.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/checkmark.xml b/app/src/main/res/drawable/checkmark.xml deleted file mode 100644 index 1c3a3fe..0000000 --- a/app/src/main/res/drawable/checkmark.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/chevron_back.xml b/app/src/main/res/drawable/chevron_back.xml index 1b7aef3..eb431e5 100644 --- a/app/src/main/res/drawable/chevron_back.xml +++ b/app/src/main/res/drawable/chevron_back.xml @@ -3,11 +3,12 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/chevron_down.xml b/app/src/main/res/drawable/chevron_down.xml index 41cdd90..3ae861a 100644 --- a/app/src/main/res/drawable/chevron_down.xml +++ b/app/src/main/res/drawable/chevron_down.xml @@ -3,11 +3,12 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/chevron_forward.xml b/app/src/main/res/drawable/chevron_forward.xml index 24a5848..008b66c 100644 --- a/app/src/main/res/drawable/chevron_forward.xml +++ b/app/src/main/res/drawable/chevron_forward.xml @@ -3,11 +3,12 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/chevron_up.xml b/app/src/main/res/drawable/chevron_up.xml index 257133c..1097389 100644 --- a/app/src/main/res/drawable/chevron_up.xml +++ b/app/src/main/res/drawable/chevron_up.xml @@ -1,13 +1,14 @@ - + + diff --git a/app/src/main/res/drawable/close.xml b/app/src/main/res/drawable/close.xml index 3b93ed8..fd3d5a2 100644 --- a/app/src/main/res/drawable/close.xml +++ b/app/src/main/res/drawable/close.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/color_palette.xml b/app/src/main/res/drawable/color_palette.xml index 5c347bc..637c9e5 100644 --- a/app/src/main/res/drawable/color_palette.xml +++ b/app/src/main/res/drawable/color_palette.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/delete.xml b/app/src/main/res/drawable/delete.xml new file mode 100644 index 0000000..883bcaa --- /dev/null +++ b/app/src/main/res/drawable/delete.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/disc.xml b/app/src/main/res/drawable/disc.xml index fd3f2a8..e30f9ab 100644 --- a/app/src/main/res/drawable/disc.xml +++ b/app/src/main/res/drawable/disc.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/download.xml b/app/src/main/res/drawable/download.xml index 9f5e415..7ee95a0 100644 --- a/app/src/main/res/drawable/download.xml +++ b/app/src/main/res/drawable/download.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/ellipsis_horizontal.xml b/app/src/main/res/drawable/ellipsis_horizontal.xml index 2695851..d7bc2bf 100644 --- a/app/src/main/res/drawable/ellipsis_horizontal.xml +++ b/app/src/main/res/drawable/ellipsis_horizontal.xml @@ -3,13 +3,14 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/ellipsis_vertical.xml b/app/src/main/res/drawable/ellipsis_vertical.xml deleted file mode 100644 index f3f6171..0000000 --- a/app/src/main/res/drawable/ellipsis_vertical.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/enqueue.xml b/app/src/main/res/drawable/enqueue.xml index c717a87..bcfc505 100644 --- a/app/src/main/res/drawable/enqueue.xml +++ b/app/src/main/res/drawable/enqueue.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/equalizer.xml b/app/src/main/res/drawable/equalizer.xml index d366546..e07566b 100644 --- a/app/src/main/res/drawable/equalizer.xml +++ b/app/src/main/res/drawable/equalizer.xml @@ -3,13 +3,14 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/film.xml b/app/src/main/res/drawable/film.xml index 5e334a8..6774ad9 100644 --- a/app/src/main/res/drawable/film.xml +++ b/app/src/main/res/drawable/film.xml @@ -1,9 +1,12 @@ - + + diff --git a/app/src/main/res/drawable/globe.xml b/app/src/main/res/drawable/globe.xml index 10a3b37..98af4fe 100644 --- a/app/src/main/res/drawable/globe.xml +++ b/app/src/main/res/drawable/globe.xml @@ -1,33 +1,36 @@ - - - - - - - - - + + + + + + + + + + diff --git a/app/src/main/res/drawable/heart.xml b/app/src/main/res/drawable/heart.xml index 2a71a1e..738c5be 100644 --- a/app/src/main/res/drawable/heart.xml +++ b/app/src/main/res/drawable/heart.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/heart_dislike.xml b/app/src/main/res/drawable/heart_dislike.xml deleted file mode 100644 index 510c67d..0000000 --- a/app/src/main/res/drawable/heart_dislike.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/heart_outline.xml b/app/src/main/res/drawable/heart_outline.xml index ba8e689..af0579b 100644 --- a/app/src/main/res/drawable/heart_outline.xml +++ b/app/src/main/res/drawable/heart_outline.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/ic_banner_foreground.xml b/app/src/main/res/drawable/ic_banner_foreground.xml index d4f6fc4..d5a8d37 100644 --- a/app/src/main/res/drawable/ic_banner_foreground.xml +++ b/app/src/main/res/drawable/ic_banner_foreground.xml @@ -1,46 +1,60 @@ - - - - - + android:viewportHeight="180" + tools:ignore="VectorRaster"> - - - - - - - - - - + + + + + + + + + + + + + + + + + - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 4065946..0cbe879 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -3,17 +3,19 @@ android:height="108dp" android:viewportWidth="122.98" android:viewportHeight="126.97"> - - - - + + + + + diff --git a/app/src/main/res/drawable/infinite.xml b/app/src/main/res/drawable/infinite.xml index b8444f3..493c8f9 100644 --- a/app/src/main/res/drawable/infinite.xml +++ b/app/src/main/res/drawable/infinite.xml @@ -3,16 +3,17 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/information.xml b/app/src/main/res/drawable/information.xml index 97e7373..551c2e4 100644 --- a/app/src/main/res/drawable/information.xml +++ b/app/src/main/res/drawable/information.xml @@ -3,20 +3,21 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/library.xml b/app/src/main/res/drawable/library.xml index 1105723..4f80b0f 100644 --- a/app/src/main/res/drawable/library.xml +++ b/app/src/main/res/drawable/library.xml @@ -3,22 +3,23 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - + + + + + + + diff --git a/app/src/main/res/drawable/link.xml b/app/src/main/res/drawable/link.xml deleted file mode 100644 index c4de0e7..0000000 --- a/app/src/main/res/drawable/link.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/medical.xml b/app/src/main/res/drawable/medical.xml index aaba699..b7344aa 100644 --- a/app/src/main/res/drawable/medical.xml +++ b/app/src/main/res/drawable/medical.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/musical_notes.xml b/app/src/main/res/drawable/musical_notes.xml index 8d3d474..e63dfa1 100644 --- a/app/src/main/res/drawable/musical_notes.xml +++ b/app/src/main/res/drawable/musical_notes.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/notifications.xml b/app/src/main/res/drawable/notifications.xml deleted file mode 100644 index a06a9c2..0000000 --- a/app/src/main/res/drawable/notifications.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/app/src/main/res/drawable/pause.xml b/app/src/main/res/drawable/pause.xml index 3280645..4ce2015 100644 --- a/app/src/main/res/drawable/pause.xml +++ b/app/src/main/res/drawable/pause.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/pencil.xml b/app/src/main/res/drawable/pencil.xml index c1c2df0..f053326 100644 --- a/app/src/main/res/drawable/pencil.xml +++ b/app/src/main/res/drawable/pencil.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/person.xml b/app/src/main/res/drawable/person.xml index 4fcfdc8..57334fe 100644 --- a/app/src/main/res/drawable/person.xml +++ b/app/src/main/res/drawable/person.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/play.xml b/app/src/main/res/drawable/play.xml index 4951da4..84199e2 100644 --- a/app/src/main/res/drawable/play.xml +++ b/app/src/main/res/drawable/play.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/play_skip_back.xml b/app/src/main/res/drawable/play_skip_back.xml index 14602d8..6a5047c 100644 --- a/app/src/main/res/drawable/play_skip_back.xml +++ b/app/src/main/res/drawable/play_skip_back.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/play_skip_forward.xml b/app/src/main/res/drawable/play_skip_forward.xml index 24f14c4..b227e50 100644 --- a/app/src/main/res/drawable/play_skip_forward.xml +++ b/app/src/main/res/drawable/play_skip_forward.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/playlist.xml b/app/src/main/res/drawable/playlist.xml index 05a33e6..8c499c4 100644 --- a/app/src/main/res/drawable/playlist.xml +++ b/app/src/main/res/drawable/playlist.xml @@ -3,44 +3,45 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - + + + + + + + + diff --git a/app/src/main/res/drawable/radio.xml b/app/src/main/res/drawable/radio.xml index 4fa7b4a..0de0905 100644 --- a/app/src/main/res/drawable/radio.xml +++ b/app/src/main/res/drawable/radio.xml @@ -3,25 +3,26 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - - - + + + + + + + + diff --git a/app/src/main/res/drawable/remove_circle_outline.xml b/app/src/main/res/drawable/remove_circle_outline.xml new file mode 100644 index 0000000..a927613 --- /dev/null +++ b/app/src/main/res/drawable/remove_circle_outline.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/reorder.xml b/app/src/main/res/drawable/reorder.xml index 5d31552..754826e 100644 --- a/app/src/main/res/drawable/reorder.xml +++ b/app/src/main/res/drawable/reorder.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml index 7a42efa..2f3db67 100644 --- a/app/src/main/res/drawable/search.xml +++ b/app/src/main/res/drawable/search.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/server.xml b/app/src/main/res/drawable/server.xml index b481fd9..7390f0d 100644 --- a/app/src/main/res/drawable/server.xml +++ b/app/src/main/res/drawable/server.xml @@ -3,16 +3,17 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - + + + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml new file mode 100644 index 0000000..a709655 --- /dev/null +++ b/app/src/main/res/drawable/settings.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/shapes.xml b/app/src/main/res/drawable/shapes.xml index 3693f7e..540cbfc 100644 --- a/app/src/main/res/drawable/shapes.xml +++ b/app/src/main/res/drawable/shapes.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/share_social.xml b/app/src/main/res/drawable/share_social.xml index c992b57..58161a0 100644 --- a/app/src/main/res/drawable/share_social.xml +++ b/app/src/main/res/drawable/share_social.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/shuffle.xml b/app/src/main/res/drawable/shuffle.xml index 330b175..e55bf15 100644 --- a/app/src/main/res/drawable/shuffle.xml +++ b/app/src/main/res/drawable/shuffle.xml @@ -3,39 +3,40 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - - - + + + + + + diff --git a/app/src/main/res/drawable/sort.xml b/app/src/main/res/drawable/sort.xml deleted file mode 100644 index b0ca74f..0000000 --- a/app/src/main/res/drawable/sort.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - diff --git a/app/src/main/res/drawable/sparkles.xml b/app/src/main/res/drawable/sparkles.xml index e0c6622..dd569e2 100644 --- a/app/src/main/res/drawable/sparkles.xml +++ b/app/src/main/res/drawable/sparkles.xml @@ -3,13 +3,14 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/speed.xml b/app/src/main/res/drawable/speed.xml new file mode 100644 index 0000000..4126ae3 --- /dev/null +++ b/app/src/main/res/drawable/speed.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/star.xml b/app/src/main/res/drawable/star.xml index 6313be6..dbaad33 100644 --- a/app/src/main/res/drawable/star.xml +++ b/app/src/main/res/drawable/star.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/sync.xml b/app/src/main/res/drawable/sync.xml index c20ef5e..896db86 100644 --- a/app/src/main/res/drawable/sync.xml +++ b/app/src/main/res/drawable/sync.xml @@ -3,25 +3,26 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - - + + + + diff --git a/app/src/main/res/drawable/text.xml b/app/src/main/res/drawable/text.xml index 70c024a..28bdf41 100644 --- a/app/src/main/res/drawable/text.xml +++ b/app/src/main/res/drawable/text.xml @@ -3,10 +3,11 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/time.xml b/app/src/main/res/drawable/time.xml index 13be868..a6d20af 100644 --- a/app/src/main/res/drawable/time.xml +++ b/app/src/main/res/drawable/time.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/trash.xml b/app/src/main/res/drawable/trash.xml index 9025eaa..391946e 100644 --- a/app/src/main/res/drawable/trash.xml +++ b/app/src/main/res/drawable/trash.xml @@ -3,7 +3,8 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - + + diff --git a/app/src/main/res/drawable/trending.xml b/app/src/main/res/drawable/trending.xml index ed34e01..78df21f 100644 --- a/app/src/main/res/drawable/trending.xml +++ b/app/src/main/res/drawable/trending.xml @@ -3,18 +3,19 @@ android:height="24dp" android:viewportWidth="512" android:viewportHeight="512"> - - + + + diff --git a/app/src/main/res/drawable/trending_up.xml b/app/src/main/res/drawable/trending_up.xml new file mode 100644 index 0000000..4e85252 --- /dev/null +++ b/app/src/main/res/drawable/trending_up.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/volume_up.xml b/app/src/main/res/drawable/volume_up.xml new file mode 100644 index 0000000..5c36f58 --- /dev/null +++ b/app/src/main/res/drawable/volume_up.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 55e632d..9c5b452 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index c8c9275..03004cc 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 25f6472..701e00d 100644 Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 6b8ba77..b563322 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index b37ca66..e4f75ce 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index ad20e13..e32b07a 100644 Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index ee28e15..93244f2 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 2b44f55..cbec00e 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index cf4b9ff..d26e146 100644 Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index aa00305..6567716 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index b9d2838..9276d50 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index a1f1620..a96911e 100644 Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 281042d..3a59867 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index 7bdeae1..62a954d 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index 3cb030b..6e084b5 100644 Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..50b6e52 --- /dev/null +++ b/app/src/main/res/values-de/strings.xml @@ -0,0 +1,365 @@ + + + %1$dmin + %1$dStd. + %1$dd + %1$s verbleiben + %1$s dB + %1$s kbps + %1$s ms + + Mehr von %1$s + Top %1$s + Meine Top %1$s + Top %1$s… + %1$sx + + 24 Stunden + 1 Woche + 1 Monat + 1 Jahr + Alles + + Songs + Wiedergabelisten + Alben + Künstler + Singles + + Favoriten + Offline + Übersicht + Bibliothek + Entdecken + Lokal + Startseite + Stimmung + Online + Videos + Info + Erscheinungsbild + Farben + Formen + + Zurückspringen + Vorwärts springen + Abspielen + Pause + Like + Shuffle + Sync + Radio Starten + Als nächstes wiedergeben + Einreihen + + Schlaf-Timer + Schlaf-Timer beendet + Möchten Sie den Schlaf-Timer anhalten? + Equalizer + Schlaf-Timer einstellen + + Abbrechen + Fertig + Bestätigen + Nein + Stop + Einstellung + Ausblenden + Umbenennen + Löschen + Reset + Löschen + Alle anzeigen + + an + aus + Unbekannt + Jetzt spielt + + Name der Wiedergabeliste eingeben + Neue Wiedergabeliste + Zur Wiedergabeliste hinzufügen + + Zum Album gehen + + Auf YouTube ansehen + Öffnen in YouTube Musik + Wiedergabeliste auf YouTube ansehen + YouTube Music is not installed on your device! + + Aus der Warteschlange entfernen + Aus der Wiedergabeliste entfernen + + Ausblenden von \"Schnell-Auswahl\" + Möchten Sie dieses Lied wirklich ausblenden? Die Wiedergabezeit und der Cache werden gelöscht. Diese Aktion ist nicht umkehrbar. + Willst du diese Wiedergabeliste wirklich löschen? + + Alternative Version + Dieses Album hat keine alternative Version + + Quelle: Wikipedia + Von Wikipedia unter Creative Commons Namensnennung CC-BY-SA 3.0 + + + + Stimmungen und Genres + Neu veröffentlichte Alben + + Dieser Künstler hat noch kein Album veröffentlicht + Dieser Künstler hat noch keine Single veröffentlicht + + Es ist ein Fehler aufgetreten + Diese lokale Musikdatei existiert nicht mehr + Es ist ein Netzwerkfehler aufgetreten + Kann kein abspielbares Audioformat finden + Die ursprüngliche Videoquelle zu diesem Lied wurde gelöscht + Dieses Lied kann aufgrund von Serverbeschränkungen nicht abgespielt werden + Die zurückgegebene Video-ID stimmt nicht mit der angeforderten überein + Es ist ein unbekannter Wiedergabefehler aufgetreten + Bei der Verknüpfung Ihres Piped-Kontos ist ein unbekannter Fehler aufgetreten. Bitte versuchen Sie es erneut. + Liste der Piped-Instanzen ist derzeit nicht verfügbar. + Es ist ein Fehler bei der Initialisierung von Bass Boost aufgetreten. Wahrscheinlich unterstützt Ihr Gerät diese Funktion nicht. Ändern Sie den Bassverstärkungspegel oder versuchen Sie es erneut. + An unknown error occurred during pre-caching. Please try again. + + Berechtigung abgelehnt, bitte erteilen Sie die Medienberechtigung in den Einstellungen Ihres Geräts. + Einstellungen öffnen + Keine Elemente gefunden + Keine Ergebnisse gefunden. Bitte versuchen Sie eine andere Anfrage oder Kategorie. + + Konnte keine Anwendung zum Surfen im Internet finden + Konnte keine Anwendung zum Entzerren von Audio finden + Konnte keine Anwendung zum Erstellen von Dokumenten finden + Konnte keine Einstellungen zur Batterieoptimierung finden, bitte ViMusic manuell auf die Whitelist setzen + + Ähnliche Alben + Ähnliche Künstler + Wiedergabelisten, die Ihnen gefallen könnten + + Geben Sie den Liedtext ein + Es konnten kein Liedtext gefunden werden + Liedtext auswählen + Beim Abrufen des synchronisierten Liedtextes ist ein Fehler aufgetreten + Beim Abrufen des Liedtextes ist ein Fehler aufgetreten + Für dieses Lied sind keine synchronisierten Liedtexte verfügbar. + Für dieses Lied ist kein Text verfügbar + Ungültiger synchronisierter Liedtext, rufen Sie den Text erneut ab oder bearbeiten Sie ihn und versuchen Sie es erneut + Unsynchronisierten Liedtext anzeigen + Synchronisierte Liedtexte anzeigen + Zur Verfügung gestellt von lrclib.net & kugou.com + Liedtext bearbeiten + Liedtexte online suchen + Liedext erneut laden + Liedtext von lrclib.net auswählen + Start-Offset einstellen + Verschiebt den synchronisierten Liedtext um die aktuelle Wiedergabezeit + + Wiedergabegeschwindigkeit + Warum haben Sie das getan?! + Song-Lautstärke erhöhen + Liedschleife + Warteschleife + Warteschlange zur Wiedergabeliste hinzufügen + + Id + Itag + Bitrate + Größe + Zwischengespeichert + Lautstärke + + Album ansehen + Wiedergabeliste ansehen + + Suchen… + v%1$s von vfsfitvnm + Soziales + Kontakt + GitHub + Den Quellcode anzeigen + Ein Problem melden + Wenn Sie Hilfe bei einem Fehler benötigen, können Sie einen Fehler auf GitHub melden (zum Weiterleiten klicken) + Eine Funktion anfordern oder eine Idee vorschlagen + Sie werden zu GitHub weitergeleitet + + Thema + Standard + Dynamisch + Rein Schwarz + + Theme-Modus + Hell + Dunkel + System + + Rundheit der Vorschaubilder + Keine + Leicht + Mittel + Stark + Noch stärker + Stärkste + + Text + Systemschriftart verwenden + Verwenden Sie die vom System verwendete Schriftart + Auffüllen der Schrift + Textabstände hinzufügen + + Sperrbildschirm + Song-Cover anzeigen + Verwenden Sie das Cover des gespielten Songs als Hintergrundbild für den Sperrbildschirm + + Player + Vorherige Taste, wenn sie zugeklappt ist + Zeigt die Schaltfläche für den vorherigen Titel an, wenn der Player zusammengeklappt ist. + Horizontal wischen zum Schließen + Schließt den Player, wenn Sie auf dem eingeklappten Player nach links/rechts wischen. Nützlich für Benutzer, bei denen der Einhandmodus von Android aktiviert ist. + Schaltfläche "Gefällt-Mir" anzeigen + Anzeigen der Gefällt-Mir-Schaltfläche direkt im Player + Wischen zum Entfernen eines Titels + Wischen Sie nach links, um ein Element aus der Warteschlange zu entfernen + + Zwischenspeicher + Wenn der Platz im Zwischenspeicher voll ist, werden die Ressourcen, auf die am längsten nicht mehr zugegriffen wurde, gelöscht. + Bild-Zwischenspeicher + Max. Größe + %1$s verwendet (%2$s%%) + Song-Zwischenspeicher + verwendet + Datenbank + + Zurücksetzen + Pausieren des Wiedergabeverlaufs + Stoppt die Verwendung von Wiedergabeereignissen für die Schnellauswahl + Bitte beachten Sie: Dies hat keine Auswirkungen auf das Offline-Caching! + Schnellwahlen zurücksetzen + Schnellauswahlen werden gelöscht + Wiedergabezeit pausieren + Stoppt die Speicherung der Wiedergabedauer. Dadurch wird die Statistik in der Wiedergabeliste "Meine Top %1$s" angehalten! + + Sicherung + Persönliche Einstellungen (z. B. der Themenmodus) und der Zwischenspeicher sind ausgeschlossen. + Exportieren Sie die Musik-Datenbank auf den externen Speicher + Wiederherstellen + Vorhandene Daten werden überschrieben.\nViMusic schließt sich nach der Wiederherstellung der Datenbank automatisch. + Importieren Sie die Musik-Datenbank vom externen Speicher + + Sonstiges + Android Auto + Unterstützung für Android Auto aktivieren + Denken Sie daran, \"Unbekannte Quellen\" in den Entwicklereinstellungen von Android Auto zu aktivieren. + Suchverlauf + Pausieren des Suchverlaufs + Weder neue Suchanfragen speichern noch Verlauf anzeigen + Suchverlauf löschen + %1$s Suchanfragen löschen + Der Verlauf ist leer + Integrierte Wiedergabelisten + Meine Top Wiedergabeliste + Begrenzt die Länge der Wiedergabeliste \'Mein Top x\'. + + Akku-Optimierung + Wenn die Akkulaufzeit optimiert wird, kann die Benachrichtigung über die Wiedergabe plötzlich verschwinden, wenn die Wiedergabe angehalten wird. + Seit Android 12 ist die Deaktivierung der Akku-Optimierung erforderlich, damit die Option "Unbesiegbar" zur Verfügung steht. + Akku-Optimierungen ignorieren + Beschränkung bereits aufgehoben + Hintergrundbeschränkungen deaktivieren + Unbesiegbarer Service + Sollte die Wiedergabe in 99,99% der Fälle aufrechterhalten, falls das Ausschalten der Batterieoptimierungen nicht ausreicht. + + Brauchen Sie Hilfe? + In den meisten Fällen ist es nicht die Schuld des Entwicklers (selbst nach dem Einschalten des unbesiegbaren Dienstes), dass die App im Hintergrund nicht mehr richtig funktioniert.\nPrüfen Sie, ob Ihr Gerätehersteller Ihre Apps löscht (zum Weiterleiten klicken) + Wenn Sie wirklich glauben, dass etwas mit der App selbst nicht stimmt, gehen Sie auf die Registerkarte Info + Fehlersuche + Achtung: Verwenden Sie diese Tasten als letzten Ausweg, wenn die Audiowiedergabe fehlschlägt + App-Interna neu laden + Anwendung beenden + Abschnitt zur Fehlerbehebung anzeigen + + Player & Audio + Dauerhafte Warteschlange + Abgespielte Titel speichern und wiederherstellen + Wiedergabe fortsetzen + Wenn ein kabelgebundenes oder Bluetooth-Gerät angeschlossen ist + Anhalten wenn geschlossen + Wenn Sie die App schließen, wird die Musik nicht mehr abgespielt + + Audio + Stille überspringen + Stille Teile während der Wiedergabe überspringen + Mindestlänge der Stille + Die Mindestzeit, die der Ton still sein muss, um übersprungen zu werden + Damit die Änderungen wirksam werden, muss der Player neu gestartet werden! + Dienst neu starten + Lautstärke-Normalisierung + Einstellen der Lautstärke auf einen festen Wert + Lautstärke Basisverstärkung + Die "Ziel"-Verstärkung für die Lautstärke-Normalisierung + Bassverstärkung + Verstärkung der tiefen Frequenzen für ein besseres Hörerlebnis + Pegel der Bassverstärkung + Pegel (0–1000) der Anhebung der tiefen Frequenzen; Verwendung auf eigene Gefahr! + Mit dem System-Equalizer interagieren + + Suchen… + + Basierend auf dem letzten Lied + Trend + Startseite: Inhalte entdecken + + Spieler-Layout + Klassisch + Modern (neu) + + Piped + Instanz + Zum Auswählen anklicken + Benutzername + Passwort + Anmelden + Sie können Wiedergabelisten hosten und mit ViMusic synchronisieren. Derzeit wird nur Piped unterstützt. + Account hinzufügen + Verknüpfen Sie ein Piped-Konto mit Ihrer Instanz, Ihrem Benutzernamen und Ihrem Passwort. + Mehr erfahren + Sie wissen nicht, was Piped ist oder haben noch kein Account? Klicken Sie hier, um zu den Dokumentationen weitergeleitet zu werden. + Piped-Sitzung + Benutzerdefinierte Instanz verwenden + Instanz-API-URL + + Lied-Zeit-Achse + Statisch + Wellenförmig + + Von der schwarzen Liste entfernen + Zur schwarzen Liste hinzufügen + Schwarze Liste zurücksetzen + Schwarze Liste ist leer + + Zwischenspeichern + + Wischen, um Lied zu löschen + Wenn Sie einen Titel nach links wischen, wird er aus der Datenbank und dem Zwischenspeicher entfernt. + + Version + Nach Updates suchen + Sie verwenden derzeit die Version v%1$s + Beim Abrufen von Daten von GitHub ist ein unbekannter Fehler aufgetreten + Eine neue Version ist verfügbar + Weitere Informationen + Sie sind derzeit auf dem neuesten Stand: keine Updates verfügbar + + + Entferne %1$s Lied von der schwarzen Liste + Alle %1$s Songs von der Schwarzen Liste entfernen + + + + Wiedergabeereignis %1$s löschen + %1$s Wiedergabeereignisse löschen + + + + %1$d Lied + %1$d Lieder + + diff --git a/app/src/main/res/values-night-v29/themes.xml b/app/src/main/res/values-night-v29/themes.xml new file mode 100644 index 0000000..e49aedd --- /dev/null +++ b/app/src/main/res/values-night-v29/themes.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml index 0167352..2842c29 100644 --- a/app/src/main/res/values-night/themes.xml +++ b/app/src/main/res/values-night/themes.xml @@ -1,4 +1,4 @@ - + diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..2473679 --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,378 @@ + + + %1$dm + %1$du + %1$dd + nog %1$s + %1$s dB + %1$s kb/s + %1$s ms + %1$s pixels + + Meer van %1$s + Top %1$s + Mijn top %1$s + Laat top %1$s zien van… + %1$sx + + Afgelopen 24 uur + Afgelopen week + Afgelopen maand + Afgelopen jaar + Sinds het begin + + Songs + Afspeellijsten + Albums + Artiesten + Singles + + Favorieten + Offline + Overzicht + Bibliotheek + Ontdekken + Lokaal + Snelle keuzes + Stemming + Online + Video\'s + Over + Uiterlijk + Kleuren + Vormen + + Spring terug + Spring vooruit + Afspelen + Pauzeren + Like + Shuffle + Synchroniseer + Start radio + Als volgende afspelen + Zet in wachtrij + + Slaaptimer + Slaaptimer voorbij + Wil je de timer stoppen? + Equalizer + Slaaptimer instellen + + Annuleer + Klaar + Bevestigen + Nee + Stop + Instellen + Verbergen + Hernoemen + Verwijderen + Reset + Wissen + Toon alles + + aan + uit + Onbekend + Speelt nu + + Geef de naam van de afspeellijst + Nieuwe afspeellijst + Toevoegen aan afspeellijst + + Ga naar album + + Bekijk op YouTube + Open in YouTube Music + Bekijk playlist op YouTube + YouTube Music is niet geïnstalleerd op je apparaat! + + Verwijder van wachtrij + Verwijder van playlist + + Verberg van \"Snelle keuzes\" + Wil je echt dit nummer verbergen? De afspeeltijd en cache worden weggegooid. Deze actie is onomkeerbaar. + Wil je deze afspeellijst echt verbergen? + + Overige versies + Dit album heeft geen alternatieve versies + + Van Wikipedia + Van Wikipedia onder Creative Commons Naamsvermelding CC-BY-SA 3.0 + + + + Stemmingen en genres + Nieuwe uitgebrachte albums + + Deze artiest heeft geen albums uitbebracht + Deze artiest heeft geen singles uitbebracht + + Een error heeft zich voorgedaan + Dit lokale muziekbestand bestaat niet meer + Een netwerkfout heeft zich voorgedaan + Kon geen afspeelbaar audio formaat vinden + De originele videobron van dit nummer is verwijderd + Dit nummer kan niet worden afgespeeld door server restricties + De teruggegeven video ID is niet dezelfde als de gevraagde ID + Een onbekende afspeelfout heeft zich voorgedaan + Het linken met je Piped account is mislukt. Probeer het opnieuw. + De lijst van Piped instanties is niet beschikbaar + Er was een error bij het initialiseren van bass boost. Waarschijnlijk ondersteunt je apparaat het niet. Probeer het niveau aan te passen of probeer opnieuw. + Een onbekende error heeft zich voorgedaan bij het pre-cachen. Probeer het opnieuw. + + Permissie afgewezen, verleen media permissie in de instellingen van je apparaat. + Open instellingen + Geen items gevonden + Geen resultaten gevonden. Probeer een andere zoekterm of categorie + + Kan geen applicatie vinden om het internet te browsen + Kan geen applicatie vinden om audio bij te stellen + Kan geen applicatie vinden om bestanden te maken + Kan geen batterij optimalisatie instellingen vinden, maak handmatig een uitzondering voor ViMusic + + Gerelateerde albums + Vergelijkbare artiesten + Afspeelijsten die je misschien leuk vindt + + Voer de songtekst in + Geen songtekst gevonden + Kies songtekst + Er heeft zich een error voorgedaan bij het laden van de gesynchroniseerde songtekst + Er heeft zich een error voorgedaan bij het laden van de songtekst + Gesynchroniseerde songtekst niet beschikbaar + Songtekst niet beschikbaar + Ongeldige gesynchroniseerde songtekst, laad opnieuw in of bewerk de songtekst en probeer het opnieuw + Laat niet-gesynchroniseerde songtekst zien + Laat gesynchroniseerde songtekst zien + Verstrekt door lrclib.net & kugou.com + Bewerk songtekst + Zoek online naar songtekst + Laad songtekst opnieuw in + Kies songtekst van lrclib.net + Stel startpunt in + Laat de gesynchroniseerde songtekst beginnen vanaf de huidige tijd + + Afspeelsnelheid + Waarom zou je dat doen?! + Volume-boost + Nummer herhalen + Wachtrij herhalen + Voeg wachtrij toe aan afspeellijst + + Id + Itag + Bitrate + Grootte + Gecachet + Luidheid + + Toon album + Toon afspeellijst + + Zoek… + v%1$s door vfsfitvnm + Sociaal + Contact + GitHub + Toon broncode + Meld probleem + Als je hulp nodig hebt kun je een probleem melden op GitHub (klik om door te verwijzen) + Verzoek een functie of suggereer een idee + Je wordt doorverwezen naar GitHub + + Thema + Standaard + Dynamisch + PuurZwart + AMOLED + + Modus + Licht + Donker + Systeem + + Rondheid van miniaturen + Geen + Licht + Gemiddeld + Sterk + Nog sterker + Sterkste + + Tekst + Gebruik systeemlettertype + Gebruik het lettertype van het systeem + Gebruik marges rond lettertype + Voegt afstand toe tussen de tekst + + Vergrendelscherm + Toon albumhoes + Gebruik de albumhoes van het afspelende nummer als achtergrond op het vergrendelscherm + + Speler + Vorige knop op ingeklapte speler + Laat de knop om naar het vorige nummer te gaan zien op de ingeklapte speler + Swipe horizontaal om te sluiten + Sluit de speler zodra je horizontaal swipet. Handig voor gebruikers met de één-hand modus van Android. + Toon like knop + Laat de like knop direct in de speler zien + Swipe om item te verwijderen + Swipe naar links om een nummer uit de wachtrij te halen + + Cache + Wanneer de cache vol raakt worden de bestanden die het langst niet zijn gebruikt verwijderd + Afbeeldingcache + Maximale grootte + %1$s gebruikt (%2$s%%) + Songcache + gebruikt + Database + + Opruimen + Pauzeer afspeelgeschiedenis + Stopt de verzameling van afspeelgebeurtenissen voor Snelle keuzes + Let op: dit verandert niets aan offline cachen! + Reset Snelle keuzes + Snelle keuzes geleegd + Pauzeer afspeeltijd + Stopt met het opslaan van afspeeltijd. Dit pauzeert de statistieken in de \'Mijn top %1$s\' afspeellijst + + Back-up + Persoonlijke voorkeuren (bijvoorbeeld het thema) en de cache worden niet opgeslagen. + Exporteer de database naar de externe opslag + Herstellen + Bestaande data zal worden vervangen. ViMusic sluit zichzelf automatisch nadat de database is hersteld. + Importeer de database van de externe opslag + + Overig + Android Auto + Zet Android Auto support aan + Vergeet niet om \"Onbekende bronnen\" aan te zetten in de Developer Settings van Android Auto. + Zoekgeschiedenis + Pauzeer zoekgeschiedenis + Sla nieuwe zoektermen niet op en toon geen geschiedenis + Verwijder zoekgeschiedenis + Verwijder %1$s zoektermen + Geschiedenis is leeg + Ingebouwde afspeellijsten + Lengte Top afspeellijst + Limiteert de lengte van de \'Mijn top x\' afspeellijst + + Levensduur van de service + Als batterijoptimalisatie is toegepast kan de afspeelnotificatie prompt verdwijnen wanneer de speler is gepauzeerd. + Sinds Android 12 is het uitzetten van batterijoptimalisatie verplicht om de onaantastbare service aan te kunnen zetten. + Zet batterijoptimalisatie uit + Restrictie opgeheven + Zet achtergrondrestricties uit + Onaantastbare service + Dit zou het afspelen 99.99% van de tijd in stand moeten houden, voor wanneer het uitzetten van batterijoptimalisatie niet genoeg is + + Hulp nodig? + Vaak is het niet de fout van de ontwikkelaar (zelfs na het aanzetten van de onaantastbare service) dat de app soms stopt goed te werken in de achtergrond. Controleer of jouw fabrikant jouw apps ongevraagd beëindigt (klik om door te sturen) + Als je echt denkt dat er iets mis is met de app zelf, ga dan naar het Over tabblad + Foutopsporing + Voorzichtig: gebruik deze knoppen alleen als een laatste hoop als het afspelen niet werkt + Herlaad interne onderdelen van de app + Beëindig app + Laat foutopsporing zien + + Speler & Audio + Blijvende wachtrij + Wachtrij opslaan en herstellen + Hervat afspelen + Zodra een bedraad of Bluetooth apparaat is aangesloten + Stop bij sluiten + Wanneer je de app afsluit stopt de muziek met afspelen + + Audio + Sla stilte over + Sla stille gedeeltes over tijdens het afspelen + Minimale duur stilte + De minimale tijd dat de muziek stil moet zijn om over te worden geslagen + De speler moet worden herstart om de wijzigingen te bevestigen. + Herstart service + Normalisering + Pas het volume van de audio aan naar een vast niveau + Basisversterking + De doelwaarde voor de normalisering + Bass boost + Versterk lage frequenties om luisterervaring te verbeteren + Bass boost niveau + Niveau (0–1000) van het versterken van lage frequenties; gebruik op eigen risico! + Gebruik de equalizer van het systeem + + Filteren… + + Laatst afgespeeld + Trending + Bron voor Snelle keuzes + + Lay-out van de speler + Klassiek + Modern (nieuw) + + Piped + Instantie + Klik om te selecteren + Gebruikersnaam + Wachtwoord + Login + Je kan playlists elders bewaren en synchroniseren met ViMusic. Ondersteunt momenteel alleen Piped. + Account toevoegen + Link een Piped account met jouw instantie, gebruikersnaam en wachtwoord + Meer informatie + Geen idee wat Piped is of geen account? Klik hier om doorgestuurd te worden naar de documentatie + Piped sessies + Gebruik andere instantie + API URL van de instantie + + Stijl van de zoekbalk + Statisch + Golvend + Kwaliteit van de golvende zoekbalk + Slecht + Laag + Gemiddeld + Hoog + Fantastisch + Subpixel + + Haal van de zwarte lijst af + Voeg toe aan de zwarte lijst + Reset zwarte lijst + Zwarte lijst leeg + + Van tevoren cachen + + Swipe om song te verbergen + Wanneer je een song naar links swipet wordt deze verwijderd uit de database en cache + + Versie + Zoek naar updates + Je gebruikt momenteel versie v%1$s + Een onbekende fout heeft zich voorgedaan tijdens het verkrijgen van data bij GitHub + Er is een nieuwe versie beschikbaar + Meer informatie + Je bent up-to-date: er is geen update beschikbaar + + Dynamische miniaturen + Maximale dynamische miniatuurgrootte + De maximale grootte van een miniatuur wanneer een dynamische miniatuur wordt gebruikt + + + Haal %1$s nummer van de zwarte lijst + Haal %1$s nummers van de zwarte lijst + + + + Verwijder %1$s afspeelgebeurtenis + Verwijder %1$s afspeelgebeurtenissen + + + + %1$d nummer + %1$d nummers + + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml new file mode 100644 index 0000000..47f0ccc --- /dev/null +++ b/app/src/main/res/values-ru/strings.xml @@ -0,0 +1,317 @@ + + + %1$dm + %1$dh + %1$dd + осталось %1$s + %1$s дБ + %1$s kbps + %1$s ms + %1$spx + ещё от %1$s + Топ %1$s + Мой топ %1$s + Топ %1$s за период: + %1$sx + Сутки + Неделя + Месяц + Год + Всё время + Песни + Плейлисты + Альбомы + Исполнители + Синглы + Любимое + Сохранённое + Обзор + Библиотека + Навигация + Локальное + Обзор + Настроение + Онлайн + Видео + Инфо + Внешний вид + Цвета + Формы + Предыдущая + Следующая + Играть + Пауза + Лайк + Повтор + Синхронизация + Включить радио + Играть следующим + В очередь + Таймер сна + Таймер сна завершён + Остановить таймер сна? + Эквалайзер + Установить таймер сна + Отмена + Готово + Подтвердить + Нет + Стоп + Установить + Скрыть + Переименовать + Удалить + Сбросить + Очистить + См. все + вкл + выкл + Неизвестный + Сейчас играет + Введите название плейлиста + Новый плейлист + Добавить в плейлист + Перейти в альбом + Смотреть в YouTube + Открыть в YouTube Music + Смотреть плейлист в YouTube + YT Music не установлен. Скачайте его из Play Market + Убрать из очереди + Убрать из плейлиста + Скрыть из обзора + Вы действительно хотите скрыть эту песню? Время ее воспроизведения и кэш будут удалены. Это действие необратимо. + Вы точно хотите удалить плейлист? + Другие версии + Нет других версий + Из Википедии + Из Википедии с атрибуцией Creative Commons CC-BY-SA 3.0 + + + Настроения и жанры + Новые альбомы + Этот исполнитель не выпустил ни одного альбома + Этот исполнитель не выпустил ни одного сингла + Какая-то ошибка... Попробуйте перезапустить приложение. + Локальный файл песни больше недоступен + Ошибка сети. Вы подключены к интернету? + Не найден подходящий формат + Видео в YouTube было удалено. Попробуйте найти песню заново + На эту песню ввели ограничения в YouTube + ID видео вернул неверный ответ + Неизвестная ошибка воспроизведения + Ошибка при связке. Попробуйте еще раз + Временно недоступно + Произошла ошибка при инициализации усиления басов. Вероятно, ваше устройство не поддерживает его. Попробуйте изменить уровень усиления басов или повторите попытку. + Ошибка при сохранении. Попробуйте еще раз + Вы не предоставили разрешения. Предоставьте их в настройках устройства + Открыть настройки + Ничего не найдено + Нет результатов + Не найдено приложения + У вас не установлен эквалайзер + Нет приложения для создания документов + Не найдено настроек батареи + Похожие альбомы + Похожие исполнители + Плейлисты, которые вам понравятся + Введите текст + Текст этой песни не найден. + Выберите текст песни + Ошибка при загрузке синхронизированного текста + Ошибка при загрузке текста + Синхр. текст недоступен для этой песни + Текст не найден + Неверный синхронизированный текст, повторите выборку или отредактируйте текст и повторите попытку + Несинхр. текст + Синхр. текст + При помощи сервисов lrclib.net & kugou.com + Изменить текст + Найти текст онлайн + Обновить текст + Выбрать текст из базы данных + Старт. положение + Смещает синхронизированный текст песни на текущее время воспроизведения + Скорость воспроизведения + Не издевайтесь на песенкой + Усиление звука + Повтор песни + Повтор очереди + Добавить очередь в плейлист + id + itag + Битрейт + Размер + Кешировано + Громкость + Посмотреть альбом + Посмотреть плейлист + Введите название + v%1$s от Hamy Studio + Социальное + Контакты + GitHub + Исходный код + Сообщить о проблеме + Если вам нужна помощь с ошибкой, вы можете отправить сообщение о проблеме в телеграм (нажмите, чтобы открыть) + Запрос функции или предложение идеи + Вы будете перенаправлены в GitHub + Тема + Обычная + Динамичная + Абсолютно чёрная + Амолед + Ночная тема + Светлая + Тёмная + Системная + Скругление + Отключено + Лёгкое + Среднее + Сильное + Ещё сильнее + Максимальное + Текст + Использовать системный шрифт + Использовать шрифт, который установлен в системе + Отступы шрифта + Интервалы текста + Заблокированный экран + Показывать обложку + Использовать обложку песни в качестве обоев (не на всех версиях Android) + Плеер + Кнопка предыдущей песни + Показывает кнопку переключения на предыдущую песню в мини плеере + Свайп по горизонтали + Остановит плеер при свайпе плеера в бок + Показывать кнопку лайка + Показывать кнопку лайка в плеере + Свайп для удаления + Свайп по треку в бок для удаления из очереди + Кэш + Когда в кэше заканчивается свободное место, очищаются ресурсы, которые давно не используются + Кэш картинок + Макс. размер + %1$s использовано (%2$s%%) + Кэш песен + использовано + Данные + Очистить + Приостановить историю песен + Приостановит ведение истории прослушанных песен + Пожалуйста, обратите внимание: это не повлияет на автономное кэширование! + Сброс вкладки обзор + Очистить обзор + Пауза времени воспроизведения + Останавливает сохранение времени воспроизведения. При этом статистика в плейлисте \"Мой топ %1$s\" приостанавливается! + Бэкап + Исключаются настройки (тема и т.д.) + Экспорт базы данных в локальное хранилище + Восстановление + Существующие данные будут перезаписаны + Импорт базы данных + Другое + Андроид авто + Включить поддержку + Не забудьте включить \"Неизвестные источники\" в настройках разработчика Android Auto. + История поиска + Пауза истории поиска + Не сохранять новые поисковые запросы, не показывать историю + Очистить историю поиска + Удалить %1$s историй поиска + История пуста + Встроенные плейлисты + Длина топ-листа + Сколько песен должно быть в плейлисте \"Мой топ\"? + Работа в фоне + Если включена оптимизация заряда батареи, уведомление с плеером может внезапно исчезнуть + Начиная с Android 12, для доступности опции invincible service требуется отключить оптимизацию заряда батареи. + Игнор. оптимизации батареи + Ограничение уже снято + Отключить ограничения работы в фоне + Invincible service + Воспроизведение должно продолжаться в 99,99%. Это на случай, если отключения оптимизации заряда батареи не помогает + Нужна помощь? + В большинстве случаев разработчик не виноват (даже после включения службы invincible) в том, что приложение перестает работать в фоновом режиме. Проверьте, не отключает ли вам смартфон ваши приложения (нажмите, чтобы открыть) + Если вы действительно считаете, что с самим приложением что-то не так, перейдите на вкладку \"О программе\". + Диагностика + Внимание: используйте эти кнопки в качестве крайней меры при сбое воспроизведения звука + Интервал перезапуска + Убить приложение + Открыть диагностику + Плеер & Аудио + Постоянная очередь + Сохранять и восстанавливать песни + Возобновить воспроизведение + Когда подключается bluetooth + Остановить при закрытии + Останавливать песню при закрытии приложения + Аудио + Пропуск тишины + Пропускать тихие моменты в песне + Минимальная длина тишины + Минимальное время, в течение которого звук должен быть отключен, чтобы его пропустить + Муза будет перезапущена! + Перезапуск + Нормализация звука + Фиксированная громкость + Базовое усиление громкости + \"Целевое\" усиление для нормализации громкости + Усиление низких частот + Повышайте низкие частоты для улучшения качества прослушивания + Уровень усиления + Уровень усиления низких частот (0-1000); используйте на свой страх и риск! + Взаимодействие с системным эквалайзером + Поиск... + На основе прослушанного + Популярное + Источник вкладки обзор + Стиль плеера + Классическая + Материал 3 + Piped + Сервер + Нажмите, чтобы выбрать + Никнейм + Пароль + Войти + Вы можете размещать плейлисты в другом месте и синхронизировать их с Muza. В настоящее время поддерживается только Piped. + Добавить аккаунт + Войдите в аккаунт на сервере Piped + Информация + Не знаете, что такое Piped, или у вас нет учетной записи? Нажмите здесь, чтобы перейти к их документам + Сессии Piped + Кастомн. сервер + url + Стиль полосы в плеере + Статичная + Волнистая + Качество волны + Плохое + Ниже среднего + Среднее + Высокое + Отличное + Ультра + Убрать из чс + Добавить в чс + Сбросить чс + Черный список пуст + Сохранить + Свайп для скрытия песни + Когда вы проводите пальцем по песне влево, она удаляется из базы данных и кэша + Версия + Проверить обновления + Текущая версия %1$s + Ошибка... + Доступная новая версия + Информация + У вас последняя версия + Динамичные обложки + Макс. размер обложки + Максимальный размер миниатюры при использовании динамической миниатюры + Прокси + Включить прокси + Обновить + RuStore + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml deleted file mode 100644 index 5f9e09d..0000000 --- a/app/src/main/res/values/colors.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #ffffff - diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml index fef3d10..00fcbd8 100644 --- a/app/src/main/res/values/ic_launcher_background.xml +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -1,4 +1,4 @@ - #EFE0FF + #EDE3FB \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..49022cf --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,384 @@ + + + %1$dm + %1$dh + %1$dd + %1$s left + %1$s dB + %1$s kbps + %1$s ms + %1$spx + More from %1$s + Top %1$s + My top %1$s + View top %1$s of … + %1$sx + + Past 24 hours + Past week + Past month + Past year + All time + + Songs + Playlists + Albums + Artists + Singles + + Favorites + Offline + Overview + Library + Discover + Local + Quick picks + Mood + Online + Videos + About + Appearance + Colors + Shapes + + Skip back + Skip forward + Play + Pause + Like + Shuffle + Sync + Start radio + Play next + Enqueue + + Sleep timer + Sleep timer ended + Do you want to stop the sleep timer? + Equalizer + Set sleep timer + + Cancel + Done + Confirm + No + Stop + Set + Hide + Rename + Delete + Reset + Clear + View all + + on + off + Unknown + Now playing + + Enter the playlist name + New playlist + Add to playlist + + Go to album + + Watch on YouTube + Open in YouTube Music + Watch playlist on YouTube + YouTube Music is not installed on your device! + + Remove from queue + Remove from playlist + + Hide from \"Quick picks\" + Do you really want to hide this song? Its playback time and cache will be wiped.\nThis action is irreversible. + Do you really want to delete this playlist? + + Other versions + This album doesn\'t have any alternative version + + From Wikipedia + From Wikipedia under Creative Commons Attribution CC-BY-SA 3.0 + + + + Moods and genres + New released albums + + This artist hasn\'t released an album yet + This artist hasn\'t released a single yet + + An error has occurred + This local music file does not exist anymore + A network error has occurred + Couldn\'t find a playable audio format + The original video source of this song has been deleted + This song cannot be played due to server restrictions + The returned video id doesn\'t match the requested one + An unknown playback error has occurred + There was an unknown error linking your Piped account. Please try again. + Piped instance list currently unavailable + There was an error initializing Bass Boost. Probably your device doesn\'t support it. Try changing the bass boost level or try again. + An unknown error occurred during pre-caching. Please try again. + + Permission declined, please grant media permissions in the settings of your device. + Open settings + No items found + No results found. Please try a different query or category + + Couldn\'t find an application to browse the internet + Couldn\'t find an application to equalize audio + Couldn\'t find an application to create documents + Couldn\'t find battery optimization settings, please whitelist ViMusic manually + + Related albums + Similar artists + Playlists you might like + + Enter the lyrics + No lyric tracks could be found + Choose lyric track + An error has occurred while fetching the synchronized lyrics + An error has occurred while fetching the lyrics + Synchronized lyrics are not available for this song + Lyrics are not available for this song + Invalid synchronized lyrics, refetch or edit the lyrics and try again + Show unsynchronized lyrics + Show synchronized lyrics + Provided by lrclib.net & kugou.com + Edit lyrics + Search lyrics online + Fetch lyrics again + Pick lyrics from lrclib.net + Set start offset + Offsets the synchronized lyrics by the current playback time + + Playback speed + Why would you do this?! + Song volume boost + Song loop + Queue loop + Add queue to playlist + + Id + Itag + Bitrate + Size + Cached + Loudness + + View album + View playlist + + Enter a name + v%1$s by vfsfitvnm + Social + Contact + GitHub + View the source code + Report an issue + If you need help with a bug you can file an issue on GitHub (click to redirect) + Request a feature or suggest an idea + You will be redirected to GitHub + + Theme + Default + Dynamic + PureBlack + AMOLED + + Theme mode + Light + Dark + System + + Thumbnail roundness + None + Light + Medium + Heavy + Even heavier + Heaviest + + Text + Use system font + Use the font applied by the system + Apply font padding + Add spacing around texts + + Lock screen + Show song cover + Use the playing song cover as the lockscreen wallpaper + + Player + Previous button while collapsed + Shows the previous song button while the player is collapsed + Swipe horizontally to close + Closes the player when swiping left/right on the collapsed player. Useful for users with Android\'s one-handed mode enabled. + Show like button + Show the like button directly in the player + Swipe to remove item + Swipe left to remove an item from the queue + + Cache + When the cache runs out of space, the resources that haven\'t been accessed for the longest time are cleared + Image cache + Max size + %1$s used (%2$s%%) + Song cache + used + Database + + Cleanup + Pause playback history + Stops playback events being used for quick picks + Please note: this won\'t affect offline caching! + Reset quick picks + Quick picks are cleared + Pause playback time + Stops playback time from being saved. This pauses the statistics in the \'My Top %1$s\' playlist! + + Backup + Personal preferences (i.e. the theme mode) and the cache are excluded. + Export the database to the external storage + Restore + Existing data will be overwritten.\nViMusic will automatically close itself after restoring the database. + Import the database from the external storage + + Other + Android Auto + Enable Android Auto support + Remember to enable \"Unknown sources\" in the Developer Settings of Android Auto. + Search history + Pause search history + Neither save new searched queries nor show history + Clear search history + Delete %1$s search queries + History is empty + Built-in playlists + Top list length + Limits the length of the \'My top x\' playlist + + Service lifetime + If battery optimizations are applied, the playback notification can suddenly disappear when paused. + Since Android 12, disabling battery optimizations is required for the invincible service option to be available. + Ignore battery optimizations + Restriction already lifted + Disable background restrictions + Invincible service + Should keep the playback going 99.99% of the time, in case turning off the battery optimizations is not enough + + Need help? + Most of the time, it is not the developer\'s fault (even after turning on invincible service) that the app stops working properly in the background. Check if your device manufacturer kills your apps (click to redirect) + If you really think there is something wrong with the app itself, hop on to the About tab + Troubleshooting + Caution: use these buttons as a last resort when audio playback fails + Reload app internals + Kill app + Show troubleshoot section + + Player & Audio + Persistent queue + Save and restore playing songs + Resume playback + When a wired or Bluetooth device is connected + Stop when closed + When you close the app, the music stops playing + + Audio + Skip silence + Skip silent parts during playback + Minimum silence length + The minimum time the audio has to be silent to get skipped + Player has to be restarted for the changes to be effective! + Restart service + Loudness normalization + Adjust the volume to a fixed level + Loudness base gain + The \'target\' gain for the loudness normalization + Bass boost + Boost low frequencies to improve listening experience + Bass boost level + Level (0–1000) of boosting low frequencies; use at own risk! + Interact with the system equalizer + + Filter… + + Last Played + Trending + Quick Picks Source + + Player layout + Classic + Modern (new) + + Piped + Instance + Click to select + Username + Password + Login + You can host playlists elsewhere and synchronize them with ViMusic. Currently only supports Piped. + Add account + Link a Piped account with your instance, username and password. + Learn more + Don\'t know what Piped is or don\'t have an account? Click here to get redirected to their docs + Piped sessions + Use Custom Instance + Instance API URL + + Seek bar style + Static + Wavy + Wavy seek bar quality + Poor + Low + Medium + High + Great + Subpixel + + Remove from blacklist + Add to blacklist + Reset blacklist + Blacklist empty + + Pre cache + + Swipe to hide song + When you swipe a song to the left, it gets removed from the database and cache + + Version + Check for updates + You\'re currently running version v%1$s + An unknown error occurred while fetching data from GitHub + A new version is available + More information + You\'re currently up-to-date: no updates available + + Dynamic thumbnails + Max dynamic thumbnail size + The maximum size of a thumbnail when a dynamic thumbnail is used + Proxy + Enable proxy + + + Remove %1$s song from the blacklist + Remove all %1$s songs from the blacklist + + + + Удалить %1$s историю воспроизведения + Удалить %1$s истории воспроизведения + + + + %1$d песен + %1$d песня + %1$d песни + %1$d песен + %1$d песен + + Update + RuStore + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 2035917..224f917 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,4 +1,4 @@ - + diff --git a/build.gradle.kts b/build.gradle.kts index 7490649..9ce2278 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,30 +1,53 @@ -buildscript { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } +import io.gitlab.arturbosch.detekt.Detekt +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - dependencies { - classpath("com.android.tools.build", "gradle", "7.3.0") - classpath(kotlin("gradle-plugin", libs.versions.kotlin.get())) - } +plugins { + alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.kotlin.serialization) apply false + alias(libs.plugins.kotlin.android) apply false + alias(libs.plugins.android.application) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.lint) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.detekt) } -tasks.register("clean", Delete::class) { - delete(rootProject.buildDir) +val clean by tasks.registering(Delete::class) { + delete(rootProject.layout.buildDirectory.asFile) } subprojects { - tasks.withType().configureEach { + tasks.withType().configureEach { kotlinOptions { - if (project.findProperty("enableComposeCompilerReports") == "true") { - arrayOf("reports", "metrics").forEach { - freeCompilerArgs = freeCompilerArgs + listOf( - "-P", "plugin:androidx.compose.compiler.plugins.kotlin:${it}Destination=${project.buildDir.absolutePath}/compose_metrics" - ) - } + if (project.findProperty("enableComposeCompilerReports") != "true") return@kotlinOptions + arrayOf("reports", "metrics").forEach { + freeCompilerArgs = freeCompilerArgs + listOf( + "-P", + "plugin:androidx.compose.compiler.plugins.kotlin:${it}Destination=${ + layout.buildDirectory.asFile.get().absolutePath + }/compose_metrics" + ) } } } } + +allprojects { + group = "it.hamy.muza" + version = "0.6.0" + + apply(plugin = "io.gitlab.arturbosch.detekt") + + detekt { + buildUponDefaultConfig = true + allRules = false + config.setFrom("$rootDir/detekt.yml") + } + + tasks.withType().configureEach { + jvmTarget = "11" + reports { + html.required = true + } + } +} diff --git a/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMap.kt b/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMap.kt deleted file mode 100644 index 13e6762..0000000 --- a/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMap.kt +++ /dev/null @@ -1,3 +0,0 @@ -package it.hamy.compose.persist - -typealias PersistMap = HashMap diff --git a/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMapCleanup.kt b/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMapCleanup.kt deleted file mode 100644 index 02c2c55..0000000 --- a/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMapCleanup.kt +++ /dev/null @@ -1,19 +0,0 @@ -package it.hamy.compose.persist - -import android.app.Activity -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.ui.platform.LocalContext - -@Composable -fun PersistMapCleanup(tagPrefix: String) { - val context = LocalContext.current - - DisposableEffect(context) { - onDispose { - if (context.findOwner()?.isChangingConfigurations == false) { - context.persistMap?.keys?.removeAll { it.startsWith(tagPrefix) } - } - } - } -} diff --git a/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMapOwner.kt b/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMapOwner.kt deleted file mode 100644 index b07413f..0000000 --- a/compose-persist/src/main/kotlin/it/hamy/compose/persist/PersistMapOwner.kt +++ /dev/null @@ -1,5 +0,0 @@ -package it.hamy.compose.persist - -interface PersistMapOwner { - val persistMap: PersistMap -} diff --git a/compose-persist/src/main/kotlin/it/hamy/compose/persist/Utils.kt b/compose-persist/src/main/kotlin/it/hamy/compose/persist/Utils.kt deleted file mode 100644 index e77129d..0000000 --- a/compose-persist/src/main/kotlin/it/hamy/compose/persist/Utils.kt +++ /dev/null @@ -1,16 +0,0 @@ -package it.hamy.compose.persist - -import android.content.Context -import android.content.ContextWrapper - -val Context.persistMap: PersistMap? - get() = findOwner()?.persistMap - -internal inline fun Context.findOwner(): T? { - var context = this - while (context is ContextWrapper) { - if (context is T) return context - context = context.baseContext - } - return null -} diff --git a/compose-reordering/src/main/AndroidManifest.xml b/compose-reordering/src/main/AndroidManifest.xml deleted file mode 100644 index 10728cc..0000000 --- a/compose-reordering/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingLazyColumn.kt b/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingLazyColumn.kt deleted file mode 100644 index 0eb1b73..0000000 --- a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingLazyColumn.kt +++ /dev/null @@ -1,40 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package it.hamy.compose.reordering - -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp - -@Composable -fun ReorderingLazyColumn( - reorderingState: ReorderingState, - modifier: Modifier = Modifier, - contentPadding: PaddingValues = PaddingValues(0.dp), - reverseLayout: Boolean = false, - verticalArrangement: Arrangement.Vertical = - if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, - horizontalAlignment: Alignment.Horizontal = Alignment.Start, - flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), - userScrollEnabled: Boolean = true, - content: LazyListScope.() -> Unit -) { - ReorderingLazyList( - modifier = modifier, - reorderingState = reorderingState, - contentPadding = contentPadding, - flingBehavior = flingBehavior, - horizontalAlignment = horizontalAlignment, - verticalArrangement = verticalArrangement, - isVertical = true, - reverseLayout = reverseLayout, - userScrollEnabled = userScrollEnabled, - content = content - ) -} diff --git a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingLazyList.kt b/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingLazyList.kt deleted file mode 100644 index 91f325a..0000000 --- a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingLazyList.kt +++ /dev/null @@ -1,293 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package it.hamy.compose.reordering - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.OverscrollEffect -import androidx.compose.foundation.checkScrollableContainerConstraints -import androidx.compose.foundation.clipScrollableContainer -import androidx.compose.foundation.gestures.FlingBehavior -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.ScrollableDefaults -import androidx.compose.foundation.gestures.scrollable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.calculateEndPadding -import androidx.compose.foundation.layout.calculateStartPadding -import androidx.compose.foundation.lazy.DataIndex -import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo -import androidx.compose.foundation.lazy.LazyListItemPlacementAnimator -import androidx.compose.foundation.lazy.LazyListItemProvider -import androidx.compose.foundation.lazy.LazyListMeasureResult -import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.foundation.lazy.LazyMeasuredItem -import androidx.compose.foundation.lazy.LazyMeasuredItemProvider -import androidx.compose.foundation.lazy.layout.LazyLayout -import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope -import androidx.compose.foundation.lazy.layout.lazyLayoutSemantics -import androidx.compose.foundation.lazy.lazyListBeyondBoundsModifier -import androidx.compose.foundation.lazy.lazyListPinningModifier -import androidx.compose.foundation.lazy.measureLazyList -import androidx.compose.foundation.lazy.rememberLazyListItemProvider -import androidx.compose.foundation.lazy.rememberLazyListSemanticState -import androidx.compose.foundation.overscroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.snapshots.Snapshot -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.MeasureResult -import androidx.compose.ui.platform.LocalLayoutDirection -import androidx.compose.ui.unit.Constraints -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.constrainHeight -import androidx.compose.ui.unit.constrainWidth -import androidx.compose.ui.unit.offset - -@OptIn(ExperimentalFoundationApi::class) -@Composable -internal fun ReorderingLazyList( - modifier: Modifier, - reorderingState: ReorderingState, - contentPadding: PaddingValues, - reverseLayout: Boolean, - isVertical: Boolean, - flingBehavior: FlingBehavior, - userScrollEnabled: Boolean, - horizontalAlignment: Alignment.Horizontal? = null, - verticalArrangement: Arrangement.Vertical? = null, - verticalAlignment: Alignment.Vertical? = null, - horizontalArrangement: Arrangement.Horizontal? = null, - content: LazyListScope.() -> Unit -) { - val overscrollEffect = ScrollableDefaults.overscrollEffect() - val itemProvider = rememberLazyListItemProvider(reorderingState.lazyListState, content) - val semanticState = - rememberLazyListSemanticState(reorderingState.lazyListState, itemProvider, reverseLayout, isVertical) - val beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo - val scope = rememberCoroutineScope() - val placementAnimator = remember(reorderingState.lazyListState, isVertical) { - LazyListItemPlacementAnimator(scope, isVertical) - } - reorderingState.lazyListState.placementAnimator = placementAnimator - - val measurePolicy = rememberLazyListMeasurePolicy( - itemProvider, - reorderingState.lazyListState, - beyondBoundsInfo, - overscrollEffect, - contentPadding, - reverseLayout, - isVertical, - horizontalAlignment, - verticalAlignment, - horizontalArrangement, - verticalArrangement, - placementAnimator - ) - - val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal - LazyLayout( - modifier = modifier - .then(reorderingState.lazyListState.remeasurementModifier) - .then(reorderingState.lazyListState.awaitLayoutModifier) - .lazyLayoutSemantics( - itemProvider = itemProvider, - state = semanticState, - orientation = orientation, - userScrollEnabled = userScrollEnabled - ) - .clipScrollableContainer(orientation) - .lazyListBeyondBoundsModifier(reorderingState.lazyListState, beyondBoundsInfo, reverseLayout) - .lazyListPinningModifier(reorderingState.lazyListState, beyondBoundsInfo) - .overscroll(overscrollEffect) - .scrollable( - orientation = orientation, - reverseDirection = ScrollableDefaults.reverseDirection( - LocalLayoutDirection.current, - orientation, - reverseLayout - ), - interactionSource = reorderingState.lazyListState.internalInteractionSource, - flingBehavior = flingBehavior, - state = reorderingState.lazyListState, - overscrollEffect = overscrollEffect, - enabled = userScrollEnabled - ), - prefetchState = reorderingState.lazyListState.prefetchState, - measurePolicy = measurePolicy, - itemProvider = itemProvider - ) -} - -@ExperimentalFoundationApi -@Composable -private fun rememberLazyListMeasurePolicy( - itemProvider: LazyListItemProvider, - state: LazyListState, - beyondBoundsInfo: LazyListBeyondBoundsInfo, - overscrollEffect: OverscrollEffect, - contentPadding: PaddingValues, - reverseLayout: Boolean, - isVertical: Boolean, - horizontalAlignment: Alignment.Horizontal? = null, - verticalAlignment: Alignment.Vertical? = null, - horizontalArrangement: Arrangement.Horizontal? = null, - verticalArrangement: Arrangement.Vertical? = null, - placementAnimator: LazyListItemPlacementAnimator -) = remember MeasureResult>( - state, - beyondBoundsInfo, - overscrollEffect, - contentPadding, - reverseLayout, - isVertical, - horizontalAlignment, - verticalAlignment, - horizontalArrangement, - verticalArrangement, - placementAnimator -) { - { containerConstraints -> - checkScrollableContainerConstraints( - containerConstraints, - if (isVertical) Orientation.Vertical else Orientation.Horizontal - ) - - val startPadding = - if (isVertical) { - contentPadding.calculateLeftPadding(layoutDirection).roundToPx() - } else { - contentPadding.calculateStartPadding(layoutDirection).roundToPx() - } - - val endPadding = - if (isVertical) { - contentPadding.calculateRightPadding(layoutDirection).roundToPx() - } else { - contentPadding.calculateEndPadding(layoutDirection).roundToPx() - } - val topPadding = contentPadding.calculateTopPadding().roundToPx() - val bottomPadding = contentPadding.calculateBottomPadding().roundToPx() - val totalVerticalPadding = topPadding + bottomPadding - val totalHorizontalPadding = startPadding + endPadding - val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding - val beforeContentPadding = when { - isVertical && !reverseLayout -> topPadding - isVertical && reverseLayout -> bottomPadding - !isVertical && !reverseLayout -> startPadding - else -> endPadding - } - val afterContentPadding = totalMainAxisPadding - beforeContentPadding - val contentConstraints = - containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) - - state.density = this - - itemProvider.itemScope.setMaxSize( - width = contentConstraints.maxWidth, - height = contentConstraints.maxHeight - ) - - val spaceBetweenItemsDp = if (isVertical) { - requireNotNull(verticalArrangement).spacing - } else { - requireNotNull(horizontalArrangement).spacing - } - val spaceBetweenItems = spaceBetweenItemsDp.roundToPx() - - val itemsCount = itemProvider.itemCount - - val mainAxisAvailableSize = if (isVertical) { - containerConstraints.maxHeight - totalVerticalPadding - } else { - containerConstraints.maxWidth - totalHorizontalPadding - } - val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) { - IntOffset(startPadding, topPadding) - } else { - IntOffset( - if (isVertical) startPadding else startPadding + mainAxisAvailableSize, - if (isVertical) topPadding + mainAxisAvailableSize else topPadding - ) - } - - val measuredItemProvider = LazyMeasuredItemProvider( - contentConstraints, - isVertical, - itemProvider, - this - ) { index, key, placeables -> - val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems - LazyMeasuredItem( - index = index.value, - placeables = placeables, - isVertical = isVertical, - horizontalAlignment = horizontalAlignment, - verticalAlignment = verticalAlignment, - layoutDirection = layoutDirection, - reverseLayout = reverseLayout, - beforeContentPadding = beforeContentPadding, - afterContentPadding = afterContentPadding, - spacing = spacing, - visualOffset = visualItemOffset, - key = key, - placementAnimator = placementAnimator - ) - } - state.premeasureConstraints = measuredItemProvider.childConstraints - - val firstVisibleItemIndex: DataIndex - val firstVisibleScrollOffset: Int - Snapshot.withoutReadObservation { - firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex) - firstVisibleScrollOffset = state.firstVisibleItemScrollOffset - } - - measureLazyList( - itemsCount = itemsCount, - itemProvider = measuredItemProvider, - mainAxisAvailableSize = mainAxisAvailableSize, - beforeContentPadding = beforeContentPadding, - afterContentPadding = afterContentPadding, - spaceBetweenItems = spaceBetweenItems, - firstVisibleItemIndex = firstVisibleItemIndex, - firstVisibleItemScrollOffset = firstVisibleScrollOffset, - scrollToBeConsumed = state.scrollToBeConsumed, - constraints = contentConstraints, - isVertical = isVertical, - headerIndexes = itemProvider.headerIndexes, - verticalArrangement = verticalArrangement, - horizontalArrangement = horizontalArrangement, - reverseLayout = reverseLayout, - density = this, - placementAnimator = placementAnimator, - beyondBoundsInfo = beyondBoundsInfo, - layout = { width, height, placement -> - layout( - containerConstraints.constrainWidth(width + totalHorizontalPadding), - containerConstraints.constrainHeight(height + totalVerticalPadding), - emptyMap(), - placement - ) - } - ).also { - state.applyMeasureResult(it) - refreshOverscrollInfo(overscrollEffect, it) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -private fun refreshOverscrollInfo( - overscrollEffect: OverscrollEffect, - result: LazyListMeasureResult -) { - val canScrollForward = result.canScrollForward - val canScrollBackward = (result.firstVisibleItem?.index ?: 0) != 0 || - result.firstVisibleItemScrollOffset != 0 - - overscrollEffect.isEnabled = canScrollForward || canScrollBackward -} diff --git a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingState.kt b/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingState.kt deleted file mode 100644 index 8630c73..0000000 --- a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingState.kt +++ /dev/null @@ -1,225 +0,0 @@ -@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") - -package it.hamy.compose.reordering - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationVector1D -import androidx.compose.animation.core.VectorConverter -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo -import androidx.compose.foundation.lazy.LazyListItemInfo -import androidx.compose.foundation.lazy.LazyListState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.input.pointer.PointerInputChange -import kotlin.math.roundToInt -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -@Stable -class ReorderingState( - val lazyListState: LazyListState, - val coroutineScope: CoroutineScope, - private val lastIndex: Int, - internal val onDragStart: () -> Unit, - internal val onDragEnd: (Int, Int) -> Unit, - private val extraItemCount: Int -) { - private lateinit var lazyListBeyondBoundsInfoInterval: LazyListBeyondBoundsInfo.Interval - internal val lazyListBeyondBoundsInfo = LazyListBeyondBoundsInfo() - internal val offset = Animatable(0, Int.VectorConverter) - - internal var draggingIndex by mutableStateOf(-1) - internal var reachedIndex by mutableStateOf(-1) - internal var draggingItemSize by mutableStateOf(0) - - lateinit var itemInfo: LazyListItemInfo - - private var previousItemSize = 0 - private var nextItemSize = 0 - - private var overscrolled = 0 - - internal var indexesToAnimate = mutableStateMapOf>() - private var animatablesPool: AnimatablesPool? = null - - val isDragging: Boolean - get() = draggingIndex != -1 - - fun onDragStart(index: Int) { - overscrolled = 0 - itemInfo = lazyListState.layoutInfo.visibleItemsInfo.find { - it.index == index + extraItemCount - } ?: return - onDragStart.invoke() - draggingIndex = index - reachedIndex = index - draggingItemSize = itemInfo.size - - nextItemSize = draggingItemSize - previousItemSize = -draggingItemSize - - offset.updateBounds( - lowerBound = -index * draggingItemSize, - upperBound = (lastIndex - index) * draggingItemSize - ) - - lazyListBeyondBoundsInfoInterval = - lazyListBeyondBoundsInfo.addInterval(index + extraItemCount, index + extraItemCount) - - val size = - lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset - - animatablesPool = AnimatablesPool(size / draggingItemSize + 2, 0, Int.VectorConverter) - } - - fun onDrag(change: PointerInputChange, dragAmount: Offset) { - if (!isDragging) return - change.consume() - - val delta = when (lazyListState.layoutInfo.orientation) { - Orientation.Vertical -> dragAmount.y - Orientation.Horizontal -> dragAmount.x - }.roundToInt() - - val targetOffset = offset.value + delta - - coroutineScope.launch { - offset.snapTo(targetOffset) - } - - if (targetOffset > nextItemSize) { - if (reachedIndex < lastIndex) { - reachedIndex += 1 - nextItemSize += draggingItemSize - previousItemSize += draggingItemSize - - val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1 - - coroutineScope.launch { - val animatable = indexesToAnimate.getOrPut(indexToAnimate) { - animatablesPool?.acquire() ?: return@launch - } - - if (draggingIndex < reachedIndex) { - animatable.snapTo(0) - animatable.animateTo(-draggingItemSize) - } else { - animatable.snapTo(draggingItemSize) - animatable.animateTo(0) - } - - indexesToAnimate.remove(indexToAnimate) - animatablesPool?.release(animatable) - } - } - } else if (targetOffset < previousItemSize) { - if (reachedIndex > 0) { - reachedIndex -= 1 - previousItemSize -= draggingItemSize - nextItemSize -= draggingItemSize - - val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1 - - coroutineScope.launch { - val animatable = indexesToAnimate.getOrPut(indexToAnimate) { - animatablesPool?.acquire() ?: return@launch - } - - if (draggingIndex > reachedIndex) { - animatable.snapTo(0) - animatable.animateTo(draggingItemSize) - } else { - animatable.snapTo(-draggingItemSize) - animatable.animateTo(0) - } - indexesToAnimate.remove(indexToAnimate) - animatablesPool?.release(animatable) - } - } - } else { - val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled - - val topOverscroll = lazyListState.layoutInfo.viewportStartOffset + - lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort - - val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset - - lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size - - if (topOverscroll > 0) { - overscroll(topOverscroll) - } else if (bottomOverscroll < 0) { - overscroll(bottomOverscroll) - } - } - } - - fun onDragEnd() { - if (!isDragging) return - - coroutineScope.launch { - offset.animateTo((previousItemSize + nextItemSize) / 2) - - withContext(Dispatchers.Main) { - onDragEnd.invoke(draggingIndex, reachedIndex) - } - - if (areEquals()) { - draggingIndex = -1 - reachedIndex = -1 - draggingItemSize = 0 - offset.snapTo(0) - } - - lazyListBeyondBoundsInfo.removeInterval(lazyListBeyondBoundsInfoInterval) - animatablesPool = null - } - } - - private fun overscroll(overscroll: Int) { - lazyListState.dispatchRawDelta(-overscroll.toFloat()) - coroutineScope.launch { - offset.snapTo(offset.value - overscroll) - } - overscrolled -= overscroll - } - - private fun areEquals(): Boolean { - return lazyListState.layoutInfo.visibleItemsInfo.find { - it.index + extraItemCount == draggingIndex - }?.key == lazyListState.layoutInfo.visibleItemsInfo.find { - it.index + extraItemCount == reachedIndex - }?.key - } -} - -@Composable -fun rememberReorderingState( - lazyListState: LazyListState, - key: Any, - onDragEnd: (Int, Int) -> Unit, - onDragStart: () -> Unit = {}, - extraItemCount: Int = 0 -): ReorderingState { - val coroutineScope = rememberCoroutineScope() - - return remember(key) { - ReorderingState( - lazyListState = lazyListState, - coroutineScope = coroutineScope, - lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount, - onDragStart = onDragStart, - onDragEnd = onDragEnd, - extraItemCount = extraItemCount, - ) - } -} diff --git a/compose-routing/src/main/AndroidManifest.xml b/compose-routing/src/main/AndroidManifest.xml deleted file mode 100644 index 10728cc..0000000 --- a/compose-routing/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/compose-persist/.gitignore b/compose/persist/.gitignore similarity index 100% rename from compose-persist/.gitignore rename to compose/persist/.gitignore diff --git a/compose-persist/build.gradle.kts b/compose/persist/build.gradle.kts similarity index 62% rename from compose-persist/build.gradle.kts rename to compose/persist/build.gradle.kts index 6365389..d0fcd06 100644 --- a/compose-persist/build.gradle.kts +++ b/compose/persist/build.gradle.kts @@ -1,20 +1,19 @@ plugins { - id("com.android.library") - kotlin("android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { namespace = "it.hamy.compose.persist" - compileSdk = 33 + compileSdk = 34 defaultConfig { minSdk = 21 - targetSdk = 33 } buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } @@ -27,20 +26,19 @@ android { compose = true } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } +} - kotlinOptions { - jvmTarget = "1.8" - } +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) } dependencies { + implementation(platform(libs.compose.bom)) implementation(libs.compose.foundation) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) } diff --git a/compose-persist/src/main/AndroidManifest.xml b/compose/persist/src/main/AndroidManifest.xml similarity index 100% rename from compose-persist/src/main/AndroidManifest.xml rename to compose/persist/src/main/AndroidManifest.xml diff --git a/compose-persist/src/main/kotlin/it/hamy/compose/persist/Persist.kt b/compose/persist/src/main/kotlin/it/hamy/compose/persist/Persist.kt similarity index 50% rename from compose-persist/src/main/kotlin/it/hamy/compose/persist/Persist.kt rename to compose/persist/src/main/kotlin/it/hamy/compose/persist/Persist.kt index ded6d72..36a7a94 100644 --- a/compose-persist/src/main/kotlin/it/hamy/compose/persist/Persist.kt +++ b/compose/persist/src/main/kotlin/it/hamy/compose/persist/Persist.kt @@ -2,18 +2,23 @@ package it.hamy.compose.persist import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SnapshotMutationPolicy import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext +import androidx.compose.runtime.structuralEqualityPolicy @Suppress("UNCHECKED_CAST") @Composable -fun persist(tag: String, initialValue: T): MutableState { - val context = LocalContext.current +fun persist( + tag: String, + initialValue: T, + policy: SnapshotMutationPolicy = structuralEqualityPolicy() +): MutableState { + val persistMap = LocalPersistMap.current - return remember { - context.persistMap?.getOrPut(tag) { mutableStateOf(initialValue) } as? MutableState - ?: mutableStateOf(initialValue) + return remember(persistMap) { + persistMap?.map?.getOrPut(tag) { mutableStateOf(initialValue, policy) } as? MutableState + ?: mutableStateOf(initialValue, policy) } } diff --git a/compose/persist/src/main/kotlin/it/hamy/compose/persist/PersistMap.kt b/compose/persist/src/main/kotlin/it/hamy/compose/persist/PersistMap.kt new file mode 100644 index 0000000..dcb01cf --- /dev/null +++ b/compose/persist/src/main/kotlin/it/hamy/compose/persist/PersistMap.kt @@ -0,0 +1,16 @@ +package it.hamy.compose.persist + +import android.util.Log +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.compositionLocalOf + +@JvmInline +value class PersistMap(val map: MutableMap> = hashMapOf()) { + fun clean(prefix: String) = map.keys.removeAll { it.startsWith(prefix) } +} + +val LocalPersistMap = compositionLocalOf { + Log.e("PersistMap", "Tried to reference uninitialized PersistMap, stacktrace:") + runCatching { error("Stack:") }.exceptionOrNull()?.printStackTrace() + null +} diff --git a/compose/persist/src/main/kotlin/it/hamy/compose/persist/PersistMapCleanup.kt b/compose/persist/src/main/kotlin/it/hamy/compose/persist/PersistMapCleanup.kt new file mode 100644 index 0000000..7f563b3 --- /dev/null +++ b/compose/persist/src/main/kotlin/it/hamy/compose/persist/PersistMapCleanup.kt @@ -0,0 +1,30 @@ +package it.hamy.compose.persist + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext + +@Composable +fun PersistMapCleanup(prefix: String) { + val context = LocalContext.current + val persistMap = LocalPersistMap.current + + DisposableEffect(persistMap) { + onDispose { + if (context.findActivityNullable()?.isChangingConfigurations == false) + persistMap?.clean(prefix) + } + } +} + +fun Context.findActivityNullable(): Activity? { + var current = this + while (current is ContextWrapper) { + if (current is Activity) return current + current = current.baseContext + } + return null +} diff --git a/compose-reordering/.gitignore b/compose/preferences/.gitignore similarity index 100% rename from compose-reordering/.gitignore rename to compose/preferences/.gitignore diff --git a/compose/preferences/build.gradle.kts b/compose/preferences/build.gradle.kts new file mode 100644 index 0000000..96ad0b0 --- /dev/null +++ b/compose/preferences/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) +} + +android { + namespace = "it.hamy.compose.preferences" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.foundation) + + implementation(libs.core.ktx) + + implementation(libs.kotlin.coroutines) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} diff --git a/compose/preferences/src/main/AndroidManifest.xml b/compose/preferences/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e100076 --- /dev/null +++ b/compose/preferences/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/kotlin/it/hamy/muza/preferences/PreferencesHolder.kt b/compose/preferences/src/main/kotlin/it/hamy/compose/preferences/PreferencesHolders.kt similarity index 67% rename from app/src/main/kotlin/it/hamy/muza/preferences/PreferencesHolder.kt rename to compose/preferences/src/main/kotlin/it/hamy/compose/preferences/PreferencesHolders.kt index e938e21..1aa882a 100644 --- a/app/src/main/kotlin/it/hamy/muza/preferences/PreferencesHolder.kt +++ b/compose/preferences/src/main/kotlin/it/hamy/compose/preferences/PreferencesHolders.kt @@ -1,14 +1,17 @@ -package it.hamy.muza.preferences +package it.hamy.compose.preferences import android.app.Application import android.content.Context import android.content.SharedPreferences import android.content.SharedPreferences.OnSharedPreferenceChangeListener +import androidx.compose.runtime.Stable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshots.Snapshot import androidx.core.content.edit import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty @@ -19,16 +22,35 @@ fun sharedPreferencesProperty( getValue: SharedPreferences.(key: String) -> T, setValue: SharedPreferences.Editor.(key: String, value: T) -> Unit, defaultValue: T -) = object : ReadWriteProperty { - private var state = mutableStateOf(defaultValue) +) = SharedPreferencesProperty( + get = getValue, + set = setValue, + default = defaultValue +) + +@Stable +data class SharedPreferencesProperty internal constructor( + private val get: SharedPreferences.(key: String) -> T, + private val set: SharedPreferences.Editor.(key: String, value: T) -> Unit, + private val default: T +) : ReadWriteProperty { + private val state = mutableStateOf(default) + val stateFlow = MutableStateFlow(default) // TODO: hotfix private var listener: OnSharedPreferenceChangeListener? = null + private fun setState(newValue: T) { + state.value = newValue + stateFlow.update { newValue } + } + override fun getValue(thisRef: PreferencesHolder, property: KProperty<*>): T { - if (listener == null && !Snapshot.current.readOnly) { - state.value = thisRef.getValue(property.name) + if (listener == null && !Snapshot.current.readOnly && !Snapshot.current.root.readOnly) { + setState(thisRef.get(property.name)) + listener = OnSharedPreferenceChangeListener { preferences, key -> - if (key == property.name) preferences.getValue(property.name) - .let { if (it != state && !Snapshot.current.readOnly) state.value = it } + if (key == property.name) preferences.get(property.name).let { + if (it != state.value && !Snapshot.current.readOnly) setState(it) + } } thisRef.registerOnSharedPreferenceChangeListener(listener) } @@ -38,16 +60,17 @@ fun sharedPreferencesProperty( override fun setValue(thisRef: PreferencesHolder, property: KProperty<*>, value: T) = coroutineScope.launch { thisRef.edit(commit = true) { - setValue(property.name, value) + set(property.name, value) } }.let { } } /** * A snapshottable, thread-safe, compose-first, extensible SharedPreferences wrapper that supports - * virtually all types, and if it doesn't, one could simply type `fun myNewType(...) = sharedPreferencesProperty(...)` - * and start implementing. Starts off as given defaultValue until we are allowed to subscribe to SharedPreferences - * @sample AppearancePreferences + * virtually all types, and if it doesn't, one could simply type + * `fun myNewType(...) = sharedPreferencesProperty(...)` and start implementing. Starts off as given + * defaultValue until we are allowed to subscribe to SharedPreferences. Caution: the type of the + * preference has to be [Stable], otherwise UB will occur. */ open class PreferencesHolder( application: Application, @@ -86,7 +109,8 @@ open class PreferencesHolder( inline fun > enum(defaultValue: T) = sharedPreferencesProperty( getValue = { - getString(it, null)?.let { runCatching { enumValueOf(it) }.getOrNull() } ?: defaultValue + getString(it, null)?.let { runCatching { enumValueOf(it) }.getOrNull() } + ?: defaultValue }, setValue = { k, v -> putString(k, v.name) }, defaultValue @@ -97,4 +121,4 @@ open class PreferencesHolder( setValue = { k, v -> putStringSet(k, v) }, defaultValue ) -} \ No newline at end of file +} diff --git a/compose-routing/.gitignore b/compose/reordering/.gitignore similarity index 100% rename from compose-routing/.gitignore rename to compose/reordering/.gitignore diff --git a/compose-reordering/build.gradle.kts b/compose/reordering/build.gradle.kts similarity index 54% rename from compose-reordering/build.gradle.kts rename to compose/reordering/build.gradle.kts index 4f01d5e..0e36706 100644 --- a/compose-reordering/build.gradle.kts +++ b/compose/reordering/build.gradle.kts @@ -1,47 +1,44 @@ plugins { - id("com.android.library") - kotlin("android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { namespace = "it.hamy.compose.reordering" - compileSdk = 33 + compileSdk = 34 defaultConfig { minSdk = 21 - targetSdk = 33 } buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } - sourceSets.all { - kotlin.srcDir("src/$name/kotlin") - } - buildFeatures { compose = true } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" - jvmTarget = "1.8" + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") } } -dependencies { - implementation(libs.compose.foundation) +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} + +dependencies { + implementation(platform(libs.compose.bom)) + implementation(libs.compose.foundation) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) } diff --git a/compose/reordering/src/main/AndroidManifest.xml b/compose/reordering/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/compose/reordering/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/AnimatablesPool.kt b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/AnimatablesPool.kt similarity index 63% rename from compose-reordering/src/main/kotlin/it/hamy/compose/reordering/AnimatablesPool.kt rename to compose/reordering/src/main/kotlin/it/hamy/compose/reordering/AnimatablesPool.kt index 074eb5c..8bbbf6a 100644 --- a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/AnimatablesPool.kt +++ b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/AnimatablesPool.kt @@ -21,18 +21,14 @@ class AnimatablesPool( require(size > 0) } - suspend fun acquire(): Animatable? { - return mutex.withLock { - if (values.isNotEmpty()) values.removeFirst() else null - } + suspend fun acquire() = mutex.withLock { + if (values.isNotEmpty()) values.removeFirst() else null } - suspend fun release(animatable: Animatable) { - mutex.withLock { - if (values.size < size) { - animatable.snapTo(initialValue) - values.add(animatable) - } + suspend fun release(animatable: Animatable) = mutex.withLock { + if (values.size < size) { + animatable.snapTo(initialValue) + values.add(animatable) } } } diff --git a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/AnimateItemPlacement.kt b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/AnimateItemPlacement.kt similarity index 100% rename from compose-reordering/src/main/kotlin/it/hamy/compose/reordering/AnimateItemPlacement.kt rename to compose/reordering/src/main/kotlin/it/hamy/compose/reordering/AnimateItemPlacement.kt diff --git a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/DraggedItem.kt b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/DraggedItem.kt similarity index 52% rename from compose-reordering/src/main/kotlin/it/hamy/compose/reordering/DraggedItem.kt rename to compose/reordering/src/main/kotlin/it/hamy/compose/reordering/DraggedItem.kt index cf88a37..9c7dab1 100644 --- a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/DraggedItem.kt +++ b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/DraggedItem.kt @@ -1,14 +1,23 @@ package it.hamy.compose.reordering +import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.layout.LocalPinnableContainer +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex fun Modifier.draggedItem( reorderingState: ReorderingState, - index: Int + index: Int, + draggedElevation: Dp = 4.dp ): Modifier = when (reorderingState.draggingIndex) { -1 -> this index -> offset { @@ -17,11 +26,12 @@ fun Modifier.draggedItem( Orientation.Horizontal -> IntOffset(reorderingState.offset.value, 0) } }.zIndex(1f) + else -> offset { - val offset = when (index) { + val offset = when (index) { in reorderingState.indexesToAnimate -> reorderingState.indexesToAnimate.getValue(index).value in (reorderingState.draggingIndex + 1)..reorderingState.reachedIndex -> -reorderingState.draggingItemSize - in reorderingState.reachedIndex until reorderingState.draggingIndex -> reorderingState.draggingItemSize + in reorderingState.reachedIndex.. reorderingState.draggingItemSize else -> 0 } when (reorderingState.lazyListState.layoutInfo.orientation) { @@ -29,4 +39,20 @@ fun Modifier.draggedItem( Orientation.Horizontal -> IntOffset(offset, 0) } } +}.composed { + val container = LocalPinnableContainer.current + val elevation by animateDpAsState( + targetValue = if (reorderingState.draggingIndex == index) draggedElevation else 0.dp, + label = "" + ) + + DisposableEffect(reorderingState.draggingIndex) { + val handle = if (reorderingState.draggingIndex == index) container?.pin() else null + + onDispose { + handle?.release() + } + } + + this.shadow(elevation = elevation) } diff --git a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/Reorder.kt b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/Reorder.kt similarity index 66% rename from compose-reordering/src/main/kotlin/it/hamy/compose/reordering/Reorder.kt rename to compose/reordering/src/main/kotlin/it/hamy/compose/reordering/Reorder.kt index bfdc9fa..11856e0 100644 --- a/compose-reordering/src/main/kotlin/it/hamy/compose/reordering/Reorder.kt +++ b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/Reorder.kt @@ -1,7 +1,6 @@ package it.hamy.compose.reordering import androidx.compose.foundation.gestures.detectDragGestures -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.pointer.PointerInputChange @@ -11,34 +10,25 @@ import androidx.compose.ui.input.pointer.pointerInput private fun Modifier.reorder( reorderingState: ReorderingState, index: Int, - detectDragGestures: DetectDragGestures, -): Modifier = pointerInput(reorderingState) { + detectDragGestures: DetectDragGestures +) = this.pointerInput(reorderingState) { with(detectDragGestures) { detectDragGestures( onDragStart = { reorderingState.onDragStart(index) }, onDrag = reorderingState::onDrag, onDragEnd = reorderingState::onDragEnd, - onDragCancel = reorderingState::onDragEnd, + onDragCancel = reorderingState::onDragEnd ) } } fun Modifier.reorder( - reorderingState: ReorderingState, - index: Int, -): Modifier = reorder( - reorderingState = reorderingState, - index = index, - detectDragGestures = PointerInputScope::detectDragGestures, -) - -fun Modifier.reorderAfterLongPress( reorderingState: ReorderingState, index: Int -): Modifier = reorder( +) = this.reorder( reorderingState = reorderingState, index = index, - detectDragGestures = PointerInputScope::detectDragGesturesAfterLongPress, + detectDragGestures = PointerInputScope::detectDragGestures ) private fun interface DetectDragGestures { diff --git a/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingState.kt b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingState.kt new file mode 100644 index 0000000..67e1cfc --- /dev/null +++ b/compose/reordering/src/main/kotlin/it/hamy/compose/reordering/ReorderingState.kt @@ -0,0 +1,224 @@ +package it.hamy.compose.reordering + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.scrollBy +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +@Stable +class ReorderingState( + val lazyListState: LazyListState, + val coroutineScope: CoroutineScope, + private val lastIndex: Int, + internal val onDragStart: () -> Unit, + internal val onDragEnd: (Int, Int) -> Unit, + private val extraItemCount: Int +) { + internal val offset = Animatable(0, Int.VectorConverter) + + internal var draggingIndex by mutableIntStateOf(-1) + internal var reachedIndex by mutableIntStateOf(-1) + internal var draggingItemSize by mutableIntStateOf(0) + + private lateinit var itemInfo: LazyListItemInfo + + private var previousItemSize = 0 + private var nextItemSize = 0 + + private var overscrolled = 0 + + internal var indexesToAnimate = mutableStateMapOf>() + private var animatablesPool: AnimatablesPool? = null + + val isDragging: Boolean + get() = draggingIndex != -1 + + fun onDragStart(index: Int) { + overscrolled = 0 + itemInfo = lazyListState.layoutInfo.visibleItemsInfo + .find { it.index == index + extraItemCount } ?: return + + onDragStart() + draggingIndex = index + reachedIndex = index + draggingItemSize = itemInfo.size + + nextItemSize = draggingItemSize + previousItemSize = -draggingItemSize + + offset.updateBounds( + lowerBound = -index * draggingItemSize, + upperBound = (lastIndex - index) * draggingItemSize + ) + + animatablesPool = AnimatablesPool( + size = (lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset) / + (draggingItemSize + 2), + initialValue = 0, + typeConverter = Int.VectorConverter + ) + } + + @Suppress("CyclomaticComplexMethod") + fun onDrag(change: PointerInputChange, dragAmount: Offset) { + if (!isDragging) return + + change.consume() + + val delta = when (lazyListState.layoutInfo.orientation) { + Orientation.Vertical -> dragAmount.y + Orientation.Horizontal -> dragAmount.x + }.roundToInt() + + val targetOffset = offset.value + delta + + coroutineScope.launch { offset.snapTo(targetOffset) } + + when { + targetOffset > nextItemSize -> { + if (reachedIndex < lastIndex) { + reachedIndex += 1 + nextItemSize += draggingItemSize + previousItemSize += draggingItemSize + + val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1 + + coroutineScope.launch { + val animatable = indexesToAnimate.getOrPut(indexToAnimate) { + animatablesPool?.acquire() ?: return@launch + } + + if (draggingIndex < reachedIndex) { + animatable.snapTo(0) + animatable.animateTo(-draggingItemSize) + } else { + animatable.snapTo(draggingItemSize) + animatable.animateTo(0) + } + + indexesToAnimate.remove(indexToAnimate) + animatablesPool?.release(animatable) + } + } + } + + targetOffset < previousItemSize -> { + if (reachedIndex > 0) { + reachedIndex -= 1 + previousItemSize -= draggingItemSize + nextItemSize -= draggingItemSize + + val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1 + + coroutineScope.launch { + val animatable = indexesToAnimate.getOrPut(indexToAnimate) { + animatablesPool?.acquire() ?: return@launch + } + + if (draggingIndex > reachedIndex) { + animatable.snapTo(0) + animatable.animateTo(draggingItemSize) + } else { + animatable.snapTo(-draggingItemSize) + animatable.animateTo(0) + } + indexesToAnimate.remove(indexToAnimate) + animatablesPool?.release(animatable) + } + } + } + + else -> { + val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled + + val topOverscroll = lazyListState.layoutInfo.viewportStartOffset + + lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort + val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset - + lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size + + if (topOverscroll > 0) overscroll(topOverscroll) else if (bottomOverscroll < 0) + overscroll(bottomOverscroll) + } + } + } + + fun onDragEnd() { + if (!isDragging) return + + coroutineScope.launch { + offset.animateTo((previousItemSize + nextItemSize) / 2) + + withContext(Dispatchers.Main) { onDragEnd(draggingIndex, reachedIndex) } + + if (areEquals()) { + draggingIndex = -1 + reachedIndex = -1 + draggingItemSize = 0 + offset.snapTo(0) + } + + animatablesPool = null + } + } + + private fun overscroll(overscroll: Int) { + val newHeight = itemInfo.offset - overscroll + @Suppress("ComplexCondition") + if ( + !(overscroll > 0 && newHeight <= lazyListState.layoutInfo.viewportEndOffset) && + !(overscroll < 0 && newHeight >= lazyListState.layoutInfo.viewportStartOffset) + ) return + + coroutineScope.launch { + lazyListState.scrollBy(-overscroll.toFloat()) + offset.snapTo(offset.value - overscroll) + } + overscrolled -= overscroll + } + + private fun areEquals() = lazyListState.layoutInfo.visibleItemsInfo.find { + it.index + extraItemCount == draggingIndex + }?.key == lazyListState.layoutInfo.visibleItemsInfo.find { + it.index + extraItemCount == reachedIndex + }?.key +} + +@Composable +fun rememberReorderingState( + lazyListState: LazyListState, + key: Any, + onDragEnd: (Int, Int) -> Unit, + onDragStart: () -> Unit = {}, + extraItemCount: Int = 0 +): ReorderingState { + val coroutineScope = rememberCoroutineScope() + + return remember(key) { + ReorderingState( + lazyListState = lazyListState, + coroutineScope = coroutineScope, + lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount, + onDragStart = onDragStart, + onDragEnd = onDragEnd, + extraItemCount = extraItemCount + ) + } +} diff --git a/innertube/.gitignore b/compose/routing/.gitignore similarity index 100% rename from innertube/.gitignore rename to compose/routing/.gitignore diff --git a/compose-routing/build.gradle.kts b/compose/routing/build.gradle.kts similarity index 54% rename from compose-routing/build.gradle.kts rename to compose/routing/build.gradle.kts index 474e8be..4d9b05a 100644 --- a/compose-routing/build.gradle.kts +++ b/compose/routing/build.gradle.kts @@ -1,48 +1,46 @@ plugins { - id("com.android.library") - kotlin("android") + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { namespace = "it.hamy.compose.routing" - compileSdk = 33 + compileSdk = 34 defaultConfig { minSdk = 21 - targetSdk = 33 } buildTypes { release { - isMinifyEnabled = true + isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) } } - sourceSets.all { - kotlin.srcDir("src/$name/kotlin") - } - buildFeatures { compose = true } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } kotlinOptions { - freeCompilerArgs += "-Xcontext-receivers" - jvmTarget = "1.8" + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") } } +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} + dependencies { + implementation(platform(libs.compose.bom)) implementation(libs.compose.activity) implementation(libs.compose.foundation) + implementation(libs.compose.animation) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) } diff --git a/compose/routing/src/main/AndroidManifest.xml b/compose/routing/src/main/AndroidManifest.xml new file mode 100644 index 0000000..568741e --- /dev/null +++ b/compose/routing/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/compose-routing/src/main/kotlin/it/hamy/compose/routing/GlobalRoute.kt b/compose/routing/src/main/kotlin/it/hamy/compose/routing/GlobalRoute.kt similarity index 100% rename from compose-routing/src/main/kotlin/it/hamy/compose/routing/GlobalRoute.kt rename to compose/routing/src/main/kotlin/it/hamy/compose/routing/GlobalRoute.kt diff --git a/compose-routing/src/main/kotlin/it/hamy/compose/routing/Route.kt b/compose/routing/src/main/kotlin/it/hamy/compose/routing/Route.kt similarity index 54% rename from compose-routing/src/main/kotlin/it/hamy/compose/routing/Route.kt rename to compose/routing/src/main/kotlin/it/hamy/compose/routing/Route.kt index ad202cd..45e4032 100644 --- a/compose-routing/src/main/kotlin/it/hamy/compose/routing/Route.kt +++ b/compose/routing/src/main/kotlin/it/hamy/compose/routing/Route.kt @@ -10,21 +10,17 @@ import kotlinx.coroutines.flow.first @Immutable open class Route internal constructor(val tag: String) { - override fun equals(other: Any?): Boolean { - return when { - this === other -> true - other is Route -> tag == other.tag - else -> false - } + override fun equals(other: Any?) = when { + this === other -> true + other is Route -> tag == other.tag + else -> false } - override fun hashCode(): Int { - return tag.hashCode() - } + override fun hashCode() = tag.hashCode() object Saver : androidx.compose.runtime.saveable.Saver { override fun restore(value: String): Route? = value.takeIf(String::isNotEmpty)?.let(::Route) - override fun SaverScope.save(value: Route?): String = value?.tag ?: "" + override fun SaverScope.save(value: Route?): String = value?.tag.orEmpty() } } @@ -33,14 +29,17 @@ class Route0(tag: String) : Route(tag) { context(RouteHandlerScope) @Composable operator fun invoke(content: @Composable () -> Unit) { - if (this == route) { - content() - } + if (this == route) content() } fun global() { globalRouteFlow.tryEmit(this to emptyArray()) } + + suspend fun ensureGlobal() { + globalRouteFlow.subscriptionCount.filter { it > 0 }.first() + globalRouteFlow.emit(this to arrayOf()) + } } @Immutable @@ -48,9 +47,7 @@ class Route1(tag: String) : Route(tag) { context(RouteHandlerScope) @Composable operator fun invoke(content: @Composable (P0) -> Unit) { - if (this == route) { - content(parameters[0] as P0) - } + if (this == route) content(parameters[0] as P0) } fun global(p0: P0) { @@ -68,12 +65,33 @@ class Route2(tag: String) : Route(tag) { context(RouteHandlerScope) @Composable operator fun invoke(content: @Composable (P0, P1) -> Unit) { - if (this == route) { - content(parameters[0] as P0, parameters[1] as P1) - } + if (this == route) content(parameters[0] as P0, parameters[1] as P1) } fun global(p0: P0, p1: P1) { globalRouteFlow.tryEmit(this to arrayOf(p0, p1)) } + + suspend fun ensureGlobal(p0: P0, p1: P1) { + globalRouteFlow.subscriptionCount.filter { it > 0 }.first() + globalRouteFlow.emit(this to arrayOf(p0, p1)) + } +} + +@Immutable +class Route3(tag: String) : Route(tag) { + context(RouteHandlerScope) + @Composable + operator fun invoke(content: @Composable (P0, P1, P2) -> Unit) { + if (this == route) content(parameters[0] as P0, parameters[1] as P1, parameters[2] as P2) + } + + fun global(p0: P0, p1: P1, p2: P2) { + globalRouteFlow.tryEmit(this to arrayOf(p0, p1, p2)) + } + + suspend fun ensureGlobal(p0: P0, p1: P1, p2: P2) { + globalRouteFlow.subscriptionCount.filter { it > 0 }.first() + globalRouteFlow.emit(this to arrayOf(p0, p1, p2)) + } } diff --git a/compose-routing/src/main/kotlin/it/hamy/compose/routing/RouteHandler.kt b/compose/routing/src/main/kotlin/it/hamy/compose/routing/RouteHandler.kt similarity index 88% rename from compose-routing/src/main/kotlin/it/hamy/compose/routing/RouteHandler.kt rename to compose/routing/src/main/kotlin/it/hamy/compose/routing/RouteHandler.kt index 4a8df5f..47e4c42 100644 --- a/compose-routing/src/main/kotlin/it/hamy/compose/routing/RouteHandler.kt +++ b/compose/routing/src/main/kotlin/it/hamy/compose/routing/RouteHandler.kt @@ -3,7 +3,7 @@ package it.hamy.compose.routing import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.core.updateTransition @@ -15,13 +15,13 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -@ExperimentalAnimationApi +@OptIn(ExperimentalAnimationApi::class) @Composable fun RouteHandler( modifier: Modifier = Modifier, listenToGlobalEmitter: Boolean = false, handleBackPress: Boolean = true, - transitionSpec: AnimatedContentScope.() -> ContentTransform = { + transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = { when { isStacking -> defaultStacking isStill -> defaultStill @@ -53,7 +53,7 @@ fun RouteHandler( modifier: Modifier = Modifier, listenToGlobalEmitter: Boolean = false, handleBackPress: Boolean = true, - transitionSpec: AnimatedContentScope.() -> ContentTransform = { + transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform = { when { isStacking -> defaultStacking isStill -> defaultStill @@ -65,7 +65,7 @@ fun RouteHandler( val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val parameters = rememberSaveable { - arrayOfNulls(2) + arrayOfNulls(3) } val scope = remember(route) { @@ -91,7 +91,7 @@ fun RouteHandler( updateTransition(targetState = scope, label = null).AnimatedContent( transitionSpec = transitionSpec, contentKey = RouteHandlerScope::route, - modifier = modifier, + modifier = modifier ) { it.content() } diff --git a/compose-routing/src/main/kotlin/it/hamy/compose/routing/RouteHandlerScope.kt b/compose/routing/src/main/kotlin/it/hamy/compose/routing/RouteHandlerScope.kt similarity index 63% rename from compose-routing/src/main/kotlin/it/hamy/compose/routing/RouteHandlerScope.kt rename to compose/routing/src/main/kotlin/it/hamy/compose/routing/RouteHandlerScope.kt index 530177b..51f6d45 100644 --- a/compose-routing/src/main/kotlin/it/hamy/compose/routing/RouteHandlerScope.kt +++ b/compose/routing/src/main/kotlin/it/hamy/compose/routing/RouteHandlerScope.kt @@ -1,6 +1,5 @@ package it.hamy.compose.routing -import android.annotation.SuppressLint import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable @@ -9,19 +8,14 @@ class RouteHandlerScope( val route: Route?, val parameters: Array, private val push: (Route?) -> Unit, - val pop: () -> Unit, + val pop: () -> Unit ) { - @SuppressLint("ComposableNaming") @Composable - inline fun host(content: @Composable () -> Unit) { - if (route == null) { - content() - } + inline fun NavHost(content: @Composable () -> Unit) { + if (route == null) content() } - operator fun Route.invoke() { - push(this) - } + operator fun Route.invoke() = push(this) operator fun Route.invoke(p0: P0) { parameters[0] = p0 @@ -32,4 +26,9 @@ class RouteHandlerScope( parameters[1] = p1 invoke(p0) } + + operator fun Route.invoke(p0: P0, p1: P1, p2: P2) { + parameters[2] = p2 + invoke(p0, p1) + } } diff --git a/compose-routing/src/main/kotlin/it/hamy/compose/routing/Transitions.kt b/compose/routing/src/main/kotlin/it/hamy/compose/routing/Transitions.kt similarity index 79% rename from compose-routing/src/main/kotlin/it/hamy/compose/routing/Transitions.kt rename to compose/routing/src/main/kotlin/it/hamy/compose/routing/Transitions.kt index 3a9781d..1bd5db4 100644 --- a/compose-routing/src/main/kotlin/it/hamy/compose/routing/Transitions.kt +++ b/compose/routing/src/main/kotlin/it/hamy/compose/routing/Transitions.kt @@ -1,6 +1,6 @@ package it.hamy.compose.routing -import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExperimentalAnimationApi @@ -30,17 +30,17 @@ val defaultStill = ContentTransform( ) @ExperimentalAnimationApi -inline val AnimatedContentScope.isStacking: Boolean +val AnimatedContentTransitionScope.isStacking: Boolean get() = initialState.route == null && targetState.route != null @ExperimentalAnimationApi -inline val AnimatedContentScope.isUnstacking: Boolean +val AnimatedContentTransitionScope.isUnstacking: Boolean get() = initialState.route != null && targetState.route == null @ExperimentalAnimationApi -inline val AnimatedContentScope.isStill: Boolean +val AnimatedContentTransitionScope.isStill: Boolean get() = initialState.route == null && targetState.route == null @ExperimentalAnimationApi -inline val AnimatedContentScope.isUnknown: Boolean +val AnimatedContentTransitionScope.isUnknown: Boolean get() = initialState.route != null && targetState.route != null diff --git a/core/data/.gitignore b/core/data/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/core/data/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts new file mode 100644 index 0000000..17517c6 --- /dev/null +++ b/core/data/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) +} + +android { + namespace = "it.hamy.compose.core.data" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + } +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} + +dependencies { + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} diff --git a/core/data/src/main/AndroidManifest.xml b/core/data/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e100076 --- /dev/null +++ b/core/data/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/kotlin/it/hamy/muza/enums/AlbumSortBy.kt b/core/data/src/main/kotlin/it/hamy/muza/enums/AlbumSortBy.kt similarity index 100% rename from app/src/main/kotlin/it/hamy/muza/enums/AlbumSortBy.kt rename to core/data/src/main/kotlin/it/hamy/muza/enums/AlbumSortBy.kt diff --git a/app/src/main/kotlin/it/hamy/muza/enums/ArtistSortBy.kt b/core/data/src/main/kotlin/it/hamy/muza/enums/ArtistSortBy.kt similarity index 100% rename from app/src/main/kotlin/it/hamy/muza/enums/ArtistSortBy.kt rename to core/data/src/main/kotlin/it/hamy/muza/enums/ArtistSortBy.kt diff --git a/app/src/main/kotlin/it/hamy/muza/enums/BuiltInPlaylist.kt b/core/data/src/main/kotlin/it/hamy/muza/enums/BuiltInPlaylist.kt similarity index 77% rename from app/src/main/kotlin/it/hamy/muza/enums/BuiltInPlaylist.kt rename to core/data/src/main/kotlin/it/hamy/muza/enums/BuiltInPlaylist.kt index fd10aa6..0369475 100644 --- a/app/src/main/kotlin/it/hamy/muza/enums/BuiltInPlaylist.kt +++ b/core/data/src/main/kotlin/it/hamy/muza/enums/BuiltInPlaylist.kt @@ -2,5 +2,6 @@ package it.hamy.muza.enums enum class BuiltInPlaylist { Favorites, - Offline + Offline, + Top } diff --git a/core/data/src/main/kotlin/it/hamy/muza/enums/CoilDiskCacheSize.kt b/core/data/src/main/kotlin/it/hamy/muza/enums/CoilDiskCacheSize.kt new file mode 100644 index 0000000..4429ffb --- /dev/null +++ b/core/data/src/main/kotlin/it/hamy/muza/enums/CoilDiskCacheSize.kt @@ -0,0 +1,13 @@ +package it.hamy.muza.enums + +import it.hamy.muza.utils.mb + +@Suppress("unused", "EnumEntryName") +enum class CoilDiskCacheSize(val bytes: Long) { + `64MB`(bytes = 64.mb), + `128MB`(bytes = 128.mb), + `256MB`(bytes = 256.mb), + `512MB`(bytes = 512.mb), + `1GB`(bytes = 1024.mb), + `2GB`(bytes = 2048.mb) +} diff --git a/core/data/src/main/kotlin/it/hamy/muza/enums/ExoPlayerDiskCacheSize.kt b/core/data/src/main/kotlin/it/hamy/muza/enums/ExoPlayerDiskCacheSize.kt new file mode 100644 index 0000000..0e1ae3f --- /dev/null +++ b/core/data/src/main/kotlin/it/hamy/muza/enums/ExoPlayerDiskCacheSize.kt @@ -0,0 +1,17 @@ +package it.hamy.muza.enums + +import it.hamy.muza.utils.mb + +@Suppress("EnumEntryName", "unused") +enum class ExoPlayerDiskCacheSize(val bytes: Long) { + `32MB`(bytes = 32.mb), + `64MB`(bytes = 64.mb), + `128MB`(bytes = 128.mb), + `256MB`(bytes = 256.mb), + `512MB`(bytes = 512.mb), + `1GB`(bytes = 1024.mb), + `2GB`(bytes = 2048.mb), + `4GB`(bytes = 4096.mb), + `8GB`(bytes = 8192.mb), + Unlimited(bytes = 0) +} diff --git a/app/src/main/kotlin/it/hamy/muza/enums/PlaylistSortBy.kt b/core/data/src/main/kotlin/it/hamy/muza/enums/PlaylistSortBy.kt similarity index 100% rename from app/src/main/kotlin/it/hamy/muza/enums/PlaylistSortBy.kt rename to core/data/src/main/kotlin/it/hamy/muza/enums/PlaylistSortBy.kt diff --git a/app/src/main/kotlin/it/hamy/muza/enums/SongSortBy.kt b/core/data/src/main/kotlin/it/hamy/muza/enums/SongSortBy.kt similarity index 100% rename from app/src/main/kotlin/it/hamy/muza/enums/SongSortBy.kt rename to core/data/src/main/kotlin/it/hamy/muza/enums/SongSortBy.kt diff --git a/app/src/main/kotlin/it/hamy/muza/enums/SortOrder.kt b/core/data/src/main/kotlin/it/hamy/muza/enums/SortOrder.kt similarity index 100% rename from app/src/main/kotlin/it/hamy/muza/enums/SortOrder.kt rename to core/data/src/main/kotlin/it/hamy/muza/enums/SortOrder.kt diff --git a/core/data/src/main/kotlin/it/hamy/muza/utils/Bytes.kt b/core/data/src/main/kotlin/it/hamy/muza/utils/Bytes.kt new file mode 100644 index 0000000..b49609c --- /dev/null +++ b/core/data/src/main/kotlin/it/hamy/muza/utils/Bytes.kt @@ -0,0 +1,3 @@ +package it.hamy.muza.utils + +val Int.mb get() = this * 1_048_576L diff --git a/core/data/src/main/kotlin/it/hamy/muza/utils/Versions.kt b/core/data/src/main/kotlin/it/hamy/muza/utils/Versions.kt new file mode 100644 index 0000000..1258432 --- /dev/null +++ b/core/data/src/main/kotlin/it/hamy/muza/utils/Versions.kt @@ -0,0 +1,20 @@ +package it.hamy.muza.utils + +inline val String.version get() = Version(value = this) + +@JvmInline +value class Version(private val parts: List) { + constructor(value: String) : this(value.split(".").mapNotNull { it.toIntOrNull() }) + + val major get() = parts.firstOrNull() + val minor get() = parts.getOrNull(1) + val patch get() = parts.getOrNull(2) + + companion object { + private val comparator = compareBy { it.major } then + compareBy { it.minor } then + compareBy { it.patch } + } + + operator fun compareTo(other: Version) = comparator.compare(this, other) +} diff --git a/core/ui/.gitignore b/core/ui/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/core/ui/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts new file mode 100644 index 0000000..54ca7a7 --- /dev/null +++ b/core/ui/build.gradle.kts @@ -0,0 +1,50 @@ +plugins { + alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) +} + +android { + namespace = "it.hamy.compose.core.ui" + compileSdk = 34 + + defaultConfig { + minSdk = 21 + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } + + buildFeatures { + compose = true + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() + } + + kotlinOptions { + freeCompilerArgs = freeCompilerArgs + listOf("-Xcontext-receivers") + } +} + +dependencies { + implementation(projects.core.data) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.animation) + implementation(libs.compose.foundation) + implementation(libs.compose.ripple) + implementation(libs.compose.shimmer) + implementation(libs.compose.ui) + implementation(libs.compose.ui.util) + implementation(libs.compose.material3) + implementation(libs.palette) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} \ No newline at end of file diff --git a/core/ui/src/main/AndroidManifest.xml b/core/ui/src/main/AndroidManifest.xml new file mode 100644 index 0000000..e100076 --- /dev/null +++ b/core/ui/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + diff --git a/core/ui/src/main/kotlin/it/hamy/muza/Extensions.kt b/core/ui/src/main/kotlin/it/hamy/muza/Extensions.kt new file mode 100644 index 0000000..b59f908 --- /dev/null +++ b/core/ui/src/main/kotlin/it/hamy/muza/Extensions.kt @@ -0,0 +1,6 @@ +package it.hamy.muza + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.unit.Dp + +val Dp.roundedShape get() = RoundedCornerShape(this) diff --git a/app/src/main/kotlin/it/hamy/muza/enums/ColorPaletteMode.kt b/core/ui/src/main/kotlin/it/hamy/muza/enums/ColorPaletteMode.kt similarity index 100% rename from app/src/main/kotlin/it/hamy/muza/enums/ColorPaletteMode.kt rename to core/ui/src/main/kotlin/it/hamy/muza/enums/ColorPaletteMode.kt diff --git a/app/src/main/kotlin/it/hamy/muza/enums/ColorPaletteName.kt b/core/ui/src/main/kotlin/it/hamy/muza/enums/ColorPaletteName.kt similarity index 76% rename from app/src/main/kotlin/it/hamy/muza/enums/ColorPaletteName.kt rename to core/ui/src/main/kotlin/it/hamy/muza/enums/ColorPaletteName.kt index 974b253..2f902ee 100644 --- a/app/src/main/kotlin/it/hamy/muza/enums/ColorPaletteName.kt +++ b/core/ui/src/main/kotlin/it/hamy/muza/enums/ColorPaletteName.kt @@ -3,5 +3,6 @@ package it.hamy.muza.enums enum class ColorPaletteName { Default, Dynamic, - PureBlack + PureBlack, + AMOLED } diff --git a/core/ui/src/main/kotlin/it/hamy/muza/enums/ThumbnailRoundness.kt b/core/ui/src/main/kotlin/it/hamy/muza/enums/ThumbnailRoundness.kt new file mode 100644 index 0000000..c3cbcb4 --- /dev/null +++ b/core/ui/src/main/kotlin/it/hamy/muza/enums/ThumbnailRoundness.kt @@ -0,0 +1,16 @@ +package it.hamy.muza.enums + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import it.hamy.muza.roundedShape + +enum class ThumbnailRoundness(val dp: Dp) { + None(0.dp), + Light(2.dp), + Medium(8.dp), + Heavy(12.dp), + Heavier(16.dp), + Heaviest(16.dp); + + val shape get() = dp.roundedShape +} diff --git a/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Appearance.kt b/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Appearance.kt new file mode 100644 index 0000000..5618831 --- /dev/null +++ b/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Appearance.kt @@ -0,0 +1,34 @@ +package it.hamy.muza.ui.styling + +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import it.hamy.muza.roundedShape + +data class Appearance( + val colorPalette: ColorPalette, + val typography: Typography, + val thumbnailShapeCorners: Dp +) { + val thumbnailShape = thumbnailShapeCorners.roundedShape + operator fun component4() = thumbnailShape + + companion object AppearanceSaver : Saver> { + @Suppress("UNCHECKED_CAST") + override fun restore(value: List) = Appearance( + colorPalette = ColorPalette.restore(value[0] as List), + typography = Typography.restore(value[1] as List), + thumbnailShapeCorners = (value[2] as Float).dp + ) + + override fun SaverScope.save(value: Appearance) = listOf( + with(ColorPalette.Companion) { save(value.colorPalette) }, + with(Typography.Companion) { save(value.typography) }, + value.thumbnailShapeCorners.value + ) + } +} + +val LocalAppearance by lazy { staticCompositionLocalOf { error("No appearance provided") } } diff --git a/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/ColorPalette.kt b/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/ColorPalette.kt new file mode 100644 index 0000000..e67e49f --- /dev/null +++ b/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/ColorPalette.kt @@ -0,0 +1,221 @@ +package it.hamy.muza.ui.styling + +import android.graphics.Bitmap +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.core.graphics.ColorUtils +import androidx.palette.graphics.Palette +import it.hamy.muza.enums.ColorPaletteMode +import it.hamy.muza.enums.ColorPaletteName + +@Immutable +data class ColorPalette( + val background0: Color, + val background1: Color, + val background2: Color, + val accent: Color, + val onAccent: Color, + val red: Color = Color(0xffbf4040), + val blue: Color = Color(0xff4472cf), + val text: Color, + val textSecondary: Color, + val textDisabled: Color, + val isDark: Boolean, + val isAmoled: Boolean +) { + companion object : Saver> { + override fun restore(value: List) = when (val accent = value[0] as Int) { + 0 -> DefaultDarkColorPalette + 1 -> DefaultLightColorPalette + 2 -> PureBlackColorPalette + else -> dynamicColorPaletteOf( + accentColor = accent, + isDark = value[1] as Boolean, + isAmoled = value[2] as Boolean + ) + } + + override fun SaverScope.save(value: ColorPalette) = listOf( + when { + value === DefaultDarkColorPalette -> 0 + value === DefaultLightColorPalette -> 1 + value === PureBlackColorPalette -> 2 + else -> value.accent.toArgb() + }, + value.isDark, + value.isAmoled + ) + } +} + +val DefaultDarkColorPalette = ColorPalette( + background0 = Color(0xff16171d), + background1 = Color(0xff1f2029), + background2 = Color(0xff2b2d3b), + text = Color(0xffe1e1e2), + textSecondary = Color(0xffa3a4a6), + textDisabled = Color(0xff6f6f73), + accent = Color(0xff5055c0), + onAccent = Color.White, + isDark = true, + isAmoled = false +) + +val DefaultLightColorPalette = ColorPalette( + background0 = Color(0xfffdfdfe), + background1 = Color(0xfff8f8fc), + background2 = Color(0xffeaeaf5), + text = Color(0xff212121), + textSecondary = Color(0xff656566), + textDisabled = Color(0xff9d9d9d), + accent = Color(0xff5055c0), + onAccent = Color.White, + isDark = false, + isAmoled = false +) + +val PureBlackColorPalette = DefaultDarkColorPalette.copy( + background0 = Color.Black, + background1 = Color.Black, + background2 = Color.Black +) + +fun colorPaletteOf( + name: ColorPaletteName, + mode: ColorPaletteMode, + isDark: Boolean +) = when (name) { + ColorPaletteName.Default, + ColorPaletteName.Dynamic -> when (mode) { + ColorPaletteMode.Light -> DefaultLightColorPalette + ColorPaletteMode.Dark -> DefaultDarkColorPalette + ColorPaletteMode.System -> if (isDark) DefaultDarkColorPalette else DefaultLightColorPalette + } + + ColorPaletteName.PureBlack -> PureBlackColorPalette + ColorPaletteName.AMOLED -> PureBlackColorPalette.copy(isAmoled = true) +} + +fun dynamicColorPaletteOf( + bitmap: Bitmap, + isDark: Boolean, + isAmoled: Boolean +): ColorPalette? { + val palette = Palette + .from(bitmap) + .maximumColorCount(8) + .addFilter(if (isDark) ({ _, hsl -> hsl[0] !in 36f..100f }) else null) + .generate() + + val hsl = if (isDark) { + palette.dominantSwatch ?: Palette + .from(bitmap) + .maximumColorCount(8) + .generate() + .dominantSwatch + } else { + palette.dominantSwatch + }?.hsl ?: return null + + return dynamicColorPaletteOf( + hsl = if (hsl[1] < 0.08) + palette.swatches + .map(Palette.Swatch::getHsl) + .sortedByDescending(FloatArray::component2) + .find { it[1] != 0f } + ?: hsl + else hsl, + isDark = isDark, + isAmoled = isAmoled + ) +} + +fun dynamicColorPaletteOf( + hsl: FloatArray, + isDark: Boolean, + isAmoled: Boolean +) = hsl.let { (hue, saturation) -> + colorPaletteOf( + name = if (isAmoled) ColorPaletteName.AMOLED else ColorPaletteName.Dynamic, + mode = if (isDark || isAmoled) ColorPaletteMode.Dark else ColorPaletteMode.Light, + isDark = false + ).copy( + background0 = if (isAmoled) PureBlackColorPalette.background0 else Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.1f), + lightness = if (isDark) 0.10f else 0.925f + ), + background1 = if (isAmoled) PureBlackColorPalette.background1 else Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.3f), + lightness = if (isDark) 0.15f else 0.90f + ), + background2 = if (isAmoled) PureBlackColorPalette.background2 else Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.4f), + lightness = if (isDark) 0.2f else 0.85f + ), + accent = Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(if (isAmoled) 0.4f else 0.5f), + lightness = 0.5f + ), + text = if (isAmoled) PureBlackColorPalette.text else Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.02f), + lightness = if (isDark) 0.88f else 0.12f + ), + textSecondary = if (isAmoled) PureBlackColorPalette.textSecondary else Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.1f), + lightness = if (isDark) 0.65f else 0.40f + ), + textDisabled = if (isAmoled) PureBlackColorPalette.textDisabled else Color.hsl( + hue = hue, + saturation = saturation.coerceAtMost(0.2f), + lightness = if (isDark) 0.40f else 0.65f + ) + ) +} + +fun dynamicColorPaletteOf( + accentColor: Color, + isDark: Boolean, + isAmoled: Boolean +) = dynamicColorPaletteOf( + accentColor = accentColor.toArgb(), + isDark = isDark, + isAmoled = isAmoled +) + +fun dynamicColorPaletteOf( + accentColor: Int, + isDark: Boolean, + isAmoled: Boolean +) = dynamicColorPaletteOf( + hsl = FloatArray(3).apply { ColorUtils.colorToHSL(accentColor, this) }, + isDark = isDark, + isAmoled = isAmoled +) + +inline val ColorPalette.isDefault + get() = + this === DefaultDarkColorPalette || this === DefaultLightColorPalette || this === PureBlackColorPalette + +inline val ColorPalette.collapsedPlayerProgressBar get() = if (isDefault) text else accent +inline val ColorPalette.favoritesIcon get() = if (isDefault) red else accent +inline val ColorPalette.shimmer get() = if (isDefault) Color(0xff838383) else accent +inline val ColorPalette.primaryButton + get() = if (this === PureBlackColorPalette || isAmoled) Color(0xFF272727) else background2 + +@Suppress("UnusedReceiverParameter") +inline val ColorPalette.overlay get() = PureBlackColorPalette.background0.copy(alpha = 0.75f) + +@Suppress("UnusedReceiverParameter") +inline val ColorPalette.onOverlay get() = PureBlackColorPalette.text + +@Suppress("UnusedReceiverParameter") +inline val ColorPalette.onOverlayShimmer get() = PureBlackColorPalette.shimmer diff --git a/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Dimensions.kt b/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Dimensions.kt new file mode 100644 index 0000000..4c9a55b --- /dev/null +++ b/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Dimensions.kt @@ -0,0 +1,44 @@ +package it.hamy.muza.ui.styling + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.unit.dp + +object Dimensions { + object Thumbnails { + val album = 108.dp + val artist = 92.dp + val song = 54.dp + val playlist = album + + val player = Player + + object Player { + val song + @Composable get() = with(LocalConfiguration.current) { + minOf(screenHeightDp, screenWidthDp) + }.dp + } + } + + val thumbnails = Thumbnails + + object Items { + val moodHeight = 64.dp + val headerHeight = 140.dp + val collapsedPlayerHeight = 64.dp + + val verticalPadding = 8.dp + val horizontalPadding = 16.dp + } + + val items = Items + + object NavigationRail { + val width = 64.dp + val widthLandscape = 128.dp + val iconOffset = 6.dp + } + + val navigationRail = NavigationRail +} diff --git a/app/src/main/kotlin/it/hamy/muza/ui/styling/Typography.kt b/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Typography.kt similarity index 57% rename from app/src/main/kotlin/it/hamy/muza/ui/styling/Typography.kt rename to core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Typography.kt index 8f2530c..93df9a6 100644 --- a/app/src/main/kotlin/it/hamy/muza/ui/styling/Typography.kt +++ b/core/ui/src/main/kotlin/it/hamy/muza/ui/styling/Typography.kt @@ -10,7 +10,7 @@ import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import it.hamy.muza.R +import it.hamy.compose.core.ui.R @Immutable data class Typography( @@ -19,7 +19,7 @@ data class Typography( val s: TextStyle, val m: TextStyle, val l: TextStyle, - val xxl: TextStyle, + val xxl: TextStyle ) { fun copy(color: Color) = Typography( xxs = xxs.copy(color = color), @@ -37,46 +37,41 @@ data class Typography( value[2] as Boolean ) - override fun SaverScope.save(value: Typography) = - listOf( - value.xxs.color.value.toLong(), - value.xxs.fontFamily == FontFamily.Default, - value.xxs.platformStyle?.paragraphStyle?.includeFontPadding ?: false - ) + override fun SaverScope.save(value: Typography) = listOf( + value.xxs.color.value.toLong(), + value.xxs.fontFamily == FontFamily.Default, + value.xxs.platformStyle?.paragraphStyle?.includeFontPadding ?: false + ) } } fun typographyOf(color: Color, useSystemFont: Boolean, applyFontPadding: Boolean): Typography { val textStyle = TextStyle( - fontFamily = if (useSystemFont) { - FontFamily.Default - } else { - FontFamily( - Font( - resId = R.font.poppins_w300, - weight = FontWeight.Light - ), - Font( - resId = R.font.poppins_w400, - weight = FontWeight.Normal - ), - Font( - resId = R.font.poppins_w500, - weight = FontWeight.Medium - ), - Font( - resId = R.font.poppins_w600, - weight = FontWeight.SemiBold - ), - Font( - resId = R.font.poppins_w700, - weight = FontWeight.Bold - ), + fontFamily = if (useSystemFont) FontFamily.Default else FontFamily( + Font( + resId = R.font.poppins_w300, + weight = FontWeight.Light + ), + Font( + resId = R.font.poppins_w400, + weight = FontWeight.Normal + ), + Font( + resId = R.font.poppins_w500, + weight = FontWeight.Medium + ), + Font( + resId = R.font.poppins_w600, + weight = FontWeight.SemiBold + ), + Font( + resId = R.font.poppins_w700, + weight = FontWeight.Bold ) - }, + ), fontWeight = FontWeight.Normal, color = color, - platformStyle = @Suppress("DEPRECATION") (PlatformTextStyle(includeFontPadding = applyFontPadding)) + platformStyle = PlatformTextStyle(includeFontPadding = applyFontPadding) ) return Typography( diff --git a/core/ui/src/main/kotlin/it/hamy/muza/utils/Pixels.kt b/core/ui/src/main/kotlin/it/hamy/muza/utils/Pixels.kt new file mode 100644 index 0000000..ea56166 --- /dev/null +++ b/core/ui/src/main/kotlin/it/hamy/muza/utils/Pixels.kt @@ -0,0 +1,21 @@ +package it.hamy.muza.utils + +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Dp +import kotlin.math.roundToInt + +@Suppress("NOTHING_TO_INLINE") +@JvmInline +value class Px(val value: Int) { + inline val dp @Composable get() = dp(LocalDensity.current) + inline fun dp(density: Density) = with(density) { value.toDp() } +} + +inline val Int.px inline get() = Px(value = this) +inline val Float.px inline get() = roundToInt().px + +inline val Dp.px: Int + @Composable + inline get() = with(LocalDensity.current) { roundToPx() } diff --git a/core/ui/src/main/res/font/poppins_w300.ttf b/core/ui/src/main/res/font/poppins_w300.ttf new file mode 100644 index 0000000..2ab0221 Binary files /dev/null and b/core/ui/src/main/res/font/poppins_w300.ttf differ diff --git a/core/ui/src/main/res/font/poppins_w400.ttf b/core/ui/src/main/res/font/poppins_w400.ttf new file mode 100644 index 0000000..9f0c71b Binary files /dev/null and b/core/ui/src/main/res/font/poppins_w400.ttf differ diff --git a/core/ui/src/main/res/font/poppins_w500.ttf b/core/ui/src/main/res/font/poppins_w500.ttf new file mode 100644 index 0000000..6bcdcc2 Binary files /dev/null and b/core/ui/src/main/res/font/poppins_w500.ttf differ diff --git a/core/ui/src/main/res/font/poppins_w600.ttf b/core/ui/src/main/res/font/poppins_w600.ttf new file mode 100644 index 0000000..74c726e Binary files /dev/null and b/core/ui/src/main/res/font/poppins_w600.ttf differ diff --git a/core/ui/src/main/res/font/poppins_w700.ttf b/core/ui/src/main/res/font/poppins_w700.ttf new file mode 100644 index 0000000..00559ee Binary files /dev/null and b/core/ui/src/main/res/font/poppins_w700.ttf differ diff --git a/detekt.yml b/detekt.yml new file mode 100644 index 0000000..80a27fd --- /dev/null +++ b/detekt.yml @@ -0,0 +1,120 @@ +config: + validation: true + warningsAsErrors: false + +Compose: + ComposableAnnotationNaming: + active: true + CompositionLocalAllowlist: + active: false + CompositionLocalNaming: + active: true + ContentEmitterReturningValues: + active: true + DefaultsVisibility: + active: true + ModifierClickableOrder: + active: true + ModifierComposable: + active: true + ModifierMissing: + active: true + ignoreAnnotated: + - it.hamy.muza.ui.screens.Route + ModifierNaming: + active: true + ModifierNotUsedAtRoot: + active: true + ModifierReused: + active: true + ModifierWithoutDefault: + active: true + MultipleEmitters: + active: true + MutableParams: + active: true + ComposableNaming: + active: true + ComposableParamOrder: + active: true + PreviewAnnotationNaming: + active: true + PreviewPublic: + active: true + RememberMissing: + active: true + RememberContentMissing: + active: true + UnstableCollections: + active: true + ViewModelForwarding: + active: true + ViewModelInjection: + active: true + +complexity: + CyclomaticComplexMethod: + ignoreAnnotated: + - androidx.compose.runtime.Composable + LongParameterList: + ignoreAnnotated: + - androidx.compose.runtime.Composable + ignoreDefaultParameters: true + ignoreDataClasses: true + LongMethod: + active: false + TooManyFunctions: + excludes: + - '**/util/**' + - '**/utils/**' + +exceptions: + SwallowedException: + ignoredExceptionTypes: + - ActivityNotFoundException + +formatting: + AnnotationOnSeparateLine: + active: true + ignoreAnnotated: + - kotlinx.serialization.Serializable + CommentWrapping: + # Because argument names in comment are a thing: Java API's do not support named arguments + active: false + EnumEntryNameCase: + active: false # Handled by Android Lint + Indentation: + active: false # Idea/Android Studio handles indentation differently + MultiLineIfElse: + active: false + TrailingCommaOnCallSite: + active: true + useTrailingCommaOnCallSite: false + TrailingCommaOnDeclarationSite: + active: true + useTrailingCommaOnDeclarationSite: false + +naming: + EnumNaming: + active: false # Handled by Android Lint + FunctionNaming: + ignoreAnnotated: + - androidx.compose.runtime.Composable + MatchingDeclarationName: + active: false + TopLevelPropertyNaming: + constantPattern: '[A-Z][_A-Z0-9]*' + +style: + ForbiddenComment: + active: false + MagicNumber: + active: false # For now, since there are way too many of them + MaxLineLength: + active: false # Overlaps with MaximumLineLength, ktlint preferred because of auto-correct + ModifierOrder: + active: false # Overlaps with ModifierOrdering, ktlint preferred because of auto-correct + NewLineAtEndOfFile: + active: false # Overlaps with FinalNewline, ktlint preferred because of auto-correct + ThrowsCount: + active: false diff --git a/fastlane/metadata/android/en-US/changelogs/10.txt b/fastlane/metadata/android/en-US/changelogs/10.txt new file mode 100644 index 0000000..0ed817f --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/10.txt @@ -0,0 +1,8 @@ +* The Offline (renamed from Cached) playlist is now displayed only if the playlist grid is expanded +* The Sleep Timer functionality is moved to the playing song menu +* The search screen text input now correctly handles playlist URLs +* The 200 song limit problem when opening or importing a YouTube playlist is now fixed +* The stats for nerds (long press the playing song thumbnail) also display the bitrate +* The stats for nerds are now correctly updated when skipping songs +* The song list can now be sorted also by title (other than play time and date) +* The playlists can now be sorted by name, song count and date \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/11.txt b/fastlane/metadata/android/en-US/changelogs/11.txt new file mode 100644 index 0000000..938ca3e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/11.txt @@ -0,0 +1,7 @@ +* The radio can now be started from the playing song +* The queue can now be shuffled +* The collapsed player can now be dismissed to stop the playback +* The player is automatically expanded when a new queue is started +* The player is automatically expanded when opening the app when clicking the playback notification +* Many age restricted videos can now be played +* The player and settings UIs have been redesigned diff --git a/fastlane/metadata/android/en-US/changelogs/12.txt b/fastlane/metadata/android/en-US/changelogs/12.txt new file mode 100644 index 0000000..3e5897a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/12.txt @@ -0,0 +1,3 @@ +* A dynamic new theme, which adapts its colors based on the playing song cover, is added +* The ability to fetch and display synchronized lyrics (from a third party provider) is added +* The song cover can now be displayed as lockscreen wallpaper diff --git a/fastlane/metadata/android/en-US/changelogs/13.txt b/fastlane/metadata/android/en-US/changelogs/13.txt new file mode 100644 index 0000000..33babc9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/13.txt @@ -0,0 +1 @@ +* Fix crash when fetching album metadata diff --git a/fastlane/metadata/android/en-US/changelogs/14.txt b/fastlane/metadata/android/en-US/changelogs/14.txt new file mode 100644 index 0000000..5968f20 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/14.txt @@ -0,0 +1 @@ +* The screen now automatically scrolls when reordering the queue or playlists songs diff --git a/fastlane/metadata/android/en-US/changelogs/15.txt b/fastlane/metadata/android/en-US/changelogs/15.txt new file mode 100644 index 0000000..a376694 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/15.txt @@ -0,0 +1 @@ +* Minor changes and fixes diff --git a/fastlane/metadata/android/en-US/changelogs/16.txt b/fastlane/metadata/android/en-US/changelogs/16.txt new file mode 100644 index 0000000..a3c463c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/16.txt @@ -0,0 +1,10 @@ +* New user interface +* Added "Quick picks", "Albums" and "Artists" tabs to the home screen +* Added "Songs", "Albums" and "Singles" tabs to the artist screen +* Added "Other versions" tab to the album screen +* Albums and artists can be bookmarked +* Added "Library" tab to the search screen +* Removed the "loop none" option +* Imported playlists can now be synchronized +* Added the ability to open channel urls +* Opening a song url now automatically starts the playback diff --git a/fastlane/metadata/android/en-US/changelogs/17.txt b/fastlane/metadata/android/en-US/changelogs/17.txt new file mode 100644 index 0000000..50a9f01 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/17.txt @@ -0,0 +1,6 @@ +* Added Android Auto support (must be enabled in the settings) +* Improved the audio normalization algorithm +* Fixed a bug which caused play time of a song to be incorrectly calculated +* Fixed a bug which caused artists and albums to be incorrectly sorted +* Fixed a crash that occurred when searching +* Fixed "this video in not available in your country" problem that affected GB users diff --git a/fastlane/metadata/android/en-US/changelogs/18.txt b/fastlane/metadata/android/en-US/changelogs/18.txt new file mode 100644 index 0000000..6678e38 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/18.txt @@ -0,0 +1,6 @@ +* Added option to pause search history +* Added option to clear trending song +* Added description to artist screen +* Fixed "Unknown" artist problem +* Fixed "Quick picks" not loading +* Fixed playback controls not working in Android < 26 diff --git a/fastlane/metadata/android/en-US/changelogs/19.txt b/fastlane/metadata/android/en-US/changelogs/19.txt new file mode 100644 index 0000000..f266d2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/19.txt @@ -0,0 +1,6 @@ +* Android 13 is now officially supported +* Added "Queue loop" toggle button +* Added option to use the system font +* Added option to automatically resume the playback when a wired or bluetooth device is connected +* The screen will now not turn off when synchronized lyrics are displayed +* Fixed a couple of crashes diff --git a/fastlane/metadata/android/en-US/changelogs/20.txt b/fastlane/metadata/android/en-US/changelogs/20.txt new file mode 100644 index 0000000..41d8b1a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/20.txt @@ -0,0 +1 @@ +* Minor fixes and improvements diff --git a/fastlane/metadata/android/en-US/changelogs/21.txt b/fastlane/metadata/android/en-US/changelogs/21.txt new file mode 100644 index 0000000..35fb904 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/21.txt @@ -0,0 +1,20 @@ +* Android 14 is now officially supported +* Improved the look of the player and added more animations +* Added Discover tab in the home screen for even easier song browsing +* Added support for local music +* Added My top x playlist (a personal trending playlist) +* Added more levels of thumbnail roundness +* Added song-level volume boosting +* Added a playback speed slider +* Added customizability for Skip silence and Loudness normalization +* Added Bass boost +* Added more info about service lifetime issues +* Added troubleshooting options +* Added more appearance preferences +* Added a search bar for the Songs tab +* Added functionality to add all songs in queue to a new/existing playlist +* Added synchronized lyrics provided by lrclib.net for even more songs supported by synchronized lyrics +* Added more helpful error messages +* Fixed a few minor inconsistencies with the player and screen rotations +* Fixed a couple of crashes +* Performance improvements \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/22.txt b/fastlane/metadata/android/en-US/changelogs/22.txt new file mode 100644 index 0000000..3f0baf4 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/22.txt @@ -0,0 +1,7 @@ +* Sharing to ViMusic to import video's/music/playlists/albums/radio from YouTube music is now supported +* Fixed an inconsistency with player settings +* Added 'stop when closed' setting +* Fixed an inconsistency where before local songs could be trending +* Fixed an inconsistency where local songs could sometimes not show metadata, like play time +* Fixed the bug where offline songs could disappear +* Fixed an inconsistency with the trending playlists, the order of the lists now represent more accurately what you watch listen to \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/23.txt b/fastlane/metadata/android/en-US/changelogs/23.txt new file mode 100644 index 0000000..0589482 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/23.txt @@ -0,0 +1,6 @@ +* Added a swipe gesture to skip to the previous/next song +* Fixed inconsistent behavior/UI of settings +* Fixed weird behavior of the reordering animation +* Fixed formatting in the lyrics picking dialog +* You can now click on the artist name directly from the player +* Added a few more animations \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/24.txt b/fastlane/metadata/android/en-US/changelogs/24.txt new file mode 100644 index 0000000..2a1c65c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/24.txt @@ -0,0 +1,5 @@ +* Added support for local music on Android 12 and below +* Fixed inconsistent behavior with local music +* Added a setting to swipe horizontally to close the player, useful for Android's one-handed mode users +* Added like button in the player's notification +* Fixed crashes where the app crashes when you load invalid synchronized lyrics \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/25.txt b/fastlane/metadata/android/en-US/changelogs/25.txt new file mode 100644 index 0000000..e731eb5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/25.txt @@ -0,0 +1,8 @@ +* Improved searching songs in Songs and Local tab, now also searches for artist +* Fixed a bug that made local songs inaccessible +* Cleaned up some inconsistencies +* Added more animations to settings +* Swapped queue buttons for consistency with the buttons on the collapsed queue / player +* Fixed a bug that caused the like button to be invisible on some Android versions +* Added the like button directly in the player behind a setting in the Appearance settings +* Added swiping in the queue to remove music from the playlist \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/26.txt b/fastlane/metadata/android/en-US/changelogs/26.txt new file mode 100644 index 0000000..12eb3b3 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/26.txt @@ -0,0 +1,6 @@ +* Fixed a bug where the search function in the Songs and Local tabs caused the player to play the wrong song when a song is clicked +* Fixed an inconsistency: the loading spinner is now tinted correctly +* Fixed an inconsistency with swiping the player / in the queue: the gesture now works correctly (easier to use and smoother) +* Fixed inconsistencies with the plurality of songs: one song is no longer "1 songs" +* Fixed literal "null" being displayed in the UI when metadata is not available +* Added German translations \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/27.txt b/fastlane/metadata/android/en-US/changelogs/27.txt new file mode 100644 index 0000000..3b6b863 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/27.txt @@ -0,0 +1,9 @@ +* Added Dutch translations +* Fixed inconsistency with synchronized lyrics: empty lrc lines are allowed again +* Added the possibility to choose the source for 'Quick Picks' +* Localized YouTube API: moods, genres and recommendations are now in your own language +* Fixed infinite loading with some playlists +* Added classic UI back: easier to use for smaller screens, enable in settings +* Added shadow to the album cover in the player +* Fixed typo in Dutch language +* Adjusted settings icon to more suitable icon \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/28.txt b/fastlane/metadata/android/en-US/changelogs/28.txt new file mode 100644 index 0000000..e3ccc26 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/28.txt @@ -0,0 +1,15 @@ +* Added more German translations +* Added Piped playlist sync +* Fixed a bug that caused the app to crash on some builds of Android 14 +* Added an error message for when Bass Boost is unsupported by your device: the app no longer crashes +* Added blacklisting: filter songs from radio +* Added static/wavy seek bar preference +* Improved thumbnails in the UI: there is no longer a big gap in places where the thumbnail couldn't load +* Fixed inconsistencies with search +* Added song count to the Songs and Local tab +* Added pre-caching +* Removed Featured in search, YouTube unfortunately no longer provides this data +* Fixed layout inconsistencies +* Fixed typo's +* Added Swipe to hide in the Songs and Local tab +* Removed version type from settings \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/29.txt b/fastlane/metadata/android/en-US/changelogs/29.txt new file mode 100644 index 0000000..356282e --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/29.txt @@ -0,0 +1,4 @@ +* Fixed text field dialogs causing a crash +* Fixed a few inconsistencies +* Changed the done icon in the keyboard to the search icon when searching for songs +* Improved snapping behavior \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/30.txt b/fastlane/metadata/android/en-US/changelogs/30.txt new file mode 100644 index 0000000..c0916a9 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/30.txt @@ -0,0 +1,3 @@ +* Added more translations +* Fixed a layout issue with the coloring of songs in a list +* Fixed problems with all sliders \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/31.txt b/fastlane/metadata/android/en-US/changelogs/31.txt new file mode 100644 index 0000000..55a47a7 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/31.txt @@ -0,0 +1,14 @@ +* Optimized performance +* Fixed edge cases that caused the duration of a song to appear incorrectly +* Fixed an issue that caused ghost files to appear in Local music in some cases +* Fixed an edge case that caused the app to crash when non-synchronized lyrics are enabled +* Move to adminforge.de as the main Piped instance to bypass some restrictions and avoid downtime +* Fixed slider behavior +* Fixed an issue that caused the app to crash when your device is out of memory or doesn't support normalization +* Fixed an issue where some search queries caused searching for video's to load infinitely +* Reduced item sizes in horizontal grids in portrait mode to only 75% of the maximum available size +* Optimized network traffic while fetching mood data +* Improved consistency in Discover tab +* Added suggestions to the queue +* Fixed an issue that caused a crash while pre-caching when no songs have ever been played or pre-cached +* Simplified player interfaces \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/32.txt b/fastlane/metadata/android/en-US/changelogs/32.txt new file mode 100644 index 0000000..d82bdf5 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/32.txt @@ -0,0 +1,11 @@ +* Added update check in Settings -> About +* Fixed issues with reordering +* Changed some corner rounding values +* Fixed inconsistencies with all bottom sheets +* Fixed visual glitch with pre-cache progress bar +* Fixed some performance issues +* Fixed visual glitch while scrolling synchronized lyrics +* Fixed visual glitch with removing songs from queue/Songs tab +* Added 'pinch to enlarge' lyrics +* Fixed inconsistencies with the wavy seek bar and improved look +* Fixed padding in lyrics picker dialog \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/33.txt b/fastlane/metadata/android/en-US/changelogs/33.txt new file mode 100644 index 0000000..07f9d93 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/33.txt @@ -0,0 +1,8 @@ +* Added AMOLED theme +* Added more control on thumbnail quality +* Added rounding for consistency with the rest of the UI +* Improved performance +* Added 'wavy seek bar quality' +* Added more playlist/album info +* Added top/local playlists to Android Auto +* Improved animations \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/9.txt b/fastlane/metadata/android/en-US/changelogs/9.txt new file mode 100644 index 0000000..5f517f8 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/9.txt @@ -0,0 +1 @@ +* Initial release \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt new file mode 100644 index 0000000..fa7dd60 --- /dev/null +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -0,0 +1,23 @@ +Features: + +* Search and play any song or video from YouTube Music +* Background playback w/ notification +* Search for songs, albums, artists, videos and playlists +* Discover new songs by mood/genre +* Bookmark artists and albums +* Import playlists +* Import local music +* Share to import from other YouTube Music apps +* Automatic cache system for offline playback and saving resources +* Fetch and edit lyrics and synchronized lyrics +* Open YouTube/YouTube Music links +* Local playlists management +* Queue management +* Favorites and Offline built-in playlists +* Sleep timer +* Skip silence +* Persistent queue +* Loudness/audio normalization +* Android Auto +* Simple and minimal UI +* Ridiculously lightweight APK diff --git a/fastlane/metadata/android/en-US/images/icon.png b/fastlane/metadata/android/en-US/images/icon.png new file mode 100644 index 0000000..f23c265 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/icon.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg new file mode 100644 index 0000000..b664a00 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/1.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg new file mode 100644 index 0000000..7e04a11 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/2.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg new file mode 100644 index 0000000..749ea53 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/3.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg new file mode 100644 index 0000000..88c54b7 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/4.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg new file mode 100644 index 0000000..17ce690 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/5.jpg differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg new file mode 100644 index 0000000..7f05706 Binary files /dev/null and b/fastlane/metadata/android/en-US/images/phoneScreenshots/6.jpg differ diff --git a/fastlane/metadata/android/en-US/short_description.txt b/fastlane/metadata/android/en-US/short_description.txt new file mode 100644 index 0000000..027dcf3 --- /dev/null +++ b/fastlane/metadata/android/en-US/short_description.txt @@ -0,0 +1 @@ +Seamlessly stream music from YouTube Music diff --git a/fastlane/metadata/android/en-US/title.txt b/fastlane/metadata/android/en-US/title.txt new file mode 100644 index 0000000..3e377a5 --- /dev/null +++ b/fastlane/metadata/android/en-US/title.txt @@ -0,0 +1 @@ +ViMusic \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index a0153bd..48bce2c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,8 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.caching=true +org.gradle.configuration-cache=true +org.gradle.parallel=true android.useAndroidX=true android.enableJetifier=false kotlin.code.style=official -android.enableR8.fullMode=true +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..604165f --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,69 @@ +[versions] +kotlin = "1.9.22" +ksp = "1.9.22-1.0.17" + +jvm = "17" +agp = "8.4.0-alpha11" + +compose_compiler = "1.5.8" + +room = "2.6.1" +media3 = "1.2.1" +ktor = "2.3.8" +detekt = "1.23.5" +workmanager = "2.9.0" + +[plugins] +android_application = { id = "com.android.application", version.ref = "agp" } +android_library = { id = "com.android.library", version.ref = "agp" } +android_lint = { id = "com.android.lint", version.ref = "agp" } +kotlin_jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin_android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +kotlin_serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } + +[libraries] +core_ktx = { module = "androidx.core:core-ktx", version = "1.12.0" } + +kotlin_coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version = "1.8.0" } +kotlin_datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version = "0.5.0" } +kotlin_immutable = { module = "org.jetbrains.kotlinx:kotlinx-collections-immutable", version = "0.3.7" } + +compose_bom = { module = "androidx.compose:compose-bom", version = "2024.02.01" } +compose_animation = { module = "androidx.compose.animation:animation" } +compose_foundation = { module = "androidx.compose.foundation:foundation", version = "1.6.2" } +compose_ui = { module = "androidx.compose.ui:ui" } +compose_ui_util = { module = "androidx.compose.ui:ui-util" } +compose_ripple = { module = "androidx.compose.material:material-ripple" } +compose_material3 = { module = "androidx.compose.material3:material3", version = "1.2.0" } + +compose_activity = { module = "androidx.activity:activity-compose", version = "1.8.2" } +compose_shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version = "1.2.0" } +compose_coil = { module = "io.coil-kt:coil-compose", version = "2.6.0" } + +room = { module = "androidx.room:room-ktx", version.ref = "room" } +room_compiler = { module = "androidx.room:room-compiler", version.ref = "room" } + +exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } +exoplayer_workmanager = { module = "androidx.media3:media3-exoplayer-workmanager", version.ref = "media3" } +workmanager = { module = "androidx.work:work-runtime", version.ref = "workmanager" } +workmanager_ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workmanager" } + +ktor_http = { module = "io.ktor:ktor-http", version.ref = "ktor" } + +ktor_client_core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor_client_cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor_client_okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor_client_content_negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor_client_encoding = { module = "io.ktor:ktor-client-encoding", version.ref = "ktor" } +ktor_client_serialization = { module = "io.ktor:ktor-client-serialization", version.ref = "ktor" } +ktor_serialization_json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } + +brotli = { module = "org.brotli:dec", version = "0.1.2" } +palette = { module = "androidx.palette:palette", version = "1.0.0" } + +desugaring = { module = "com.android.tools:desugar_jdk_libs", version = "2.0.4" } + +detekt_compose = { module = "io.nlopez.compose.rules:detekt", version = "0.3.11" } +detekt_formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index e708b1c..d64cd49 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fada095..a80b22c 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Wed Jul 06 23:33:16 CEST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index 4f906e0..1aa94a4 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ -#!/usr/bin/env sh +#!/bin/sh # -# Copyright 2015 the original author or authors. +# Copyright © 2015-2021 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -17,67 +17,99 @@ # ############################################################################## -## -## Gradle start up script for UN*X -## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# ############################################################################## # Attempt to set APP_HOME + # Resolve links: $0 may be a link -PRG="$0" -# Need this for relative symlinks. -while [ -h "$PRG" ] ; do - ls=`ls -ld "$PRG"` - link=`expr "$ls" : '.*-> \(.*\)$'` - if expr "$link" : '/.*' > /dev/null; then - PRG="$link" - else - PRG=`dirname "$PRG"`"/$link" - fi +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac done -SAVED="`pwd`" -cd "`dirname \"$PRG\"`/" >/dev/null -APP_HOME="`pwd -P`" -cd "$SAVED" >/dev/null -APP_NAME="Gradle" -APP_BASE_NAME=`basename "$0"` - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD="maximum" +MAX_FD=maximum warn () { echo "$*" -} +} >&2 die () { echo echo "$*" echo exit 1 -} +} >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false -case "`uname`" in - CYGWIN* ) - cygwin=true - ;; - Darwin* ) - darwin=true - ;; - MINGW* ) - msys=true - ;; - NONSTOP* ) - nonstop=true - ;; +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; esac CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar @@ -87,9 +119,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACMD=$JAVA_HOME/jre/sh/java else - JAVACMD="$JAVA_HOME/bin/java" + JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME @@ -98,88 +130,120 @@ Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else - JAVACMD="java" - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. -if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then - MAX_FD_LIMIT=`ulimit -H -n` - if [ $? -eq 0 ] ; then - if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then - MAX_FD="$MAX_FD_LIMIT" - fi - ulimit -n $MAX_FD - if [ $? -ne 0 ] ; then - warn "Could not set maximum file descriptor limit: $MAX_FD" - fi - else - warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" - fi -fi - -# For Darwin, add options to specify how the application appears in the dock -if $darwin; then - GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" -fi - -# For Cygwin or MSYS, switch paths to Windows format before running java -if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then - APP_HOME=`cygpath --path --mixed "$APP_HOME"` - CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` - - JAVACMD=`cygpath --unix "$JAVACMD"` - - # We build the pattern for arguments to be converted via cygpath - ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` - SEP="" - for dir in $ROOTDIRSRAW ; do - ROOTDIRS="$ROOTDIRS$SEP$dir" - SEP="|" - done - OURCYGPATTERN="(^($ROOTDIRS))" - # Add a user-defined pattern to the cygpath arguments - if [ "$GRADLE_CYGPATTERN" != "" ] ; then - OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" - fi - # Now convert the arguments - kludge to limit ourselves to /bin/sh - i=0 - for arg in "$@" ; do - CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` - CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option - - if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition - eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` - else - eval `echo args$i`="\"$arg\"" - fi - i=`expr $i + 1` - done - case $i in - 0) set -- ;; - 1) set -- "$args0" ;; - 2) set -- "$args0" "$args1" ;; - 3) set -- "$args0" "$args1" "$args2" ;; - 4) set -- "$args0" "$args1" "$args2" "$args3" ;; - 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; - 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; - 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; - 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; - 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi -# Escape application args -save () { - for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done - echo " " -} -APP_ARGS=`save "$@"` +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. -# Collect all arguments for the java command, following the shell quoting and substitution rules -eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat index 107acd3..93e3f59 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -14,7 +14,7 @@ @rem limitations under the License. @rem -@if "%DEBUG%" == "" @echo off +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -25,7 +25,8 @@ if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto execute +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/Context.kt b/innertube/src/main/kotlin/it/hamy/innertube/models/Context.kt deleted file mode 100644 index a29574e..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/Context.kt +++ /dev/null @@ -1,53 +0,0 @@ -package it.hamy.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Context( - val client: Client, - val thirdParty: ThirdParty? = null, -) { - @Serializable - data class Client( - val clientName: String, - val clientVersion: String, - val platform: String, - val hl: String = "en", - val visitorData: String = "CgtEUlRINDFjdm1YayjX1pSaBg%3D%3D", - val androidSdkVersion: Int? = null, - val userAgent: String? = null - ) - - @Serializable - data class ThirdParty( - val embedUrl: String, - ) - - companion object { - val DefaultWeb = Context( - client = Client( - clientName = "WEB_REMIX", - clientVersion = "1.20220918", - platform = "DESKTOP", - ) - ) - - val DefaultAndroid = Context( - client = Client( - clientName = "ANDROID_MUSIC", - clientVersion = "5.28.1", - platform = "MOBILE", - androidSdkVersion = 30, - userAgent = "com.google.android.apps.youtube.music/5.28.1 (Linux; U; Android 11) gzip" - ) - ) - - val DefaultAgeRestrictionBypass = Context( - client = Client( - clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", - clientVersion = "2.0", - platform = "TV" - ) - ) - } -} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/MusicTwoRowItemRenderer.kt b/innertube/src/main/kotlin/it/hamy/innertube/models/MusicTwoRowItemRenderer.kt deleted file mode 100644 index 4bd0f7a..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/MusicTwoRowItemRenderer.kt +++ /dev/null @@ -1,11 +0,0 @@ -package it.hamy.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class MusicTwoRowItemRenderer( - val navigationEndpoint: NavigationEndpoint?, - val thumbnailRenderer: ThumbnailRenderer?, - val title: Runs?, - val subtitle: Runs?, -) diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/NavigationEndpoint.kt b/innertube/src/main/kotlin/it/hamy/innertube/models/NavigationEndpoint.kt deleted file mode 100644 index bd651f6..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/NavigationEndpoint.kt +++ /dev/null @@ -1,203 +0,0 @@ -package it.hamy.innertube.models - -import kotlinx.serialization.Serializable - -/** - * watchPlaylistEndpoint: params, playlistId - * watchEndpoint: params, playlistId, videoId, index - * browseEndpoint: params, browseId - * searchEndpoint: params, query - */ -//@Serializable -//data class NavigationEndpoint( -// @JsonNames("watchEndpoint", "watchPlaylistEndpoint", "navigationEndpoint", "browseEndpoint", "searchEndpoint") -// val endpoint: Endpoint -//) { -// @Serializable -// data class Endpoint( -// val params: String?, -// val playlistId: String?, -// val videoId: String?, -// val index: Int?, -// val browseId: String?, -// val query: String?, -// val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs?, -// val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs?, -// ) { -// @Serializable -// data class WatchEndpointMusicSupportedConfigs( -// val watchEndpointMusicConfig: WatchEndpointMusicConfig -// ) { -// @Serializable -// data class WatchEndpointMusicConfig( -// val musicVideoType: String -// ) -// } -// -// @Serializable -// data class BrowseEndpointContextSupportedConfigs( -// val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig -// ) { -// @Serializable -// data class BrowseEndpointContextMusicConfig( -// val pageType: String -// ) -// } -// } -//} - -@Serializable -data class NavigationEndpoint( - val watchEndpoint: Endpoint.Watch?, - val watchPlaylistEndpoint: Endpoint.WatchPlaylist?, - val browseEndpoint: Endpoint.Browse?, - val searchEndpoint: Endpoint.Search?, -) { - val endpoint: Endpoint? - get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint - - @Serializable - sealed class Endpoint { - @Serializable - data class Watch( - val params: String? = null, - val playlistId: String? = null, - val videoId: String? = null, - val index: Int? = null, - val playlistSetVideoId: String? = null, - val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null, - ) : Endpoint() { - val type: String? - get() = watchEndpointMusicSupportedConfigs - ?.watchEndpointMusicConfig - ?.musicVideoType - - @Serializable - data class WatchEndpointMusicSupportedConfigs( - val watchEndpointMusicConfig: WatchEndpointMusicConfig? - ) { - - @Serializable - data class WatchEndpointMusicConfig( - val musicVideoType: String? - ) - } - } - - @Serializable - data class WatchPlaylist( - val params: String?, - val playlistId: String?, - ) : Endpoint() - - @Serializable - data class Browse( - val params: String? = null, - val browseId: String? = null, - val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null, - ) : Endpoint() { - val type: String? - get() = browseEndpointContextSupportedConfigs - ?.browseEndpointContextMusicConfig - ?.pageType - - @Serializable - data class BrowseEndpointContextSupportedConfigs( - val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig - ) { - - @Serializable - data class BrowseEndpointContextMusicConfig( - val pageType: String - ) - } - } - - @Serializable - data class Search( - val params: String?, - val query: String, - ) : Endpoint() - } -} - -//@Serializable(with = NavigationEndpoint.Serializer::class) -//sealed class NavigationEndpoint { -// @Serializable -// data class Watch( -// val watchEndpoint: Data -// ) : NavigationEndpoint() { -// @Serializable -// data class Data( -// val params: String?, -// val playlistId: String, -// val videoId: String, -//// val index: Int? -// val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs, -// ) -// -// @Serializable -// data class WatchEndpointMusicSupportedConfigs( -// val watchEndpointMusicConfig: WatchEndpointMusicConfig -// ) { -// @Serializable -// data class WatchEndpointMusicConfig( -// val musicVideoType: String -// ) -// } -// } -// -// @Serializable -// data class WatchPlaylist( -// val watchPlaylistEndpoint: Data -// ) : NavigationEndpoint() { -// @Serializable -// data class Data( -// val params: String?, -// val playlistId: String, -// ) -// } -// -// @Serializable -// data class Browse( -// val browseEndpoint: Data -// ) : NavigationEndpoint() { -// @Serializable -// data class Data( -// val params: String?, -// val browseId: String, -// val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs, -// ) -// -// @Serializable -// data class BrowseEndpointContextSupportedConfigs( -// val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig -// ) { -// @Serializable -// data class BrowseEndpointContextMusicConfig( -// val pageType: String -// ) -// } -// } -// -// @Serializable -// data class Search( -// val searchEndpoint: Data -// ) : NavigationEndpoint() { -// @Serializable -// data class Data( -// val params: String?, -// val query: String, -// ) -// } -// -// object Serializer : JsonContentPolymorphicSerializer(NavigationEndpoint::class) { -// override fun selectDeserializer(element: JsonElement) = when { -// "watchEndpoint" in element.jsonObject -> Watch.serializer() -// "watchPlaylistEndpoint" in element.jsonObject -> WatchPlaylist.serializer() -// "browseEndpoint" in element.jsonObject -> Browse.serializer() -// "searchEndpoint" in element.jsonObject -> Search.serializer() -// else -> TODO() -// } -// } -//} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/Runs.kt b/innertube/src/main/kotlin/it/hamy/innertube/models/Runs.kt deleted file mode 100644 index c560180..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/Runs.kt +++ /dev/null @@ -1,31 +0,0 @@ -package it.hamy.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Runs( - val runs: List = listOf() -) { - val text: String - get() = runs.joinToString("") { it.text ?: "" } - - fun splitBySeparator(): List> { - return runs.flatMapIndexed { index, run -> - when { - index == 0 || index == runs.lastIndex -> listOf(index) - run.text == " • " -> listOf(index - 1, index + 1) - else -> emptyList() - } - }.windowed(size = 2, step = 2) { (from, to) -> runs.slice(from..to) }.let { - it.ifEmpty { - listOf(runs) - } - } - } - - @Serializable - data class Run( - val text: String?, - val navigationEndpoint: NavigationEndpoint?, - ) -} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/Thumbnail.kt b/innertube/src/main/kotlin/it/hamy/innertube/models/Thumbnail.kt deleted file mode 100644 index d64619f..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/Thumbnail.kt +++ /dev/null @@ -1,21 +0,0 @@ -package it.hamy.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Thumbnail( - val url: String, - val height: Int?, - val width: Int? -) { - val isResizable: Boolean - get() = !url.startsWith("https://i.ytimg.com") - - fun size(size: Int): String { - return when { - url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size" - url.startsWith("https://yt3.ggpht.com") -> "$url-s$size" - else -> url - } - } -} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/AlbumPage.kt b/innertube/src/main/kotlin/it/hamy/innertube/requests/AlbumPage.kt deleted file mode 100644 index 0bf18b5..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/AlbumPage.kt +++ /dev/null @@ -1,36 +0,0 @@ -package it.hamy.innertube.requests - -import io.ktor.http.Url -import it.hamy.innertube.Innertube -import it.hamy.innertube.models.NavigationEndpoint -import it.hamy.innertube.models.bodies.BrowseBody - -suspend fun Innertube.albumPage(body: BrowseBody): Result? { - return playlistPage(body)?.map { album -> - album.url?.let { Url(it).parameters["list"] }?.let { playlistId -> - playlistPage(BrowseBody(browseId = "VL$playlistId"))?.getOrNull()?.let { playlist -> - album.copy(songsPage = playlist.songsPage) - } - } ?: album - }?.map { album -> - val albumInfo = Innertube.Info( - name = album.title, - endpoint = NavigationEndpoint.Endpoint.Browse( - browseId = body.browseId, - params = body.params - ) - ) - - album.copy( - songsPage = album.songsPage?.copy( - items = album.songsPage.items?.map { song -> - song.copy( - authors = song.authors ?: album.authors, - album = albumInfo, - thumbnail = album.thumbnail - ) - } - ) - ) - } -} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/ArtistPage.kt b/innertube/src/main/kotlin/it/hamy/innertube/requests/ArtistPage.kt deleted file mode 100644 index ebc50df..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/ArtistPage.kt +++ /dev/null @@ -1,106 +0,0 @@ -package it.hamy.innertube.requests - -import io.ktor.client.call.body -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import it.hamy.innertube.Innertube -import it.hamy.innertube.models.BrowseResponse -import it.hamy.innertube.models.MusicCarouselShelfRenderer -import it.hamy.innertube.models.MusicShelfRenderer -import it.hamy.innertube.models.SectionListRenderer -import it.hamy.innertube.models.bodies.BrowseBody -import it.hamy.innertube.utils.findSectionByTitle -import it.hamy.innertube.utils.from -import it.hamy.innertube.utils.runCatchingNonCancellable - -suspend fun Innertube.artistPage(body: BrowseBody): Result? = - runCatchingNonCancellable { - val response = client.post(browse) { - setBody(body) - mask("contents,header") - }.body() - - fun findSectionByTitle(text: String): SectionListRenderer.Content? { - return response - .contents - ?.singleColumnBrowseResultsRenderer - ?.tabs - ?.get(0) - ?.tabRenderer - ?.content - ?.sectionListRenderer - ?.findSectionByTitle(text) - } - - val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer - val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer - val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer - - Innertube.ArtistPage( - name = response - .header - ?.musicImmersiveHeaderRenderer - ?.title - ?.text, - description = response - .header - ?.musicImmersiveHeaderRenderer - ?.description - ?.text, - thumbnail = (response - .header - ?.musicImmersiveHeaderRenderer - ?.foregroundThumbnail - ?: response - .header - ?.musicImmersiveHeaderRenderer - ?.thumbnail) - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.getOrNull(0), - shuffleEndpoint = response - .header - ?.musicImmersiveHeaderRenderer - ?.playButton - ?.buttonRenderer - ?.navigationEndpoint - ?.watchEndpoint, - radioEndpoint = response - .header - ?.musicImmersiveHeaderRenderer - ?.startRadioButton - ?.buttonRenderer - ?.navigationEndpoint - ?.watchEndpoint, - songs = songsSection - ?.contents - ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) - ?.mapNotNull(Innertube.SongItem::from), - songsEndpoint = songsSection - ?.bottomEndpoint - ?.browseEndpoint, - albums = albumsSection - ?.contents - ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) - ?.mapNotNull(Innertube.AlbumItem::from), - albumsEndpoint = albumsSection - ?.header - ?.musicCarouselShelfBasicHeaderRenderer - ?.moreContentButton - ?.buttonRenderer - ?.navigationEndpoint - ?.browseEndpoint, - singles = singlesSection - ?.contents - ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) - ?.mapNotNull(Innertube.AlbumItem::from), - singlesEndpoint = singlesSection - ?.header - ?.musicCarouselShelfBasicHeaderRenderer - ?.moreContentButton - ?.buttonRenderer - ?.navigationEndpoint - ?.browseEndpoint, - ) - } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicTwoRowItemRenderer.kt b/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicTwoRowItemRenderer.kt deleted file mode 100644 index 3fb8a1c..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicTwoRowItemRenderer.kt +++ /dev/null @@ -1,76 +0,0 @@ -package it.hamy.innertube.utils - -import it.hamy.innertube.Innertube -import it.hamy.innertube.models.MusicTwoRowItemRenderer - -fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.AlbumItem? { - return Innertube.AlbumItem( - info = renderer - .title - ?.runs - ?.firstOrNull() - ?.let(Innertube::Info), - authors = null, - year = renderer - .subtitle - ?.runs - ?.lastOrNull() - ?.text, - thumbnail = renderer - .thumbnailRenderer - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.firstOrNull() - ).takeIf { it.info?.endpoint?.browseId != null } -} - -fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.ArtistItem? { - return Innertube.ArtistItem( - info = renderer - .title - ?.runs - ?.firstOrNull() - ?.let(Innertube::Info), - subscribersCountText = renderer - .subtitle - ?.runs - ?.firstOrNull() - ?.text, - thumbnail = renderer - .thumbnailRenderer - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.firstOrNull() - ).takeIf { it.info?.endpoint?.browseId != null } -} - -fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer): Innertube.PlaylistItem? { - return Innertube.PlaylistItem( - info = renderer - .title - ?.runs - ?.firstOrNull() - ?.let(Innertube::Info), - channel = renderer - .subtitle - ?.runs - ?.getOrNull(2) - ?.let(Innertube::Info), - songCount = renderer - .subtitle - ?.runs - ?.getOrNull(4) - ?.text - ?.split(' ') - ?.firstOrNull() - ?.toIntOrNull(), - thumbnail = renderer - .thumbnailRenderer - ?.musicThumbnailRenderer - ?.thumbnail - ?.thumbnails - ?.firstOrNull() - ).takeIf { it.info?.endpoint?.browseId != null } -} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/utils/FromPlaylistPanelVideoRenderer.kt b/innertube/src/main/kotlin/it/hamy/innertube/utils/FromPlaylistPanelVideoRenderer.kt deleted file mode 100644 index 3d97f88..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/utils/FromPlaylistPanelVideoRenderer.kt +++ /dev/null @@ -1,35 +0,0 @@ -package it.hamy.innertube.utils - -import it.hamy.innertube.Innertube -import it.hamy.innertube.models.PlaylistPanelVideoRenderer - -fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer): Innertube.SongItem? { - return Innertube.SongItem( - info = Innertube.Info( - name = renderer - .title - ?.text, - endpoint = renderer - .navigationEndpoint - ?.watchEndpoint - ), - authors = renderer - .longBylineText - ?.splitBySeparator() - ?.getOrNull(0) - ?.map(Innertube::Info), - album = renderer - .longBylineText - ?.splitBySeparator() - ?.getOrNull(1) - ?.getOrNull(0) - ?.let(Innertube::Info), - thumbnail = renderer - .thumbnail - ?.thumbnails - ?.getOrNull(0), - durationText = renderer - .lengthText - ?.text - ).takeIf { it.info?.endpoint?.videoId != null } -} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/utils/Utils.kt b/innertube/src/main/kotlin/it/hamy/innertube/utils/Utils.kt deleted file mode 100644 index 5fcf861..0000000 --- a/innertube/src/main/kotlin/it/hamy/innertube/utils/Utils.kt +++ /dev/null @@ -1,50 +0,0 @@ -package it.hamy.innertube.utils - -import io.ktor.utils.io.CancellationException -import it.hamy.innertube.Innertube -import it.hamy.innertube.models.SectionListRenderer - -internal fun SectionListRenderer.findSectionByTitle(text: String): SectionListRenderer.Content? { - return contents?.find { content -> - val title = content - .musicCarouselShelfRenderer - ?.header - ?.musicCarouselShelfBasicHeaderRenderer - ?.title - ?: content - .musicShelfRenderer - ?.title - - title - ?.runs - ?.firstOrNull() - ?.text == text - } -} - -internal fun SectionListRenderer.findSectionByStrapline(text: String): SectionListRenderer.Content? { - return contents?.find { content -> - content - .musicCarouselShelfRenderer - ?.header - ?.musicCarouselShelfBasicHeaderRenderer - ?.strapline - ?.runs - ?.firstOrNull() - ?.text == text - } -} - -internal inline fun runCatchingNonCancellable(block: () -> R): Result? { - val result = runCatching(block) - return when (result.exceptionOrNull()) { - is CancellationException -> null - else -> result - } -} - -infix operator fun Innertube.ItemsPage?.plus(other: Innertube.ItemsPage) = - other.copy( - items = (this?.items?.plus(other.items ?: emptyList()) - ?: other.items)?.distinctBy(Innertube.Item::key) - ) diff --git a/innertube/src/test/kotlin/Test.kt b/innertube/src/test/kotlin/Test.kt deleted file mode 100644 index 124168c..0000000 --- a/innertube/src/test/kotlin/Test.kt +++ /dev/null @@ -1,10 +0,0 @@ -import kotlinx.coroutines.runBlocking -import org.junit.Test - -class Test { - @Test - @Throws(Exception::class) - fun test() = runBlocking { - - } -} diff --git a/ktor-client-brotli/build.gradle.kts b/ktor-client-brotli/build.gradle.kts index 564d1d3..0c9b8b9 100644 --- a/ktor-client-brotli/build.gradle.kts +++ b/ktor-client-brotli/build.gradle.kts @@ -1,12 +1,16 @@ plugins { - kotlin("jvm") -} - -sourceSets.all { - java.srcDir("src/$name/kotlin") + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.android.lint) } dependencies { implementation(libs.ktor.client.encoding) implementation(libs.brotli) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) } \ No newline at end of file diff --git a/kugou/src/main/kotlin/it/hamy/kugou/KuGou.kt b/kugou/src/main/kotlin/it/hamy/kugou/KuGou.kt deleted file mode 100644 index 60716ce..0000000 --- a/kugou/src/main/kotlin/it/hamy/kugou/KuGou.kt +++ /dev/null @@ -1,213 +0,0 @@ -package it.hamy.kugou - -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.BrowserUserAgent -import io.ktor.client.plugins.compression.ContentEncoding -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.plugins.defaultRequest -import io.ktor.client.request.get -import io.ktor.client.request.parameter -import io.ktor.http.ContentType -import io.ktor.http.encodeURLParameter -import io.ktor.serialization.kotlinx.json.json -import io.ktor.util.decodeBase64String -import it.hamy.kugou.models.DownloadLyricsResponse -import it.hamy.kugou.models.SearchLyricsResponse -import it.hamy.kugou.models.SearchSongResponse -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json - -object KuGou { - @OptIn(ExperimentalSerializationApi::class) - private val client by lazy { - HttpClient(OkHttp) { - BrowserUserAgent() - - expectSuccess = true - - install(ContentNegotiation) { - val feature = Json { - ignoreUnknownKeys = true - explicitNulls = false - encodeDefaults = true - } - - json(feature) - json(feature, ContentType.Text.Html) - json(feature, ContentType.Text.Plain) - } - - install(ContentEncoding) { - gzip() - deflate() - } - - defaultRequest { - url("https://krcs.kugou.com") - } - } - } - - suspend fun lyrics(artist: String, title: String, duration: Long): Result? { - return runCatching { - val keyword = keyword(artist, title) - val infoByKeyword = searchSong(keyword) - - if (infoByKeyword.isNotEmpty()) { - var tolerance = 0 - - while (tolerance <= 5) { - for (info in infoByKeyword) { - if (info.duration >= duration - tolerance && info.duration <= duration + tolerance) { - searchLyricsByHash(info.hash).firstOrNull()?.let { candidate -> - return@runCatching downloadLyrics(candidate.id, candidate.accessKey).normalize() - } - } - } - - tolerance++ - } - } - - searchLyricsByKeyword(keyword).firstOrNull()?.let { candidate -> - return@runCatching downloadLyrics(candidate.id, candidate.accessKey).normalize() - } - - null - }.recoverIfCancelled() - } - - private suspend fun downloadLyrics(id: Long, accessKey: String): Lyrics { - return client.get("/download") { - parameter("ver", 1) - parameter("man", "yes") - parameter("client", "pc") - parameter("fmt", "lrc") - parameter("id", id) - parameter("accesskey", accessKey) - }.body().content.decodeBase64String().let(::Lyrics) - } - - private suspend fun searchLyricsByHash(hash: String): List { - return client.get("/search") { - parameter("ver", 1) - parameter("man", "yes") - parameter("client", "mobi") - parameter("hash", hash) - }.body().candidates - } - - private suspend fun searchLyricsByKeyword(keyword: String): List { - return client.get("/search") { - parameter("ver", 1) - parameter("man", "yes") - parameter("client", "mobi") - url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) - }.body().candidates - } - - private suspend fun searchSong(keyword: String): List { - return client.get("https://mobileservice.kugou.com/api/v3/search/song") { - parameter("version", 9108) - parameter("plat", 0) - parameter("pagesize", 8) - parameter("showtype", 0) - url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) - }.body().data.info - } - - private fun keyword(artist: String, title: String): String { - val (newTitle, featuring) = title.extract(" (feat. ", ')') - - val newArtist = (if (featuring.isEmpty()) artist else "$artist, $featuring") - .replace(", ", "、") - .replace(" & ", "、") - .replace(".", "") - - return "$newArtist - $newTitle" - } - - private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair { - val startIndex = indexOf(startDelimiter) - - if (startIndex == -1) return this to "" - - val endIndex = indexOf(endDelimiter, startIndex) - - if (endIndex == -1) return this to "" - - return removeRange( - startIndex, - endIndex + 1 - ) to substring(startIndex + startDelimiter.length, endIndex) - } - - @JvmInline - value class Lyrics(val value: String) : CharSequence by value { - val sentences: List> - get() = mutableListOf(0L to "").apply { - for (line in value.trim().lines()) { - try { - val position = line.take(10).run { - get(8).digitToInt() * 10L + - get(7).digitToInt() * 100 + - get(5).digitToInt() * 1000 + - get(4).digitToInt() * 10000 + - get(2).digitToInt() * 60 * 1000 + - get(1).digitToInt() * 600 * 1000 - } - - add(position to line.substring(10)) - } catch (_: Throwable) { - } - } - } - - fun normalize(): Lyrics { - var toDrop = 0 - var maybeToDrop = 0 - - val text = value.replace("\r\n", "\n").trim() - - for (line in text.lineSequence()) { - if (line.startsWith("[ti:") || - line.startsWith("[ar:") || - line.startsWith("[al:") || - line.startsWith("[by:") || - line.startsWith("[hash:") || - line.startsWith("[sign:") || - line.startsWith("[qq:") || - line.startsWith("[total:") || - line.startsWith("[offset:") || - line.startsWith("[id:") || - line.containsAt("]Written by:", 9) || - line.containsAt("]Lyrics by:", 9) || - line.containsAt("]Composed by:", 9) || - line.containsAt("]Producer:", 9) || - line.containsAt("]作曲 : ", 9) || - line.containsAt("]作词 : ", 9) - ) { - toDrop += line.length + 1 + maybeToDrop - maybeToDrop = 0 - } else { - if (maybeToDrop == 0) { - maybeToDrop = line.length + 1 - } else { - maybeToDrop = 0 - break - } - } - } - - return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities()) - } - - private fun String.containsAt(charSequence: CharSequence, startIndex: Int): Boolean = - regionMatches(startIndex, charSequence, 0, charSequence.length) - - private fun String.removeHtmlEntities(): String = - replace("'", "'") - } -} diff --git a/kugou/src/main/kotlin/it/hamy/kugou/Result.kt b/kugou/src/main/kotlin/it/hamy/kugou/Result.kt deleted file mode 100644 index 65297cb..0000000 --- a/kugou/src/main/kotlin/it/hamy/kugou/Result.kt +++ /dev/null @@ -1,10 +0,0 @@ -package it.hamy.kugou - -import kotlin.coroutines.cancellation.CancellationException - -internal fun Result.recoverIfCancelled(): Result? { - return when (exceptionOrNull()) { - is CancellationException -> null - else -> this - } -} diff --git a/kugou/src/test/kotlin/Test.kt b/kugou/src/test/kotlin/Test.kt deleted file mode 100644 index 47c0114..0000000 --- a/kugou/src/test/kotlin/Test.kt +++ /dev/null @@ -1,11 +0,0 @@ -import kotlinx.coroutines.runBlocking -import org.junit.Test - -class Test { - @Test - @Throws(Exception::class) - fun test() { - runBlocking { - } - } -} diff --git a/providers/common/.gitignore b/providers/common/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/providers/common/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/providers/common/build.gradle.kts b/providers/common/build.gradle.kts new file mode 100644 index 0000000..5572874 --- /dev/null +++ b/providers/common/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(libs.kotlin.coroutines) + implementation(libs.kotlin.datetime) + + implementation(libs.ktor.http) + implementation(libs.ktor.serialization.json) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} diff --git a/providers/common/src/main/kotlin/it/hamy/extensions/Coroutines.kt b/providers/common/src/main/kotlin/it/hamy/extensions/Coroutines.kt new file mode 100644 index 0000000..9749fce --- /dev/null +++ b/providers/common/src/main/kotlin/it/hamy/extensions/Coroutines.kt @@ -0,0 +1,6 @@ +package it.hamy.extensions + +import kotlinx.coroutines.CancellationException + +inline fun runCatchingCancellable(block: () -> T) = + runCatching(block).takeIf { it.exceptionOrNull() !is CancellationException } diff --git a/providers/common/src/main/kotlin/it/hamy/extensions/Serializers.kt b/providers/common/src/main/kotlin/it/hamy/extensions/Serializers.kt new file mode 100644 index 0000000..8a7e6bd --- /dev/null +++ b/providers/common/src/main/kotlin/it/hamy/extensions/Serializers.kt @@ -0,0 +1,26 @@ +package it.hamy.extensions + +import io.ktor.http.Url +import kotlinx.datetime.LocalDateTime +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +object UrlSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Url", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder) = Url(decoder.decodeString()) + override fun serialize(encoder: Encoder, value: Url) = encoder.encodeString(value.toString()) +} + +typealias SerializableUrl = @Serializable(with = UrlSerializer::class) Url + +object Iso8601DateSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("Iso8601LocalDateTime", PrimitiveKind.STRING) + override fun deserialize(decoder: Decoder) = LocalDateTime.parse(decoder.decodeString().removeSuffix("Z")) + override fun serialize(encoder: Encoder, value: LocalDateTime) = encoder.encodeString(value.toString()) +} + +typealias SerializableIso8601Date = @Serializable(with = Iso8601DateSerializer::class) LocalDateTime diff --git a/providers/github/.gitignore b/providers/github/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/providers/github/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/kugou/build.gradle.kts b/providers/github/build.gradle.kts similarity index 58% rename from kugou/build.gradle.kts rename to providers/github/build.gradle.kts index 709f0a3..5eb4332 100644 --- a/kugou/build.gradle.kts +++ b/providers/github/build.gradle.kts @@ -1,22 +1,24 @@ plugins { - kotlin("jvm") - @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) -} - -sourceSets.all { - java.srcDir("src/$name/kotlin") + alias(libs.plugins.android.lint) } dependencies { + implementation(projects.providers.common) + implementation(libs.kotlin.coroutines) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.encoding) implementation(libs.ktor.client.serialization) implementation(libs.ktor.serialization.json) - testImplementation(testLibs.junit) + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) } diff --git a/providers/github/src/main/kotlin/it/hamy/github/GitHub.kt b/providers/github/src/main/kotlin/it/hamy/github/GitHub.kt new file mode 100644 index 0000000..c4a28f0 --- /dev/null +++ b/providers/github/src/main/kotlin/it/hamy/github/GitHub.kt @@ -0,0 +1,55 @@ +package it.hamy.github + +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.parameter +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.json +import kotlinx.serialization.json.Json + +private const val API_VERSION = "2022-11-28" +private const val CONTENT_TYPE = "application" +private const val CONTENT_SUBTYPE = "vnd.github+json" + +object GitHub { + internal val httpClient by lazy { + HttpClient(CIO) { + val contentType = ContentType(CONTENT_TYPE, CONTENT_SUBTYPE) + + install(ContentNegotiation) { + val json = Json { + ignoreUnknownKeys = true + } + + json(json) + json( + json = json, + contentType = contentType + ) + } + + defaultRequest { + url("https://api.github.com") + headers["X-GitHub-Api-Version"] = API_VERSION + + accept(contentType) + contentType(ContentType.Application.Json) + } + + expectSuccess = true + } + } + + fun HttpRequestBuilder.withPagination(size: Int, page: Int) { + require(page > 0) { "GitHub error: invalid page ($page), pagination starts at page 1" } + require(size > 0) { "GitHub error: invalid page size ($size), a page has to have at least a single item" } + + parameter("per_page", size) + parameter("page", page) + } +} diff --git a/providers/github/src/main/kotlin/it/hamy/github/models/Reactions.kt b/providers/github/src/main/kotlin/it/hamy/github/models/Reactions.kt new file mode 100644 index 0000000..384dd4d --- /dev/null +++ b/providers/github/src/main/kotlin/it/hamy/github/models/Reactions.kt @@ -0,0 +1,26 @@ +package it.hamy.github.models + +import it.hamy.extensions.SerializableUrl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Reactions( + val url: SerializableUrl, + @SerialName("total_count") + val count: Int, + @SerialName("+1") + val likes: Int, + @SerialName("-1") + val dislikes: Int, + @SerialName("laugh") + val laughs: Int, + val confused: Int, + @SerialName("heart") + val hearts: Int, + @SerialName("hooray") + val hoorays: Int, + val eyes: Int, + @SerialName("rocket") + val rockets: Int +) diff --git a/providers/github/src/main/kotlin/it/hamy/github/models/Release.kt b/providers/github/src/main/kotlin/it/hamy/github/models/Release.kt new file mode 100644 index 0000000..2d941cc --- /dev/null +++ b/providers/github/src/main/kotlin/it/hamy/github/models/Release.kt @@ -0,0 +1,71 @@ +package it.hamy.github.models + +import it.hamy.extensions.SerializableIso8601Date +import it.hamy.extensions.SerializableUrl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Release( + val id: Int, + @SerialName("node_id") + val nodeId: String, + val url: SerializableUrl, + @SerialName("html_url") + val frontendUrl: SerializableUrl, + @SerialName("assets_url") + val assetsUrl: SerializableUrl, + @SerialName("tag_name") + val tag: String, + val name: String? = null, + @SerialName("body") + val markdown: String? = null, + val draft: Boolean, + @SerialName("prerelease") + val preRelease: Boolean, + @SerialName("created_at") + val createdAt: SerializableIso8601Date, + @SerialName("published_at") + val publishedAt: SerializableIso8601Date? = null, + val author: SimpleUser, + val assets: List = emptyList(), + @SerialName("body_html") + val html: String? = null, + @SerialName("body_text") + val text: String? = null, + @SerialName("discussion_url") + val discussionUrl: SerializableUrl? = null, + val reactions: Reactions? = null +) { + @Serializable + data class Asset( + val url: SerializableUrl, + @SerialName("browser_download_url") + val downloadUrl: SerializableUrl, + val id: Int, + @SerialName("node_id") + val nodeId: String, + val name: String, + val label: String? = null, + val state: State, + @SerialName("content_type") + val contentType: String, + val size: Long, + @SerialName("download_count") + val downloads: Int, + @SerialName("created_at") + val createdAt: SerializableIso8601Date, + @SerialName("updated_at") + val updatedAt: SerializableIso8601Date, + val uploader: SimpleUser? = null + ) { + @Serializable + enum class State { + @SerialName("uploaded") + Uploaded, + + @SerialName("open") + Open + } + } +} diff --git a/providers/github/src/main/kotlin/it/hamy/github/models/SimpleUser.kt b/providers/github/src/main/kotlin/it/hamy/github/models/SimpleUser.kt new file mode 100644 index 0000000..f3e2851 --- /dev/null +++ b/providers/github/src/main/kotlin/it/hamy/github/models/SimpleUser.kt @@ -0,0 +1,43 @@ +package it.hamy.github.models + +import it.hamy.extensions.SerializableUrl +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class SimpleUser( + val name: String? = null, + val email: String? = null, + val login: String, + val id: Int, + @SerialName("node_id") + val nodeId: String, + @SerialName("avatar_url") + val avatarUrl: SerializableUrl, + @SerialName("gravatar_id") + val gravatarId: String? = null, + val url: SerializableUrl, + @SerialName("html_url") + val frontendUrl: SerializableUrl, + @SerialName("followers_url") + val followersUrl: SerializableUrl, + @SerialName("following_url") + val followingUrl: SerializableUrl, + @SerialName("gists_url") + val gistsUrl: SerializableUrl, + @SerialName("starred_url") + val starredUrl: SerializableUrl, + @SerialName("subscriptions_url") + val subscriptionsUrl: SerializableUrl, + @SerialName("organizations_url") + val organizationsUrl: SerializableUrl, + @SerialName("repos_url") + val reposUrl: SerializableUrl, + @SerialName("events_url") + val eventsUrl: SerializableUrl, + @SerialName("received_events_url") + val receivedEventsUrl: SerializableUrl, + val type: String, + @SerialName("site_admin") + val admin: Boolean +) diff --git a/providers/github/src/main/kotlin/it/hamy/github/requests/Releases.kt b/providers/github/src/main/kotlin/it/hamy/github/requests/Releases.kt new file mode 100644 index 0000000..32b0752 --- /dev/null +++ b/providers/github/src/main/kotlin/it/hamy/github/requests/Releases.kt @@ -0,0 +1,18 @@ +package it.hamy.github.requests + +import io.ktor.client.call.body +import io.ktor.client.request.get +import it.hamy.extensions.runCatchingCancellable +import it.hamy.github.GitHub +import it.hamy.github.models.Release + +suspend fun GitHub.releases( + owner: String, + repo: String, + page: Int = 1, + pageSize: Int = 30 +) = runCatchingCancellable { + httpClient.get("repos/$owner/$repo/releases") { + withPagination(page = page, size = pageSize) + }.body>() +} diff --git a/kugou/.gitignore b/providers/innertube/.gitignore similarity index 100% rename from kugou/.gitignore rename to providers/innertube/.gitignore diff --git a/providers/innertube/build.gradle.kts b/providers/innertube/build.gradle.kts new file mode 100644 index 0000000..291d3a8 --- /dev/null +++ b/providers/innertube/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(projects.ktorClientBrotli) + implementation(projects.providers.common) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.encoding) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.json) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) + + compilerOptions { + freeCompilerArgs.addAll("-Xcontext-receivers") + } +} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/Innertube.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/Innertube.kt similarity index 64% rename from innertube/src/main/kotlin/it/hamy/innertube/Innertube.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/Innertube.kt index 4390723..96e08b5 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/Innertube.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/Innertube.kt @@ -2,16 +2,18 @@ package it.hamy.innertube import io.ktor.client.HttpClient import io.ktor.client.engine.okhttp.OkHttp -import io.ktor.client.plugins.BrowserUserAgent import io.ktor.client.plugins.compression.ContentEncoding import io.ktor.client.plugins.compression.brotli import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.defaultRequest import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.header +import io.ktor.client.request.headers import io.ktor.http.ContentType -import io.ktor.http.HttpHeaders +import io.ktor.http.contentType +import io.ktor.http.parameters import io.ktor.serialization.kotlinx.json.json +import it.hamy.innertube.models.MusicNavigationButtonRenderer import it.hamy.innertube.models.NavigationEndpoint import it.hamy.innertube.models.Runs import it.hamy.innertube.models.Thumbnail @@ -21,34 +23,40 @@ import kotlinx.serialization.json.Json import java.net.InetSocketAddress import java.net.Proxy - object Innertube { val client = HttpClient(OkHttp) { - BrowserUserAgent() - expectSuccess = true install(ContentNegotiation) { @OptIn(ExperimentalSerializationApi::class) - json(Json { - ignoreUnknownKeys = true - explicitNulls = false - encodeDefaults = true - }) + json( + Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + ) } install(ContentEncoding) { - brotli() + brotli(1.0f) + gzip(0.9f) + deflate(0.8f) } defaultRequest { - url(scheme = "https", host ="music.youtube.com") { - headers.append(HttpHeaders.ContentType, ContentType.Application.Json.toString()) - headers.append("X-Goog-Api-Key", "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8") - parameters.append("prettyPrint", "false") + url(scheme = "https", host = "music.youtube.com") { + contentType(ContentType.Application.Json) + headers { + append("X-Goog-Api-Key", API_KEY) + append("x-origin", ORIGIN) + } + parameters { + append("prettyPrint", "false") + append("key", API_KEY) + } } } - ProxyPreferences.preference?.let { engine { proxy = Proxy( @@ -60,19 +68,23 @@ object Innertube { ) } } - } - internal const val browse = "/youtubei/v1/browse" - internal const val next = "/youtubei/v1/next" - internal const val player = "/youtubei/v1/player" - internal const val queue = "/youtubei/v1/music/get_queue" - internal const val search = "/youtubei/v1/search" - internal const val searchSuggestions = "/youtubei/v1/music/get_search_suggestions" + private const val API_KEY = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX30" + private const val ORIGIN = "https://music.youtube.com" - internal const val musicResponsiveListItemRendererMask = "musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)" - internal const val musicTwoRowItemRendererMask = "musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)" - const val playlistPanelVideoRendererMask = "playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)" + internal const val BROWSE = "/youtubei/v1/browse" + internal const val NEXT = "/youtubei/v1/next" + internal const val PLAYER = "/youtubei/v1/player" + internal const val QUEUE = "/youtubei/v1/music/get_queue" + internal const val SEARCH = "/youtubei/v1/search" + internal const val SEARCH_SUGGESTIONS = "/youtubei/v1/music/get_search_suggestions" + internal const val MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK = + "musicResponsiveListItemRenderer(flexColumns,fixedColumns,thumbnail,navigationEndpoint)" + internal const val MUSIC_TWO_ROW_ITEM_RENDERER_MASK = + "musicTwoRowItemRenderer(thumbnailRenderer,title,subtitle,navigationEndpoint)" + internal const val PLAYLIST_PANEL_VIDEO_RENDERER_MASK = + "playlistPanelVideoRenderer(title,navigationEndpoint,longBylineText,shortBylineText,thumbnail,lengthText)" internal fun HttpRequestBuilder.mask(value: String = "*") = header("X-Goog-FieldMask", value) @@ -91,12 +103,11 @@ object Innertube { @JvmInline value class SearchFilter(val value: String) { companion object { - val Song = SearchFilter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") - val Video = SearchFilter("EgWKAQIQAWoKEAkQChAFEAMQBA%3D%3D") - val Album = SearchFilter("EgWKAQIYAWoKEAkQChAFEAMQBA%3D%3D") - val Artist = SearchFilter("EgWKAQIgAWoKEAkQChAFEAMQBA%3D%3D") - val CommunityPlaylist = SearchFilter("EgeKAQQoAEABagoQAxAEEAoQCRAF") - val FeaturedPlaylist = SearchFilter("EgeKAQQoADgBagwQDhAKEAMQBRAJEAQ%3D") + val Song = SearchFilter("EgWKAQIIAWoOEAMQBBAJEAoQBRAQEBU%3D") + val Video = SearchFilter("EgWKAQIQAWoOEAMQBBAJEAoQBRAQEBU%3D") + val Album = SearchFilter("EgWKAQIYAWoOEAMQBBAJEAoQBRAQEBU%3D") + val Artist = SearchFilter("EgWKAQIgAWoOEAMQBBAJEAoQBRAQEBU%3D") + val CommunityPlaylist = SearchFilter("EgeKAQQoAEABag4QAxAEEAkQChAFEBAQFQ%3D%3D") } } @@ -133,13 +144,6 @@ object Innertube { ?.watchEndpointMusicConfig ?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV" - val isUserGeneratedContent: Boolean - get() = info - ?.endpoint - ?.watchEndpointMusicSupportedConfigs - ?.watchEndpointMusicConfig - ?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC" - companion object } @@ -186,17 +190,19 @@ object Innertube { val albums: List?, val albumsEndpoint: NavigationEndpoint.Endpoint.Browse?, val singles: List?, - val singlesEndpoint: NavigationEndpoint.Endpoint.Browse?, + val singlesEndpoint: NavigationEndpoint.Endpoint.Browse? ) data class PlaylistOrAlbumPage( val title: String?, + val description: String?, val authors: List>?, val year: String?, val thumbnail: Thumbnail?, val url: String?, val songsPage: ItemsPage?, - val otherVersions: List? + val otherVersions: List?, + val otherInfo: String? ) data class NextPage( @@ -210,9 +216,34 @@ object Innertube { val songs: List? = null, val playlists: List? = null, val albums: List? = null, - val artists: List? = null, + val artists: List? = null ) + data class DiscoverPage( + val newReleaseAlbums: List, + val moods: List + ) + + data class Mood( + val title: String, + val items: List + ) { + data class Item( + val title: String, + val stripeColor: Long, + val endpoint: NavigationEndpoint.Endpoint.Browse + ) + } + + @Suppress("ReturnCount") + fun MusicNavigationButtonRenderer.toMood(): Mood.Item? { + return Mood.Item( + title = buttonText.runs.firstOrNull()?.text ?: return null, + stripeColor = solid?.leftStripeColor ?: return null, + endpoint = clickCommand.browseEndpoint ?: return null + ) + } + data class ItemsPage( val items: List?, val continuation: String? diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/BrowseResponse.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/BrowseResponse.kt similarity index 88% rename from innertube/src/main/kotlin/it/hamy/innertube/models/BrowseResponse.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/BrowseResponse.kt index 12253d8..4f0fa3a 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/BrowseResponse.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/BrowseResponse.kt @@ -13,21 +13,23 @@ data class BrowseResponse( @Serializable data class Contents( val singleColumnBrowseResultsRenderer: Tabs?, - val sectionListRenderer: SectionListRenderer?, + val sectionListRenderer: SectionListRenderer? ) @Serializable - data class Header @OptIn(ExperimentalSerializationApi::class) constructor( + @OptIn(ExperimentalSerializationApi::class) + data class Header( @JsonNames("musicVisualHeaderRenderer") val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?, - val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?, + val musicDetailHeaderRenderer: MusicDetailHeaderRenderer? ) { @Serializable data class MusicDetailHeaderRenderer( val title: Runs?, + val description: Runs?, val subtitle: Runs?, val secondSubtitle: Runs?, - val thumbnail: ThumbnailRenderer?, + val thumbnail: ThumbnailRenderer? ) @Serializable diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/ButtonRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/ButtonRenderer.kt similarity index 100% rename from innertube/src/main/kotlin/it/hamy/innertube/models/ButtonRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/ButtonRenderer.kt diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Context.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Context.kt new file mode 100644 index 0000000..48f9b2a --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Context.kt @@ -0,0 +1,85 @@ +package it.hamy.innertube.models + +import io.ktor.client.request.headers +import io.ktor.http.HttpMessageBuilder +import io.ktor.http.userAgent +import kotlinx.serialization.Serializable +import java.util.Locale + +@Serializable +data class Context( + val client: Client, + val thirdParty: ThirdParty? = null +) { + @Serializable + data class Client( + val clientName: String, + val clientVersion: String, + val platform: String, + val hl: String = "en", + val gl: String = "US", + val visitorData: String = DEFAULT_VISITOR_DATA, + val androidSdkVersion: Int? = null, + val userAgent: String? = null, + val referer: String? = null + ) + + @Serializable + data class ThirdParty( + val embedUrl: String + ) + + context(HttpMessageBuilder) + fun apply() { + client.userAgent?.let { userAgent(it) } + + headers { + client.referer?.let { append("Referer", it) } + append("X-Youtube-Bootstrap-Logged-In", "false") + append("X-YouTube-Client-Name", client.clientName) + append("X-YouTube-Client-Version", client.clientVersion) + } + } + + companion object { + const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D" + + val DefaultWeb get() = DefaultWebNoLang.let { + it.copy( + client = it.client.copy( + hl = Locale.getDefault().toLanguageTag(), + gl = Locale.getDefault().country + ) + ) + } + + val DefaultWebNoLang = Context( + client = Client( + clientName = "WEB_REMIX", + clientVersion = "1.20220606.03.00", + platform = "DESKTOP", + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/74.0.3729.157 Safari/537.36", + referer = "https://music.youtube.com/" + ) + ) + + val DefaultAndroid = Context( + client = Client( + clientName = "ANDROID_MUSIC", + clientVersion = "5.28.1", + platform = "MOBILE", + androidSdkVersion = 30, + userAgent = "com.google.android.apps.youtube.music/5.28.1 (Linux; U; Android 11) gzip" + ) + ) + + val DefaultAgeRestrictionBypass = Context( + client = Client( + clientName = "TVHTML5_SIMPLY_EMBEDDED_PLAYER", + clientVersion = "2.0", + platform = "TV", + userAgent = "Mozilla/5.0 (PlayStation 4 5.55) AppleWebKit/601.2 (KHTML, like Gecko)" + ) + ) + } +} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/Continuation.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Continuation.kt similarity index 100% rename from innertube/src/main/kotlin/it/hamy/innertube/models/Continuation.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/Continuation.kt diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/ContinuationResponse.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/ContinuationResponse.kt similarity index 84% rename from innertube/src/main/kotlin/it/hamy/innertube/models/ContinuationResponse.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/ContinuationResponse.kt index 44987ca..9b12725 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/ContinuationResponse.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/ContinuationResponse.kt @@ -7,12 +7,12 @@ import kotlinx.serialization.json.JsonNames @OptIn(ExperimentalSerializationApi::class) @Serializable data class ContinuationResponse( - val continuationContents: ContinuationContents?, + val continuationContents: ContinuationContents? ) { @Serializable data class ContinuationContents( @JsonNames("musicPlaylistShelfContinuation") val musicShelfContinuation: MusicShelfRenderer?, - val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?, + val playlistPanelContinuation: NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer? ) } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/GetQueueResponse.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/GetQueueResponse.kt similarity index 71% rename from innertube/src/main/kotlin/it/hamy/innertube/models/GetQueueResponse.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/GetQueueResponse.kt index 73f2c57..00f4aa1 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/GetQueueResponse.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/GetQueueResponse.kt @@ -1,10 +1,12 @@ package it.hamy.innertube.models +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class GetQueueResponse( - val queueDatas: List?, + @SerialName("queueDatas") + val queueData: List? ) { @Serializable data class QueueData( diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/GridRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/GridRenderer.kt similarity index 55% rename from innertube/src/main/kotlin/it/hamy/innertube/models/GridRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/GridRenderer.kt index 6535435..770f6db 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/GridRenderer.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/GridRenderer.kt @@ -5,9 +5,20 @@ import kotlinx.serialization.Serializable @Serializable data class GridRenderer( val items: List?, + val header: Header? ) { @Serializable data class Item( val musicTwoRowItemRenderer: MusicTwoRowItemRenderer? ) + + @Serializable + data class Header( + val gridHeaderRenderer: GridHeaderRenderer? + ) + + @Serializable + data class GridHeaderRenderer( + val title: Runs? + ) } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/MusicCarouselShelfRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicCarouselShelfRenderer.kt similarity index 86% rename from innertube/src/main/kotlin/it/hamy/innertube/models/MusicCarouselShelfRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicCarouselShelfRenderer.kt index 9bdf8a2..e6093a5 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/MusicCarouselShelfRenderer.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicCarouselShelfRenderer.kt @@ -5,12 +5,13 @@ import kotlinx.serialization.Serializable @Serializable data class MusicCarouselShelfRenderer( val header: Header?, - val contents: List?, + val contents: List? ) { @Serializable data class Content( val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, + val musicNavigationButtonRenderer: MusicNavigationButtonRenderer? = null ) @Serializable @@ -23,7 +24,7 @@ data class MusicCarouselShelfRenderer( data class MusicCarouselShelfBasicHeaderRenderer( val moreContentButton: MoreContentButton?, val title: Runs?, - val strapline: Runs?, + val strapline: Runs? ) { @Serializable data class MoreContentButton( diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicNavigationButtonRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicNavigationButtonRenderer.kt new file mode 100644 index 0000000..464d59c --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicNavigationButtonRenderer.kt @@ -0,0 +1,26 @@ +package it.hamy.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicNavigationButtonRenderer( + val buttonText: Runs, + val solid: Solid?, + val iconStyle: IconStyle?, + val clickCommand: NavigationEndpoint +) { + @Serializable + data class Solid( + val leftStripeColor: Long + ) + + @Serializable + data class IconStyle( + val icon: Icon + ) + + @Serializable + data class Icon( + val iconType: String + ) +} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/MusicResponsiveListItemRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicResponsiveListItemRenderer.kt similarity index 93% rename from innertube/src/main/kotlin/it/hamy/innertube/models/MusicResponsiveListItemRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicResponsiveListItemRenderer.kt index 9c83330..c1ed026 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/MusicResponsiveListItemRenderer.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicResponsiveListItemRenderer.kt @@ -10,7 +10,7 @@ data class MusicResponsiveListItemRenderer( val fixedColumns: List?, val flexColumns: List, val thumbnail: ThumbnailRenderer?, - val navigationEndpoint: NavigationEndpoint?, + val navigationEndpoint: NavigationEndpoint? ) { @Serializable data class FlexColumn( diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/MusicShelfRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicShelfRenderer.kt similarity index 79% rename from innertube/src/main/kotlin/it/hamy/innertube/models/MusicShelfRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicShelfRenderer.kt index 1b60e81..7917999 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/MusicShelfRenderer.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicShelfRenderer.kt @@ -11,24 +11,23 @@ data class MusicShelfRenderer( ) { @Serializable data class Content( - val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer? ) { val runs: Pair, List>> - get() = (musicResponsiveListItemRenderer + get() = musicResponsiveListItemRenderer ?.flexColumns ?.firstOrNull() ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs - ?: emptyList()) to - (musicResponsiveListItemRenderer + .orEmpty() to + musicResponsiveListItemRenderer ?.flexColumns - ?.getOrNull(1) + ?.let { it.getOrNull(1) ?: it.lastOrNull() } ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.splitBySeparator() - ?: emptyList() - ) + .orEmpty() val thumbnail: Thumbnail? get() = musicResponsiveListItemRenderer diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicTwoRowItemRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicTwoRowItemRenderer.kt new file mode 100644 index 0000000..b5d8c67 --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/MusicTwoRowItemRenderer.kt @@ -0,0 +1,26 @@ +package it.hamy.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicTwoRowItemRenderer( + val navigationEndpoint: NavigationEndpoint?, + val thumbnailRenderer: ThumbnailRenderer?, + val title: Runs?, + val subtitle: Runs?, + val thumbnailOverlay: ThumbnailOverlay? +) { + val isPlaylist: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_PLAYLIST" + + val isAlbum: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ALBUM" || + navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_AUDIOBOOK" + + val isArtist: Boolean + get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig?.pageType == "MUSIC_PAGE_TYPE_ARTIST" +} diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/models/NavigationEndpoint.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/NavigationEndpoint.kt new file mode 100644 index 0000000..9dcf69e --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/NavigationEndpoint.kt @@ -0,0 +1,77 @@ +package it.hamy.innertube.models + +import kotlinx.serialization.Serializable + +/** + * watchPlaylistEndpoint: params, playlistId + * watchEndpoint: params, playlistId, videoId, index + * browseEndpoint: params, browseId + * searchEndpoint: params, query + */ + +@Serializable +data class NavigationEndpoint( + val watchEndpoint: Endpoint.Watch?, + val watchPlaylistEndpoint: Endpoint.WatchPlaylist?, + val browseEndpoint: Endpoint.Browse?, + val searchEndpoint: Endpoint.Search? +) { + val endpoint get() = watchEndpoint ?: browseEndpoint ?: watchPlaylistEndpoint ?: searchEndpoint + + @Serializable + sealed class Endpoint { + @Serializable + data class Watch( + val params: String? = null, + val playlistId: String? = null, + val videoId: String? = null, + val index: Int? = null, + val playlistSetVideoId: String? = null, + val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null + ) : Endpoint() { + @Serializable + data class WatchEndpointMusicSupportedConfigs( + val watchEndpointMusicConfig: WatchEndpointMusicConfig? + ) { + @Serializable + data class WatchEndpointMusicConfig( + val musicVideoType: String? + ) + } + } + + @Serializable + data class WatchPlaylist( + val params: String?, + val playlistId: String? + ) : Endpoint() + + @Serializable + data class Browse( + val params: String? = null, + val browseId: String? = null, + val browseEndpointContextSupportedConfigs: BrowseEndpointContextSupportedConfigs? = null + ) : Endpoint() { + val type: String? + get() = browseEndpointContextSupportedConfigs + ?.browseEndpointContextMusicConfig + ?.pageType + + @Serializable + data class BrowseEndpointContextSupportedConfigs( + val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig + ) { + @Serializable + data class BrowseEndpointContextMusicConfig( + val pageType: String + ) + } + } + + @Serializable + data class Search( + val params: String?, + val query: String + ) : Endpoint() + } +} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/NextResponse.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/NextResponse.kt similarity index 97% rename from innertube/src/main/kotlin/it/hamy/innertube/models/NextResponse.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/NextResponse.kt index 42a1176..40e94cc 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/NextResponse.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/NextResponse.kt @@ -21,12 +21,12 @@ data class NextResponse( @Serializable data class PlaylistPanelRenderer( val contents: List?, - val continuations: List?, + val continuations: List? ) { @Serializable data class Content( val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?, - val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer?, + val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer? ) { @Serializable diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/PlayerResponse.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/PlayerResponse.kt similarity index 95% rename from innertube/src/main/kotlin/it/hamy/innertube/models/PlayerResponse.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/PlayerResponse.kt index d63046c..c4302b3 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/PlayerResponse.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/PlayerResponse.kt @@ -7,7 +7,7 @@ data class PlayerResponse( val playabilityStatus: PlayabilityStatus?, val playerConfig: PlayerConfig?, val streamingData: StreamingData?, - val videoDetails: VideoDetails?, + val videoDetails: VideoDetails? ) { @Serializable data class PlayabilityStatus( @@ -47,7 +47,7 @@ data class PlayerResponse( val lastModified: Long?, val loudnessDb: Double?, val audioSampleRate: Int?, - val url: String?, + val url: String? ) } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/PlaylistPanelVideoRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/PlaylistPanelVideoRenderer.kt similarity index 97% rename from innertube/src/main/kotlin/it/hamy/innertube/models/PlaylistPanelVideoRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/PlaylistPanelVideoRenderer.kt index 1cffa18..a90654b 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/PlaylistPanelVideoRenderer.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/PlaylistPanelVideoRenderer.kt @@ -9,5 +9,5 @@ data class PlaylistPanelVideoRenderer( val shortBylineText: Runs?, val lengthText: Runs?, val navigationEndpoint: NavigationEndpoint?, - val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail?, + val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail? ) diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Runs.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Runs.kt new file mode 100644 index 0000000..27abfb2 --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Runs.kt @@ -0,0 +1,52 @@ +package it.hamy.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Runs( + val runs: List = listOf() +) { + companion object { + const val SEPARATOR = " • " + } + + val text: String + get() = runs.joinToString("") { it.text.orEmpty() } + + fun splitBySeparator(): List> { + return runs.flatMapIndexed { index, run -> + when { + index == 0 || index == runs.lastIndex -> listOf(index) + run.text == SEPARATOR -> listOf(index - 1, index + 1) + else -> emptyList() + } + }.windowed(size = 2, step = 2) { (from, to) -> runs.slice(from..to) }.let { + it.ifEmpty { + listOf(runs) + } + } + } + + @Serializable + data class Run( + val text: String?, + val navigationEndpoint: NavigationEndpoint? + ) +} + +fun List.splitBySeparator(): List> { + val res = mutableListOf>() + var tmp = mutableListOf() + forEach { run -> + if (run.text == " • ") { + res.add(tmp) + tmp = mutableListOf() + } else { + tmp.add(run) + } + } + res.add(tmp) + return res +} + +fun List.oddElements() = filterIndexed { index, _ -> index % 2 == 0 } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/SearchResponse.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/SearchResponse.kt similarity index 88% rename from innertube/src/main/kotlin/it/hamy/innertube/models/SearchResponse.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/SearchResponse.kt index 87f50ed..4778904 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/SearchResponse.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/SearchResponse.kt @@ -4,7 +4,7 @@ import kotlinx.serialization.Serializable @Serializable data class SearchResponse( - val contents: Contents?, + val contents: Contents? ) { @Serializable data class Contents( diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/SearchSuggestionsResponse.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/SearchSuggestionsResponse.kt similarity index 91% rename from innertube/src/main/kotlin/it/hamy/innertube/models/SearchSuggestionsResponse.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/SearchSuggestionsResponse.kt index d8f978d..10c0916 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/SearchSuggestionsResponse.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/SearchSuggestionsResponse.kt @@ -20,7 +20,7 @@ data class SearchSuggestionsResponse( ) { @Serializable data class SearchSuggestionRenderer( - val navigationEndpoint: NavigationEndpoint?, + val navigationEndpoint: NavigationEndpoint? ) } } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/SectionListRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/SectionListRenderer.kt similarity index 94% rename from innertube/src/main/kotlin/it/hamy/innertube/models/SectionListRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/SectionListRenderer.kt index 62fb3f3..f34cc4a 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/SectionListRenderer.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/SectionListRenderer.kt @@ -17,13 +17,12 @@ data class SectionListRenderer( @JsonNames("musicPlaylistShelfRenderer") val musicShelfRenderer: MusicShelfRenderer?, val gridRenderer: GridRenderer?, - val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?, + val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer? ) { @Serializable data class MusicDescriptionShelfRenderer( - val description: Runs?, + val description: Runs? ) } - } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/Tabs.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Tabs.kt similarity index 81% rename from innertube/src/main/kotlin/it/hamy/innertube/models/Tabs.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/Tabs.kt index c8e504b..2f724c4 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/Tabs.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Tabs.kt @@ -14,11 +14,11 @@ data class Tabs( data class TabRenderer( val content: Content?, val title: String?, - val tabIdentifier: String?, + val tabIdentifier: String? ) { @Serializable data class Content( - val sectionListRenderer: SectionListRenderer?, + val sectionListRenderer: SectionListRenderer? ) } } diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Thumbnail.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Thumbnail.kt new file mode 100644 index 0000000..6681c06 --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/Thumbnail.kt @@ -0,0 +1,16 @@ +package it.hamy.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Thumbnail( + val url: String, + val height: Int?, + val width: Int? +) { + fun size(size: Int) = when { + url.startsWith("https://lh3.googleusercontent.com") -> "$url-w$size-h$size" + url.startsWith("https://yt3.ggpht.com") -> "$url-s$size" + else -> url + } +} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/ThumbnailRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/ThumbnailRenderer.kt similarity index 53% rename from innertube/src/main/kotlin/it/hamy/innertube/models/ThumbnailRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/ThumbnailRenderer.kt index 6730118..2148e09 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/ThumbnailRenderer.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/ThumbnailRenderer.kt @@ -20,3 +20,23 @@ data class ThumbnailRenderer( ) } } + +@Serializable +data class ThumbnailOverlay( + val musicItemThumbnailOverlayRenderer: MusicItemThumbnailOverlayRenderer +) { + @Serializable + data class MusicItemThumbnailOverlayRenderer( + val content: Content + ) { + @Serializable + data class Content( + val musicPlayButtonRenderer: MusicPlayButtonRenderer + ) { + @Serializable + data class MusicPlayButtonRenderer( + val playNavigationEndpoint: NavigationEndpoint? + ) + } + } +} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/BrowseBody.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/BrowseBody.kt similarity index 100% rename from innertube/src/main/kotlin/it/hamy/innertube/models/bodies/BrowseBody.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/BrowseBody.kt diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/ContinuationBody.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/ContinuationBody.kt similarity index 87% rename from innertube/src/main/kotlin/it/hamy/innertube/models/bodies/ContinuationBody.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/ContinuationBody.kt index 4786cff..38b8ad4 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/ContinuationBody.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/ContinuationBody.kt @@ -6,5 +6,5 @@ import kotlinx.serialization.Serializable @Serializable data class ContinuationBody( val context: Context = Context.DefaultWeb, - val continuation: String, + val continuation: String ) diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/NextBody.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/NextBody.kt similarity index 100% rename from innertube/src/main/kotlin/it/hamy/innertube/models/bodies/NextBody.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/NextBody.kt diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/PlayerBody.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/PlayerBody.kt similarity index 100% rename from innertube/src/main/kotlin/it/hamy/innertube/models/bodies/PlayerBody.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/PlayerBody.kt diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/QueueBody.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/QueueBody.kt similarity index 87% rename from innertube/src/main/kotlin/it/hamy/innertube/models/bodies/QueueBody.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/QueueBody.kt index 86de51f..ed97817 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/QueueBody.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/QueueBody.kt @@ -7,5 +7,5 @@ import kotlinx.serialization.Serializable data class QueueBody( val context: Context = Context.DefaultWeb, val videoIds: List? = null, - val playlistId: String? = null, + val playlistId: String? = null ) diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/SearchBody.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/SearchBody.kt similarity index 100% rename from innertube/src/main/kotlin/it/hamy/innertube/models/bodies/SearchBody.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/SearchBody.kt diff --git a/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/SearchSuggestionsBody.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/SearchSuggestionsBody.kt similarity index 100% rename from innertube/src/main/kotlin/it/hamy/innertube/models/bodies/SearchSuggestionsBody.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/models/bodies/SearchSuggestionsBody.kt diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/AlbumPage.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/AlbumPage.kt new file mode 100644 index 0000000..8c97ea8 --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/AlbumPage.kt @@ -0,0 +1,34 @@ +package it.hamy.innertube.requests + +import io.ktor.http.Url +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.NavigationEndpoint +import it.hamy.innertube.models.bodies.BrowseBody + +suspend fun Innertube.albumPage(body: BrowseBody) = playlistPage(body)?.map { album -> + album.url?.let { Url(it).parameters["list"] }?.let { playlistId -> + playlistPage(BrowseBody(browseId = "VL$playlistId"))?.getOrNull()?.let { playlist -> + album.copy(songsPage = playlist.songsPage) + } + } ?: album +}?.map { album -> + val albumInfo = Innertube.Info( + name = album.title, + endpoint = NavigationEndpoint.Endpoint.Browse( + browseId = body.browseId, + params = body.params + ) + ) + + album.copy( + songsPage = album.songsPage?.copy( + items = album.songsPage.items?.map { song -> + song.copy( + authors = song.authors ?: album.authors, + album = albumInfo, + thumbnail = album.thumbnail + ) + } + ) + ) +} diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/ArtistPage.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/ArtistPage.kt new file mode 100644 index 0000000..153aa9b --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/ArtistPage.kt @@ -0,0 +1,127 @@ +package it.hamy.innertube.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.BrowseResponse +import it.hamy.innertube.models.Context +import it.hamy.innertube.models.MusicCarouselShelfRenderer +import it.hamy.innertube.models.MusicShelfRenderer +import it.hamy.innertube.models.bodies.BrowseBody +import it.hamy.innertube.utils.findSectionByTitle +import it.hamy.innertube.utils.from +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext + +suspend fun Innertube.artistPage(body: BrowseBody) = runCatchingCancellable { + val ctx = currentCoroutineContext() + val response = client.post(BROWSE) { + setBody(body) + mask("contents,header") + }.body() + + val responseNoLang by lazy { + CoroutineScope(ctx).async(start = CoroutineStart.LAZY) { + client.post(BROWSE) { + setBody(body.copy(context = Context.DefaultWebNoLang)) + mask("contents,header") + }.body() + } + } + + suspend fun findSectionByTitle(text: String) = response + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.get(0) + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.findSectionByTitle(text) ?: responseNoLang.await() + .contents + ?.singleColumnBrowseResultsRenderer + ?.tabs + ?.get(0) + ?.tabRenderer + ?.content + ?.sectionListRenderer + ?.findSectionByTitle(text) + + val songsSection = findSectionByTitle("Songs")?.musicShelfRenderer + val albumsSection = findSectionByTitle("Albums")?.musicCarouselShelfRenderer + val singlesSection = findSectionByTitle("Singles")?.musicCarouselShelfRenderer + + Innertube.ArtistPage( + name = response + .header + ?.musicImmersiveHeaderRenderer + ?.title + ?.text, + description = response + .header + ?.musicImmersiveHeaderRenderer + ?.description + ?.text, + thumbnail = ( + response + .header + ?.musicImmersiveHeaderRenderer + ?.foregroundThumbnail + ?: response + .header + ?.musicImmersiveHeaderRenderer + ?.thumbnail + ) + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.getOrNull(0), + shuffleEndpoint = response + .header + ?.musicImmersiveHeaderRenderer + ?.playButton + ?.buttonRenderer + ?.navigationEndpoint + ?.watchEndpoint, + radioEndpoint = response + .header + ?.musicImmersiveHeaderRenderer + ?.startRadioButton + ?.buttonRenderer + ?.navigationEndpoint + ?.watchEndpoint, + songs = songsSection + ?.contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(Innertube.SongItem::from), + songsEndpoint = songsSection + ?.bottomEndpoint + ?.browseEndpoint, + albums = albumsSection + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + albumsEndpoint = albumsSection + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint, + singles = singlesSection + ?.contents + ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + ?.mapNotNull(Innertube.AlbumItem::from), + singlesEndpoint = singlesSection + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton + ?.buttonRenderer + ?.navigationEndpoint + ?.browseEndpoint + ) +} diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Browse.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Browse.kt new file mode 100644 index 0000000..37d4ca7 --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Browse.kt @@ -0,0 +1,68 @@ +package it.hamy.innertube.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.BrowseResponse +import it.hamy.innertube.models.MusicTwoRowItemRenderer +import it.hamy.innertube.models.bodies.BrowseBody +import it.hamy.innertube.utils.from + +suspend fun Innertube.browse(body: BrowseBody) = runCatchingCancellable { + val response = client.post(BROWSE) { + setBody(body) + }.body() + + BrowseResult( + title = response.header?.musicImmersiveHeaderRenderer?.title?.text ?: response.header + ?.musicDetailHeaderRenderer?.title?.text, + items = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.mapNotNull { content -> + when { + content.gridRenderer != null -> BrowseResult.Item( + title = content.gridRenderer.header?.gridHeaderRenderer?.title?.runs + ?.firstOrNull()?.text ?: return@mapNotNull null, + items = content.gridRenderer.items?.mapNotNull { it.musicTwoRowItemRenderer?.toItem() } + .orEmpty() + ) + + content.musicCarouselShelfRenderer != null -> BrowseResult.Item( + title = content + .musicCarouselShelfRenderer + .header + ?.musicCarouselShelfBasicHeaderRenderer + ?.title + ?.runs + ?.firstOrNull() + ?.text ?: return@mapNotNull null, + items = content + .musicCarouselShelfRenderer + .contents + ?.mapNotNull { it.musicTwoRowItemRenderer?.toItem() } + .orEmpty() + ) + + else -> null + } + }.orEmpty() + ) +} + +data class BrowseResult( + val title: String?, + val items: List +) { + data class Item( + val title: String, + val items: List + ) +} + +fun MusicTwoRowItemRenderer.toItem() = when { + isAlbum -> Innertube.AlbumItem.from(this) + isPlaylist -> Innertube.PlaylistItem.from(this) + isArtist -> Innertube.ArtistItem.from(this) + else -> null +} diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/DiscoverPage.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/DiscoverPage.kt new file mode 100644 index 0000000..7407d5a --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/DiscoverPage.kt @@ -0,0 +1,51 @@ +package it.hamy.innertube.requests + +import io.ktor.client.call.body +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.BrowseResponse +import it.hamy.innertube.models.MusicTwoRowItemRenderer +import it.hamy.innertube.models.bodies.BrowseBody +import it.hamy.innertube.models.oddElements +import it.hamy.innertube.models.splitBySeparator + +suspend fun Innertube.discoverPage() = runCatchingCancellable { + val response = client.post(BROWSE) { + setBody(BrowseBody(browseId = "FEmusic_explore")) + mask("contents") + }.body() + + Innertube.DiscoverPage( + newReleaseAlbums = response.contents?.singleColumnBrowseResultsRenderer?.tabs + ?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.find { + it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint + ?.browseId == "FEmusic_new_releases_albums" + }?.musicCarouselShelfRenderer?.contents?.mapNotNull { it.musicTwoRowItemRenderer?.toNewReleaseAlbumPage() } + .orEmpty(), + moods = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.find { + it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer + ?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint + ?.browseId == "FEmusic_moods_and_genres" + }?.musicCarouselShelfRenderer?.contents?.mapNotNull { it.musicNavigationButtonRenderer?.toMood() } + .orEmpty() + ) +} + +fun MusicTwoRowItemRenderer.toNewReleaseAlbumPage() = Innertube.AlbumItem( + info = Innertube.Info( + name = title?.text, + endpoint = navigationEndpoint?.browseEndpoint + ), + authors = subtitle?.runs?.splitBySeparator()?.getOrNull(1)?.oddElements()?.map { + Innertube.Info( + name = it.text, + endpoint = it.navigationEndpoint?.browseEndpoint + ) + }, + year = subtitle?.runs?.lastOrNull()?.text, + thumbnail = thumbnailRenderer?.musicThumbnailRenderer?.thumbnail?.thumbnails?.firstOrNull() +) diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/ItemsPage.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/ItemsPage.kt similarity index 60% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/ItemsPage.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/ItemsPage.kt index 1eebafd..c8276ce 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/ItemsPage.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/ItemsPage.kt @@ -3,6 +3,7 @@ package it.hamy.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.BrowseResponse import it.hamy.innertube.models.ContinuationResponse @@ -12,16 +13,14 @@ import it.hamy.innertube.models.MusicShelfRenderer import it.hamy.innertube.models.MusicTwoRowItemRenderer import it.hamy.innertube.models.bodies.BrowseBody import it.hamy.innertube.models.bodies.ContinuationBody -import it.hamy.innertube.utils.runCatchingNonCancellable suspend fun Innertube.itemsPage( body: BrowseBody, fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, - fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }, -) = runCatchingNonCancellable { - val response = client.post(browse) { + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null } +) = runCatchingCancellable { + val response = client.post(BROWSE) { setBody(body) -// mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))") }.body() val sectionListRendererContent = response @@ -41,18 +40,17 @@ suspend fun Innertube.itemsPage( gridRenderer = sectionListRendererContent ?.gridRenderer, fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, - fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer, + fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer ) } suspend fun Innertube.itemsPage( body: ContinuationBody, fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T? = { null }, - fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null }, -) = runCatchingNonCancellable { - val response = client.post(browse) { + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? = { null } +) = runCatchingCancellable { + val response = client.post(BROWSE) { setBody(body) -// mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),gridRenderer(continuations,items.$musicTwoRowItemRendererMask))") }.body() itemsPageFromMusicShelRendererOrGridRenderer( @@ -61,7 +59,7 @@ suspend fun Innertube.itemsPage( ?.musicShelfContinuation, gridRenderer = null, fromMusicResponsiveListItemRenderer = fromMusicResponsiveListItemRenderer, - fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer, + fromMusicTwoRowItemRenderer = fromMusicTwoRowItemRenderer ) } @@ -69,29 +67,27 @@ private fun itemsPageFromMusicShelRendererOrGridRenderer( musicShelfRenderer: MusicShelfRenderer?, gridRenderer: GridRenderer?, fromMusicResponsiveListItemRenderer: (MusicResponsiveListItemRenderer) -> T?, - fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T?, -): Innertube.ItemsPage? { - return if (musicShelfRenderer != null) { - Innertube.ItemsPage( - continuation = musicShelfRenderer - .continuations - ?.firstOrNull() - ?.nextContinuationData - ?.continuation, - items = musicShelfRenderer - .contents - ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) - ?.mapNotNull(fromMusicResponsiveListItemRenderer) - ) - } else if (gridRenderer != null) { - Innertube.ItemsPage( - continuation = null, - items = gridRenderer - .items - ?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) - ?.mapNotNull(fromMusicTwoRowItemRenderer) - ) - } else { - null - } + fromMusicTwoRowItemRenderer: (MusicTwoRowItemRenderer) -> T? +) = when { + musicShelfRenderer != null -> Innertube.ItemsPage( + continuation = musicShelfRenderer + .continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation, + items = musicShelfRenderer + .contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(fromMusicResponsiveListItemRenderer) + ) + + gridRenderer != null -> Innertube.ItemsPage( + continuation = null, + items = gridRenderer + .items + ?.mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) + ?.mapNotNull(fromMusicTwoRowItemRenderer) + ) + + else -> null } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/Lyrics.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Lyrics.kt similarity index 80% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/Lyrics.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Lyrics.kt index 6b23f96..760e141 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/Lyrics.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Lyrics.kt @@ -3,16 +3,17 @@ package it.hamy.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.BrowseResponse import it.hamy.innertube.models.NextResponse import it.hamy.innertube.models.bodies.BrowseBody import it.hamy.innertube.models.bodies.NextBody -import it.hamy.innertube.utils.runCatchingNonCancellable -suspend fun Innertube.lyrics(body: NextBody): Result? = runCatchingNonCancellable { - val nextResponse = client.post(next) { +suspend fun Innertube.lyrics(body: NextBody) = runCatchingCancellable { + val nextResponse = client.post(NEXT) { setBody(body) + @Suppress("all") mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)") }.body() @@ -27,9 +28,9 @@ suspend fun Innertube.lyrics(body: NextBody): Result? = runCatchingNonC ?.endpoint ?.browseEndpoint ?.browseId - ?: return@runCatchingNonCancellable null + ?: return@runCatchingCancellable null - val response = client.post(browse) { + val response = client.post(BROWSE) { setBody(BrowseBody(browseId = browseId)) mask("contents.sectionListRenderer.contents.musicDescriptionShelfRenderer.description") }.body() diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/NextPage.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/NextPage.kt similarity index 75% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/NextPage.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/NextPage.kt index 8da1525..443831c 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/NextPage.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/NextPage.kt @@ -3,21 +3,20 @@ package it.hamy.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.ContinuationResponse import it.hamy.innertube.models.NextResponse import it.hamy.innertube.models.bodies.ContinuationBody import it.hamy.innertube.models.bodies.NextBody import it.hamy.innertube.utils.from -import it.hamy.innertube.utils.runCatchingNonCancellable - - suspend fun Innertube.nextPage(body: NextBody): Result? = - runCatchingNonCancellable { - val response = client.post(next) { + runCatchingCancellable { + val response = client.post(NEXT) { setBody(body) - mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$playlistPanelVideoRendererMask))") + @Suppress("all") + mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer.content.musicQueueRenderer.content.playlistPanelRenderer(continuations,contents(automixPreviewVideoRenderer,$PLAYLIST_PANEL_VIDEO_RENDERER_MASK))") }.body() val tabs = response @@ -45,14 +44,12 @@ suspend fun Innertube.nextPage(body: NextBody): Result? = ?.navigationEndpoint ?.watchPlaylistEndpoint - if (endpoint != null) { - return nextPage( - body.copy( - playlistId = endpoint.playlistId, - params = endpoint.params - ) + if (endpoint != null) return nextPage( + body.copy( + playlistId = endpoint.playlistId, + params = endpoint.params ) - } + ) } Innertube.NextPage( @@ -64,10 +61,11 @@ suspend fun Innertube.nextPage(body: NextBody): Result? = ) } -suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingNonCancellable { - val response = client.post(next) { +suspend fun Innertube.nextPage(body: ContinuationBody) = runCatchingCancellable { + val response = client.post(NEXT) { setBody(body) - mask("continuationContents.playlistPanelContinuation(continuations,contents.$playlistPanelVideoRendererMask)") + @Suppress("all") + mask("continuationContents.playlistPanelContinuation(continuations,contents.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK)") }.body() response @@ -80,8 +78,10 @@ private fun NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer?.toSon Innertube.ItemsPage( items = this ?.contents - ?.mapNotNull(NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content::playlistPanelVideoRenderer) - ?.mapNotNull(Innertube.SongItem::from), + ?.mapNotNull( + NextResponse.MusicQueueRenderer.Content.PlaylistPanelRenderer.Content + ::playlistPanelVideoRenderer + )?.mapNotNull(Innertube.SongItem::from), continuation = this ?.continuations ?.firstOrNull() diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/Player.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Player.kt similarity index 83% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/Player.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Player.kt index 23155ff..cd6c272 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/Player.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Player.kt @@ -6,15 +6,15 @@ import io.ktor.client.request.post import io.ktor.client.request.setBody import io.ktor.http.ContentType import io.ktor.http.contentType +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.Context import it.hamy.innertube.models.PlayerResponse import it.hamy.innertube.models.bodies.PlayerBody -import it.hamy.innertube.utils.runCatchingNonCancellable import kotlinx.serialization.Serializable -suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable { - val response = client.post(player) { +suspend fun Innertube.player(body: PlayerBody) = runCatchingCancellable { + val response = client.post(PLAYER) { setBody(body) mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") }.body() @@ -33,24 +33,24 @@ suspend fun Innertube.player(body: PlayerBody) = runCatchingNonCancellable { val audioStreams: List ) - val safePlayerResponse = client.post(player) { + val safePlayerResponse = client.post(PLAYER) { setBody( body.copy( context = Context.DefaultAgeRestrictionBypass.copy( thirdParty = Context.ThirdParty( embedUrl = "https://www.youtube.com/watch?v=${body.videoId}" ) - ), + ) ) ) mask("playabilityStatus.status,playerConfig.audioConfig,streamingData.adaptiveFormats,videoDetails.videoId") }.body() if (safePlayerResponse.playabilityStatus?.status != "OK") { - return@runCatchingNonCancellable response + return@runCatchingCancellable response } - val audioStreams = client.get("https://watchapi.whatever.social/streams/${body.videoId}") { + val audioStreams = client.get("https://pipedapi.adminforge.de/streams/${body.videoId}") { contentType(ContentType.Application.Json) }.body().audioStreams diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/PlaylistPage.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/PlaylistPage.kt similarity index 68% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/PlaylistPage.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/PlaylistPage.kt index 43e25d5..cdee773 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/PlaylistPage.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/PlaylistPage.kt @@ -1,8 +1,10 @@ package it.hamy.innertube.requests import io.ktor.client.call.body +import io.ktor.client.request.parameter import io.ktor.client.request.post import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.BrowseResponse import it.hamy.innertube.models.ContinuationResponse @@ -11,12 +13,11 @@ import it.hamy.innertube.models.MusicShelfRenderer import it.hamy.innertube.models.bodies.BrowseBody import it.hamy.innertube.models.bodies.ContinuationBody import it.hamy.innertube.utils.from -import it.hamy.innertube.utils.runCatchingNonCancellable -suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable { - val response = client.post(browse) { +suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingCancellable { + val response = client.post(BROWSE) { setBody(body) - mask("contents.singleColumnBrowseResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents(musicPlaylistShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask),musicCarouselShelfRenderer.contents.$musicTwoRowItemRendererMask),header.musicDetailHeaderRenderer(title,subtitle,thumbnail),microformat") + body.context.apply() }.body() val musicDetailHeaderRenderer = response @@ -45,6 +46,9 @@ suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable title = musicDetailHeaderRenderer ?.title ?.text, + description = musicDetailHeaderRenderer + ?.description + ?.text, thumbnail = musicDetailHeaderRenderer ?.thumbnail ?.musicThumbnailRenderer @@ -71,14 +75,20 @@ suspend fun Innertube.playlistPage(body: BrowseBody) = runCatchingNonCancellable otherVersions = musicCarouselShelfRenderer ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) - ?.mapNotNull(Innertube.AlbumItem::from) + ?.mapNotNull(Innertube.AlbumItem::from), + otherInfo = musicDetailHeaderRenderer + ?.secondSubtitle + ?.text ) } -suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingNonCancellable { - val response = client.post(browse) { +suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingCancellable { + val response = client.post(BROWSE) { setBody(body) - mask("continuationContents.musicPlaylistShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)") + parameter("continuation", body.continuation) + parameter("ctoken", body.continuation) + parameter("type", "next") + body.context.apply() }.body() response @@ -87,15 +97,14 @@ suspend fun Innertube.playlistPage(body: ContinuationBody) = runCatchingNonCance ?.toSongsPage() } -private fun MusicShelfRenderer?.toSongsPage() = - Innertube.ItemsPage( - items = this - ?.contents - ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) - ?.mapNotNull(Innertube.SongItem::from), - continuation = this - ?.continuations - ?.firstOrNull() - ?.nextContinuationData - ?.continuation - ) +private fun MusicShelfRenderer?.toSongsPage() = Innertube.ItemsPage( + items = this + ?.contents + ?.mapNotNull(MusicShelfRenderer.Content::musicResponsiveListItemRenderer) + ?.mapNotNull(Innertube.SongItem::from), + continuation = this + ?.continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation +) diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/Queue.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Queue.kt similarity index 73% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/Queue.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Queue.kt index d35365a..98e8352 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/Queue.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/Queue.kt @@ -3,20 +3,20 @@ package it.hamy.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.GetQueueResponse import it.hamy.innertube.models.bodies.QueueBody import it.hamy.innertube.utils.from -import it.hamy.innertube.utils.runCatchingNonCancellable -suspend fun Innertube.queue(body: QueueBody) = runCatchingNonCancellable { - val response = client.post(queue) { +suspend fun Innertube.queue(body: QueueBody) = runCatchingCancellable { + val response = client.post(QUEUE) { setBody(body) - mask("queueDatas.content.$playlistPanelVideoRendererMask") + mask("queueDatas.content.$PLAYLIST_PANEL_VIDEO_RENDERER_MASK") }.body() response - .queueDatas + .queueData ?.mapNotNull { queueData -> queueData .content diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/RelatedPage.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/RelatedPage.kt similarity index 68% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/RelatedPage.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/RelatedPage.kt index 14c6fda..80222dc 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/RelatedPage.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/RelatedPage.kt @@ -3,8 +3,10 @@ package it.hamy.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.BrowseResponse +import it.hamy.innertube.models.Context import it.hamy.innertube.models.MusicCarouselShelfRenderer import it.hamy.innertube.models.NextResponse import it.hamy.innertube.models.bodies.BrowseBody @@ -12,12 +14,14 @@ import it.hamy.innertube.models.bodies.NextBody import it.hamy.innertube.utils.findSectionByStrapline import it.hamy.innertube.utils.findSectionByTitle import it.hamy.innertube.utils.from -import it.hamy.innertube.utils.runCatchingNonCancellable -suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable { - val nextResponse = client.post(next) { - setBody(body) - mask("contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)") +suspend fun Innertube.relatedPage(body: NextBody) = runCatchingCancellable { + val nextResponse = client.post(NEXT) { + setBody(body.copy(context = Context.DefaultWebNoLang)) + @Suppress("all") + mask( + "contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.tabRenderer(endpoint,title)" + ) }.body() val browseId = nextResponse @@ -31,11 +35,19 @@ suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable { ?.endpoint ?.browseEndpoint ?.browseId - ?: return@runCatchingNonCancellable null + ?: return@runCatchingCancellable null - val response = client.post(browse) { - setBody(BrowseBody(browseId = browseId)) - mask("contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($musicResponsiveListItemRendererMask,$musicTwoRowItemRendererMask))") + val response = client.post(BROWSE) { + setBody( + BrowseBody( + browseId = browseId, + context = Context.DefaultWebNoLang + ) + ) + @Suppress("all") + mask( + "contents.sectionListRenderer.contents.musicCarouselShelfRenderer(header.musicCarouselShelfBasicHeaderRenderer(title,strapline),contents($MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK,$MUSIC_TWO_ROW_ITEM_RENDERER_MASK))" + ) }.body() val sectionListRenderer = response @@ -67,6 +79,6 @@ suspend fun Innertube.relatedPage(body: NextBody) = runCatchingNonCancellable { ?.musicCarouselShelfRenderer ?.contents ?.mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) - ?.mapNotNull(Innertube.ArtistItem::from), + ?.mapNotNull(Innertube.ArtistItem::from) ) } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchPage.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchPage.kt similarity index 70% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/SearchPage.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchPage.kt index f9db35b..750a3f4 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchPage.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchPage.kt @@ -3,21 +3,22 @@ package it.hamy.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.ContinuationResponse import it.hamy.innertube.models.MusicShelfRenderer import it.hamy.innertube.models.SearchResponse import it.hamy.innertube.models.bodies.ContinuationBody import it.hamy.innertube.models.bodies.SearchBody -import it.hamy.innertube.utils.runCatchingNonCancellable suspend fun Innertube.searchPage( body: SearchBody, fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? -) = runCatchingNonCancellable { - val response = client.post(search) { +) = runCatchingCancellable { + val response = client.post(SEARCH) { setBody(body) - mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$musicResponsiveListItemRendererMask)") + @Suppress("all") + mask("contents.tabbedSearchResultsRenderer.tabs.tabRenderer.content.sectionListRenderer.contents.musicShelfRenderer(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)") }.body() response @@ -37,10 +38,11 @@ suspend fun Innertube.searchPage( suspend fun Innertube.searchPage( body: ContinuationBody, fromMusicShelfRendererContent: (MusicShelfRenderer.Content) -> T? -) = runCatchingNonCancellable { - val response = client.post(search) { +) = runCatchingCancellable { + val response = client.post(SEARCH) { setBody(body) - mask("continuationContents.musicShelfContinuation(continuations,contents.$musicResponsiveListItemRendererMask)") + @Suppress("all") + mask("continuationContents.musicShelfContinuation(continuations,contents.$MUSIC_RESPONSIVE_LIST_ITEM_RENDERER_MASK)") }.body() response @@ -49,14 +51,15 @@ suspend fun Innertube.searchPage( ?.toItemsPage(fromMusicShelfRendererContent) } -private fun MusicShelfRenderer?.toItemsPage(mapper: (MusicShelfRenderer.Content) -> T?) = - Innertube.ItemsPage( - items = this - ?.contents - ?.mapNotNull(mapper), - continuation = this - ?.continuations - ?.firstOrNull() - ?.nextContinuationData - ?.continuation - ) +private fun MusicShelfRenderer?.toItemsPage( + mapper: (MusicShelfRenderer.Content) -> T? +) = Innertube.ItemsPage( + items = this + ?.contents + ?.mapNotNull(mapper), + continuation = this + ?.continuations + ?.firstOrNull() + ?.nextContinuationData + ?.continuation +) diff --git a/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchSuggestions.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchSuggestions.kt similarity index 84% rename from innertube/src/main/kotlin/it/hamy/innertube/requests/SearchSuggestions.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchSuggestions.kt index 6cd1610..a24d671 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchSuggestions.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/requests/SearchSuggestions.kt @@ -3,14 +3,15 @@ package it.hamy.innertube.requests import io.ktor.client.call.body import io.ktor.client.request.post import io.ktor.client.request.setBody +import it.hamy.extensions.runCatchingCancellable import it.hamy.innertube.Innertube import it.hamy.innertube.models.SearchSuggestionsResponse import it.hamy.innertube.models.bodies.SearchSuggestionsBody -import it.hamy.innertube.utils.runCatchingNonCancellable -suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingNonCancellable { - val response = client.post(searchSuggestions) { +suspend fun Innertube.searchSuggestions(body: SearchSuggestionsBody) = runCatchingCancellable { + val response = client.post(SEARCH_SUGGESTIONS) { setBody(body) + @Suppress("all") mask("contents.searchSuggestionsSectionRenderer.contents.searchSuggestionRenderer.navigationEndpoint.searchEndpoint.query") }.body() diff --git a/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicResponsiveListItemRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicResponsiveListItemRenderer.kt similarity index 71% rename from innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicResponsiveListItemRenderer.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicResponsiveListItemRenderer.kt index 8eebdeb..4414da2 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicResponsiveListItemRenderer.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicResponsiveListItemRenderer.kt @@ -3,10 +3,9 @@ package it.hamy.innertube.utils import it.hamy.innertube.Innertube import it.hamy.innertube.models.MusicResponsiveListItemRenderer import it.hamy.innertube.models.NavigationEndpoint -import it.hamy.innertube.models.Runs -fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer): Innertube.SongItem? { - return Innertube.SongItem( +fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) = + Innertube.SongItem( info = renderer .flexColumns .getOrNull(0) @@ -14,14 +13,20 @@ fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) ?.text ?.runs ?.getOrNull(0) - ?.let(Innertube::Info), + ?.let { + if (it.navigationEndpoint?.endpoint is NavigationEndpoint.Endpoint.Watch) Innertube.Info( + name = it.text, + endpoint = it.navigationEndpoint.endpoint as NavigationEndpoint.Endpoint.Watch + ) else null + }, authors = renderer .flexColumns .getOrNull(1) ?.musicResponsiveListItemFlexColumnRenderer ?.text ?.runs - ?.map>(Innertube::Info) + ?.map { Innertube.Info(name = it.text, endpoint = it.navigationEndpoint?.endpoint) } + ?.filterIsInstance>() ?.takeIf(List::isNotEmpty), durationText = renderer .fixedColumns @@ -46,4 +51,3 @@ fun Innertube.SongItem.Companion.from(renderer: MusicResponsiveListItemRenderer) ?.thumbnails ?.firstOrNull() ).takeIf { it.info?.endpoint?.videoId != null } -} diff --git a/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicShelfRendererContent.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicShelfRendererContent.kt similarity index 78% rename from innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicShelfRendererContent.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicShelfRendererContent.kt index fe507b8..5a934d6 100644 --- a/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicShelfRendererContent.kt +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicShelfRendererContent.kt @@ -34,33 +34,39 @@ fun Innertube.SongItem.Companion.from(content: MusicShelfRenderer.Content): Inne album = album, durationText = otherRuns .lastOrNull() - ?.firstOrNull()?.text, - thumbnail = content - .thumbnail + ?.firstOrNull() + ?.text + ?.takeIf { ':' in it } + ?: otherRuns + .getOrNull(otherRuns.size - 2) + ?.firstOrNull() + ?.text, + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.videoId != null } } fun Innertube.VideoItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.VideoItem? { val (mainRuns, otherRuns) = content.runs - return Innertube.VideoItem( - info = mainRuns - .firstOrNull() - ?.let(Innertube::Info), - authors = otherRuns - .getOrNull(otherRuns.lastIndex - 2) - ?.map(Innertube::Info), - viewsText = otherRuns - .getOrNull(otherRuns.lastIndex - 1) - ?.firstOrNull() - ?.text, - durationText = otherRuns - .getOrNull(otherRuns.lastIndex) - ?.firstOrNull() - ?.text, - thumbnail = content - .thumbnail - ).takeIf { it.info?.endpoint?.videoId != null } + return runCatching { + Innertube.VideoItem( + info = mainRuns + .firstOrNull() + ?.let(Innertube::Info), + authors = otherRuns + .getOrNull(otherRuns.lastIndex - 2) + ?.map(Innertube::Info), + viewsText = otherRuns + .getOrNull(otherRuns.lastIndex - 1) + ?.firstOrNull() + ?.text, + durationText = otherRuns + .getOrNull(otherRuns.lastIndex) + ?.firstOrNull() + ?.text, + thumbnail = content.thumbnail + ).takeIf { it.info?.endpoint?.videoId != null } + }.getOrNull() } fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Innertube.AlbumItem? { @@ -83,8 +89,7 @@ fun Innertube.AlbumItem.Companion.from(content: MusicShelfRenderer.Content): Inn .getOrNull(otherRuns.lastIndex) ?.firstOrNull() ?.text, - thumbnail = content - .thumbnail + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.browseId != null } } @@ -105,8 +110,7 @@ fun Innertube.ArtistItem.Companion.from(content: MusicShelfRenderer.Content): In .lastOrNull() ?.last() ?.text, - thumbnail = content - .thumbnail + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.browseId != null } } @@ -134,7 +138,6 @@ fun Innertube.PlaylistItem.Companion.from(content: MusicShelfRenderer.Content): ?.split(' ') ?.firstOrNull() ?.toIntOrNull(), - thumbnail = content - .thumbnail + thumbnail = content.thumbnail ).takeIf { it.info?.endpoint?.browseId != null } } diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicTwoRowItemRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicTwoRowItemRenderer.kt new file mode 100644 index 0000000..6779aa9 --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromMusicTwoRowItemRenderer.kt @@ -0,0 +1,71 @@ +package it.hamy.innertube.utils + +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.MusicTwoRowItemRenderer + +fun Innertube.AlbumItem.Companion.from(renderer: MusicTwoRowItemRenderer) = Innertube.AlbumItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + authors = null, + year = renderer + .subtitle + ?.runs + ?.lastOrNull() + ?.text, + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() +).takeIf { it.info?.endpoint?.browseId != null } + +fun Innertube.ArtistItem.Companion.from(renderer: MusicTwoRowItemRenderer) = Innertube.ArtistItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + subscribersCountText = renderer + .subtitle + ?.runs + ?.firstOrNull() + ?.text, + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() +).takeIf { it.info?.endpoint?.browseId != null } + +fun Innertube.PlaylistItem.Companion.from(renderer: MusicTwoRowItemRenderer) = + Innertube.PlaylistItem( + info = renderer + .title + ?.runs + ?.firstOrNull() + ?.let(Innertube::Info), + channel = renderer + .subtitle + ?.runs + ?.getOrNull(2) + ?.let(Innertube::Info), + songCount = renderer + .subtitle + ?.runs + ?.getOrNull(4) + ?.text + ?.split(' ') + ?.firstOrNull() + ?.toIntOrNull(), + thumbnail = renderer + .thumbnailRenderer + ?.musicThumbnailRenderer + ?.thumbnail + ?.thumbnails + ?.firstOrNull() + ).takeIf { it.info?.endpoint?.browseId != null } diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromPlaylistPanelVideoRenderer.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromPlaylistPanelVideoRenderer.kt new file mode 100644 index 0000000..6248be3 --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/FromPlaylistPanelVideoRenderer.kt @@ -0,0 +1,33 @@ +package it.hamy.innertube.utils + +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.PlaylistPanelVideoRenderer + +fun Innertube.SongItem.Companion.from(renderer: PlaylistPanelVideoRenderer) = Innertube.SongItem( + info = Innertube.Info( + name = renderer + .title + ?.text, + endpoint = renderer + .navigationEndpoint + ?.watchEndpoint + ), + authors = renderer + .longBylineText + ?.splitBySeparator() + ?.getOrNull(0) + ?.map(Innertube::Info), + album = renderer + .longBylineText + ?.splitBySeparator() + ?.getOrNull(1) + ?.getOrNull(0) + ?.let(Innertube::Info), + thumbnail = renderer + .thumbnail + ?.thumbnails + ?.getOrNull(0), + durationText = renderer + .lengthText + ?.text +).takeIf { it.info?.endpoint?.videoId != null } diff --git a/innertube/src/main/kotlin/it/hamy/innertube/utils/ProxyPreferences.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/ProxyPreferences.kt similarity index 100% rename from innertube/src/main/kotlin/it/hamy/innertube/utils/ProxyPreferences.kt rename to providers/innertube/src/main/kotlin/it/hamy/innertube/utils/ProxyPreferences.kt diff --git a/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/Utils.kt b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/Utils.kt new file mode 100644 index 0000000..f299389 --- /dev/null +++ b/providers/innertube/src/main/kotlin/it/hamy/innertube/utils/Utils.kt @@ -0,0 +1,38 @@ +package it.hamy.innertube.utils + +import it.hamy.innertube.Innertube +import it.hamy.innertube.models.SectionListRenderer + +internal fun SectionListRenderer.findSectionByTitle(text: String) = contents?.find { + val title = it + .musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.title + ?: it + .musicShelfRenderer + ?.title + + title + ?.runs + ?.firstOrNull() + ?.text == text +} + +internal fun SectionListRenderer.findSectionByStrapline(text: String) = contents?.find { + it + .musicCarouselShelfRenderer + ?.header + ?.musicCarouselShelfBasicHeaderRenderer + ?.strapline + ?.runs + ?.firstOrNull() + ?.text == text +} + +infix operator fun Innertube.ItemsPage?.plus(other: Innertube.ItemsPage) = + other.copy( + items = (this?.items?.plus(other.items ?: emptyList()) ?: other.items) + ?.distinctBy(Innertube.Item::key), + continuation = other.continuation ?: this?.continuation + ) diff --git a/providers/kugou/.gitignore b/providers/kugou/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/providers/kugou/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/providers/kugou/build.gradle.kts b/providers/kugou/build.gradle.kts new file mode 100644 index 0000000..8febb74 --- /dev/null +++ b/providers/kugou/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(projects.providers.common) + + implementation(libs.kotlin.coroutines) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.encoding) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.json) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} diff --git a/providers/kugou/src/main/kotlin/it/hamy/kugou/KuGou.kt b/providers/kugou/src/main/kotlin/it/hamy/kugou/KuGou.kt new file mode 100644 index 0000000..8515098 --- /dev/null +++ b/providers/kugou/src/main/kotlin/it/hamy/kugou/KuGou.kt @@ -0,0 +1,184 @@ +package it.hamy.kugou + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.plugins.BrowserUserAgent +import io.ktor.client.plugins.compression.ContentEncoding +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.http.ContentType +import io.ktor.http.encodeURLParameter +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.decodeBase64String +import it.hamy.extensions.runCatchingCancellable +import it.hamy.kugou.models.DownloadLyricsResponse +import it.hamy.kugou.models.SearchLyricsResponse +import it.hamy.kugou.models.SearchSongResponse +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json + +object KuGou { + @OptIn(ExperimentalSerializationApi::class) + private val client by lazy { + HttpClient(OkHttp) { + BrowserUserAgent() + + expectSuccess = true + + install(ContentNegotiation) { + val feature = Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + } + + json(feature) + json(feature, ContentType.Text.Html) + json(feature, ContentType.Text.Plain) + } + + install(ContentEncoding) { + gzip() + deflate() + } + + defaultRequest { + url("https://krcs.kugou.com") + } + } + } + + suspend fun lyrics(artist: String, title: String, duration: Long) = runCatchingCancellable { + val keyword = keyword(artist, title) + val infoByKeyword = searchSong(keyword) + + if (infoByKeyword.isNotEmpty()) { + var tolerance = 0 + + while (tolerance <= 5) { + for (info in infoByKeyword) { + if (info.duration >= duration - tolerance && info.duration <= duration + tolerance) { + searchLyricsByHash(info.hash).firstOrNull()?.let { candidate -> + return@runCatchingCancellable downloadLyrics( + candidate.id, + candidate.accessKey + ).normalize() + } + } + } + + tolerance++ + } + } + + searchLyricsByKeyword(keyword).firstOrNull()?.let { candidate -> + return@runCatchingCancellable downloadLyrics( + candidate.id, + candidate.accessKey + ).normalize() + } + + null + } + + private suspend fun downloadLyrics(id: Long, accessKey: String) = client.get("/download") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "pc") + parameter("fmt", "lrc") + parameter("id", id) + parameter("accesskey", accessKey) + }.body().content.decodeBase64String().let(::Lyrics) + + private suspend fun searchLyricsByHash(hash: String) = client.get("/search") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "mobi") + parameter("hash", hash) + }.body().candidates + + private suspend fun searchLyricsByKeyword(keyword: String) = client.get("/search") { + parameter("ver", 1) + parameter("man", "yes") + parameter("client", "mobi") + url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) + }.body().candidates + + private suspend fun searchSong(keyword: String) = + client.get("https://mobileservice.kugou.com/api/v3/search/song") { + parameter("version", 9108) + parameter("plat", 0) + parameter("pagesize", 8) + parameter("showtype", 0) + url.encodedParameters.append("keyword", keyword.encodeURLParameter(spaceToPlus = false)) + }.body().data.info + + private fun keyword(artist: String, title: String): String { + val (newTitle, featuring) = title.extract(" (feat. ", ')') + + val newArtist = (if (featuring.isEmpty()) artist else "$artist, $featuring") + .replace(", ", "、") + .replace(" & ", "、") + .replace(".", "") + + return "$newArtist - $newTitle" + } + + @Suppress("ReturnCount") + private fun String.extract(startDelimiter: String, endDelimiter: Char): Pair { + val startIndex = indexOf(startDelimiter).takeIf { it != -1 } ?: return this to "" + val endIndex = indexOf(endDelimiter, startIndex).takeIf { it != -1 } ?: return this to "" + + return removeRange(startIndex, endIndex + 1) to substring(startIndex + startDelimiter.length, endIndex) + } + + @JvmInline + value class Lyrics(val value: String) { + @Suppress("CyclomaticComplexMethod") + fun normalize(): Lyrics { + var toDrop = 0 + var maybeToDrop = 0 + + val text = value.replace("\r\n", "\n").trim() + + for (line in text.lineSequence()) when { + line.startsWith("[ti:") || + line.startsWith("[ar:") || + line.startsWith("[al:") || + line.startsWith("[by:") || + line.startsWith("[hash:") || + line.startsWith("[sign:") || + line.startsWith("[qq:") || + line.startsWith("[total:") || + line.startsWith("[offset:") || + line.startsWith("[id:") || + line.containsAt("]Written by:", 9) || + line.containsAt("]Lyrics by:", 9) || + line.containsAt("]Composed by:", 9) || + line.containsAt("]Producer:", 9) || + line.containsAt("]作曲 : ", 9) || + line.containsAt("]作词 : ", 9) -> { + toDrop += line.length + 1 + maybeToDrop + maybeToDrop = 0 + } + + maybeToDrop == 0 -> maybeToDrop = line.length + 1 + + else -> { + maybeToDrop = 0 + break + } + } + + return Lyrics(text.drop(toDrop + maybeToDrop).removeHtmlEntities()) + } + + private fun String.containsAt(charSequence: CharSequence, startIndex: Int) = + regionMatches(startIndex, charSequence, 0, charSequence.length) + + private fun String.removeHtmlEntities() = replace("'", "'") + } +} diff --git a/kugou/src/main/kotlin/it/hamy/kugou/models/DownloadLyricsResponse.kt b/providers/kugou/src/main/kotlin/it/hamy/kugou/models/DownloadLyricsResponse.kt similarity index 100% rename from kugou/src/main/kotlin/it/hamy/kugou/models/DownloadLyricsResponse.kt rename to providers/kugou/src/main/kotlin/it/hamy/kugou/models/DownloadLyricsResponse.kt diff --git a/kugou/src/main/kotlin/it/hamy/kugou/models/SearchLyricsResponse.kt b/providers/kugou/src/main/kotlin/it/hamy/kugou/models/SearchLyricsResponse.kt similarity index 100% rename from kugou/src/main/kotlin/it/hamy/kugou/models/SearchLyricsResponse.kt rename to providers/kugou/src/main/kotlin/it/hamy/kugou/models/SearchLyricsResponse.kt diff --git a/kugou/src/main/kotlin/it/hamy/kugou/models/SearchSongResponse.kt b/providers/kugou/src/main/kotlin/it/hamy/kugou/models/SearchSongResponse.kt similarity index 91% rename from kugou/src/main/kotlin/it/hamy/kugou/models/SearchSongResponse.kt rename to providers/kugou/src/main/kotlin/it/hamy/kugou/models/SearchSongResponse.kt index 4698082..3d934c2 100644 --- a/kugou/src/main/kotlin/it/hamy/kugou/models/SearchSongResponse.kt +++ b/providers/kugou/src/main/kotlin/it/hamy/kugou/models/SearchSongResponse.kt @@ -7,7 +7,7 @@ internal data class SearchSongResponse( val data: Data ) { @Serializable - internal data class Data( + internal data class Data( val info: List ) { @Serializable diff --git a/providers/lrclib/.gitignore b/providers/lrclib/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/providers/lrclib/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/innertube/build.gradle.kts b/providers/lrclib/build.gradle.kts similarity index 51% rename from innertube/build.gradle.kts rename to providers/lrclib/build.gradle.kts index e2cf347..5eb4332 100644 --- a/innertube/build.gradle.kts +++ b/providers/lrclib/build.gradle.kts @@ -1,23 +1,24 @@ - plugins { - kotlin("jvm") - @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.serialization) -} - -sourceSets.all { - java.srcDir("src/$name/kotlin") + alias(libs.plugins.android.lint) } dependencies { - implementation(projects.ktorClientBrotli) + implementation(projects.providers.common) + + implementation(libs.kotlin.coroutines) implementation(libs.ktor.client.core) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.encoding) implementation(libs.ktor.client.serialization) implementation(libs.ktor.serialization.json) - testImplementation(testLibs.junit) + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) } diff --git a/providers/lrclib/src/main/kotlin/it/hamy/lrclib/LrcLib.kt b/providers/lrclib/src/main/kotlin/it/hamy/lrclib/LrcLib.kt new file mode 100644 index 0000000..75a2cea --- /dev/null +++ b/providers/lrclib/src/main/kotlin/it/hamy/lrclib/LrcLib.kt @@ -0,0 +1,79 @@ +package it.hamy.lrclib + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.serialization.kotlinx.json.json +import it.hamy.extensions.runCatchingCancellable +import it.hamy.lrclib.models.Track +import it.hamy.lrclib.models.bestMatchingFor +import kotlinx.serialization.json.Json +import kotlin.time.Duration + +object LrcLib { + private val client by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + } + ) + } + + defaultRequest { + url("https://lrclib.net") + } + + expectSuccess = true + } + } + + private suspend fun queryLyrics(artist: String, title: String, album: String? = null) = + client.get("/api/search") { + parameter("track_name", title) + parameter("artist_name", artist) + if (album != null) parameter("album_name", album) + }.body>().filter { it.syncedLyrics != null } + + suspend fun lyrics( + artist: String, + title: String, + duration: Duration, + album: String? = null + ) = runCatchingCancellable { + val tracks = queryLyrics(artist, title, album) + + tracks.bestMatchingFor(title, duration)?.syncedLyrics?.let(LrcLib::Lyrics) + } + + suspend fun lyrics(artist: String, title: String) = runCatchingCancellable { + queryLyrics(artist = artist, title = title, album = null) + } + + @JvmInline + value class Lyrics(val text: String) { + val sentences + get() = runCatching { + buildMap { + put(0L, "") + text.trim().lines().filter { it.length >= 10 }.forEach { + put( + it[8].digitToInt() * 10L + + it[7].digitToInt() * 100 + + it[5].digitToInt() * 1000 + + it[4].digitToInt() * 10000 + + it[2].digitToInt() * 60 * 1000 + + it[1].digitToInt() * 600 * 1000, + it.substring(10) + ) + } + } + }.getOrNull() + } +} diff --git a/providers/lrclib/src/main/kotlin/it/hamy/lrclib/models/Track.kt b/providers/lrclib/src/main/kotlin/it/hamy/lrclib/models/Track.kt new file mode 100644 index 0000000..09f3635 --- /dev/null +++ b/providers/lrclib/src/main/kotlin/it/hamy/lrclib/models/Track.kt @@ -0,0 +1,19 @@ +package it.hamy.lrclib.models + +import kotlinx.serialization.Serializable +import kotlin.math.abs +import kotlin.time.Duration + +@Serializable +data class Track( + val id: Int, + val trackName: String, + val artistName: String, + val duration: Long, + val plainLyrics: String?, + val syncedLyrics: String? +) + +internal fun List.bestMatchingFor(title: String, duration: Duration) = + firstOrNull { it.duration == duration.inWholeSeconds } + ?: minByOrNull { abs(it.trackName.length - title.length) } diff --git a/providers/piped/.gitignore b/providers/piped/.gitignore new file mode 100644 index 0000000..c795b05 --- /dev/null +++ b/providers/piped/.gitignore @@ -0,0 +1 @@ +build \ No newline at end of file diff --git a/providers/piped/build.gradle.kts b/providers/piped/build.gradle.kts new file mode 100644 index 0000000..2266424 --- /dev/null +++ b/providers/piped/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.serialization) + alias(libs.plugins.android.lint) +} + +dependencies { + implementation(projects.providers.common) + + implementation(libs.kotlin.coroutines) + api(libs.kotlin.datetime) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.json) + api(libs.ktor.http) + + detektPlugins(libs.detekt.compose) + detektPlugins(libs.detekt.formatting) +} + +kotlin { + jvmToolchain(libs.versions.jvm.get().toInt()) +} diff --git a/providers/piped/src/main/kotlin/it/hamy/piped/Piped.kt b/providers/piped/src/main/kotlin/it/hamy/piped/Piped.kt new file mode 100644 index 0000000..66d5093 --- /dev/null +++ b/providers/piped/src/main/kotlin/it/hamy/piped/Piped.kt @@ -0,0 +1,169 @@ +package it.hamy.piped + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.cio.CIO +import io.ktor.client.plugins.HttpRequestRetry +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.request.HttpRequestBuilder +import io.ktor.client.request.accept +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.post +import io.ktor.client.request.request +import io.ktor.client.request.setBody +import io.ktor.client.statement.HttpResponse +import io.ktor.http.ContentType +import io.ktor.http.HttpMethod +import io.ktor.http.URLBuilder +import io.ktor.http.Url +import io.ktor.http.contentType +import io.ktor.http.path +import io.ktor.serialization.kotlinx.json.json +import it.hamy.extensions.runCatchingCancellable +import it.hamy.piped.models.CreatedPlaylist +import it.hamy.piped.models.Instance +import it.hamy.piped.models.Playlist +import it.hamy.piped.models.PlaylistPreview +import it.hamy.piped.models.Session +import it.hamy.piped.models.authenticatedWith +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.util.UUID + +operator fun Url.div(path: String) = URLBuilder(this).apply { path(path) }.build() +operator fun JsonElement.div(key: String) = jsonObject[key]!! + +object Piped { + private val client by lazy { + HttpClient(CIO) { + install(ContentNegotiation) { + json( + Json { + isLenient = true + ignoreUnknownKeys = true + } + ) + } + + install(HttpRequestRetry) { + exponentialDelay() + maxRetries = 2 + } + + install(HttpTimeout) { + connectTimeoutMillis = 1000L + requestTimeoutMillis = 5000L + } + + expectSuccess = true + + defaultRequest { + accept(ContentType.Application.Json) + contentType(ContentType.Application.Json) + } + } + } + + private val mutex = Mutex() + + private suspend fun request( + session: Session, + endpoint: String, + block: HttpRequestBuilder.() -> Unit = { } + ) = mutex.withLock { + client.request(url = session.apiBaseUrl / endpoint) { + block() + header("Authorization", session.token) + } + } + + private suspend fun HttpResponse.isOk() = + (body() / "message").jsonPrimitive.content == "ok" + + suspend fun getInstances() = runCatchingCancellable { + client.get("https://piped-instances.kavin.rocks/").body>() + } + + suspend fun login(apiBaseUrl: Url, username: String, password: String) = + runCatchingCancellable { + apiBaseUrl authenticatedWith ( + client.post(apiBaseUrl / "login") { + setBody( + mapOf( + "username" to username, + "password" to password + ) + ) + }.body() / "token" + ).jsonPrimitive.content + } + + val playlist = Playlists() + + class Playlists internal constructor() { + suspend fun list(session: Session) = runCatchingCancellable { + request(session, "user/playlists").body>() + } + + suspend fun create(session: Session, name: String) = runCatchingCancellable { + request(session, "user/playlists/create") { + method = HttpMethod.Post + setBody(mapOf("name" to name)) + }.body() + } + + suspend fun rename(session: Session, id: UUID, name: String) = runCatchingCancellable { + request(session, "user/playlists/rename") { + method = HttpMethod.Post + setBody( + mapOf( + "playlistId" to id.toString(), + "newName" to name + ) + ) + }.isOk() + } + + suspend fun delete(session: Session, id: UUID) = runCatchingCancellable { + request(session, "user/playlists/delete") { + method = HttpMethod.Post + setBody(mapOf("playlistId" to id.toString())) + }.isOk() + } + + suspend fun add(session: Session, id: UUID, videos: List) = runCatchingCancellable { + request(session, "user/playlists/add") { + method = HttpMethod.Post + setBody( + mapOf( + "playlistId" to id.toString(), + "videoIds" to videos + ) + ) + }.isOk() + } + + suspend fun remove(session: Session, id: UUID, idx: Int) = runCatchingCancellable { + request(session, "user/playlists/remove") { + method = HttpMethod.Post + setBody( + mapOf( + "playlistId" to id.toString(), + "index" to idx + ) + ) + }.isOk() + } + + suspend fun songs(session: Session, id: UUID) = runCatchingCancellable { + request(session, "playlists/$id").body() + } + } +} diff --git a/providers/piped/src/main/kotlin/it/hamy/piped/models/Instance.kt b/providers/piped/src/main/kotlin/it/hamy/piped/models/Instance.kt new file mode 100644 index 0000000..0dd9d46 --- /dev/null +++ b/providers/piped/src/main/kotlin/it/hamy/piped/models/Instance.kt @@ -0,0 +1,30 @@ +package it.hamy.piped.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Instance( + val name: String, + @SerialName("api_url") + val apiBaseUrl: UrlString, + @SerialName("locations") + val locationsFormatted: String, + val version: String, + @SerialName("up_to_date") + val upToDate: Boolean, + @SerialName("cdn") + val isCdn: Boolean, + @SerialName("registered") + val userCount: Long, + @SerialName("last_checked") + val lastChecked: DateTimeSeconds, + @SerialName("cache") + val hasCache: Boolean, + @SerialName("s3_enabled") + val usesS3: Boolean, + @SerialName("image_proxy_url") + val imageProxyBaseUrl: UrlString, + @SerialName("registration_disabled") + val registrationDisabled: Boolean +) diff --git a/providers/piped/src/main/kotlin/it/hamy/piped/models/PlaylistPreview.kt b/providers/piped/src/main/kotlin/it/hamy/piped/models/PlaylistPreview.kt new file mode 100644 index 0000000..b313610 --- /dev/null +++ b/providers/piped/src/main/kotlin/it/hamy/piped/models/PlaylistPreview.kt @@ -0,0 +1,60 @@ +package it.hamy.piped.models + +import io.ktor.http.Url +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlin.time.Duration.Companion.seconds + +@Serializable +data class CreatedPlaylist( + @SerialName("playlistId") + val id: UUIDString +) + +@Serializable +data class PlaylistPreview( + val id: UUIDString, + val name: String, + @SerialName("shortDescription") + val description: String? = null, + @SerialName("thumbnail") + val thumbnailUrl: UrlString, + @SerialName("videos") + val videoCount: Int +) + +@Serializable +data class Playlist( + val name: String, + val thumbnailUrl: UrlString, + val description: String? = null, + val bannerUrl: UrlString? = null, + @SerialName("videos") + val videoCount: Int, + @SerialName("relatedStreams") + val videos: List