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 (`смотреть`, `плейлист`, `канал`)
- ...
## Скачать
[
](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