commit 77d630896f4d7ec74959bc54e75513f22e688ec0 Author: yattoz <12408848+yattoz@users.noreply.github.com> Date: Sat Dec 7 11:29:48 2019 +0100 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..603b140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx diff --git a/.idea/.name b/.idea/.name new file mode 100644 index 0000000..9f28921 --- /dev/null +++ b/.idea/.name @@ -0,0 +1 @@ +R-a-dio2 \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..45b5654 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + +
+ + + + xmlns:android + + ^$ + + + +
+
+ + + + xmlns:.* + + ^$ + + + BY_NAME + +
+
+ + + + .*:id + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + .*:name + + http://schemas.android.com/apk/res/android + + + +
+
+ + + + name + + ^$ + + + +
+
+ + + + style + + ^$ + + + +
+
+ + + + .* + + ^$ + + + BY_NAME + +
+
+ + + + .* + + http://schemas.android.com/apk/res/android + + + ANDROID_ATTRIBUTE_ORDER + +
+
+ + + + .* + + .* + + + BY_NAME + +
+
+
+
+ + +
+
\ No newline at end of file diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..79ee123 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/.idea/dictionaries/dict.xml b/.idea/dictionaries/dict.xml new file mode 100644 index 0000000..365cca9 --- /dev/null +++ b/.idea/dictionaries/dict.xml @@ -0,0 +1,9 @@ + + + + debouncing + requestable + requestor + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..97626ba --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml new file mode 100644 index 0000000..169fd0d --- /dev/null +++ b/.idea/gradle.xml @@ -0,0 +1,19 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..7bfef59 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml new file mode 100644 index 0000000..7f68460 --- /dev/null +++ b/.idea/runConfigurations.xml @@ -0,0 +1,12 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + 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 new file mode 100644 index 0000000..6493eee --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +![icon](https://r-a-d.io/assets/logo_image_small.png)![logo](https://r-a-d.io/assets/logotitle_2.png) + +# R/a/dio, the brand-new Android app! + +R/a/dio is a webradio that was founded 7+ years ago with the intention of bringing you a stream of (mostly) high quality anime music, and we keep that up to this day! You can always drop by and visit our website https://r-a-d.io for more information. + +![the gif](./demo.gif) + +### Features + +- Listen to R/a/dio! +- Fine-tune the volume in the app to go lower than the lowest volume of Android! +- Adapt to all screens: small phone, big tablet, horizontal, vertical, split screen! +- Start and stop the stream by headphones plugging/unplugging, or with a bluetooth headset! +- Check last played and queued tracks! +- Request any title to the AFK streamer Hanyuu-sama! +- Display and request your favorite tracks! There's even a random-request button (**.ra f**) available! +- Chat in the IRC with the embedded WebIRC! +- Wake up with the sound of R/a/dio with the built-in Alarm Clock feature! Don't worry: if there is no network, it will play a default sound instead. +- Never miss a stream with the Streamer Notification Service! (**Warning: this feature polls the server regularly and consumes battery. It is MUCH MORE RECOMMENDED to register to Hanyuu-sama's updates on Twitter and use the Twitter app. But if for some reason you don't (or can't) use the Twitter App, this should get you covered.**) +- Supports lastFM, LibreFM and Listenbrainz scrobblers with [Pano Scrobbler](https://play.google.com/store/apps/details?id=com.arn.scrobble) +- **[v1.1]** Snooze the alarm! You can set up the snooze duration you want, or avoid being tempted and disable snooze altogether. When ringing, a special notification design will display bigger buttons with text instead of icons. +- **[v1.1]** Sleep with the sound of R/a/dio! You can set up a timer to stop the app after any amount of minutes. When the timer approaches, the sound will gradually fade out. + +As always, thanks for listening! + + +# Releases + +## Release 1.1.0 + +Incoming! The date will be announced when it reaches the Play Store. The version number may be different, probably a v2.0.0 considering it's a full rewrite of the previous app. + +Features added: +- Alarm snooze +- Sleep timer +- Special notification design for alarm +- Added pull-to-refresh on favorites +- Added link to help on how to use favorites in IRC +- Added settings to control API fetch frequency when playback is stopped + +Bug fixes: +- Better handling of queue, avoid duplicates +- Should handle correctly when the stream is down ("ed" playing) +- Update the streamer picture in notification at startup +- UI updates correctly when app is opened after alarm wake-up +- Backup alarm sound is now correctly triggered only when necessary + +## Release 1.0 +- will probably stay undisclosed, as the release will surely be based on what I call 1.1.0 \ No newline at end of file diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/app/.gitignore @@ -0,0 +1 @@ +/build diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 0000000..34e4ce5 --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,110 @@ +apply plugin: 'com.android.application' + +apply plugin: 'kotlin-android' +apply plugin: 'kotlin-android-extensions' +apply plugin: 'kotlin-kapt' + +configurations.all { + resolutionStrategy.eachDependency { DependencyResolveDetails details -> + def requested = details.requested + if (requested.group == "androidx") { + if (!requested.name.startsWith("multidex")) { + details.useVersion "${targetSdk}.+" + } + } + } +} + +android { + compileSdkVersion 29 + buildToolsVersion "29.0.2" + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } + defaultConfig { + applicationId "io.r_a_d.radio2" + minSdkVersion 16 + targetSdkVersion 29 + versionCode 2 + versionName "1.1dev" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + vectorDrawables.useSupportLibrary = true + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } +} + +dependencies { + implementation fileTree(dir: 'libs', include: ['*.jar']) + /* + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version" + implementation 'androidx.appcompat:appcompat:1.1.0' + implementation 'androidx.core:core-ktx:1.1.0' + implementation 'com.google.android.material:material:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:1.1.3' + implementation 'androidx.navigation:navigation-fragment-ktx:2.1.0' + implementation 'androidx.navigation:navigation-ui-ktx:2.1.0' + implementation 'androidx.vectordrawable:vectordrawable:1.1.0' + + implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' + + testImplementation 'junit:junit:4.12' + androidTestImplementation 'androidx.test.ext:junit:1.1.1' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + + */ + + implementation "androidx.media:media:1.1.0" + implementation 'com.google.android.exoplayer:exoplayer:2.10.6' + implementation 'androidx.legacy:legacy-support-v4:1.0.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.1.0' + + implementation 'androidx.recyclerview:recyclerview:1.0.0' + implementation 'androidx.preference:preference:1.1.0' + + // jsoup HTML parser library @ https://jsoup.org/ + // implementation 'org.jsoup:jsoup:1.12.1' + + def work_version = "2.2.0" + implementation "androidx.work:work-runtime-ktx:$work_version" + + + // new implems + implementation deps.kotlin.stdlib + implementation deps.support.app_compat + implementation deps.support.design + implementation deps.support.core_ktx + implementation deps.constraint_layout + implementation deps.arch_core.runtime + + // Navigation + implementation deps.navigation.runtime_ktx + implementation deps.navigation.fragment_ktx + implementation deps.navigation.ui_ktx + + // Android Testing Support Library's runner and rules + + /* + androidTestImplementation deps.atsl.runner + androidTestImplementation deps.atsl.rules + androidTestImplementation deps.room.testing + androidTestImplementation deps.arch_core.testing + + */ + + // Espresso UI Testing + androidTestImplementation deps.espresso.core + androidTestImplementation deps.espresso.contrib + androidTestImplementation deps.espresso.intents + +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/app/src/androidTest/java/io/r_a_d/radio2/ExampleInstrumentedTest.kt b/app/src/androidTest/java/io/r_a_d/radio2/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..9e258d8 --- /dev/null +++ b/app/src/androidTest/java/io/r_a_d/radio2/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package io.r_a_d.radio2 + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("io.r_a_d.radio2", appContext.packageName) + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..bf27179 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/chat.html b/app/src/main/assets/chat.html new file mode 100644 index 0000000..650c9a2 --- /dev/null +++ b/app/src/main/assets/chat.html @@ -0,0 +1,23 @@ + + + + + + + + + + + + + +
+ +
+ + + + \ No newline at end of file diff --git a/app/src/main/assets/the_stream_is_down.mp3 b/app/src/main/assets/the_stream_is_down.mp3 new file mode 100644 index 0000000..51e2095 Binary files /dev/null and b/app/src/main/assets/the_stream_is_down.mp3 differ diff --git a/app/src/main/ic_launcher-web.png b/app/src/main/ic_launcher-web.png new file mode 100644 index 0000000..2c5335a Binary files /dev/null and b/app/src/main/ic_launcher-web.png differ diff --git a/app/src/main/java/io/r_a_d/radio2/Actions.kt b/app/src/main/java/io/r_a_d/radio2/Actions.kt new file mode 100644 index 0000000..dfdfbe6 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/Actions.kt @@ -0,0 +1,27 @@ +package io.r_a_d.radio2 + +enum class Actions +{ + PLAY, + STOP, + PAUSE, + VOLUME, + KILL, + NOTIFY, + PLAY_OR_FALLBACK, + FADE_OUT, + CANCEL_FADE_OUT, + SNOOZE +} + +enum class ActionOnError { + RESET, + NOTIFY +} + +enum class ActionOpenParam { + SLEEP, + ALARM, + CUSTOMIZE, + STREAMER_NOTIFICATION_SERVICE +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/Async.kt b/app/src/main/java/io/r_a_d/radio2/Async.kt new file mode 100644 index 0000000..7f8e88a --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/Async.kt @@ -0,0 +1,69 @@ +package io.r_a_d.radio2 + +import android.os.AsyncTask +import android.util.Log +import io.r_a_d.radio2.playerstore.PlayerStore + +class Async(val handler: (Any?) -> Any?, val post: (Any?) -> Unit = {}, + private val actionOnError: ActionOnError = ActionOnError.RESET, private val parameters: Any? = null) : + AsyncTask() { + + init { + try { + execute() + } catch (e: Exception) + { + Log.d(tag,e.toString()) + } + } + + private fun onException(e: java.lang.Exception) { + when(actionOnError) + { + ActionOnError.RESET -> resetPlayerStateOnNetworkError() + ActionOnError.NOTIFY -> return + } + } + + private fun resetPlayerStateOnNetworkError() { + var storeReset = false + + // checking isInitialized avoids setting streamerName multiple times, so it avoids a callback loop. + if (PlayerStore.instance.isInitialized) + { + PlayerStore.instance.currentSong.artist.postValue("") + PlayerStore.instance.isInitialized = false + PlayerStore.instance.streamerName.postValue("") + PlayerStore.instance.queue.clear() + PlayerStore.instance.lp.clear() + PlayerStore.instance.isQueueUpdated.postValue(true) + PlayerStore.instance.isLpUpdated.postValue(true) + // safe-update for the title avoids callback loop too. + if (PlayerStore.instance.currentSong.title.value != noConnectionValue) + PlayerStore.instance.currentSong.title.postValue(noConnectionValue) + storeReset = true + } + + + Log.d(tag, "fallback for no network. Store reset : $storeReset") + } + + override fun doInBackground(vararg params: Any?): Any? { + try { + return handler(parameters) + } catch (e: Exception) { + Log.d(tag,e.toString()) + onException(e) + } + return null + } + + override fun onPostExecute(result: Any?) { + try { + post(result) + } catch (e: Exception) { + Log.d(tag,e.toString()) + onException(e) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/BaseActivity.kt b/app/src/main/java/io/r_a_d/radio2/BaseActivity.kt new file mode 100644 index 0000000..cb956bf --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/BaseActivity.kt @@ -0,0 +1,85 @@ +package io.r_a_d.radio2 + +import android.content.Intent +import android.os.Bundle +import android.util.Log +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver + +import androidx.appcompat.app.AppCompatActivity +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.preference.PreferenceManager +import com.google.android.material.bottomnavigation.BottomNavigationView + +abstract class BaseActivity : AppCompatActivity() { + + private val keyboardLayoutListener : ViewTreeObserver.OnGlobalLayoutListener = ViewTreeObserver.OnGlobalLayoutListener { + val viewHeight = (rootLayout?.rootView?.height ?: 0) + val viewWidth = (rootLayout?.rootView?.width ?: 0) + + val height = ((rootLayout?.height ?: 0)) + val width = ((rootLayout?.width ?: 0)) + + Log.d(tag, "$viewWidth, $viewHeight, $width, $height, ${viewHeight.toDouble()/viewWidth.toDouble()}, ${height.toDouble()/width.toDouble()}") + + val broadcastManager = LocalBroadcastManager.getInstance(this@BaseActivity) + if(height <= viewHeight * 2 / 3 /*height.toDouble()/width.toDouble() < 1.20 */){ + val keyboardHeight = viewHeight - height + onShowKeyboard(keyboardHeight) + + val intent = Intent("KeyboardWillShow") + intent.putExtra("KeyboardHeight", keyboardHeight) + broadcastManager.sendBroadcast(intent) + } else { + onHideKeyboard() + + val intent = Intent("KeyboardWillHide") + broadcastManager.sendBroadcast(intent) + } + + } + + private var keyboardListenersAttached = false + private var rootLayout: ViewGroup? = null + + + // keyboard stuff + private fun onShowKeyboard(keyboardHeight: Int) { + // do things when keyboard is shown + val bottomNavigationView = findViewById(R.id.bottom_nav) + bottomNavigationView.visibility = View.GONE + Log.d(tag, "bottomNav visibility set to GONE (height $keyboardHeight)") + } + + private fun onHideKeyboard() { + // do things when keyboard is hidden + val bottomNavigationView = findViewById(R.id.bottom_nav) + bottomNavigationView.visibility = View.VISIBLE + Log.d(tag, "bottomNav visibility set to VISIBLE") + } + + protected fun attachKeyboardListeners() { + + if (keyboardListenersAttached) { + return + } + + rootLayout = findViewById(R.id.rootLayout) + rootLayout!!.viewTreeObserver.addOnGlobalLayoutListener(keyboardLayoutListener) + + keyboardListenersAttached = true + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + preferenceStore = PreferenceManager.getDefaultSharedPreferences(this) + } + + override fun onDestroy() { + super.onDestroy() + if (keyboardListenersAttached) { + rootLayout?.viewTreeObserver?.removeOnGlobalLayoutListener(keyboardLayoutListener) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/BaseNotification.kt b/app/src/main/java/io/r_a_d/radio2/BaseNotification.kt new file mode 100644 index 0000000..1c4e4ef --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/BaseNotification.kt @@ -0,0 +1,94 @@ +package io.r_a_d.radio2 + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat + +abstract class BaseNotification(private val notificationChannelId: String, + private val notificationChannel : Int, + private val notificationId: Int, + private val notificationImportance: Int + ) { + + + // ######################################## + // ########## BASE NOTIFICATION ########### + // ######################################## + + // Define the notification in android's swipe-down menu + lateinit var notification: Notification + protected lateinit var notificationManager: NotificationManager + protected lateinit var builder: NotificationCompat.Builder + + @RequiresApi(api = Build.VERSION_CODES.O) + protected fun createNotificationChannel(c: Context): String { + val chanName = notificationChannel + val notificationChannelImportance = + when(notificationImportance) { + NotificationCompat.PRIORITY_LOW -> NotificationManager.IMPORTANCE_LOW + NotificationCompat.PRIORITY_DEFAULT -> NotificationManager.IMPORTANCE_DEFAULT + NotificationCompat.PRIORITY_HIGH-> NotificationManager.IMPORTANCE_HIGH + NotificationCompat.PRIORITY_MAX -> NotificationManager.IMPORTANCE_MAX + NotificationCompat.PRIORITY_MIN -> NotificationManager.IMPORTANCE_MIN + else -> NotificationManager.IMPORTANCE_DEFAULT + } + val chan = NotificationChannel(this.notificationChannelId, c.getString(chanName), notificationChannelImportance) + chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + notificationManager.createNotificationChannel(chan) + return this.notificationChannelId + } + + fun show() + { + notification = builder.build() + notificationManager.notify(notificationId, notification) + } + + open fun create(c: Context) { + notificationManager = c.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val notificationIntent = Intent(c, MainActivity::class.java) + // The PendingIntent will launch the SAME activity + // thanks to the launchMode specified in the Manifest : android:launchMode="singleTop" + val pendingIntent = PendingIntent.getActivity( + c, 0, + notificationIntent, 0 + ) + var channelID = "" + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + channelID = createNotificationChannel(c) + } + builder = NotificationCompat.Builder(c, channelID) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + builder.setSmallIcon(R.drawable.lollipop_logo) + builder.color = -0x20b3c6 + } else { + builder.setSmallIcon(R.drawable.normal_logo) + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + } + + builder.priority = notificationImportance + + // The PendingIntent will launch the SAME activity + // thanks to the launchMode specified in the Manifest : android:launchMode="singleTop" + builder.setContentIntent(pendingIntent) + + builder.setColorized(true) + } + + fun clear() + { + notificationManager.cancel(notificationId) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/BootBroadcastReceiver.kt b/app/src/main/java/io/r_a_d/radio2/BootBroadcastReceiver.kt new file mode 100644 index 0000000..d9c1abe --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/BootBroadcastReceiver.kt @@ -0,0 +1,43 @@ +package io.r_a_d.radio2 + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.preference.PreferenceManager +import io.r_a_d.radio2.alarm.RadioAlarm +import io.r_a_d.radio2.playerstore.PlayerStore +import io.r_a_d.radio2.streamerNotificationService.WorkerStore +import io.r_a_d.radio2.streamerNotificationService.startStreamerMonitor + +class BootBroadcastReceiver : BroadcastReceiver(){ + + override fun onReceive(context: Context, arg1: Intent) { + Log.d(tag, "Broadcast Receiver received $arg1") + // define preferenceStore for places of the program that needs to access Preferences without a context + preferenceStore = PreferenceManager.getDefaultSharedPreferences(context) + + if (arg1.action == Intent.ACTION_BOOT_COMPLETED) { + WorkerStore.instance.init(context) + startStreamerMonitor(context) // will actually start it only if enabled in settings + RadioAlarm.instance.setNextAlarm(context) // schedule next alarm + } + + if (arg1.getStringExtra("action") == "io.r_a_d.radio2.${Actions.PLAY_OR_FALLBACK.name}" ) + { + RadioAlarm.instance.setNextAlarm(context) // schedule next alarm + if (PlayerStore.instance.streamerName.value.isNullOrBlank()) + PlayerStore.instance.initPicture(context) + if (!PlayerStore.instance.isInitialized) + PlayerStore.instance.initApi() + + val i = Intent(context, RadioService::class.java) + i.putExtra("action", Actions.PLAY_OR_FALLBACK.name) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + context.startForegroundService(i) + else + context.startService(i) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/MainActivity.kt b/app/src/main/java/io/r_a_d/radio2/MainActivity.kt new file mode 100644 index 0000000..7aa3648 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/MainActivity.kt @@ -0,0 +1,284 @@ +package io.r_a_d.radio2 + +import android.os.Bundle +import com.google.android.material.bottomnavigation.BottomNavigationView +import androidx.navigation.ui.setupActionBarWithNavController +import android.content.Intent +import android.util.Log +import android.view.Menu +import androidx.appcompat.widget.Toolbar +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.navigation.NavController +import io.r_a_d.radio2.playerstore.PlayerStore + +import java.util.Timer +import android.view.MenuItem +import androidx.preference.PreferenceManager +import com.google.android.material.snackbar.Snackbar +import io.r_a_d.radio2.alarm.RadioAlarm +import io.r_a_d.radio2.streamerNotificationService.WorkerStore +import io.r_a_d.radio2.streamerNotificationService.startStreamerMonitor +import io.r_a_d.radio2.ui.songs.request.Requestor + + +/* Log to file import +import android.os.Environment +import java.io.File +import java.io.IOException +*/ + +class MainActivity : BaseActivity() { + + private val clockTicker: Timer = Timer() + private var currentNavController: LiveData? = null + private var isTimerStarted = false + + /** + * Called on first creation and when restoring state. + */ + private fun setupBottomNavigationBar() { + val bottomNavigationView = findViewById(R.id.bottom_nav) + + //val navGraphIds = listOf(R.navigation.home, R.navigation.list, R.navigation.form) + val navGraphIds = listOf(R.navigation.navigation_nowplaying, R.navigation.navigation_songs, + R.navigation.navigation_news, R.navigation.navigation_chat) + + // Setup the bottom navigation view with a list of navigation graphs + val controller = bottomNavigationView.setupWithNavController( + navGraphIds = navGraphIds, + fragmentManager = supportFragmentManager, + containerId = R.id.nav_host_container, + intent = intent + ) + + // Whenever the selected controller changes, setup the action bar. + controller.observe(this, Observer { navController -> + setupActionBarWithNavController(navController) + }) + currentNavController = controller + } + + override fun onSupportNavigateUp(): Boolean { + return currentNavController?.value?.navigateUp() ?: false + } + + // ##################################### + // ######### LIFECYCLE CALLBACKS ####### + // ##################################### + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + // Inflate the menu, this adds items to the action bar if it is present. + menuInflater.inflate(R.menu.toolbar_menu, menu) + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + // Handle item selection + return when (item.itemId) { + R.id.action_refresh -> { + PlayerStore.instance.queue.clear() + PlayerStore.instance.lp.clear() + PlayerStore.instance.initApi() + Requestor.instance.initFavorites() + val s = Snackbar.make(findViewById(R.id.nav_host_container), "Refreshing data..." as CharSequence, Snackbar.LENGTH_LONG) + s.show() + true + } + R.id.action_settings -> { + val i = Intent(this, ParametersActivity::class.java) + startActivity(i) + true + } + R.id.action_sleep -> { + val i = Intent(this, ParametersActivity::class.java) + i.putExtra("action", ActionOpenParam.SLEEP.name) // TODO change value with Actions.something + startActivity(i) + true + } + R.id.action_alarm -> { + val i = Intent(this, ParametersActivity::class.java) + i.putExtra("action", ActionOpenParam.ALARM.name) // TODO change value with Actions.something + startActivity(i) + true + } + + else -> super.onOptionsItemSelected(item) + } + } + + override fun onRestoreInstanceState(savedInstanceState: Bundle?) { + super.onRestoreInstanceState(savedInstanceState ?: Bundle()) + // Now that BottomNavigationBar has restored its instance state + // and its selectedItemId, we can proceed with setting up the + // BottomNavigationBar with Navigation + setupBottomNavigationBar() + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + WorkerStore.instance.init(this) + startStreamerMonitor(this) // this checks the preferenceStore before actually starting a service, don't worry. + + RadioAlarm.instance.cancelAlarm(c = this) + RadioAlarm.instance.setNextAlarm(c = this) // this checks the preferenceStore before actually setting an alarm, don't worry. + + // initialize programmatically accessible colors + colorBlue = ResourcesCompat.getColor(resources, R.color.bluereq, null) + colorWhited = ResourcesCompat.getColor(resources, R.color.whited, null) + colorGreenList = (ResourcesCompat.getColorStateList(resources, R.color.button_green, null)) + colorRedList = (ResourcesCompat.getColorStateList(resources, R.color.button_red, null)) + colorGreenListCompat = (ResourcesCompat.getColorStateList(resources, R.color.button_green_compat, null)) + + PlayerStore.instance.initApi() // the service will call the initApi on defining the streamerName Observer too, but it's better to initialize the API as soon as the user opens the activity. + + // Post-UI Launch + if (PlayerStore.instance.isInitialized) + { + Log.d(tag, "skipped initialization") + } else { + // if the service is not started, start it in STOP mode. + // It's not a dummy action : with STOP mode, the player does not buffer audio (and does not use data connection without the user's consent). + // this is useful since the service must be started to register bluetooth devices buttons. + // (in case someone opens the app then pushes the PLAY button from their bluetooth device) + if(!PlayerStore.instance.isServiceStarted.value!!) + actionOnService(Actions.STOP) + + // initialize some API data + PlayerStore.instance.initPicture(this) + PlayerStore.instance.streamerName.value = "" // initializing the streamer name will trigger an initApi from the observer in the Service. + + // initialize the favorites + Requestor.instance.initFavorites() + } + + if (!isTimerStarted) + { + // timers + // the clockTicker is used to update the UI. It's OK if it dies when the app loses focus. + // the timer is declared after access to PlayerStore so that PlayerStore is already initialized: + // Otherwise it makes the PlayerStore call its init{} block from a background thread --> crash + clockTicker.schedule( + Tick(), + 0, + 500 + ) + isTimerStarted = true + } + + // initialize the UI + setTheme(R.style.AppTheme) + setContentView(R.layout.activity_main) + attachKeyboardListeners() + + val toolbar : Toolbar = findViewById(R.id.toolbar) + + // before setting up the bottom bar, we must declare the top bar that will be used by the bottom bar to display titles. + setSupportActionBar(toolbar) + + if (savedInstanceState == null) { + setupBottomNavigationBar() + } // Else, need to wait for onRestoreInstanceState + } + + override fun onDestroy() { + clockTicker.cancel() + super.onDestroy() + } + + // #################################### + // ####### SERVICE PLAY / PAUSE ####### + // #################################### + + + private fun actionOnService(a: Actions, v: Int = 0) + { + val i = Intent(this, RadioService::class.java) + i.putExtra("action", a.name) + i.putExtra("value", v) + Log.d(tag, "Sending intent ${a.name}") + startService(i) + } + + + // #################################### + // ###### SERVICE BINDER MANAGER ###### + // #################################### + + // NO BINDERS, only intents. That's the magic. + // Avoid code duplication, keep a single entry point to modify the service, and manage the service independently + // (no coupling between service and activity, as it should be ! Cause the notification makes changes too.) + + + /* + // #################################### + // ####### LOGGING TO FILE ############ + // #################################### + + // Checks if external storage is available for read and write + private val isExternalStorageWritable: Boolean + get() { + val state = Environment.getExternalStorageState() + return Environment.MEDIA_MOUNTED == state + } + + // Checks if external storage is available to at least read + private val isExternalStorageReadable: Boolean + get() { + val state = Environment.getExternalStorageState() + return Environment.MEDIA_MOUNTED == state || Environment.MEDIA_MOUNTED_READ_ONLY == state + } + + + private fun logToFile() + { + + // Logging + when { + isExternalStorageWritable -> { + + val appDirectory = Environment.getExternalStorageDirectory() + // File(getExternalFilesDir(Environment.DIRECTORY_DOCUMENTS) + "/MyPersonalAppFolder") + val logDirectory = File("$appDirectory/log") + val logFile = File(logDirectory, "logcat" + System.currentTimeMillis() + ".txt") + Log.d( + tag, + "appDirectory : $appDirectory, logDirectory : $logDirectory, logFile : $logFile" + ) + + // create app folder + if (!appDirectory.exists()) { + appDirectory.mkdir() + Log.d(tag, "$appDirectory created") + } + + // create log folder + if (!logDirectory.exists()) { + logDirectory.mkdir() + Log.d(tag, "$logDirectory created") + } + + // clear the previous logcat and then write the new one to the file + try { + Runtime.getRuntime().exec("logcat -c") + Runtime.getRuntime().exec("logcat -v time -f $logFile *:E $tag:V ") + Log.d(tag, "logcat started") + } catch (e: IOException) { + e.printStackTrace() + } + + } + isExternalStorageReadable -> { + // only readable + } + else -> { + // not accessible + } + } + } + + */ + +} diff --git a/app/src/main/java/io/r_a_d/radio2/NavigationExtensions.kt b/app/src/main/java/io/r_a_d/radio2/NavigationExtensions.kt new file mode 100644 index 0000000..2b454e7 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/NavigationExtensions.kt @@ -0,0 +1,245 @@ +package io.r_a_d.radio2 + + +import android.content.Intent +import android.util.SparseArray +import androidx.core.util.forEach +import androidx.core.util.set +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.navigation.NavController +import androidx.navigation.fragment.NavHostFragment +import com.google.android.material.bottomnavigation.BottomNavigationView + +/** + * YATTOZ' NOTE : this file has been scavenged from Android's "architecture-components-samples" repo. + * See it here: + * https://github.com/android/architecture-components-samples/blob/master/NavigationAdvancedSample/app/src/main/java/com/example/android/navigationadvancedsample/NavigationExtensions.kt + * it allows, among other things, to keep and restore fragments' state when they're swapped. Useful for IRC. + */ + + +/** + * Manages the various graphs needed for a [BottomNavigationView]. + * + * This sample is a workaround until the Navigation Component supports multiple back stacks. + */ +fun BottomNavigationView.setupWithNavController( + navGraphIds: List, + fragmentManager: FragmentManager, + containerId: Int, + intent: Intent +): LiveData { + + // Map of tags + val graphIdToTagMap = SparseArray() + // Result. Mutable live data with the selected controlled + val selectedNavController = MutableLiveData() + + var firstFragmentGraphId = 0 + + // First create a NavHostFragment for each NavGraph ID + navGraphIds.forEachIndexed { index, navGraphId -> + val fragmentTag = getFragmentTag(index) + + // Find or create the Navigation host fragment + val navHostFragment = obtainNavHostFragment( + fragmentManager, + fragmentTag, + navGraphId, + containerId + ) + + // Obtain its id + val graphId = navHostFragment.navController.graph.id + + if (index == 0) { + firstFragmentGraphId = graphId + } + + // Save to the map + graphIdToTagMap[graphId] = fragmentTag + + // Attach or detach nav host fragment depending on whether it's the selected item. + if (this.selectedItemId == graphId) { + // Update livedata with the selected graph + selectedNavController.value = navHostFragment.navController + attachNavHostFragment(fragmentManager, navHostFragment, index == 0) + } else { + detachNavHostFragment(fragmentManager, navHostFragment) + } + } + + // Now connect selecting an item with swapping Fragments + var selectedItemTag = graphIdToTagMap[this.selectedItemId] + val firstFragmentTag = graphIdToTagMap[firstFragmentGraphId] + var isOnFirstFragment = selectedItemTag == firstFragmentTag + + // When a navigation item is selected + setOnNavigationItemSelectedListener { item -> + // Don't do anything if the state is state has already been saved. + if (fragmentManager.isStateSaved) { + false + } else { + val newlySelectedItemTag = graphIdToTagMap[item.itemId] + if (selectedItemTag != newlySelectedItemTag) { + // Pop everything above the first fragment (the "fixed start destination") + fragmentManager.popBackStack(firstFragmentTag, + FragmentManager.POP_BACK_STACK_INCLUSIVE) + val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag) + as NavHostFragment + + // Exclude the first fragment tag because it's always in the back stack. + if (firstFragmentTag != newlySelectedItemTag) { + // Commit a transaction that cleans the back stack and adds the first fragment + // to it, creating the fixed started destination. + fragmentManager.beginTransaction() + /* // YATTOZ' NOTE - disabling animations, it feels snappier and more in place. + .setCustomAnimations( + R.anim.nav_default_enter_anim, + R.anim.nav_default_exit_anim, + R.anim.nav_default_pop_enter_anim, + R.anim.nav_default_pop_exit_anim) + */ + .attach(selectedFragment) + .setPrimaryNavigationFragment(selectedFragment) + .apply { + // Detach all other Fragments + graphIdToTagMap.forEach { _, fragmentTagIter -> + if (fragmentTagIter != newlySelectedItemTag) { + detach(fragmentManager.findFragmentByTag(firstFragmentTag)!!) + } + } + } + .addToBackStack(firstFragmentTag) + .setReorderingAllowed(true) + .commit() + } + selectedItemTag = newlySelectedItemTag + isOnFirstFragment = selectedItemTag == firstFragmentTag + selectedNavController.value = selectedFragment.navController + true + } else { + false + } + } + } + + // Optional: on item reselected, pop back stack to the destination of the graph + setupItemReselected(graphIdToTagMap, fragmentManager) + + // Handle deep link + setupDeepLinks(navGraphIds, fragmentManager, containerId, intent) + + // Finally, ensure that we update our BottomNavigationView when the back stack changes + fragmentManager.addOnBackStackChangedListener { + if (!isOnFirstFragment && !fragmentManager.isOnBackStack(firstFragmentTag)) { + this.selectedItemId = firstFragmentGraphId + } + + // Reset the graph if the currentDestination is not valid (happens when the back + // stack is popped after using the back button). + selectedNavController.value?.let { controller -> + if (controller.currentDestination == null) { + controller.navigate(controller.graph.id) + } + } + } + return selectedNavController +} + +private fun BottomNavigationView.setupDeepLinks( + navGraphIds: List, + fragmentManager: FragmentManager, + containerId: Int, + intent: Intent +) { + navGraphIds.forEachIndexed { index, navGraphId -> + val fragmentTag = getFragmentTag(index) + + // Find or create the Navigation host fragment + val navHostFragment = obtainNavHostFragment( + fragmentManager, + fragmentTag, + navGraphId, + containerId + ) + // Handle Intent + if (navHostFragment.navController.handleDeepLink(intent) + && selectedItemId != navHostFragment.navController.graph.id) { + this.selectedItemId = navHostFragment.navController.graph.id + } + } +} + +private fun BottomNavigationView.setupItemReselected( + graphIdToTagMap: SparseArray, + fragmentManager: FragmentManager +) { + setOnNavigationItemReselectedListener { item -> + val newlySelectedItemTag = graphIdToTagMap[item.itemId] + val selectedFragment = fragmentManager.findFragmentByTag(newlySelectedItemTag) + as NavHostFragment + val navController = selectedFragment.navController + // Pop the back stack to the start destination of the current navController graph + navController.popBackStack( + navController.graph.startDestination, false + ) + } +} + +private fun detachNavHostFragment( + fragmentManager: FragmentManager, + navHostFragment: NavHostFragment +) { + fragmentManager.beginTransaction() + .detach(navHostFragment) + .commitNow() +} + +private fun attachNavHostFragment( + fragmentManager: FragmentManager, + navHostFragment: NavHostFragment, + isPrimaryNavFragment: Boolean +) { + fragmentManager.beginTransaction() + .attach(navHostFragment) + .apply { + if (isPrimaryNavFragment) { + setPrimaryNavigationFragment(navHostFragment) + } + } + .commitNow() + +} + +private fun obtainNavHostFragment( + fragmentManager: FragmentManager, + fragmentTag: String, + navGraphId: Int, + containerId: Int +): NavHostFragment { + // If the Nav Host fragment exists, return it + val existingFragment = fragmentManager.findFragmentByTag(fragmentTag) as NavHostFragment? + existingFragment?.let { return it } + + // Otherwise, create it and return it. + val navHostFragment = NavHostFragment.create(navGraphId) + fragmentManager.beginTransaction() + .add(containerId, navHostFragment, fragmentTag) + .commitNow() + return navHostFragment +} + +private fun FragmentManager.isOnBackStack(backStackName: String): Boolean { + val backStackCount = backStackEntryCount + for (index in 0 until backStackCount) { + if (getBackStackEntryAt(index).name == backStackName) { + return true + } + } + return false +} + +private fun getFragmentTag(index: Int) = "bottomNavigation#$index" \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/NowPlayingNotification.kt b/app/src/main/java/io/r_a_d/radio2/NowPlayingNotification.kt new file mode 100644 index 0000000..af1db5c --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/NowPlayingNotification.kt @@ -0,0 +1,109 @@ +package io.r_a_d.radio2 + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.core.app.NotificationCompat +import io.r_a_d.radio2.playerstore.PlayerStore +import io.r_a_d.radio2.BootBroadcastReceiver + +class NowPlayingNotification( + notificationChannelId: String, + notificationChannel : Int, + notificationId: Int, + notificationImportance: Int + +) : BaseNotification + ( + notificationChannelId, + notificationChannel, + notificationId, + notificationImportance +){ + + // ######################################## + // ###### NOW PLAYING NOTIFICATION ######## + // ######################################## + + lateinit var mediaStyle: androidx.media.app.NotificationCompat.DecoratedMediaCustomViewStyle + + fun create(c: Context, m: MediaSessionCompat) { + super.create(c) + + // got it right + val delIntent = Intent(c, RadioService::class.java) + delIntent.putExtra("action", Actions.KILL.name) + val deleteIntent = PendingIntent.getService(c, 0, delIntent, PendingIntent.FLAG_NO_CREATE) + builder.setDeleteIntent(deleteIntent) + + mediaStyle = androidx.media.app.NotificationCompat.DecoratedMediaCustomViewStyle().also { + it.setMediaSession(m.sessionToken) + it.setShowActionsInCompactView(0) // index 0 = show actions 0 and 1 (show action #0 (play/pause)) + it.setCancelButtonIntent(deleteIntent) + } + builder.setStyle(mediaStyle) + update(c) + } + + fun update(c: Context, isUpdatingNotificationButton: Boolean = false, isRinging: Boolean = false) { + + if (isUpdatingNotificationButton) + builder.mActions.clear() + + // Title : Title of notification (usu. songArtist is first) + // Text : Text of the notification (usu. songTitle is second) + builder.setContentTitle(PlayerStore.instance.currentSong.artist.value) + builder.setContentText(PlayerStore.instance.currentSong.title.value) + // As subText, we show when the player is stopped. This is a friendly reminder that the metadata won't get updated. + // Maybe later we could replace it by a nice progressBar? Would it be interesting to have one here? I don't know. + if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_STOPPED) { + builder.setSubText("Stopped") + builder.setShowWhen(false) + } + else { + builder.setSubText(null) + builder.setShowWhen(true) + } + + if (builder.mActions.isEmpty()) { + val intent = Intent(c, RadioService::class.java) + val playPauseAction: NotificationCompat.Action + + playPauseAction = if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_PLAYING) { + intent.putExtra("action", Actions.PAUSE.name) + val pendingButtonIntent = PendingIntent.getService(c, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT) + NotificationCompat.Action.Builder(R.drawable.ic_pause, "Pause", pendingButtonIntent).build() + } else { + intent.putExtra("action", Actions.PLAY.name) + val pendingButtonIntent = PendingIntent.getService(c, 1, intent, PendingIntent.FLAG_UPDATE_CURRENT) + NotificationCompat.Action.Builder(R.drawable.ic_play,"Play", pendingButtonIntent).build() + } + builder.addAction(playPauseAction) + val intent2 = Intent(c, RadioService::class.java) + intent2.putExtra("action", Actions.KILL.name) + val pendingButtonIntent = PendingIntent.getService(c, 2, intent2, PendingIntent.FLAG_UPDATE_CURRENT) + val stopAction = NotificationCompat.Action.Builder(R.drawable.ic_stop,"Stop", pendingButtonIntent).build() + builder.addAction(stopAction) + + if (isRinging) { + val snoozeString = preferenceStore.getString("snoozeDuration", "10") ?: "10" + val snoozeMinutes = if (snoozeString == c.getString(R.string.disable)) 0 else Integer.parseInt(snoozeString) + + val snoozeIntent = Intent(c, RadioService::class.java) + snoozeIntent.putExtra("action", Actions.SNOOZE.name) + val pendingSnoozeIntent = PendingIntent.getService(c, 5, snoozeIntent, PendingIntent.FLAG_UPDATE_CURRENT) + val snoozeAction = NotificationCompat.Action.Builder(R.drawable.ic_alarm, "Snooze ($snoozeMinutes min.)", pendingSnoozeIntent ).build() + if (snoozeMinutes > 0) + builder.addAction(snoozeAction) + builder.setStyle(NotificationCompat.DecoratedCustomViewStyle()) + } else { + builder.setStyle(mediaStyle) + } + } + builder.setLargeIcon(PlayerStore.instance.streamerPicture.value) + + super.show() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ParametersActivity.kt b/app/src/main/java/io/r_a_d/radio2/ParametersActivity.kt new file mode 100644 index 0000000..c526360 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ParametersActivity.kt @@ -0,0 +1,42 @@ +package io.r_a_d.radio2 + +import android.os.Bundle +import io.r_a_d.radio2.preferences.* + + +class ParametersActivity : BaseActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + // UI Launch + setTheme(R.style.AppTheme_Parameters) + setContentView(R.layout.activity_parameters) + + // my_child_toolbar is defined in the layout file + setSupportActionBar(findViewById(R.id.toolbar)) + + // Get a support ActionBar corresponding to this toolbar and enable the Up button + supportActionBar?.setDisplayHomeAsUpEnabled(true) + + val extra = if (savedInstanceState == null) { + intent.extras?.getString("action") + } else { + savedInstanceState.getSerializable("action") as String + } + + val fragmentToLoad = when(extra) { + ActionOpenParam.ALARM.name -> AlarmFragment() + ActionOpenParam.SLEEP.name -> SleepFragment() + ActionOpenParam.CUSTOMIZE.name -> CustomizeFragment() + ActionOpenParam.STREAMER_NOTIFICATION_SERVICE.name -> StreamerNotifServiceFragment() + else -> MainPreferenceFragment() + } + + + supportFragmentManager + .beginTransaction() + .replace(R.id.parameters_host_container, fragmentToLoad) + .commit() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/RadioService.kt b/app/src/main/java/io/r_a_d/radio2/RadioService.kt new file mode 100644 index 0000000..e3f4106 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/RadioService.kt @@ -0,0 +1,664 @@ +package io.r_a_d.radio2 + +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Bitmap +import android.support.v4.media.MediaBrowserCompat +import android.util.Log +import androidx.media.MediaBrowserServiceCompat +import android.media.AudioManager +import android.os.* +import android.support.v4.media.session.MediaSessionCompat +import android.support.v4.media.session.PlaybackStateCompat +import androidx.media.session.MediaButtonReceiver +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.util.Util.getUserAgent +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory + +import android.net.Uri +import android.support.v4.media.MediaMetadataCompat +import android.telephony.PhoneStateListener +import android.telephony.TelephonyManager +import android.view.KeyEvent +import androidx.core.app.NotificationCompat +import androidx.core.content.edit +import androidx.lifecycle.Observer +import androidx.media.AudioAttributesCompat +import androidx.media.AudioFocusRequestCompat +import androidx.media.AudioManagerCompat +import androidx.preference.PreferenceManager +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.metadata.icy.* +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector +import io.r_a_d.radio2.alarm.RadioAlarm +import io.r_a_d.radio2.alarm.RadioSleeper +import io.r_a_d.radio2.playerstore.PlayerStore +import java.util.* +import kotlin.math.exp +import kotlin.math.ln +import kotlin.system.exitProcess + + +class RadioService : MediaBrowserServiceCompat() { + + private val radioTag = "======RadioService=====" + private lateinit var nowPlayingNotification: NowPlayingNotification + private val radioServiceId = 1 + private var numberOfSongs = 0 + private val apiTicker: Timer = Timer() + private var isAlarmStopped: Boolean = false + + // Define the broadcast receiver to handle any broadcasts + private val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val action = intent.action + if (action != null && action == AudioManager.ACTION_AUDIO_BECOMING_NOISY) { + val i = Intent(context, RadioService::class.java) + i.putExtra("action", Actions.STOP.name) + context.startService(i) + } + if (action != null && action == Intent.ACTION_HEADSET_PLUG) + { + var headsetPluggedIn = false + + // In the Intent state there's the value whether the headphones are plugged or not. + // This *should* work in any case... + when (intent.getIntExtra("state", -1)) { + 0 -> { + Log.d(tag, radioTag + "Headset is unplugged") + } + 1 -> { + Log.d(tag, radioTag + "Headset is plugged") + headsetPluggedIn = true + } + else -> { + Log.d(tag, radioTag + "I have no idea what the headset state is") + } + } + /* + val am = getSystemService(AUDIO_SERVICE) as AudioManager + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + { + val devices = am.getDevices(AudioManager.GET_DEVICES_OUTPUTS) + for (d in devices) + { + if (d.type == AudioDeviceInfo.TYPE_WIRED_HEADSET || d.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES) + headsetPluggedIn = true + } + } + else + { + Log.d(tag, radioTag + "Can't get state?") + } + + */ + if((mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_STOPPED + || mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_PAUSED) + && headsetPluggedIn) + beginPlaying() + } + } + } + + // ################################################## + // ################### OBSERVERS #################### + // ################################################## + + private val titleObserver: Observer = Observer { + if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_PLAYING) + { + Log.d(tag, radioTag + "SONG CHANGED AND PLAYING") + // we activate latency compensation only if it's been at least 2 songs... + when { + PlayerStore.instance.isStreamDown -> { + // if we reach here, it means that the observer has been called by a new song and that the stream was down previously. + // so the stream is now back to normal. + PlayerStore.instance.isStreamDown = false + PlayerStore.instance.initApi() + } + PlayerStore.instance.currentSong.title.value == getString(R.string.ed) -> { + PlayerStore.instance.isStreamDown = true + } + else -> { + PlayerStore.instance.fetchApi(numberOfSongs >= 2) + } + } + } + nowPlayingNotification.update(this) + } + + private val volumeObserver: Observer = Observer { + setVolume(it) + } + + private val isMutedObserver: Observer = Observer { + setVolume(if (it) null else -1) + } + + private val isPlayingObserver: Observer = Observer { + if (it) + beginPlaying() + else + stopPlaying() + } + + private val startTimeObserver = Observer { + // We're listening to startTime to determine if we have to update Queue and Lp. + // this is because startTime is set by the API and never by the ICY, so both cases are covered (playing and stopped) + // should be OK even when a new streamer comes in. + if (it != PlayerStore.instance.currentSongBackup.startTime.value) // we have a new song + { + PlayerStore.instance.updateLp() + PlayerStore.instance.updateQueue() + } + } + + private val streamerObserver = Observer { + PlayerStore.instance.initApi() + nowPlayingNotification.update(this) // should update the streamer icon + } + + private val streamerPictureObserver = Observer { + nowPlayingNotification.update(this) + } + + // ################################################## + // ############## LIFECYCLE CALLBACKS ############### + // ################################################## + + override fun onLoadChildren( + parentId: String, + result: Result> + ) { + result.sendResult(null) + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + // Clients can connect, but you can't browse internet radio + // so onLoadChildren returns nothing. This disables the ability to browse for content. + return BrowserRoot(getString(R.string.MEDIA_ROOT_ID), null) + } + + override fun onCreate() { + super.onCreate() + + preferenceStore = PreferenceManager.getDefaultSharedPreferences(this) + + // Define managers + telephonyManager = getSystemService(Context.TELEPHONY_SERVICE) as TelephonyManager + telephonyManager?.listen(phoneStateListener, PhoneStateListener.LISTEN_CALL_STATE) + audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager + + //define the audioFocusRequest + val audioFocusRequestBuilder = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN) + audioFocusRequestBuilder.setOnAudioFocusChangeListener(focusChangeListener) + val audioAttributes = AudioAttributesCompat.Builder() + audioAttributes.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) + audioAttributes.setUsage(AudioAttributesCompat.USAGE_MEDIA) + audioFocusRequestBuilder.setAudioAttributes(audioAttributes.build()) + audioFocusRequest = audioFocusRequestBuilder.build() + + // This stuff is for the broadcast receiver + val filter = IntentFilter() + filter.addAction(AudioManager.ACTION_AUDIO_BECOMING_NOISY) + filter.addAction(Intent.ACTION_HEADSET_PLUG) + registerReceiver(receiver, filter) + + // setup media player + setupMediaPlayer() + createMediaSession() + + nowPlayingNotification = NowPlayingNotification( + notificationChannelId = this.getString(R.string.nowPlayingChannelId), + notificationChannel = R.string.nowPlayingNotificationChannel, + notificationId = 1, + notificationImportance = NotificationCompat.PRIORITY_LOW + ) + nowPlayingNotification.create(this, mediaSession) + + + PlayerStore.instance.streamerName.observeForever(streamerObserver) + PlayerStore.instance.currentSong.title.observeForever(titleObserver) + PlayerStore.instance.currentSong.startTime.observeForever(startTimeObserver) + PlayerStore.instance.volume.observeForever(volumeObserver) + PlayerStore.instance.isPlaying.observeForever(isPlayingObserver) + PlayerStore.instance.isMuted.observeForever(isMutedObserver) + PlayerStore.instance.streamerPicture.observeForever(streamerPictureObserver) + + startForeground(radioServiceId, nowPlayingNotification.notification) + + // start ticker for when the player is stopped + val periodString = PreferenceManager.getDefaultSharedPreferences(this).getString("fetchPeriod", "10") ?: "10" + val period: Long = Integer.parseInt(periodString).toLong() + if (period > 0) + apiTicker.schedule(ApiFetchTick(), 0, period * 1000) + + PlayerStore.instance.isServiceStarted.value = true + Log.d(tag, radioTag + "created") + } + + private val handler = Handler() + class LowerVolumeRunnable : Runnable { + override fun run() { + PlayerStore.instance.volume.postValue( + (PlayerStore.instance.volume.value!!.toFloat() * (9f / 10f)).toInt() + ) // the setVolume is called by the volumeObserver in RadioService (on main thread for ExoPlayer!) + } + } + private val lowerVolumeRunnable = LowerVolumeRunnable() + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.getStringExtra("action") == null) + return super.onStartCommand(intent, flags, startId) + + if (MediaButtonReceiver.handleIntent(mediaSession, intent) != null) + return super.onStartCommand(intent, flags, startId) + + when (intent.getStringExtra("action")) { + Actions.PLAY.name -> beginPlaying() + Actions.STOP.name -> { setVolume(PlayerStore.instance.volume.value); stopPlaying() } // setVolume is here to reset the volume to the user's preference when the alarm (that sets volume to 100) is dismissed + Actions.PAUSE.name -> { setVolume(PlayerStore.instance.volume.value); pausePlaying() } + Actions.VOLUME.name -> setVolume(intent.getIntExtra("value", 100)) + Actions.KILL.name -> {stopForeground(true); stopSelf(); return Service.START_NOT_STICKY} + Actions.NOTIFY.name -> nowPlayingNotification.update(this) + Actions.PLAY_OR_FALLBACK.name -> beginPlayingOrFallback() + Actions.FADE_OUT.name -> { + for (i in 1 until 30) // we schedule 30 "LowerVolumeRunnable" every 2 seconds (i * 2) + { + // I couldn't find how to send multiple times the same PendingIntent using AlarmManager, so I relied on Handler instead. + // I think there's no guarantee of exact time with the Handler, especially when the device is in deep sleep, + // But when I force-set the Deep Sleep mode with ADB, it worked fine, so I'll leave it as this. + // BUT! SOMETIMES IT DIDN'T WORK AND I DON'T KNOW WHY. + // I hope that moving the handler in the RadioService would solve the issue and trigger it correctly. + handler.postDelayed(lowerVolumeRunnable, (i * 2 * 1000).toLong()) + } + } + Actions.CANCEL_FADE_OUT.name -> { handler.removeCallbacks(lowerVolumeRunnable) } + Actions.SNOOZE.name -> { RadioAlarm.instance.snooze(this) } + } + Log.d(tag, radioTag + "intent received : " + intent.getStringExtra("action")) + super.onStartCommand(intent, flags, startId) + // The service must be re-created if it is destroyed by the system. This allows the user to keep actions like Bluetooth and headphones plug available. + return START_STICKY + } + + override fun onTaskRemoved(rootIntent: Intent) { + if (mediaSession.controller.playbackState.state != PlaybackStateCompat.STATE_PLAYING) { + nowPlayingNotification.clear() + stopSelf() + } + super.onTaskRemoved(rootIntent) + Log.d(tag, radioTag + "task removed") + } + + override fun onDestroy() { + super.onDestroy() + player.stop() + player.release() + unregisterReceiver(receiver) + PlayerStore.instance.currentSong.title.removeObserver(titleObserver) + PlayerStore.instance.currentSong.startTime.removeObserver(startTimeObserver) + PlayerStore.instance.volume.removeObserver(volumeObserver) + PlayerStore.instance.isPlaying.removeObserver(isPlayingObserver) + PlayerStore.instance.isMuted.removeObserver(isMutedObserver) + PlayerStore.instance.streamerPicture.removeObserver(streamerPictureObserver) + + + mediaSession.isActive = false + mediaSession.setMediaButtonReceiver(null) + + mediaSession.release() + + PlayerStore.instance.isServiceStarted.value = false + PlayerStore.instance.isInitialized = false + + PreferenceManager.getDefaultSharedPreferences(this).edit { + this.putBoolean("isSleeping", false) + this.commit() + } + RadioSleeper.instance.cancelSleep(this) + + apiTicker.cancel() // stops the timer. + Log.d(tag, radioTag + "destroyed") + // if the service is destroyed, the application had become useless. + exitProcess(0) + } + + // ######################################## + // ######## AUDIO FOCUS MANAGEMENT ######## + //######################################### + + // Define the managers + private var telephonyManager: TelephonyManager? = null + private lateinit var audioManager: AudioManager + private lateinit var audioFocusRequest: AudioFocusRequestCompat + + private val phoneStateListener = object : PhoneStateListener() { + override fun onCallStateChanged(state: Int, incomingNumber: String) { + super.onCallStateChanged(state, incomingNumber) + + if (state != TelephonyManager.CALL_STATE_IDLE) { + setVolume(0) + } else { + setVolume(PlayerStore.instance.volume.value!!) + } + } + } + + // Define the listener that will control what happens when focus is changed + private val focusChangeListener = + AudioManager.OnAudioFocusChangeListener { focusChange -> + when (focusChange) { + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> setVolume((0.20f * PlayerStore.instance.volume.value!!).toInt()) //20% of current volume. + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> setVolume(0) + AudioManager.AUDIOFOCUS_LOSS -> stopPlaying() + AudioManager.AUDIOFOCUS_GAIN -> setVolume(PlayerStore.instance.volume.value!!) + else -> {} + } + } + + // ######################################## + // ######## MEDIA PLAYER / SESSION ######## + // ######################################## + + private lateinit var mediaSession : MediaSessionCompat + private lateinit var playbackStateBuilder: PlaybackStateCompat.Builder + private lateinit var metadataBuilder: MediaMetadataCompat.Builder + private lateinit var player: SimpleExoPlayer + private lateinit var radioMediaSource: ProgressiveMediaSource + private lateinit var fallbackMediaSource: ProgressiveMediaSource + + private fun setupMediaPlayer(){ + + val minBufferMillis = 15 * 1000 // Default value + val maxBufferMillis = 50 * 1000 // Default value + val bufferForPlayback = 4 * 1000 // Default is 2.5s. + // Increasing it makes it more robust to short connection loss, at the expense of latency when we press Play. 4s seems reasonable to me. + val bufferForPlaybackAfterRebuffer = 7 * 1000 // Default is 5s. + + val loadControl = DefaultLoadControl.Builder().apply { + setBufferDurationsMs(minBufferMillis, maxBufferMillis, bufferForPlayback, bufferForPlaybackAfterRebuffer) + }.createDefaultLoadControl() + + player = ExoPlayerFactory.newSimpleInstance(this, DefaultTrackSelector(), loadControl) + player.addMetadataOutput { + for (i in 0 until it.length()) { + val entry = it.get(i) + if (entry is IcyHeaders) { + Log.d(tag, radioTag + "onMetadata: IcyHeaders $entry") + } + if (entry is IcyInfo) { + Log.d(tag, radioTag + "onMetadata: Title ----> ${entry.title}") + // Note : Kotlin supports UTF-8 by default. + numberOfSongs++ + val data = entry.title!! + PlayerStore.instance.currentSong.setTitleArtist(data) + } + val d : Long = ((PlayerStore.instance.currentSong.stopTime.value?.minus(PlayerStore.instance.currentSong.startTime.value!!) ?: 0) / 1000) + val duration = if (d > 0) d - (PlayerStore.instance.latencyCompensator) else 0 + metadataBuilder.putString( + MediaMetadataCompat.METADATA_KEY_TITLE, + PlayerStore.instance.currentSong.title.value + ) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, PlayerStore.instance.currentSong.artist.value) + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration) + + mediaSession.setMetadata(metadataBuilder.build()) + + val intent = Intent("com.android.music.metachanged") + intent.putExtra("artist", PlayerStore.instance.currentSong.artist.value) + intent.putExtra("track", PlayerStore.instance.currentSong.title.value) + intent.putExtra("duration", duration) + intent.putExtra("position", 0) + sendBroadcast(intent) + } + } + // this listener allows to reset numberOfSongs if the connection is lost. + player.addListener(exoPlayerEventListener) + + // Produces DataSource instances through which media data is loaded. + val dataSourceFactory = DefaultDataSourceFactory( + this, + getUserAgent(this, getString(R.string.app_name)) + ) + // This is the MediaSource representing the media to be played. + radioMediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(Uri.parse(getString(R.string.STREAM_URL_RADIO))) + + fallbackMediaSource = ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(Uri.parse("file:///android_asset/the_stream_is_down.mp3")) + } + + private fun createMediaSession() { + mediaSession = MediaSessionCompat(this, "RadioMediaSession") + // Deprecated flags + // mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS and MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS) + mediaSession.isActive = true + mediaSession.setCallback(mediaSessionCallback) + playbackStateBuilder = PlaybackStateCompat.Builder() + playbackStateBuilder.setActions(PlaybackStateCompat.ACTION_PLAY_PAUSE) + .setState(PlaybackStateCompat.STATE_STOPPED, 0, 1.0f, SystemClock.elapsedRealtime()) + + metadataBuilder = MediaMetadataCompat.Builder() + mediaSession.setPlaybackState(playbackStateBuilder.build()) + } + + // ######################################## + // ######### SERVICE START/STOP ########### + // ######################################## + + // this function is playing the stream if available, or a default sound if there's a problem. + private fun beginPlayingOrFallback() + { + PlayerStore.instance.volume.value = 100 // we set the max volume for exoPlayer to be sure it rings correctly. + beginPlaying(isRinging = true, isFallback = false) + val wait: (Any?) -> Any = { + /* + Here we lower the isAlarmStopped flag and we wait for 17s. (seems like 12 could be a bit too short since I increased the buffer!!) + If the player stops the alarm (by calling an intent), the isAlarmStopped flag will be raised. + */ + isAlarmStopped = false // reset the flag + var i = 0 + while (i < 17) + { + Thread.sleep(1000) + i++ + } + } + val post: (Any?) -> Unit = { + // we verify : if the player is not playing, and if the user didn't stop it, it means that there's a network issue. + // So we use the fallback sound to wake up the user!! + // (note: player.isPlaying is only accessible on main thread, so we can't check in the wait() lambda) + if (!player.isPlaying && !isAlarmStopped) + beginPlaying(isRinging = true, isFallback = true) + } + Async(wait, post) + } + + fun beginPlaying(isRinging: Boolean = false, isFallback: Boolean = false) + { + //define the audioFocusRequest + val audioFocusRequestBuilder = AudioFocusRequestCompat.Builder(AudioManagerCompat.AUDIOFOCUS_GAIN) + audioFocusRequestBuilder.setOnAudioFocusChangeListener(focusChangeListener) + val audioAttributes = AudioAttributesCompat.Builder() + audioAttributes.setContentType(AudioAttributesCompat.CONTENT_TYPE_MUSIC) + if (isRinging) + { + audioAttributes.setUsage(AudioAttributesCompat.USAGE_ALARM) + audioFocusRequestBuilder.setAudioAttributes(audioAttributes.build()) + audioFocusRequest = audioFocusRequestBuilder.build() + player.audioAttributes = com.google.android.exoplayer2.audio.AudioAttributes + .Builder() + .setContentType(C.CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_ALARM) + .build() + } else { + audioAttributes.setUsage(AudioAttributesCompat.USAGE_MEDIA) + audioFocusRequestBuilder.setAudioAttributes(audioAttributes.build()) + audioFocusRequest = audioFocusRequestBuilder.build() + player.audioAttributes = com.google.android.exoplayer2.audio.AudioAttributes + .Builder() + .setContentType(C.CONTENT_TYPE_MUSIC) + .setUsage(C.USAGE_MEDIA) + .build() + } + // the old requestAudioFocus is deprecated on API26+. Using AudioManagerCompat library for consistent code across versions + val result = AudioManagerCompat.requestAudioFocus(audioManager, audioFocusRequest) + if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { + return + } + + if (mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_PLAYING && !isRinging) + return // nothing to do here + PlayerStore.instance.playbackState.value = PlaybackStateCompat.STATE_PLAYING + + // Reinitialize media player. Otherwise the playback doesn't resume when beginPlaying. Dunno why. + // Prepare the player with the source. + if (isFallback) + { + player.prepare(fallbackMediaSource) + player.repeatMode = ExoPlayer.REPEAT_MODE_ALL + } + else { + player.prepare(radioMediaSource) + player.repeatMode = ExoPlayer.REPEAT_MODE_OFF + } + + // START PLAYBACK, LET'S ROCK + player.playWhenReady = true + nowPlayingNotification.update(this, isUpdatingNotificationButton = true, isRinging = isRinging) + + playbackStateBuilder.setState( + PlaybackStateCompat.STATE_PLAYING, + 0, + 1.0f, + SystemClock.elapsedRealtime() + ) + mediaSession.setPlaybackState(playbackStateBuilder.build()) + Log.d(tag, radioTag + "begin playing") + } + + private fun pausePlaying() + { + stopPlaying() + } + + // stop playing but keep the notification. + fun stopPlaying() + { + isAlarmStopped = true + if (mediaSession.controller.playbackState.state == PlaybackStateCompat.STATE_STOPPED) + return // nothing to do here + PlayerStore.instance.playbackState.value = PlaybackStateCompat.STATE_STOPPED + + // STOP THE PLAYBACK + player.stop() + + nowPlayingNotification.update(this, true) + playbackStateBuilder.setState( + PlaybackStateCompat.STATE_STOPPED, + 0, + 1.0f, + SystemClock.elapsedRealtime() + ) + Log.d(tag, radioTag + "stopped") + + mediaSession.setPlaybackState(playbackStateBuilder.build()) + } + + fun setVolume(vol: Int?) { + var v = vol + when(v) + { + null -> { player.volume = 0f ; return } // null means "mute" + -1 -> v = PlayerStore.instance.volume.value // -1 means "restore previous volume" + } + + // re-shaped volume setter with a logarithmic (ln) function. + // I think it sounds more natural this way. Adjust coefficient to change the function shape. + // visualize it on any graphic calculator if you're unsure. + val c : Float = 2.toFloat() + val x : Float = v!!.toFloat()/100 + player.volume = -(1/c)* ln(1-(1- exp(-c))*x) + } + + private val mediaSessionCallback = object : MediaSessionCompat.Callback() { + override fun onPlay() { + beginPlaying() + } + + override fun onPause() { + pausePlaying() + } + + override fun onStop() { + stopPlaying() + } + + override fun onMediaButtonEvent(mediaButtonEvent: Intent?): Boolean { + // explicit handling of Media Buttons (for example bluetooth commands) + // The hardware key on a corded headphones are handled in the MainActivity (for (Intent.EXTRA_KEY_EVENT) + if (keyEvent == null || ((keyEvent.action) != KeyEvent.ACTION_DOWN)) { + return false + } + + when (keyEvent.keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, KeyEvent.KEYCODE_HEADSETHOOK -> { + //// Is this some kind of debouncing ? I'm not sure. + //if (keyEvent.repeatCount > 0) { + // return false + //} else { + if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_PLAYING) + pausePlaying() + else + beginPlaying() + //} + return true + } + KeyEvent.KEYCODE_MEDIA_STOP -> stopPlaying() + KeyEvent.KEYCODE_MEDIA_PAUSE -> pausePlaying() + KeyEvent.KEYCODE_MEDIA_PLAY -> beginPlaying() + else -> return false // these actions are the only ones we acknowledge. + } + + } + return false + } + } + + private val exoPlayerEventListener = object : Player.EventListener { + override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { + super.onPlayerStateChanged(playWhenReady, playbackState) + numberOfSongs = 0 + var state = "" + when(playbackState) + { + Player.STATE_BUFFERING -> state = "Player.STATE_BUFFERING" + Player.STATE_IDLE -> { + state = "Player.STATE_IDLE" + // inform the PlayerStore that the playback has stopped. This enables the ticker, triggers API fetch, and updates UI in no-network state. + if (PlayerStore.instance.playbackState.value != PlaybackStateCompat.STATE_STOPPED) + { + PlayerStore.instance.playbackState.postValue(PlaybackStateCompat.STATE_STOPPED) + PlayerStore.instance.isPlaying.postValue(false) + } + } + Player.STATE_ENDED -> state = "Player.STATE_ENDED" + Player.STATE_READY -> state = "Player.STATE_READY" + } + Log.d(tag, radioTag + "Player changed state: ${state}. numberOfSongs reset.") + } + } + + + +} diff --git a/app/src/main/java/io/r_a_d/radio2/Tickers.kt b/app/src/main/java/io/r_a_d/radio2/Tickers.kt new file mode 100644 index 0000000..8b3c02e --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/Tickers.kt @@ -0,0 +1,21 @@ +package io.r_a_d.radio2 + +import android.support.v4.media.session.PlaybackStateCompat +import io.r_a_d.radio2.alarm.RadioSleeper +import io.r_a_d.radio2.playerstore.PlayerStore +import java.util.* + +class Tick : TimerTask() { + override fun run() { + PlayerStore.instance.currentTime.postValue(PlayerStore.instance.currentTime.value!! + 500) + } +} + +class ApiFetchTick : TimerTask() { + override fun run() { + if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_STOPPED) + { + PlayerStore.instance.fetchApi() + } + } +} diff --git a/app/src/main/java/io/r_a_d/radio2/Values.kt b/app/src/main/java/io/r_a_d/radio2/Values.kt new file mode 100644 index 0000000..b350253 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/Values.kt @@ -0,0 +1,14 @@ +package io.r_a_d.radio2 + +import android.content.SharedPreferences +import android.content.res.ColorStateList + +const val tag = "io.r_a_d.radio2" +const val noConnectionValue = "No connection" +var colorBlue: Int = 0 +var colorWhited: Int = 0 +var colorGreenList: ColorStateList? = ColorStateList.valueOf(0) +var colorRedList: ColorStateList? = ColorStateList.valueOf(0) +var colorGreenListCompat : ColorStateList? = ColorStateList.valueOf(0) + +lateinit var preferenceStore : SharedPreferences \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/alarm/RadioAlarm.kt b/app/src/main/java/io/r_a_d/radio2/alarm/RadioAlarm.kt new file mode 100644 index 0000000..52de66b --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/alarm/RadioAlarm.kt @@ -0,0 +1,117 @@ +package io.r_a_d.radio2.alarm + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.app.AlarmManagerCompat +import io.r_a_d.radio2.BootBroadcastReceiver +import androidx.preference.PreferenceManager +import io.r_a_d.radio2.* +import java.util.* + +class RadioAlarm { + + companion object { + val instance by lazy { + RadioAlarm() + } + } + lateinit var alarmIntent: PendingIntent + + + fun cancelAlarm(c: Context) + { + if (::alarmIntent.isInitialized) + { + val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(alarmIntent) + } + } + + fun setNextAlarm(c: Context, isForce: Boolean = false, forceTime: Int? = null, forceDays: Set? = null) + { + // don't do anything if the preference is set to FALSE, of course. + if (!PreferenceManager.getDefaultSharedPreferences(c).getBoolean("isWakingUp", false) && !isForce) + return + + val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmIntent = Intent(c, BootBroadcastReceiver::class.java).let { intent -> + intent.putExtra("action", "io.r_a_d.radio2.${Actions.PLAY_OR_FALLBACK.name}") + PendingIntent.getBroadcast(c, 0, intent, 0) + } + val showIntent = Intent(c, ParametersActivity::class.java).let { intent -> + intent.putExtra("action", ActionOpenParam.ALARM.name) + PendingIntent.getActivity(c, 0, intent, 0) + } + val time = findNextAlarmTime(c, forceTime, forceDays) + if (time > 0) + AlarmManagerCompat.setAlarmClock(alarmManager, time, showIntent, alarmIntent) + } + + fun findNextAlarmTime(c: Context, forceTime: Int? = null, forceDays: Set? = null) : Long + { + val calendar = Calendar.getInstance() + + val days = forceDays ?: PreferenceManager.getDefaultSharedPreferences(c).getStringSet("alarmDays", setOf()) + val time = forceTime ?: PreferenceManager.getDefaultSharedPreferences(c).getInt("alarmTimeFromMidnight", 7*60) // default value is set to 07:00 AM + + val hourOfDay = time / 60 //time is in minutes + val minute = time % 60 + + val fullWeekOrdered = arrayListOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday") + val selectedDays = arrayListOf() + for (item in fullWeekOrdered) + { + if (days!!.contains(item)) + selectedDays.add(fullWeekOrdered.indexOf(item)) + } + + if (selectedDays.isEmpty()) // in case the user uncheck all boxes... do nothing. + return 0 + + + val currentDay = calendar.get(Calendar.DAY_OF_WEEK) - 1 // 0 (Sunday) to 6 (Saturday) + val datePassed = if (calendar.get(Calendar.HOUR_OF_DAY)*60 + calendar.get(Calendar.MINUTE) >= time ) 1 else 0 + var nextSelectedDay = (currentDay + datePassed)%7 + var i = 0 + datePassed + while (!selectedDays.contains(nextSelectedDay)) + { + nextSelectedDay = (nextSelectedDay + 1)%7 + i++ + } + // We found out the next selected day in the list. + // we must move 'i' days forward + calendar.isLenient = true + calendar.set(calendar.get(Calendar.YEAR), calendar.get(Calendar.MONTH), calendar.get(Calendar.DAY_OF_MONTH) + i, hourOfDay, minute) + + Log.d(tag, calendar.toString()) + + + return calendar.timeInMillis + } + + fun snooze(c: Context) + { + val snoozeString = preferenceStore.getString("snoozeDuration", "10") ?: "10" + val snoozeMinutes = if (snoozeString == c.getString(R.string.disable)) 0 else Integer.parseInt(snoozeString) + if (snoozeMinutes > 0) + { + val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmIntent = Intent(c, BootBroadcastReceiver::class.java).let { intent -> + intent.putExtra("action", "io.r_a_d.radio2.${Actions.PLAY_OR_FALLBACK.name}") + PendingIntent.getBroadcast(c, 0, intent, 0) + } + val showIntent = Intent(c, ParametersActivity::class.java).let { intent -> + intent.putExtra("action", "alarm") + PendingIntent.getActivity(c, 0, intent, 0) + } + + AlarmManagerCompat.setAlarmClock(alarmManager, Calendar.getInstance().timeInMillis + (snoozeMinutes * 60 * 1000), showIntent, alarmIntent) + + // now that the next alarm has been scheduled, kill the app + c.startService(Intent(c, RadioService::class.java).putExtra("action", Actions.KILL.name)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/alarm/RadioSleeper.kt b/app/src/main/java/io/r_a_d/radio2/alarm/RadioSleeper.kt new file mode 100644 index 0000000..3f39b98 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/alarm/RadioSleeper.kt @@ -0,0 +1,77 @@ +package io.r_a_d.radio2.alarm + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.util.Log +import androidx.core.app.AlarmManagerCompat +import androidx.lifecycle.MutableLiveData +import androidx.preference.PreferenceManager +import io.r_a_d.radio2.* + +class RadioSleeper { + + companion object { + val instance by lazy { + RadioSleeper() + } + } + + val sleepAtMillis: MutableLiveData = MutableLiveData() + + init + { + // the companion object is lazy, and is invoked by a Ticker, so a background thread. + // we MUST use postValue to set it correctly. + sleepAtMillis.postValue(null) + } + + private lateinit var sleepIntent: PendingIntent + private lateinit var fadeOutIntent: PendingIntent + + fun setSleep(c: Context, isForce: Boolean = false, forceDuration: Long? = null) + { + // don't do anything if the preference is set to FALSE, of course. + if (!PreferenceManager.getDefaultSharedPreferences(c).getBoolean("isSleeping", false) && !isForce) + return + + val minutes: Long = forceDuration ?: Integer.parseInt(PreferenceManager.getDefaultSharedPreferences(c).getString("sleepDuration", "1") ?: "1").toLong() + + val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager + sleepIntent = Intent(c, RadioService::class.java).let { intent -> + intent.putExtra("action", Actions.KILL.name) + PendingIntent.getService(c, 99, intent, 0) + } + + val currentMillis = System.currentTimeMillis() + if (minutes > 0) + { + AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, currentMillis + (minutes * 60 * 1000), sleepIntent) + fadeOutIntent = Intent(c, RadioService::class.java).let { intent -> + intent.putExtra("action", Actions.FADE_OUT.name) + PendingIntent.getService(c, 98, intent, 0) + } + AlarmManagerCompat.setExactAndAllowWhileIdle(alarmManager, AlarmManager.RTC_WAKEUP, currentMillis + (minutes * 60 * 1000) - (1 * 60 * 1000), fadeOutIntent) + sleepAtMillis.value = System.currentTimeMillis() + (minutes * 60 * 1000) - 1 // this -1 allows to round the division for display at the right integer + Log.d(tag, "set sleep to $minutes minutes") + } + } + + + fun cancelSleep(c: Context) + { + if (::sleepIntent.isInitialized) + { + val alarmManager = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager + alarmManager.cancel(sleepIntent) + alarmManager.cancel(fadeOutIntent) + + val cancelFadeOutIntent = Intent(c, RadioService::class.java).putExtra("action", Actions.CANCEL_FADE_OUT.name) + c.startService(cancelFadeOutIntent) + + Log.d(tag, "cancelled sleep") + } + sleepAtMillis.value = null + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/playerstore/PlayerStore.kt b/app/src/main/java/io/r_a_d/radio2/playerstore/PlayerStore.kt new file mode 100644 index 0000000..cbf24bf --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/playerstore/PlayerStore.kt @@ -0,0 +1,304 @@ +package io.r_a_d.radio2.playerstore + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import io.r_a_d.radio2.* +import org.json.JSONObject +import java.io.IOException +import java.io.InputStream +import java.net.URL + + +class PlayerStore { + + val isPlaying: MutableLiveData = MutableLiveData() + val isServiceStarted: MutableLiveData = MutableLiveData() + val volume: MutableLiveData = MutableLiveData() + val playbackState: MutableLiveData = MutableLiveData() + val currentTime: MutableLiveData = MutableLiveData() + val streamerPicture: MutableLiveData = MutableLiveData() + val streamerName: MutableLiveData = MutableLiveData() + val currentSong : Song = Song() + val currentSongBackup: Song = Song() + val lp : ArrayList = ArrayList() + val queue : ArrayList = ArrayList() + val isQueueUpdated: MutableLiveData = MutableLiveData() + val isLpUpdated: MutableLiveData = MutableLiveData() + val isMuted : MutableLiveData = MutableLiveData() + val listenersCount: MutableLiveData = MutableLiveData() + private val urlToScrape = "https://r-a-d.io/api" + var latencyCompensator : Long = 0 + var isInitialized: Boolean = false + var isStreamDown: Boolean = false + + init { + playbackState.value = PlaybackStateCompat.STATE_STOPPED + isPlaying.value = false + isServiceStarted.value = false + streamerName.value = "" + volume.value = preferenceStore.getInt("volume", 100) + currentTime.value = System.currentTimeMillis() + isQueueUpdated.value = false + isLpUpdated.value = false + isMuted.value = false + currentSong.title.value = noConnectionValue + listenersCount.value = 0 + } + + // ################################################## + // ################# API FUNCTIONS ################## + // ################################################## + + private fun updateApi(resMain: JSONObject, isCompensatingLatency : Boolean = false) { + // If we're not in PLAYING state, update title / artist metadata. If we're playing, the ICY will take care of that. + if (playbackState.value != PlaybackStateCompat.STATE_PLAYING || currentSong.title.value.isNullOrEmpty() + || currentSong.title.value == noConnectionValue) + currentSong.setTitleArtist(resMain.getString("np")) + + // only update the value if the song has changed. This avoids to trigger observers when they shouldn't be triggered + if (currentSong.startTime.value != resMain.getLong("start_time")*1000) + currentSong.startTime.value = resMain.getLong("start_time")*1000 + + // I noticed that the server has a big (3 to 9 seconds !!) offset for current time. + // we can measure it when the player is playing, to compensate it and have our progress bar perfectly timed + // latencyCompensator is set to null when beginPlaying() (we can't measure it at the moment we start playing, since we're in the middle of a song), + // at this moment, we set it to 0. Then, next time the updateApi is called when we're playing, we measure the latency and we set out latencyComparator. + if(isCompensatingLatency) + { + latencyCompensator = resMain.getLong("current")*1000 - (currentSong.startTime.value ?: resMain.getLong("current")*1000) + Log.d(tag, playerStoreTag + "latency compensator set to ${(latencyCompensator).toFloat()/1000} s") + } + currentSong.stopTime.value = resMain.getLong("end_time")*1000 + currentTime.value = (resMain.getLong("current"))*1000 - (latencyCompensator) + + val newStreamer = resMain.getJSONObject("dj").getString("djname") + if (newStreamer != streamerName.value) + { + val streamerPictureUrl = + "${urlToScrape}/dj-image/${resMain.getJSONObject("dj").getString("djimage")}" + fetchPicture(streamerPictureUrl) + streamerName.value = newStreamer + } + val listeners = resMain.getInt("listeners") + listenersCount.value = listeners + Log.d(tag, playerStoreTag + "store updated") + } + + private val scrape : (Any?) -> String = + { + URL(urlToScrape).readText() + } + + /* initApi is called : + - at startup + - when a streamer changes. + the idea is to fetch the queue when a streamer changes (potentially Hanyuu), and at startup. + The Last Played is only fetched if it's empty (so, only at startup), not when a streamer changes. + */ + fun initApi() + { + val post : (parameter: Any?) -> Unit = { + val result = JSONObject(it as String) + if (result.has("main")) + { + val resMain = result.getJSONObject("main") + updateApi(resMain) + currentSongBackup.copy(currentSong) + queue.clear() + if (resMain.has("queue") && resMain.getBoolean("isafkstream")) + { + val queueJSON = + resMain.getJSONArray("queue") + for (i in 0 until queueJSON.length()) + { + val t = extractSong(queueJSON[i] as JSONObject) + if (t != currentSong) // if the API is too slow and didn't remove the first song from queue... + queue.add(queue.size, t) + } + } + isQueueUpdated.value = true + Log.d(tag, playerStoreTag + queue.toString()) + + if (resMain.has("lp")) + { + val queueJSON = + resMain.getJSONArray("lp") + // if my stack is empty, I fill it entirely (startup) + if (lp.isEmpty()) + { + for (i in 0 until queueJSON.length()) + lp.add(lp.size, extractSong(queueJSON[i] as JSONObject)) + } + } + Log.d(tag, playerStoreTag + lp.toString()) + isLpUpdated.value = true + } + isInitialized = true + } + Async(scrape, post) + } + + fun fetchApi(isCompensatingLatency: Boolean = false) { + val post: (parameter: Any?) -> Unit = { + val result = JSONObject(it as String) + if (!result.isNull("main")) + { + val res = result.getJSONObject("main") + updateApi(res, isCompensatingLatency) + } + } + Async(scrape, post) + } + + // ################################################## + // ############## QUEUE / LP FUNCTIONS ############## + // ################################################## + + fun updateLp() { + // note : lp must never be empty. There should always be some songs "last played". + // if not, then the function has been called before initialization. No need to do anything. + if (lp.isNotEmpty()){ + val n = Song() + n.copy(currentSongBackup) + lp.add(0, n) + currentSongBackup.copy(currentSong) + isLpUpdated.value = true + Log.d(tag, playerStoreTag + lp.toString()) + } + } + + fun updateQueue() { + if (queue.isNotEmpty()) { + queue.remove(queue.first()) + Log.d(tag, playerStoreTag + queue.toString()) + fetchLastRequest() + isQueueUpdated.value = true + } else if (isInitialized) { + fetchLastRequest() + } else { + Log.d(tag, playerStoreTag + "queue is empty!") + } + } + + private fun fetchLastRequest() + { + val sleepScrape: (Any?) -> String = { + /* we can maximize our chances to retrieve the last queued song by specifically waiting for the number of seconds we measure between ICY metadata and API change. + we add 2 seconds just to get a higher probability that the API has correctly updated. (the latency compensator can have a jitter of 1 second usually) + If, against all odds, the API hasn't updated yet, we will retry in the same amount of seconds. So we'll have the data anyway. + This way to fetch at the most probable time is a good compromise between fetch speed and fetch frequency + We don't fetch too often, and we start to fetch at the most *probable* time. + If there's no latencyCompensator measured yet, we only wait for 3 seconds. + If the song is the same, it will be called again. 3 seconds is a good compromise between speed and frequency: + it might be called twice, rarely 3 times, and it's only the 2 first songs ; after these, the latencyCompensator is set to fetch at the most probable time. + */ + val sleepTime: Long = if (latencyCompensator > 0) latencyCompensator + 2000 else 3000 + Thread.sleep(sleepTime) // we wait a bit (10s) for the API to get updated on R/a/dio side! + URL(urlToScrape).readText() + } + + lateinit var post: (parameter: Any?) -> Unit + + fun postFun(result: JSONObject) + { + if (result.has("main")) { + val resMain = result.getJSONObject("main") + if ((resMain.has("isafkstream") && !resMain.getBoolean("isafkstream")) && + queue.isNotEmpty()) + { + queue.clear() //we're not requesting anything anymore. + isQueueUpdated.value = true + } else if (resMain.has("isafkstream") && resMain.getBoolean("isafkstream") && + queue.isEmpty()) + { + initApi() + } else if (resMain.has("queue") && queue.isNotEmpty()) { + val queueJSON = + resMain.getJSONArray("queue") + val t = extractSong(queueJSON[4] as JSONObject) + if (t == queue.last()) + { + Log.d(tag, playerStoreTag + "Song already in there: $t") + Async(sleepScrape, post) + } else { + queue.add(queue.size, t) + Log.d(tag, playerStoreTag + "added last queue song: $t") + isQueueUpdated.value = true + } + } + } + } + + post = { + val result = JSONObject(it as String) + /* The goal is to pass the result to a function that will process it (postFun). + The magic trick is, under circumstances, the last queue song might not have been updated yet when we fetch it. + So if this is detected ==> if (t == queue.last() ) + Then the function re-schedule an Async(sleepScrape, post). + To do that, the "post" must be defined BEFORE the function, but the function must be defined BEFORE the "post" value. + So I declare "post" as lateinit var, define the function, then define the "post" that calls the function. IT SHOULD WORK. + */ + postFun(result) + } + + Async(sleepScrape, post) + } + + private fun extractSong(songJSON: JSONObject) : Song { + val song = Song() + song.setTitleArtist(songJSON.getString("meta")) + song.startTime.value = songJSON.getLong("timestamp") + song.stopTime.value = song.startTime.value + song.type.value = songJSON.getInt("type") + return song + } + + // ################################################## + // ############## PICTURE FUNCTIONS ################# + // ################################################## + + private fun fetchPicture(fileUrl: String) + { + val scrape: (Any?) -> Bitmap? = { + var k: InputStream? = null + var pic: Bitmap? = null + try { + k = URL(fileUrl).content as InputStream + val options = BitmapFactory.Options() + options.inSampleSize = 1 + // this makes 1/2 of origin image size from width and height. + // it alleviates the memory for API16-API19 especially + pic = BitmapFactory.decodeStream(k, null, options) + k.close() + } catch (e: IOException) { + e.printStackTrace() + } finally { + k?.close() + } + pic + } + val post : (parameter: Any?) -> Unit = { + streamerPicture.postValue(it as Bitmap?) + } + Async(scrape, post) + } + + fun initPicture(c: Context) { + streamerPicture.value = BitmapFactory.decodeResource(c.resources, + R.drawable.actionbar_logo + ) + } + + private val playerStoreTag = "====PlayerStore====" + companion object { + val instance by lazy { + PlayerStore() + } + } +} + diff --git a/app/src/main/java/io/r_a_d/radio2/playerstore/Song.kt b/app/src/main/java/io/r_a_d/radio2/playerstore/Song.kt new file mode 100644 index 0000000..fa3ef89 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/playerstore/Song.kt @@ -0,0 +1,58 @@ +package io.r_a_d.radio2.playerstore + +import androidx.lifecycle.MutableLiveData + +class Song(artistTitle: String = "", _id : Int = 0, _isRequestable : Boolean = false) { + + // TODO : remove MutableLiveData, use a MutableLiveData Boolean on PlayerStore instead + val title: MutableLiveData = MutableLiveData() + val artist: MutableLiveData = MutableLiveData() + val type: MutableLiveData = MutableLiveData() + val startTime: MutableLiveData = MutableLiveData() + val stopTime: MutableLiveData = MutableLiveData() + var id: Int? = _id + var isRequestable : Boolean = _isRequestable + + init { + setTitleArtist(artistTitle) + type.value = 0 + startTime.value = System.currentTimeMillis() + stopTime.value = System.currentTimeMillis() + 1000 + } + + override fun toString() : String { + return "id=$id | ${artist.value} - ${title.value} | type=${type.value} | times ${startTime.value} - ${stopTime.value}\n" + } + + fun setTitleArtist(data: String) + { + val hyphenPos = data.indexOf(" - ") + try { + if (hyphenPos < 0) + throw ArrayIndexOutOfBoundsException() + if (artist.value != data.substring(0, hyphenPos)) + artist.value = data.substring(0, hyphenPos) + if (title.value != data.substring(hyphenPos + 3)) + title.value = data.substring(hyphenPos + 3) + } catch (e: Exception) { + if (artist.value != "") + artist.value = "" + if (title.value != data) + title.value = data + } + } + + override fun equals(other: Any?) : Boolean + { + val song: Song = other as Song + return this.title.value == song.title.value && this.artist.value == song.artist.value + } + + fun copy(song: Song) { + this.title.value = song.title.value + this.artist.value = song.artist.value + this.startTime.value = song.startTime.value + this.stopTime.value = song.stopTime.value + this.type.value = song.type.value + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/preferences/AlarmFragment.kt b/app/src/main/java/io/r_a_d/radio2/preferences/AlarmFragment.kt new file mode 100644 index 0000000..2e43478 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/preferences/AlarmFragment.kt @@ -0,0 +1,132 @@ +package io.r_a_d.radio2.preferences + +import android.app.TimePickerDialog +import android.os.Bundle +import android.util.Log +import androidx.core.content.edit +import androidx.preference.* +import io.r_a_d.radio2.R +import io.r_a_d.radio2.alarm.RadioAlarm +import java.util.* + +class AlarmFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.alarm_preferences, rootKey) + + val timeSet = findPreference("timeSet") + val isWakingUp = findPreference("isWakingUp") + val alarmDays = findPreference("alarmDays") + val snoozeDuration = findPreference("snoozeDuration") + + + fun updateIsWakingUpSummary(preference: SwitchPreferenceCompat?, newValue: Boolean? = true, forceTime: Int? = null, forceDays: Set? = null) + { + val dateLong = RadioAlarm.instance.findNextAlarmTime(context!!, forceTime, forceDays) + val calendar = Calendar.getInstance() + calendar.timeInMillis = dateLong + if (newValue == true && calendar.timeInMillis > 0) + { + val fullWeekOrdered = arrayListOf("Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday") + preference?.summary = "Next alarm on ${fullWeekOrdered[calendar.get(Calendar.DAY_OF_WEEK) - 1]} " + + "at ${if (calendar.get(Calendar.HOUR_OF_DAY) < 10) "0" else ""}${calendar.get(Calendar.HOUR_OF_DAY)}" + + ":${if (calendar.get(Calendar.MINUTE) < 10) "0" else ""}${calendar.get(Calendar.MINUTE)}" + } else if (newValue == true) + preference?.summary = "Select at least one day" + else + { + preference?.summary = "No alarm set" + } + } + + + val hourOfDayDefault = 7 + val minuteDefault = 0 + if (!PreferenceManager.getDefaultSharedPreferences(context!!).contains("alarmTimeFromMidnight")) + { + PreferenceManager.getDefaultSharedPreferences(context!!).edit { + putInt("alarmTimeFromMidnight", (60*hourOfDayDefault+minuteDefault)) + commit() + } + } + val time = PreferenceManager.getDefaultSharedPreferences(context!!).getInt("alarmTimeFromMidnight", (60*hourOfDayDefault+minuteDefault)) + val hourOfDay = time / 60 + val minute = time % 60 + timeSet?.summary = "${if (hourOfDay < 10) "0" else ""}$hourOfDay:${if (minute < 10) "0" else ""}$minute" + + + timeSet?.setOnPreferenceClickListener { + val timePickerOnTimeSetListener = TimePickerDialog.OnTimeSetListener { _, hourOfDay, minute -> + PreferenceManager.getDefaultSharedPreferences(context!!).edit { + putInt("alarmTimeFromMidnight", (60*hourOfDay+minute)) + commit() + } + timeSet.summary = "${if (hourOfDay < 10) "0" else ""}$hourOfDay:${if (minute < 10) "0" else ""}$minute" + RadioAlarm.instance.cancelAlarm(context!!) + RadioAlarm.instance.setNextAlarm(context!!, isForce = true, forceTime = hourOfDay*60+minute) + updateIsWakingUpSummary(isWakingUp, isWakingUp?.isChecked, forceTime = hourOfDay*60+minute) + } + val timeNew = PreferenceManager.getDefaultSharedPreferences(context!!).getInt("alarmTimeFromMidnight", 7*60) + val hourOfDayNew = timeNew / 60 + val minuteNew = timeNew % 60 + val timePicker = TimePickerDialog(context, timePickerOnTimeSetListener, hourOfDayNew, minuteNew, true) + + timePicker.show() + true + } + + fun updateDays(preference : MultiSelectListPreference?, newValue : Set?) + { + Log.d(tag, newValue.toString()) + val listOfDays : String + val fullWeek = context!!.resources.getStringArray(R.array.weekdays).toSet() + val workingWeek = context!!.resources.getStringArray(R.array.weekdays).toSet().minusElement("Saturday").minusElement("Sunday") + listOfDays = when (newValue) { + fullWeek -> context!!.getString(R.string.every_day) + workingWeek -> context!!.getString(R.string.working_days) + else -> { + // build ORDERED LIST of days... I don't know why the original one is in shambles!! + val fullWeekOrdered = arrayListOf("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") + val selectedDays = arrayListOf() + for (item in fullWeekOrdered) { + if (newValue!!.contains(item)) + selectedDays.add(item) + } + "$selectedDays".drop(1).dropLast(1) // dropping '[' and ']' + } + } + preference?.summary = listOfDays + } + + updateDays(alarmDays, PreferenceManager.getDefaultSharedPreferences(context).getStringSet("alarmDays", setOf())) + alarmDays?.setOnPreferenceChangeListener { preference, newValue -> + @Suppress("UNCHECKED_CAST") + updateDays(preference as MultiSelectListPreference, newValue as Set) + RadioAlarm.instance.cancelAlarm(context!!) + RadioAlarm.instance.setNextAlarm(context!!, isForce = true, forceDays = newValue) + updateIsWakingUpSummary(isWakingUp, isWakingUp?.isChecked, forceDays = newValue) + true + } + + updateIsWakingUpSummary(isWakingUp, isWakingUp?.isChecked) + + isWakingUp?.setOnPreferenceChangeListener { _, newValue -> + if (newValue as Boolean) + RadioAlarm.instance.setNextAlarm(context!!, isForce = true) + else + RadioAlarm.instance.cancelAlarm(context!!) + timeSet?.isEnabled = newValue + alarmDays?.isEnabled = newValue + snoozeDuration?.isEnabled = newValue + updateIsWakingUpSummary(isWakingUp, newValue) + true + } + + + alarmDays?.isEnabled = isWakingUp?.isChecked ?: false + timeSet?.isEnabled = isWakingUp?.isChecked ?: false + snoozeDuration?.isEnabled = isWakingUp?.isChecked ?: false + + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/preferences/CustomizeFragment.kt b/app/src/main/java/io/r_a_d/radio2/preferences/CustomizeFragment.kt new file mode 100644 index 0000000..ce24075 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/preferences/CustomizeFragment.kt @@ -0,0 +1,80 @@ +package io.r_a_d.radio2.preferences + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.util.Log +import androidx.appcompat.app.AlertDialog +import androidx.preference.* +import io.r_a_d.radio2.R +import io.r_a_d.radio2.preferenceStore +import io.r_a_d.radio2.ui.songs.request.Requestor + +class CustomizeFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.customize_preferences, rootKey) + + val userNamePref = preferenceScreen.findPreference("userName") + userNamePref?.summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() + userNamePref?.setOnPreferenceChangeListener { _, newValue -> + val name = newValue as String + Requestor.instance.initFavorites(name) // need to be as parameter cause the callback is called BEFORE PARAMETER SET + true + } + + val snackbarPersistent = preferenceScreen.findPreference("snackbarPersistent") + snackbarPersistent!!.summary = if (preferenceStore.getBoolean("snackbarPersistent", true)) + getString(R.string.snackbarPersistent) + else + getString(R.string.snackbarNonPersistent) + snackbarPersistent.setOnPreferenceChangeListener { preference, newValue -> + if (newValue as Boolean) + preference.setSummary(R.string.snackbarPersistent) + else + preference.setSummary(R.string.snackbarNonPersistent) + true + } + + val splitLayout = preferenceScreen.findPreference("splitLayout") + splitLayout!!.summary = if (preferenceStore.getBoolean("splitLayout", true)) + getString(R.string.split_layout) + else + getString(R.string.not_split_layout) + splitLayout.setOnPreferenceChangeListener { preference, newValue -> + if (newValue as Boolean) + preference.setSummary(R.string.split_layout) + else + preference.setSummary(R.string.not_split_layout) + true + } + + val helpFavorites = preferenceScreen.findPreference("helpFavorites") + helpFavorites?.setOnPreferenceClickListener { _ -> + val url = getString(R.string.github_url_wiki_irc_for_favorites) + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(url) + startActivity(i) + true + } + + val fetchPeriod = preferenceScreen.findPreference("fetchPeriod") + fetchPeriod?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + fetchPeriod?.setOnPreferenceChangeListener { _, newValue -> + val builder1 = AlertDialog.Builder(context!!) + if (Integer.parseInt(newValue as String) == 0) + builder1.setMessage(R.string.fetch_disabled_restart_the_app) + else + builder1.setMessage(R.string.restart_the_app) + builder1.setCancelable(true) + + builder1.setPositiveButton("Close" ) { dialog, _ -> + dialog.cancel() + } + + val alert11 = builder1.create() + alert11.show() + true + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/preferences/MainPreferenceFragment.kt b/app/src/main/java/io/r_a_d/radio2/preferences/MainPreferenceFragment.kt new file mode 100644 index 0000000..a685c2b --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/preferences/MainPreferenceFragment.kt @@ -0,0 +1,27 @@ +package io.r_a_d.radio2.preferences + +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import io.r_a_d.radio2.R +import android.annotation.SuppressLint +import androidx.preference.* + +class MainPreferenceFragment : PreferenceFragmentCompat() { + + @SuppressLint("ApplySharedPref") + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.preferences, rootKey) + preferenceScreen.isIconSpaceReserved = false + + val submitBug = preferenceScreen.findPreference("submitBug") + submitBug!!.setOnPreferenceClickListener { + val url = getString(R.string.github_url_new_issue) + val i = Intent(Intent.ACTION_VIEW) + i.data = Uri.parse(url) + startActivity(i) + true + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/preferences/SleepFragment.kt b/app/src/main/java/io/r_a_d/radio2/preferences/SleepFragment.kt new file mode 100644 index 0000000..b772bc8 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/preferences/SleepFragment.kt @@ -0,0 +1,44 @@ +package io.r_a_d.radio2.preferences + +import android.os.Bundle +import androidx.preference.* +import io.r_a_d.radio2.R +import android.text.InputType +import io.r_a_d.radio2.alarm.RadioSleeper + + +class SleepFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.sleep_preference, rootKey) + + val durationBeforeSleep = findPreference("sleepDuration") + val isSleeping = findPreference("isSleeping") + + isSleeping?.setOnPreferenceChangeListener { _, newValue -> + if (!(newValue as Boolean)) + RadioSleeper.instance.cancelSleep(context!!) + else + RadioSleeper.instance.setSleep(context!!, isForce = true) + + true + } + + + durationBeforeSleep?.setOnBindEditTextListener { + it.inputType = InputType.TYPE_CLASS_NUMBER + } + + durationBeforeSleep?.summaryProvider = EditTextPreference.SimpleSummaryProvider.getInstance() + durationBeforeSleep?.setOnPreferenceChangeListener {_, newValue -> + val time = Integer.parseInt(newValue as String) + if (time > 0) + { + RadioSleeper.instance.setSleep(context!!, isForce = true, forceDuration = time.toLong()) + isSleeping?.isChecked = true + true + } else { + false + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/preferences/StreamerNotifServiceFragment.kt b/app/src/main/java/io/r_a_d/radio2/preferences/StreamerNotifServiceFragment.kt new file mode 100644 index 0000000..db7326e --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/preferences/StreamerNotifServiceFragment.kt @@ -0,0 +1,63 @@ +package io.r_a_d.radio2.preferences + +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.preference.* +import io.r_a_d.radio2.R +import io.r_a_d.radio2.preferenceStore +import io.r_a_d.radio2.streamerNotificationService.WorkerStore +import io.r_a_d.radio2.streamerNotificationService.startStreamerMonitor +import io.r_a_d.radio2.streamerNotificationService.stopStreamerMonitor + +class StreamerNotifServiceFragment : PreferenceFragmentCompat() { + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + setPreferencesFromResource(R.xml.streamer_notif_service_preferences, rootKey) + + + val streamerPeriod = preferenceScreen.findPreference("streamerMonitorPeriodPref") + + val streamerNotification = preferenceScreen.findPreference("newStreamerNotification") + streamerNotification?.setOnPreferenceChangeListener { _, newValue -> + if ((newValue as Boolean)) { + val builder1 = AlertDialog.Builder(context!!) + builder1.setMessage(R.string.warningStreamerNotif) + builder1.setCancelable(false) + builder1.setPositiveButton( + "Yes" + ) { dialog, _ -> + startStreamerMonitor(context!!, force = true) // force enabled because the preference value is not yet set when running this callback. + streamerPeriod?.isEnabled = true + dialog.cancel() + } + + builder1.setNegativeButton( + "No" + ) { dialog, _ -> + + stopStreamerMonitor(context!!) + (streamerNotification as SwitchPreferenceCompat).isChecked = false + dialog.cancel() + } + + val alert11 = builder1.create() + alert11.show() + } + else { + stopStreamerMonitor(context!!) + streamerPeriod?.isEnabled = false + WorkerStore.instance.isServiceStarted = false + } + true + } + + streamerPeriod?.summaryProvider = ListPreference.SimpleSummaryProvider.getInstance() + streamerPeriod?.isEnabled = preferenceStore.getBoolean("newStreamerNotification", true) + streamerPeriod?.setOnPreferenceChangeListener { _, newValue -> + WorkerStore.instance.tickerPeriod = (Integer.parseInt(newValue as String)).toLong() * 60 + // this should be sufficient, the next alarm schedule should take the new tickerPeriod. + true + } + + + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/ServiceNotification.kt b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/ServiceNotification.kt new file mode 100644 index 0000000..ba2ca2b --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/ServiceNotification.kt @@ -0,0 +1,41 @@ +package io.r_a_d.radio2.streamerNotificationService + +import android.content.Context +import io.r_a_d.radio2.BaseNotification +import java.util.* + +class ServiceNotification( + notificationChannelId: String, + notificationChannel : Int, + notificationId: Int, + notificationImportance: Int + +) : BaseNotification + ( + notificationChannelId, + notificationChannel, + notificationId, + notificationImportance +){ + + override fun create(c: Context) + { + super.create(c) + update() + } + + fun update() + { + val date = Date() // given date + val calendar = Calendar.getInstance() // creates a new calendar instance + calendar.time = date // assigns calendar to given date + val hours = calendar.get(Calendar.HOUR_OF_DAY) // gets hour in 24h format + //val hours_american = calendar.get(Calendar.HOUR) // gets hour in 12h format + val minutes = calendar.get(Calendar.MINUTE) + val seconds = calendar.get(Calendar.SECOND) + + builder.setContentTitle("Never miss a stream! Current: ${WorkerStore.instance.streamerName.value}") + builder.setContentText("Last update: ${hours}:${minutes}:${seconds}") + notification = builder.build() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorExtensions.kt b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorExtensions.kt new file mode 100644 index 0000000..651ef02 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorExtensions.kt @@ -0,0 +1,114 @@ +package io.r_a_d.radio2.streamerNotificationService + +import android.app.AlarmManager +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.SystemClock +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.preference.PreferenceManager +import io.r_a_d.radio2.* +import io.r_a_d.radio2.alarm.RadioAlarm +import io.r_a_d.radio2.playerstore.PlayerStore +import org.json.JSONObject +import java.net.URL + +fun startNextAlarmStreamer(c: Context){ + // the notification works with an alarm re-scheduled at fixed rate. + // if the service stopped, the alarm is not re-scheduled. + if (WorkerStore.instance.isServiceStarted) + { + val alarmIntent = Intent(c, StreamerMonitorService::class.java).let { intent -> + intent.putExtra("action", Actions.NOTIFY.name) + PendingIntent.getService(c, 0, intent, 0) + } + + val alarmMgr = c.getSystemService(Context.ALARM_SERVICE) as AlarmManager + when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> alarmMgr.setExactAndAllowWhileIdle( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + WorkerStore.instance.tickerPeriod * 1000, + alarmIntent + ) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT -> alarmMgr.setExact( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + WorkerStore.instance.tickerPeriod * 1000, + alarmIntent + ) + else -> alarmMgr.set( + AlarmManager.ELAPSED_REALTIME_WAKEUP, + SystemClock.elapsedRealtime() + WorkerStore.instance.tickerPeriod * 1000, + alarmIntent + ) + } + } else { + Log.d(tag, "alarm called while service is dead - skipped.") + } +} + +fun stopStreamerMonitor(context: Context) +{ + val intent = Intent(context, StreamerMonitorService::class.java) + intent.putExtra("action", Actions.KILL.name) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startService(intent) + } else { + context.startService(intent) + } + + Log.i(tag, "Service stopped") +} + +fun startStreamerMonitor(context: Context, force: Boolean = false) +{ + if (!force) + { + val isNotifyingForNewStreamer = PreferenceManager.getDefaultSharedPreferences(context).getBoolean("newStreamerNotification", false) + if (!isNotifyingForNewStreamer) + return + } + + val intent = Intent(context, StreamerMonitorService::class.java) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + + Log.i(tag, "Service started on boot") +} + +fun fetchStreamer(applicationContext: Context) { + val urlToScrape = "https://r-a-d.io/api" + val scrape : (Any?) -> String = + { + URL(urlToScrape).readText() + } + val post: (parameter: Any?) -> Unit = { + val result = JSONObject(it as String) + if (!result.isNull("main")) + { + val name = result.getJSONObject("main").getJSONObject("dj").getString("djname") + WorkerStore.instance.streamerName.value = name + } + } + + // notify + val t = ServiceNotification( + notificationChannelId = applicationContext.getString(R.string.streamerServiceChannelId), + notificationChannel = R.string.streamerServiceChannel, + notificationId = 2, + notificationImportance = NotificationCompat.PRIORITY_LOW + ) + t.create(applicationContext) + t.show() + + try{ + Async(scrape, post) + Log.d(tag, "enqueue next work in ${WorkerStore.instance.tickerPeriod} seconds") + } catch (e: Exception) { + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorService.kt b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorService.kt new file mode 100644 index 0000000..50b42fe --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerMonitorService.kt @@ -0,0 +1,123 @@ +package io.r_a_d.radio2.streamerNotificationService + + +import android.app.Service +import android.content.Intent +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.lifecycle.Observer +import androidx.preference.PreferenceManager +import io.r_a_d.radio2.Actions +import io.r_a_d.radio2.R +import io.r_a_d.radio2.tag +import java.util.* + +class StreamerMonitorService : Service() { + override fun onBind(intent: Intent): IBinder? { + return null // no binding allowed nor needed + } + private val streamerNameObserver: Observer = Observer { + val previousStreamer: String + if (PreferenceManager.getDefaultSharedPreferences(this).contains("streamerName")) + { + previousStreamer = PreferenceManager.getDefaultSharedPreferences(this).getString("streamerName", "") ?: "" + /* 3 conditions: + - the streamer changed from previously + - there is a previous non-empty streamer (at least second time running it) + - the current streamer is non-empty (this can happen at Activity start where init() is called) + */ + if (previousStreamer != it && previousStreamer != "" && it != "") + { + // notify + val newStreamer = StreamerNotification( + notificationChannelId = this.getString(R.string.streamerNotificationChannelId), + notificationChannel = R.string.streamerNotificationChannel, + notificationId = 3, + notificationImportance = NotificationCompat.PRIORITY_DEFAULT + ) + newStreamer.create(this) + newStreamer.show() + } + } + + with(PreferenceManager.getDefaultSharedPreferences(this).edit()){ + putString("streamerName", it) + commit() + } + } + + override fun onCreate() { + super.onCreate() + val streamerMonitorNotification = ServiceNotification( + notificationChannelId = this.getString(R.string.streamerServiceChannelId), + notificationChannel = R.string.streamerServiceChannel, + notificationId = 2, + notificationImportance = NotificationCompat.PRIORITY_LOW + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + { + streamerMonitorNotification.create(this) + streamerMonitorNotification.update() + streamerMonitorNotification.show() + startForeground(2, streamerMonitorNotification.notification) + } + + WorkerStore.instance.tickerPeriod = 60 * + (if (PreferenceManager.getDefaultSharedPreferences(this).contains("streamerMonitorPeriodPref")) + Integer.parseInt(PreferenceManager.getDefaultSharedPreferences(this).getString("streamerMonitorPeriodPref", "15")!!).toLong() + else + 15) + Log.d(tag, "tickerPeriod = ${WorkerStore.instance.tickerPeriod}") + + with(PreferenceManager.getDefaultSharedPreferences(this).edit()){ + remove("streamerName") + commit() // I commit on main thread to be sure it's been updated before continuing. + } + WorkerStore.instance.streamerName.observeForever(streamerNameObserver) + WorkerStore.instance.isServiceStarted = true + startNextAlarmStreamer(this) + Log.d(tag, "streamerMonitor created") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val isNotifyingForNewStreamer = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("newStreamerNotification", false) + + // it's probably redundant but it shouldn't hurt + if (!isNotifyingForNewStreamer || !WorkerStore.instance.isServiceStarted) + { + stopForeground(true) + stopSelf() + return START_NOT_STICKY + } + when (intent?.getStringExtra("action")) { + Actions.NOTIFY.name -> { + val date = Date() // given date + val calendar = Calendar.getInstance() // creates a new calendar instance + calendar.time = date // assigns calendar to given date + val hours = calendar.get(Calendar.HOUR_OF_DAY) // gets hour in 24h format + //val hours_american = calendar.get(Calendar.HOUR) // gets hour in 12h format + val minutes = calendar.get(Calendar.MINUTE) // gets month number, NOTE this is zero based! + + Log.d(tag, "Fetched streamer name at ${hours}:${if (minutes < 10) "0" else ""}${minutes}") + fetchStreamer(this) + startNextAlarmStreamer(this) // schedule next alarm + return START_STICKY + } + Actions.KILL.name -> { + stopForeground(true) + stopSelf() + return START_NOT_STICKY + } + } + return START_STICKY + //super.onStartCommand(intent, flags, startId) + } + + override fun onDestroy() { + WorkerStore.instance.streamerName.removeObserver(streamerNameObserver) + WorkerStore.instance.isServiceStarted = false + super.onDestroy() + } +} diff --git a/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerNotification.kt b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerNotification.kt new file mode 100644 index 0000000..5525482 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/StreamerNotification.kt @@ -0,0 +1,35 @@ +package io.r_a_d.radio2.streamerNotificationService + +import android.content.Context +import io.r_a_d.radio2.BaseNotification +import java.util.* + +class StreamerNotification( + notificationChannelId: String, + notificationChannel : Int, + notificationId: Int, + notificationImportance: Int + +) : BaseNotification + ( + notificationChannelId, + notificationChannel, + notificationId, + notificationImportance +){ + override fun create(c: Context) { + super.create(c) + val date = Date() // given date + val calendar = Calendar.getInstance() // creates a new calendar instance + calendar.time = date // assigns calendar to given date + val hours = calendar.get(Calendar.HOUR_OF_DAY) // gets hour in 24h format + //val hours_american = calendar.get(Calendar.HOUR) // gets hour in 12h format + val minutes = calendar.get(Calendar.MINUTE) // gets month number, NOTE this is zero based! + + builder.setContentTitle("${WorkerStore.instance.streamerName.value} started streaming!") + builder.setContentText("Started at ${hours}:${if (minutes < 10) "0" else ""}${minutes}") + builder.setAutoCancel(true) + super.show() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/WorkerStore.kt b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/WorkerStore.kt new file mode 100644 index 0000000..5c5dc43 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/streamerNotificationService/WorkerStore.kt @@ -0,0 +1,46 @@ +package io.r_a_d.radio2.streamerNotificationService + +import android.content.Context +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.preference.PreferenceManager +import io.r_a_d.radio2.R +import io.r_a_d.radio2.tag + +class WorkerStore { + companion object { + val instance = WorkerStore() + } + + val streamerName = MutableLiveData() + var isServiceStarted : Boolean = false + var tickerPeriod : Long = 45 // seconds + + init { + tickerPeriod = 45 + streamerName.value = "" + isServiceStarted = false + } + + fun init(c: Context) + { + tickerPeriod = 45 + streamerName.value = "" + val tickerPeriod = 60 * + (if (PreferenceManager.getDefaultSharedPreferences(c).contains("streamerMonitorPeriodPref")) + Integer.parseInt(PreferenceManager.getDefaultSharedPreferences(c).getString("streamerMonitorPeriodPref", "15")!!).toLong() + else + 15 + ) + instance.tickerPeriod = tickerPeriod + Log.d(tag, "tickerPeriod = $tickerPeriod") + + with(PreferenceManager.getDefaultSharedPreferences(c).edit()){ + remove("streamerName") + commit() // I commit on main thread to be sure it's been updated before continuing. + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/chat/ChatFragment.kt b/app/src/main/java/io/r_a_d/radio2/ui/chat/ChatFragment.kt new file mode 100644 index 0000000..0fa081f --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/chat/ChatFragment.kt @@ -0,0 +1,52 @@ +package io.r_a_d.radio2.ui.chat + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.webkit.WebView +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProviders +import io.r_a_d.radio2.R + + +class ChatFragment : Fragment() { + + private lateinit var chatViewModel: ChatViewModel + + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + activity?.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + + chatViewModel = + ViewModelProviders.of(this).get(ChatViewModel::class.java) + + if (!chatViewModel.isChatLoaded) + { + + try { + chatViewModel.root = inflater.inflate(R.layout.fragment_chat, container, false) + chatViewModel.webView = chatViewModel.root.findViewById(R.id.chat_webview) + chatViewModel.webViewChat = WebViewChat(chatViewModel.webView as WebView) + chatViewModel.webViewChat!!.start() + } catch (e: Exception) { + chatViewModel.root = inflater.inflate(R.layout.fragment_error_chat, container, false) + } + + chatViewModel.isChatLoaded = true + Log.d(tag, "webview created") + } else { + Log.d(tag, "webview already created!?") + } + + return chatViewModel.root + } + + +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/chat/ChatViewModel.kt b/app/src/main/java/io/r_a_d/radio2/ui/chat/ChatViewModel.kt new file mode 100644 index 0000000..ca60c0c --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/chat/ChatViewModel.kt @@ -0,0 +1,12 @@ +package io.r_a_d.radio2.ui.chat + +import android.view.View +import android.webkit.WebView +import androidx.lifecycle.ViewModel + +class ChatViewModel : ViewModel() { + lateinit var root: View + var webView: WebView? = null + var isChatLoaded = false + var webViewChat: WebViewChat? = null +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/chat/WebViewChat.kt b/app/src/main/java/io/r_a_d/radio2/ui/chat/WebViewChat.kt new file mode 100644 index 0000000..aafa4d7 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/chat/WebViewChat.kt @@ -0,0 +1,47 @@ +package io.r_a_d.radio2.ui.chat + +import android.annotation.SuppressLint +import android.content.Intent +import android.net.Uri +import android.webkit.WebChromeClient +import android.webkit.WebView + +class WebViewChat(private val webView: WebView) { + + @SuppressLint("SetJavaScriptEnabled") + fun start() { + + val webSetting = this.webView.settings + webSetting.javaScriptEnabled = true + webSetting.setSupportZoom(false) + + /* TODO: in the future, it could be nice to have a parameters screen where you can: + - Set the text zoom + - Set your username (to not type it every time, would it be possible?) + - Hide the chat? + - do more? */ + webSetting.textZoom = 90 + + webSetting.setSupportMultipleWindows(true) + // needs to open target="_blank" links as KiwiIRC links have this attribute. + // shamelessly ripped off https://stackoverflow.com/questions/18187714/android-open-target-blank-links-in-webview-with-external-browser + this.webView.webChromeClient = object : WebChromeClient() { + override fun onCreateWindow( + view: WebView, + dialog: Boolean, + userGesture: Boolean, + resultMsg: android.os.Message + ): Boolean { + val result = view.hitTestResult + val data = result.extra + val context = view.context + val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(data)) + context.startActivity(browserIntent) + return false + } + } + + webView.loadUrl("file:///android_asset/chat.html") + } + + } diff --git a/app/src/main/java/io/r_a_d/radio2/ui/news/News.kt b/app/src/main/java/io/r_a_d/radio2/ui/news/News.kt new file mode 100644 index 0000000..bb126b2 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/news/News.kt @@ -0,0 +1,16 @@ +package io.r_a_d.radio2.ui.news + +import java.util.* + +class News { + var title: String = "" + var text: String = "" + var header: String = "" + var author: String = "" + var date: Date = Date() + + override fun toString() : String + { + return "$author | $title | $date | $header" + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/news/NewsAdapter.kt b/app/src/main/java/io/r_a_d/radio2/ui/news/NewsAdapter.kt new file mode 100644 index 0000000..517ff99 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/news/NewsAdapter.kt @@ -0,0 +1,67 @@ +package io.r_a_d.radio2.ui.news + +import android.annotation.SuppressLint +import android.widget.TextView +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.text.HtmlCompat +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.RecyclerView +import io.r_a_d.radio2.R +import kotlin.collections.ArrayList + +class NewsAdapter(private val dataSet: ArrayList + /*, + context: Context, + resource: Int, + objects: Array*/ +) : RecyclerView.Adapter() /*ArrayAdapter(context, resource, objects)*/ { + + + // Provide a reference to the views for each data item + // Complex data items may need more than one view per item, and + // you provide access to all the views for a data item in a view holder. + // Each data item is just a string in this case that is shown in a TextView. + class MyViewHolder(view: ConstraintLayout) : RecyclerView.ViewHolder(view) + + + // Create new views (invoked by the layout manager) + override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): MyViewHolder { + // create a new view + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.news_view, parent, false) as ConstraintLayout + // set the view's size, margins, paddings and layout parameters + //... + return MyViewHolder(view) + } + + // Replace the contents of a view (invoked by the layout manager) + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + val title = holder.itemView.findViewById(R.id.news_title) + val text = holder.itemView.findViewById(R.id.news_text) + val author = holder.itemView.findViewById(R.id.news_author) + val header = holder.itemView.findViewById(R.id.news_header) + title.text = dataSet[position].title + text.text = HtmlCompat.fromHtml(dataSet[position].text, HtmlCompat.FROM_HTML_MODE_LEGACY) + header.text = HtmlCompat.fromHtml(dataSet[position].header, HtmlCompat.FROM_HTML_MODE_LEGACY).replace(Regex("\n"), " ") + author.text = "| ${dataSet[position].author}" + TextViewCompat.setAutoSizeTextTypeWithDefaults(author, TextViewCompat.AUTO_SIZE_TEXT_TYPE_UNIFORM) + } + + // Return the size of your dataset (invoked by the layout manager) + override fun getItemCount() = dataSet.size + + + /* + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + // create a new view + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.song_view, parent, false) as ConstraintLayout + } + */ + +} + diff --git a/app/src/main/java/io/r_a_d/radio2/ui/news/NewsFragment.kt b/app/src/main/java/io/r_a_d/radio2/ui/news/NewsFragment.kt new file mode 100644 index 0000000..b4118a8 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/news/NewsFragment.kt @@ -0,0 +1,62 @@ +package io.r_a_d.radio2.ui.news + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.r_a_d.radio2.R + +class NewsFragment : Fragment() { + + private lateinit var newsViewModel: NewsViewModel + private lateinit var recyclerView: RecyclerView + private lateinit var viewAdapter: RecyclerView.Adapter<*> + private lateinit var viewManager: RecyclerView.LayoutManager + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + newsViewModel = + ViewModelProviders.of(this).get(NewsViewModel::class.java) + + val root = inflater.inflate(R.layout.fragment_news, container, false) as androidx.swiperefreshlayout.widget.SwipeRefreshLayout + + viewManager = LinearLayoutManager(context) + viewAdapter = NewsAdapter(newsViewModel.newsArray) + recyclerView = root.findViewById(R.id.news_recycler).apply { + // use this setting to improve performance if you know that changes + // in content do not change the layout size of the RecyclerView + setHasFixedSize(true) + + // use a linear layout manager + layoutManager = viewManager + + // specify an viewAdapter (see also next example) + adapter = viewAdapter + } + + root.setOnRefreshListener { + + newsViewModel.fetch(root, viewAdapter) + + } + + return root + } + + override fun onCreate(savedInstanceState: Bundle?) { + newsViewModel = + ViewModelProviders.of(this).get(NewsViewModel::class.java) + + newsViewModel.fetch() + Log.d(tag, "news fetched onCreate") + super.onCreate(savedInstanceState) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/news/NewsViewModel.kt b/app/src/main/java/io/r_a_d/radio2/ui/news/NewsViewModel.kt new file mode 100644 index 0000000..df6b65d --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/news/NewsViewModel.kt @@ -0,0 +1,52 @@ +package io.r_a_d.radio2.ui.news + +import android.util.Log +import androidx.lifecycle.ViewModel +import androidx.recyclerview.widget.RecyclerView +import io.r_a_d.radio2.Async +import io.r_a_d.radio2.tag +import org.json.JSONArray +import org.json.JSONObject +import java.net.URL +import java.text.SimpleDateFormat +import java.util.* +import kotlin.collections.ArrayList + + +class NewsViewModel : ViewModel() { + + val newsArray : ArrayList = ArrayList() + + private val urlToScrape = "https://r-a-d.io/api/news" + + private val scrape : (Any?) -> Unit = + { + val t = URL(urlToScrape).readText() + val result = JSONArray(t) + newsArray.clear() + for (n in 0 until result.length()) + { + val news = News() + news.title = (result[n] as JSONObject).getString("title") + news.author = (result[n] as JSONObject).getJSONObject("author").getString("user") + news.text = (result[n] as JSONObject).getString("text") + news.header = (result[n] as JSONObject).getString("header") + + val formatter6 = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + + news.date = formatter6.parse((result[n] as JSONObject).getString("updated_at")) ?: Date() + + Log.d(tag, "$news") + newsArray.add(news) + } + } + + fun fetch(root: androidx.swiperefreshlayout.widget.SwipeRefreshLayout? = null, viewAdapter: RecyclerView.Adapter<*>? = null) + { + val post : (parameter: Any?) -> Unit = { + root?.isRefreshing = false + viewAdapter?.notifyDataSetChanged() + } + Async(scrape, post) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingFragment.kt b/app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingFragment.kt new file mode 100644 index 0000000..8bca91c --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingFragment.kt @@ -0,0 +1,294 @@ +package io.r_a_d.radio2.ui.nowplaying + +import android.annotation.SuppressLint +import android.content.ClipboardManager +import android.content.Context +import androidx.lifecycle.ViewModelProviders +import android.os.Bundle +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import android.util.TypedValue +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.constraintlayout.widget.ConstraintSet +import androidx.core.widget.TextViewCompat +import androidx.lifecycle.Observer +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import io.r_a_d.radio2.* +import io.r_a_d.radio2.alarm.RadioSleeper +import io.r_a_d.radio2.playerstore.PlayerStore +import io.r_a_d.radio2.playerstore.Song + + +class NowPlayingFragment : Fragment() { + + private lateinit var root: View + private lateinit var nowPlayingViewModel: NowPlayingViewModel + + @SuppressLint("SetTextI18n") + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + nowPlayingViewModel = ViewModelProviders.of(this).get(NowPlayingViewModel::class.java) + root = inflater.inflate(R.layout.fragment_nowplaying, container, false) + + // View bindings to the ViewModel + val songTitleText: TextView = root.findViewById(R.id.text_song_title) + val songArtistText: TextView = root.findViewById(R.id.text_song_artist) + val seekBarVolume: SeekBar = root.findViewById(R.id.seek_bar_volume) + val volumeText: TextView = root.findViewById(R.id.volume_text) + val progressBar: ProgressBar = root.findViewById(R.id.progressBar) + val streamerPictureImageView: ImageView = root.findViewById(R.id.streamerPicture) + val streamerNameText : TextView = root.findViewById(R.id.streamerName) + val songTitleNextText: TextView = root.findViewById(R.id.text_song_title_next) + val songArtistNextText: TextView = root.findViewById(R.id.text_song_artist_next) + val volumeIconImage : ImageView = root.findViewById(R.id.volume_icon) + val listenersText : TextView = root.findViewById(R.id.listenersCount) + + + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( + streamerNameText,8, 20, 2, TypedValue.COMPLEX_UNIT_SP) + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( + listenersText,8, 16, 2, TypedValue.COMPLEX_UNIT_SP) + /* + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( + songTitleText,4, 24, 2, TypedValue.COMPLEX_UNIT_SP) + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( + songArtistText,4, 24, 2, TypedValue.COMPLEX_UNIT_SP) + */ + + PlayerStore.instance.currentSong.title.observe(viewLifecycleOwner, Observer { + songTitleText.text = it + }) + + PlayerStore.instance.currentSong.artist.observe(viewLifecycleOwner, Observer { + songArtistText.text = it + }) + + PlayerStore.instance.playbackState.observe(viewLifecycleOwner, Observer { + syncPlayPauseButtonImage(root) + }) + + // trick : I can't observe the queue because it's an ArrayDeque that doesn't trigger any change... + // so I observe a dedicated Mutable that gets set when the queue is updated. + PlayerStore.instance.isQueueUpdated.observe(viewLifecycleOwner, Observer { + val t = if (PlayerStore.instance.queue.size > 0) PlayerStore.instance.queue[0] else Song("No queue - ") // (it.peekFirst != null ? it.peekFirst : Song() ) + songTitleNextText.text = t.title.value + songArtistNextText.text = t.artist.value + }) + + fun volumeIcon(it: Int) + { + volumeText.text = "$it%" + when { + it > 66 -> volumeIconImage.setImageResource(R.drawable.ic_volume_high) + it in 33..66 -> volumeIconImage.setImageResource(R.drawable.ic_volume_medium) + it in 0..33 -> volumeIconImage.setImageResource(R.drawable.ic_volume_low) + else -> volumeIconImage.setImageResource(R.drawable.ic_volume_off) + } + } + + PlayerStore.instance.volume.observe(viewLifecycleOwner, Observer { + volumeIcon(it) + seekBarVolume.progress = it // this updates the seekbar if it's set by something else when going to sleep. + }) + + PlayerStore.instance.isMuted.observe(viewLifecycleOwner, Observer { + if (it) + volumeIconImage.setImageResource(R.drawable.ic_volume_off) + else + volumeIcon(PlayerStore.instance.volume.value!!) + }) + + + PlayerStore.instance.streamerPicture.observe(viewLifecycleOwner, Observer { pic -> + streamerPictureImageView.setImageBitmap(pic) + }) + + PlayerStore.instance.streamerName.observe(viewLifecycleOwner, Observer { + streamerNameText.text = it + }) + + PlayerStore.instance.listenersCount.observe(viewLifecycleOwner, Observer { + listenersText.text = "${getString(R.string.listeners)}: $it" + }) + + // fuck it, do it on main thread + PlayerStore.instance.currentTime.observe(viewLifecycleOwner, Observer { + val dd = (PlayerStore.instance.currentTime.value!! - PlayerStore.instance.currentSong.startTime.value!!).toInt() + progressBar.progress = dd + }) + + PlayerStore.instance.currentSong.stopTime.observe(viewLifecycleOwner, Observer { + val dd = (PlayerStore.instance.currentSong.stopTime.value!! - PlayerStore.instance.currentSong.startTime.value!!).toInt() + progressBar.max = dd + }) + + PlayerStore.instance.currentSong.stopTime.observe(viewLifecycleOwner, Observer { + val t : TextView= root.findViewById(R.id.endTime) + val minutes: String = ((PlayerStore.instance.currentSong.stopTime.value!! - PlayerStore.instance.currentSong.startTime.value!!)/60/1000).toString() + val seconds: String = ((PlayerStore.instance.currentSong.stopTime.value!! - PlayerStore.instance.currentSong.startTime.value!!)/1000%60).toString() + t.text = "$minutes:${if (seconds.toInt() < 10) "0" else ""}$seconds" + }) + + PlayerStore.instance.currentTime.observe(viewLifecycleOwner, Observer { + val t : TextView= root.findViewById(R.id.currentTime) + val minutes: String = ((PlayerStore.instance.currentTime.value!! - PlayerStore.instance.currentSong.startTime.value!!)/60/1000).toString() + val seconds: String = ((PlayerStore.instance.currentTime.value!! - PlayerStore.instance.currentSong.startTime.value!!)/1000%60).toString() + t.text = "$minutes:${if (seconds.toInt() < 10) "0" else ""}$seconds" + + val sleepInfoText = root.findViewById(R.id.sleepInfo) + val sleepAtMillis = RadioSleeper.instance.sleepAtMillis.value + if (sleepAtMillis != null) + { + val duration = ((sleepAtMillis - System.currentTimeMillis()).toFloat() / (60f * 1000f) + 1).toInt() // I put 1 + it because the division rounds to the lower integer. I'd like to display the round up, like it's usually done. + sleepInfoText.text = "Will close in $duration minute${if (duration > 1) "s" else ""}" + sleepInfoText.visibility = View.VISIBLE + } else { + sleepInfoText.visibility = View.GONE + } + }) + + + seekBarVolume.setOnSeekBarChangeListener(nowPlayingViewModel.seekBarChangeListener) + seekBarVolume.progress = PlayerStore.instance.volume.value!! + progressBar.max = 1000 + progressBar.progress = 0 + + syncPlayPauseButtonImage(root) + + // initialize the value for isPlaying when displaying the fragment + PlayerStore.instance.isPlaying.value = PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_PLAYING + + val button: ImageButton = root.findViewById(R.id.play_pause) + button.setOnClickListener{ + PlayerStore.instance.isPlaying.value = PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_STOPPED + } + + /* + /* TODO : disabled volumeIconImage click listener, it creates weird behaviors when switching fragments. + in particular, the mute state isn't retained when switching fragments, and it creates visual error + (displaying the mute icon when it's not muted). + So for the moment it's safer to disable it altogether. + */ + volumeIconImage.setOnClickListener{ + PlayerStore.instance.isMuted.value = !PlayerStore.instance.isMuted.value!! + } + + */ + + val setClipboardListener: View.OnLongClickListener = View.OnLongClickListener { + val text = PlayerStore.instance.currentSong.artist.value + " - " + PlayerStore.instance.currentSong.title.value + val clipboard = context!!.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = android.content.ClipData.newPlainText("Copied Text", text) + clipboard.setPrimaryClip(clip) + val snackBarLength = if (preferenceStore.getBoolean("snackbarPersistent", true)) + Snackbar.LENGTH_INDEFINITE + else Snackbar.LENGTH_LONG + val snackBar = Snackbar.make(it, "", snackBarLength) + + if (snackBarLength == Snackbar.LENGTH_INDEFINITE) + snackBar.setAction("OK") { snackBar.dismiss() } + + snackBar.behavior = BaseTransientBottomBar.Behavior().apply { + setSwipeDirection(BaseTransientBottomBar.Behavior.SWIPE_DIRECTION_ANY) + } + snackBar.setText(getString(R.string.song_to_clipboard)) + snackBar.show() + true + } + + songTitleText.setOnLongClickListener(setClipboardListener) + songArtistText.setOnLongClickListener(setClipboardListener) + + if (preferenceStore.getBoolean("splitLayout", true)) + root.addOnLayoutChangeListener(splitLayoutListener) + + return root + } + + private val splitLayoutListener : View.OnLayoutChangeListener = View.OnLayoutChangeListener { _: View, _: Int, _: Int, _: Int, _: Int, _: Int, _: Int, _: Int, _: Int -> + + val isSplitLayout = preferenceStore.getBoolean("splitLayout", true) + + val viewHeight = (root.rootView?.height ?: 1) + val viewWidth = (root.rootView?.width ?: 1) + + val newRatio = if (viewWidth > 0) + (viewHeight*100)/viewWidth + else + 100 + + if (isSplitLayout && nowPlayingViewModel.screenRatio != newRatio) { + onOrientation() + } + } + + override fun onViewStateRestored(savedInstanceState: Bundle?) { + onOrientation() + super.onViewStateRestored(savedInstanceState) + } + + private fun onOrientation() { + val viewHeight = (root.rootView?.height ?: 1) + val viewWidth = (root.rootView?.width ?: 1) + + val isSplitLayout = preferenceStore.getBoolean("splitLayout", true) + + // modify layout to adapt for portrait/landscape + val isLandscape = viewHeight.toDouble()/viewWidth.toDouble() < 1 + val parentLayout = root.findViewById(R.id.parentNowPlaying) + val constraintSet = ConstraintSet() + constraintSet.clone(parentLayout) + + if (isLandscape && isSplitLayout) + { + constraintSet.connect(R.id.layoutBlock1, ConstraintSet.BOTTOM, R.id.parentNowPlaying, ConstraintSet.BOTTOM) + constraintSet.connect(R.id.layoutBlock1, ConstraintSet.END, R.id.splitHorizontalLayout, ConstraintSet.END) + constraintSet.connect(R.id.layoutBlock2, ConstraintSet.TOP, R.id.parentNowPlaying, ConstraintSet.TOP) + constraintSet.connect(R.id.layoutBlock2, ConstraintSet.START, R.id.splitHorizontalLayout, ConstraintSet.END) + constraintSet.setMargin(R.id.layoutBlock1, ConstraintSet.END, 16) + constraintSet.setMargin(R.id.layoutBlock2, ConstraintSet.START, 16) + } else { + constraintSet.connect(R.id.layoutBlock1, ConstraintSet.BOTTOM, R.id.splitVerticalLayout, ConstraintSet.BOTTOM) + constraintSet.connect(R.id.layoutBlock1, ConstraintSet.END, R.id.parentNowPlaying, ConstraintSet.END) + constraintSet.connect(R.id.layoutBlock2, ConstraintSet.TOP, R.id.splitVerticalLayout, ConstraintSet.BOTTOM) + constraintSet.connect(R.id.layoutBlock2, ConstraintSet.START, R.id.parentNowPlaying, ConstraintSet.START) + constraintSet.setMargin(R.id.layoutBlock1, ConstraintSet.END, 0) + constraintSet.setMargin(R.id.layoutBlock2, ConstraintSet.START, 0) + } + constraintSet.applyTo(parentLayout) + + // note : we have to COMPARE numbers that are FRACTIONS. And everyone knows that we should NEVER compare DOUBLES because of the imprecision at the end. + // So instead, I multiply the result by 100 (to give 2 significant numbers), and do an INTEGER DIVISION. This is the right way to compare ratios. + nowPlayingViewModel.screenRatio = if (viewWidth > 0) + (viewHeight*100)/viewWidth + else + 100 + Log.d(tag, "orientation set") + } + + override fun onResume() { + super.onResume() + onOrientation() + } + + private fun syncPlayPauseButtonImage(v: View) + { + val img = v.findViewById(R.id.play_pause) + + if (PlayerStore.instance.playbackState.value == PlaybackStateCompat.STATE_STOPPED) { + img.setImageResource(R.drawable.exo_controls_play) + } else { + img.setImageResource(R.drawable.exo_controls_pause) + } + } +} diff --git a/app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingViewModel.kt b/app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingViewModel.kt new file mode 100644 index 0000000..2481c5d --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/nowplaying/NowPlayingViewModel.kt @@ -0,0 +1,34 @@ +package io.r_a_d.radio2.ui.nowplaying + +import android.widget.SeekBar +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import io.r_a_d.radio2.playerstore.PlayerStore + +class NowPlayingViewModel: ViewModel() { + + /* Note : ViewModels do not have any kind of data persistence, which is a bit of a shame. + Data persistence is currently in beta, and poorly documented (some pages don't even match!) + For the moment, we will store data related to playback state in PlayerStore. + */ + var screenRatio: Int = 100 + + + + var seekBarChangeListener: SeekBar.OnSeekBarChangeListener = + object : SeekBar.OnSeekBarChangeListener { + + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + // updated continuously as the user slides the thumb + PlayerStore.instance.volume.value = progress + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + // called when the user first touches the SeekBar + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + // called after the user finishes moving the SeekBar + } + } +} diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/SongsFragment.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/SongsFragment.kt new file mode 100644 index 0000000..cc87514 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/SongsFragment.kt @@ -0,0 +1,94 @@ +package io.r_a_d.radio2.ui.songs + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentPagerAdapter +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.viewpager.widget.ViewPager +import com.google.android.material.snackbar.BaseTransientBottomBar +import com.google.android.material.snackbar.Snackbar +import com.google.android.material.tabs.TabLayout +import io.r_a_d.radio2.R +import io.r_a_d.radio2.preferenceStore +import io.r_a_d.radio2.ui.songs.queuelp.LastPlayedFragment +import io.r_a_d.radio2.ui.songs.queuelp.QueueFragment +import io.r_a_d.radio2.ui.songs.request.FavoritesFragment +import io.r_a_d.radio2.ui.songs.request.RequestFragment +import io.r_a_d.radio2.ui.songs.request.Requestor + +class SongsFragment : Fragment() { + + private lateinit var adapter : SongsPagerAdapter + private lateinit var snackBar : Snackbar + private lateinit var root: View + private lateinit var viewPager: ViewPager + + + private val snackBarTextObserver: Observer = Observer { + if (Requestor.instance.snackBarText.value != "") + { + val snackBarLength = if (preferenceStore.getBoolean("snackbarPersistent", true)) + Snackbar.LENGTH_INDEFINITE + else Snackbar.LENGTH_LONG + snackBar = Snackbar.make(viewPager, "", snackBarLength) + + if (snackBarLength == Snackbar.LENGTH_INDEFINITE) + snackBar.setAction("OK") { + snackBar.dismiss() + } + + snackBar.behavior = BaseTransientBottomBar.Behavior().apply { + setSwipeDirection(BaseTransientBottomBar.Behavior.SWIPE_DIRECTION_ANY) + } + + val snackBarView = snackBar.view + val textView = + snackBarView.findViewById(com.google.android.material.R.id.snackbar_text) as TextView + if (Requestor.instance.addRequestMeta != "") + textView.maxLines = 4 + else + textView.maxLines = 2 + snackBar.setText((it as CharSequence)) + snackBar.show() + Requestor.instance.snackBarText.value = "" // resetting afterwards to avoid re-triggering it when we enter again the fragment + Requestor.instance.addRequestMeta = "" + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + + root = inflater.inflate(R.layout.fragment_songs, container, false) + viewPager = root.findViewById(R.id.tabPager) + adapter = SongsPagerAdapter(childFragmentManager, FragmentPagerAdapter.BEHAVIOR_RESUME_ONLY_CURRENT_FRAGMENT) + adapter.addFragment(LastPlayedFragment.newInstance(), "last played") + adapter.addFragment(QueueFragment.newInstance(), "queue") + adapter.addFragment(RequestFragment.newInstance(), "request") + adapter.addFragment(FavoritesFragment.newInstance(), "Favorites") + + viewPager.adapter = adapter + + val tabLayout : TabLayout = root.findViewById(R.id.tabLayout) + tabLayout.setupWithViewPager(viewPager) + Log.d(tag, "SongFragment view created") + + Requestor.instance.snackBarText.observeForever(snackBarTextObserver) + + return root + } + + override fun onDestroyView() { + Requestor.instance.snackBarText.removeObserver(snackBarTextObserver) + super.onDestroyView() + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/SongsPagerAdapter.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/SongsPagerAdapter.kt new file mode 100644 index 0000000..9620638 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/SongsPagerAdapter.kt @@ -0,0 +1,29 @@ +package io.r_a_d.radio2.ui.songs + +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentPagerAdapter +import androidx.fragment.app.FragmentManager + + +class SongsPagerAdapter(f: FragmentManager, t: Int) : FragmentPagerAdapter(f, t){ + + private val fragmentList = ArrayList() + private val fragmentTitleList = ArrayList() + + override fun getItem(position: Int): Fragment { + return fragmentList[position] + } + + override fun getCount(): Int { + return fragmentList.size + } + + fun addFragment(fragment: Fragment, title: String) { + fragmentList.add(fragment) + fragmentTitleList.add(title) + } + + override fun getPageTitle(position: Int): CharSequence? { + return fragmentTitleList[position] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/LastPlayedFragment.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/LastPlayedFragment.kt new file mode 100644 index 0000000..381734b --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/LastPlayedFragment.kt @@ -0,0 +1,86 @@ +package io.r_a_d.radio2.ui.songs.queuelp + +import android.os.Bundle +import android.util.Log +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.r_a_d.radio2.R +import io.r_a_d.radio2.playerstore.PlayerStore + +/** + * A simple [Fragment] subclass. + * Activities that contain this fragment must implement the + * [LastPlayedFragment.OnFragmentInteractionListener] interface + * to handle interaction events. + * Use the [LastPlayedFragment.newInstance] factory method to + * create an instance of this fragment. + */ + +class LastPlayedFragment : Fragment() { + + private val lastPlayedFragmentTag = this::class.java.name + + private lateinit var recyclerView: RecyclerView + private lateinit var viewAdapter: RecyclerView.Adapter<*> + private lateinit var viewManager: RecyclerView.LayoutManager + + + private val queueObserver = Observer { + Log.d(tag, lastPlayedFragmentTag + "queue changed") + viewAdapter.notifyDataSetChanged() + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + val root = inflater.inflate(R.layout.fragment_last_played, container, false) + + viewManager = LinearLayoutManager(context) + viewAdapter = SongAdaptater(PlayerStore.instance.lp) + + recyclerView = root.findViewById(R.id.queue_lp_recycler).apply { + // use this setting to improve performance if you know that changes + // in content do not change the layout size of the RecyclerView + setHasFixedSize(true) + + // use a linear layout manager + layoutManager = viewManager + + // specify an viewAdapter (see also next example) + adapter = viewAdapter + } + + PlayerStore.instance.isLpUpdated.observeForever(queueObserver) + + return root + } + + override fun onDestroyView() { + PlayerStore.instance.isLpUpdated.removeObserver(queueObserver) + super.onDestroyView() + } + /** + * This interface must be implemented by activities that contain this + * fragment to allow an interaction in this fragment to be communicated + * to the activity and potentially other fragments contained in that + * activity. + * + * + * See the Android Training lesson [Communicating with Other Fragments] + * (http://developer.android.com/training/basics/fragments/communicating.html) + * for more information. + */ + interface OnFragmentInteractionListener + + companion object { + @JvmStatic + fun newInstance() = LastPlayedFragment() + } +} diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/QueueFragment.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/QueueFragment.kt new file mode 100644 index 0000000..b97cb59 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/QueueFragment.kt @@ -0,0 +1,70 @@ +package io.r_a_d.radio2.ui.songs.queuelp + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import io.r_a_d.radio2.R +import io.r_a_d.radio2.playerstore.PlayerStore +import io.r_a_d.radio2.playerstore.Song + +class QueueFragment : Fragment(){ + private val lastPlayedFragmentTag = this::class.java.name + + private lateinit var recyclerView: RecyclerView + private lateinit var viewAdapter: RecyclerView.Adapter<*> + private lateinit var viewManager: RecyclerView.LayoutManager + + + private val queueObserver = Observer { + Log.d(tag, lastPlayedFragmentTag + "queue changed") + viewAdapter.notifyDataSetChanged() + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + val root = inflater.inflate(R.layout.fragment_last_played, container, false) + + viewManager = LinearLayoutManager(context) + viewAdapter = SongAdaptater( + if (PlayerStore.instance.queue.isEmpty()) + ArrayList(listOf((Song("No queue - ")))) + else + PlayerStore.instance.queue + ) + + recyclerView = root.findViewById(R.id.queue_lp_recycler).apply { + // use this setting to improve performance if you know that changes + // in content do not change the layout size of the RecyclerView + setHasFixedSize(true) + + // use a linear layout manager + layoutManager = viewManager + + // specify an viewAdapter (see also next example) + adapter = viewAdapter + } + + PlayerStore.instance.isQueueUpdated.observeForever(queueObserver) + + return root + } + + override fun onDestroyView() { + PlayerStore.instance.isQueueUpdated.removeObserver(queueObserver) + super.onDestroyView() + } + + companion object { + @JvmStatic + fun newInstance() = QueueFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/SongAdaptater.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/SongAdaptater.kt new file mode 100644 index 0000000..9101881 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/queuelp/SongAdaptater.kt @@ -0,0 +1,66 @@ +package io.r_a_d.radio2.ui.songs.queuelp + +import android.annotation.SuppressLint +import android.graphics.Color +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.content.res.ResourcesCompat +import androidx.recyclerview.widget.RecyclerView +import io.r_a_d.radio2.R +import io.r_a_d.radio2.colorBlue +import io.r_a_d.radio2.colorWhited +import io.r_a_d.radio2.playerstore.Song +import kotlinx.android.synthetic.main.song_view.view.* +import kotlin.collections.ArrayList + +class SongAdaptater(private val dataSet: ArrayList + /*, + context: Context, + resource: Int, + objects: Array*/ +) : RecyclerView.Adapter() /*ArrayAdapter(context, resource, objects)*/ { + + + // Provide a reference to the views for each data item + // Complex data items may need more than one view per item, and + // you provide access to all the views for a data item in a view holder. + // Each data item is just a string in this case that is shown in a TextView. + class MyViewHolder(view: ConstraintLayout) : RecyclerView.ViewHolder(view) + + + // Create new views (invoked by the layout manager) + override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): MyViewHolder { + // create a new view + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.song_view, parent, false) as ConstraintLayout + // set the view's size, margins, paddings and layout parameters + //... + return MyViewHolder(view) + } + + // Replace the contents of a view (invoked by the layout manager) + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + holder.itemView.item.text = "${dataSet[position].artist.value} - ${dataSet[position].title.value}" + if (dataSet[position].type.value == 1) + holder.itemView.item.setTextColor(colorBlue) + else + holder.itemView.item.setTextColor(colorWhited) + } + + // Return the size of your dataset (invoked by the layout manager) + override fun getItemCount() = dataSet.size + + + /* + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + // create a new view + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.song_view, parent, false) as ConstraintLayout + } + */ + +} + diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/request/CooldownCalculator.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/CooldownCalculator.kt new file mode 100644 index 0000000..0c268a4 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/CooldownCalculator.kt @@ -0,0 +1,69 @@ +package io.r_a_d.radio2.ui.songs.request + +import kotlin.math.exp +import kotlin.math.max + +/* +//PHP cooldown calculator + +function pretty_cooldown($lp, $lr, $rc) { + $delay = delay($rc); + $now = time(); + $cd = intval(max($lp + $delay - $now, $lr + $delay - $now)); + if ($cd <= 0) + return "Request"; + $days = intdiv_1($cd, 86400); + $cd = $cd % 86400; + $hours = intdiv_1($cd, 3600); + $cd = $cd % 3600; + $minutes = intdiv_1($cd, 60); + $seconds = $cd % 60; + if ($days > 0) + return "Requestable in ".$days."d".$hours."h"; + else if ($hours > 0) + return "Requestable in ".$hours."h".$minutes."m"; + else if ($minutes > 0) + return "Requestable in ".$minutes."m".$seconds."s"; + return "Request"; +} +function requestable($lastplayed, $requests) { + $delay = delay($requests); + return (time() - $lastplayed) > $delay; +} +function delay($priority) { + // priority is 30 max + if ($priority > 30) + $priority = 30; + // between 0 and 7 return magic + if ($priority >= 0 and $priority <= 7) + $cd = -11057 * $priority * $priority + 172954 * $priority + 81720; + // if above that, return magic crazy numbers + else + $cd = (int) (599955 * exp(0.0372 * $priority) + 0.5); + return $cd / 2; +} + */ + +// this function implements the magic delay used on R/a/dio website: +// https://github.com/R-a-dio/site/blob/develop/app/start/global.php#L125 +// (Seriously guys, what were you thinking with these crazy magic numbers...) +fun delay(rawPriority: Int) : Int { + val priority = if (rawPriority > 30) 30 else rawPriority + val coolDown : Int = + if (priority in 0..7) + -11057 * priority * priority + 172954 * priority + 81720 + else + (599955 * exp(0.0372 * (priority.toDouble()) + 0.5)).toInt() + return coolDown/2 +} + +// I tweaked this to report in a single point whether the song is requestable or not +fun coolDown(lastPlayed: Int?, lastRequest: Int?, requestsNbr: Int?) : Long { + if (requestsNbr == null || lastPlayed == null || lastRequest == null) + return Long.MAX_VALUE // maximum positive value : the song won't be requestable + + val delay = delay(requestsNbr) + val now = (System.currentTimeMillis() / 1000) + return max(lastPlayed, lastRequest) + delay - now + // if coolDown < 0, the song is requestable. +} diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/request/FavoritesFragment.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/FavoritesFragment.kt new file mode 100644 index 0000000..e2e0e35 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/FavoritesFragment.kt @@ -0,0 +1,132 @@ +package io.r_a_d.radio2.ui.songs.request + +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import android.widget.TextView +import androidx.appcompat.widget.AppCompatButton +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import io.r_a_d.radio2.* + + +class FavoritesFragment : Fragment() { + + private lateinit var recyclerView: RecyclerView + private lateinit var viewAdapter: RecyclerView.Adapter<*> + private lateinit var viewManager: RecyclerView.LayoutManager + private lateinit var searchView: SearchView + private lateinit var root: View + private lateinit var recyclerSwipe: SwipeRefreshLayout + + private val favoritesSongObserver : Observer = Observer { + viewAdapter.notifyDataSetChanged() + createView(isCallback = true) // force-re-create the view, but do not call again the initFavorites (avoid callback loop) + recyclerSwipe.isRefreshing = false // disable refreshing animation. Needs to be done manually... + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + super.onCreateView(inflater, container, savedInstanceState) + root = inflater.inflate(R.layout.fragment_request, container, false) + + return createView() + } + + private fun createView(isCallback: Boolean = false) : View? + { + + viewAdapter = RequestSongAdapter(Requestor.instance.favoritesSongArray) + + val listener : SearchView.OnQueryTextListener = object : SearchView.OnQueryTextListener{ + override fun onQueryTextSubmit(query: String?): Boolean { + // do nothing + return true + } + override fun onQueryTextChange(newText: String?): Boolean { + (viewAdapter as RequestSongAdapter).filter(newText ?: "") + return true + } + } + + searchView = root.findViewById(R.id.searchBox) + searchView.queryHint = "Search filter..." + searchView.setOnQueryTextListener(listener) + viewManager = LinearLayoutManager(context) + recyclerView = root.findViewById(R.id.request_recycler).apply { + // use this setting to improve performance if you know that changes + // in content do not change the layout size of the RecyclerView + setHasFixedSize(true) + + // use a linear layout manager + layoutManager = viewManager + + // specify an viewAdapter (see also next example) + adapter = viewAdapter + } + + val noUserNameText : TextView = root.findViewById(R.id.noUserNameText) + + recyclerSwipe = root.findViewById(R.id.recyclerSwipe) as SwipeRefreshLayout + recyclerSwipe.setOnRefreshListener { + val userName1 = preferenceStore.getString("userName", null) + Log.d(tag,"userName = $userName1") + if (userName1 != null && !userName1.isBlank()) + { + noUserNameText.visibility = View.GONE + Requestor.instance.initFavorites() + } else { + noUserNameText.visibility = View.VISIBLE + recyclerSwipe.isRefreshing = false + } + } + + + val userName1 = preferenceStore.getString("userName", null) + Log.d(tag,"userName = $userName1") + if (userName1 != null && !userName1.isBlank()) + { + noUserNameText.visibility = View.GONE + if (!isCallback) // avoid callback loop if called from the Observer. + Requestor.instance.initFavorites() + } else { + noUserNameText.visibility = View.VISIBLE + recyclerSwipe.isRefreshing = false + } + + val raFButton : AppCompatButton = root.findViewById(R.id.ra_f_button) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) // for API21+ Material Design makes ripples on the button. + raFButton.supportBackgroundTintList = colorGreenList + else // But on API20- no Material Design support, so we add some more color when clicked + raFButton.supportBackgroundTintList = colorGreenListCompat + raFButton.isEnabled = true + raFButton.isClickable = true + raFButton.setOnClickListener { + val s = Requestor.instance.raF() + Requestor.instance.snackBarText.value = "" + Requestor.instance.addRequestMeta = "Request: ${s.artist.value} - ${s.title.value}\n" + Requestor.instance.request(s.id) + } + raFButton.visibility = View.VISIBLE + + Requestor.instance.isFavoritesUpdated.observe(viewLifecycleOwner, favoritesSongObserver) + return root + } + + + companion object { + @JvmStatic + fun newInstance() = FavoritesFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestFragment.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestFragment.kt new file mode 100644 index 0000000..508a82a --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestFragment.kt @@ -0,0 +1,81 @@ +package io.r_a_d.radio2.ui.songs.request + +import android.os.Bundle +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.SearchView +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import io.r_a_d.radio2.R + +class RequestFragment : Fragment() { + + private lateinit var recyclerView: RecyclerView + private lateinit var viewAdapter: RecyclerView.Adapter<*> + private lateinit var viewManager: RecyclerView.LayoutManager + private lateinit var searchView: SearchView + + private val listener : SearchView.OnQueryTextListener = object : SearchView.OnQueryTextListener{ + override fun onQueryTextSubmit(query: String?): Boolean { + if (query == null || query.isEmpty()) + Requestor.instance.snackBarText.value = "Field is empty, no search possible." + else + Requestor.instance.search(query) + return true + } + override fun onQueryTextChange(newText: String?): Boolean { + if (newText == "") + { + Requestor.instance.reset() + viewAdapter.notifyDataSetChanged() // this is to remove the "Load more" button + } + return true + } + } + + private val requestSongObserver = Observer { + Log.d(tag, "request song list changed") + viewAdapter.notifyDataSetChanged() + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + // Inflate the layout for this fragment + val root = inflater.inflate(R.layout.fragment_request, container, false) + + val recyclerSwipe = root.findViewById(R.id.recyclerSwipe) as SwipeRefreshLayout + recyclerSwipe.isEnabled = false // don't need to pull-to-refresh for Request + + searchView = root.findViewById(R.id.searchBox) + searchView.setOnQueryTextListener(listener) + + viewManager = LinearLayoutManager(context) + viewAdapter = RequestSongAdapter(Requestor.instance.requestSongArray) + + recyclerView = root.findViewById(R.id.request_recycler).apply { + // use this setting to improve performance if you know that changes + // in content do not change the layout size of the RecyclerView + setHasFixedSize(true) + + // use a linear layout manager + layoutManager = viewManager + + // specify an viewAdapter (see also next example) + adapter = viewAdapter + } + Requestor.instance.isRequestResultUpdated.observe(viewLifecycleOwner, requestSongObserver) + return root + } + + companion object { + @JvmStatic + fun newInstance() = RequestFragment() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestResponse.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestResponse.kt new file mode 100644 index 0000000..52468cd --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestResponse.kt @@ -0,0 +1,42 @@ +package io.r_a_d.radio2.ui.songs.request + +import io.r_a_d.radio2.playerstore.Song +import org.json.JSONObject + +class RequestResponse(jsonResponse: JSONObject) { + //val total: Int = jsonResponse.getInt("total") + //val perPage: Int = jsonResponse.getInt("per_page") // should stay 20 but in any case... + val currentPage: Int = jsonResponse.getInt("current_page") + val lastPage: Int = jsonResponse.getInt("last_page") + //val fromNbr: Int = jsonResponse.getInt("from") + //val toNbr: Int = jsonResponse.getInt("to") + var songs : ArrayList = ArrayList() + + init { + val songList = jsonResponse.getJSONArray("data") + for (i in 0 until songList.length()) + { + + val title = (songList[i] as JSONObject).getString("title") + val artist = (songList[i] as JSONObject).getString("artist") + val id = (songList[i] as JSONObject).getInt("id") + + val s = Song("", id) + s.title.value = title + s.artist.value = artist + s.isRequestable = (songList[i] as JSONObject).getBoolean("requestable") + // TODO add the time before being requestable. + songs.add(s) + } + } + + override fun toString(): String { + var s = "" + for (i in 0 until songs.size) + { + s += (songs[i].artist.value + " - " + songs[i].title.value + " | ") + } + return s + } + +} \ No newline at end of file diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestSongAdapter.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestSongAdapter.kt new file mode 100644 index 0000000..35da7c0 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/RequestSongAdapter.kt @@ -0,0 +1,148 @@ +package io.r_a_d.radio2.ui.songs.request + +import android.annotation.SuppressLint +import android.os.Build +import android.util.TypedValue +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.TextView +import androidx.constraintlayout.widget.ConstraintLayout +import androidx.core.widget.TextViewCompat +import androidx.recyclerview.widget.RecyclerView +import io.r_a_d.radio2.* +import io.r_a_d.radio2.playerstore.Song +import kotlinx.android.synthetic.main.request_song_view.view.* +import android.view.View +import kotlinx.android.synthetic.main.button_load_more.view.* +import android.R.attr.name +import android.text.method.TextKeyListener.clear +import android.util.Log +import java.util.* +import kotlin.collections.ArrayList + + +class RequestSongAdapter(private val dataSet: ArrayList + /*, + context: Context, + resource: Int, + objects: Array*/ +) : RecyclerView.Adapter() /*ArrayAdapter(context, resource, objects)*/ { + + private val viewTypeCell = 1 // normal cell with song and request button + private val viewTypeFooter = 2 // the bottom cell should be the "load more" button whenever needed + + // Provide a reference to the views for each data item + // Complex data items may need more than one view per item, and + // you provide access to all the views for a data item in a view holder. + // Each data item is just a string in this case that is shown in a TextView. + class MyViewHolder(view: ConstraintLayout) : RecyclerView.ViewHolder(view) + + + // Create new views (invoked by the layout manager) + override fun onCreateViewHolder(parent: ViewGroup, + viewType: Int): MyViewHolder { + // create a new view + + val view = + if (viewType == viewTypeCell) + LayoutInflater.from(parent.context).inflate(R.layout.request_song_view, parent, false) as ConstraintLayout + else + LayoutInflater.from(parent.context).inflate(R.layout.button_load_more, parent, false) as ConstraintLayout + + // set the view's size, margins, paddings and layout parameters + //... + return MyViewHolder(view) + } + + // Replace the contents of a view (invoked by the layout manager) + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: MyViewHolder, position: Int) { + + if (itemCount <= 1) + { + // in any case, if there's nothing, don't display the loadMore button!! + holder.itemView.loadMoreButton.visibility = View.GONE + return + } + + if (holder.itemViewType == viewTypeFooter) + { + if (Requestor.instance.isLoadMoreVisible) + holder.itemView.loadMoreButton.visibility = View.VISIBLE + else + holder.itemView.loadMoreButton.visibility = View.GONE + holder.itemView.loadMoreButton.text = "Load more results" + holder.itemView.loadMoreButton.setOnClickListener{ + Requestor.instance.loadMore() + } + return + } + + val artist = holder.itemView.findViewById(R.id.request_song_artist) + val title = holder.itemView.findViewById(R.id.request_song_title) + val button = holder.itemView.request_button + + if (dataSet[position].isRequestable) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) // for API21+ Material Design makes ripples on the button. + button.supportBackgroundTintList = colorGreenList + else // But on API20- no Material Design support, so we add some more color when clicked + button.supportBackgroundTintList = colorGreenListCompat + button.isEnabled = true + button.isClickable = true + button.setOnClickListener { + Requestor.instance.request(dataSet[position].id) + } + } else { + button.supportBackgroundTintList = colorRedList + button.isEnabled = false + button.isClickable = false + } + + TextViewCompat.setAutoSizeTextTypeUniformWithConfiguration( + button,2, 24, 2, TypedValue.COMPLEX_UNIT_SP) + artist.text = dataSet[position].artist.value + title.text = dataSet[position].title.value + } + + // Return the size of your dataset (invoked by the layout manager) + override fun getItemCount() = dataSet.size + 1 // add 1 for the "Load more results" button + + override fun getItemViewType(position: Int): Int { + return if (position == dataSet.size) viewTypeFooter else viewTypeCell + } + + /* + override fun getView(position: Int, convertView: View?, parent: ViewGroup): View { + // create a new view + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.song_view, parent, false) as ConstraintLayout + } + */ + + // a filtering function. As naive as it could be, but it should work. + private val dataSetOrig = ArrayList() + init { + dataSetOrig.addAll(dataSet) + } + + fun filter(entry: String) { + var text = entry + dataSet.clear() + Log.d(tag, "entering filter") + if (text.isEmpty()) { + dataSet.addAll(dataSetOrig) + } else { + text = text.toLowerCase(locale = Locale.ROOT) + for (item in dataSetOrig) { + Log.d(tag, "$text, ${item.artist.value!!.toLowerCase(locale = Locale.ROOT)}, ${item.title.value!!.toLowerCase(locale = Locale.ROOT)}") + if (item.artist.value!!.toLowerCase(locale = Locale.ROOT).contains(text) || + item.title.value!!.toLowerCase(locale = Locale.ROOT).contains(text)) { + dataSet.add(item) + } + } + } + notifyDataSetChanged() + } + +} + diff --git a/app/src/main/java/io/r_a_d/radio2/ui/songs/request/Requestor.kt b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/Requestor.kt new file mode 100644 index 0000000..dadad93 --- /dev/null +++ b/app/src/main/java/io/r_a_d/radio2/ui/songs/request/Requestor.kt @@ -0,0 +1,301 @@ +package io.r_a_d.radio2.ui.songs.request + +import android.util.Log +import androidx.lifecycle.MutableLiveData +import io.r_a_d.radio2.ActionOnError +import io.r_a_d.radio2.Async +import io.r_a_d.radio2.playerstore.Song +import io.r_a_d.radio2.preferenceStore +import io.r_a_d.radio2.tag +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.BufferedReader +import java.io.IOException +import java.io.InputStreamReader +import java.net.CookieHandler +import java.net.CookieManager +import java.net.MalformedURLException +import java.net.URL +import java.util.* +import java.util.regex.Pattern + +import javax.net.ssl.HttpsURLConnection +import kotlin.collections.ArrayList +import kotlin.random.Random + +/** + * Requests a song via the website's API + * + * We scrape the website for a CSRF token and POST it to /request/ endpoint with + * the song id + * + * Created by Kethsar on 1/2/2017. + * Converted to Kotlin and adapted by Yattoz on 05 Nov. 2019 + */ + +class Requestor { + var addRequestMeta: String = "" + private val cookieManager: CookieManager = CookieManager() + private val requestUrl = "https://r-a-d.io/request/%1\$d" + private val searchUrl = "https://r-a-d.io/api/search/%1s?page=%2\$d" + private val favoritesUrl = "https://r-a-d.io/faves/%1s?dl=true" + private val songThresholdStep = 50 + private var songThreshold = songThresholdStep + private var localQuery = "" + + private var token: String? = null + val snackBarText : MutableLiveData = MutableLiveData() + private var responseArray : ArrayList = ArrayList() + val requestSongArray : ArrayList = ArrayList() + val favoritesSongArray : ArrayList = ArrayList() + val isRequestResultUpdated : MutableLiveData = MutableLiveData() + val isFavoritesUpdated : MutableLiveData = MutableLiveData() + var isLoadMoreVisible: Boolean = false + + + init { + snackBarText.value = "" + isRequestResultUpdated.value = false + isFavoritesUpdated.value = false + isLoadMoreVisible = false + } + + fun initFavorites(userName : String? = preferenceStore.getString("userName", null)){ + Log.d(tag, "initializing favorites") + favoritesSongArray.clear() + if (userName == null) + { + // Display is done by default in the XML. + Log.d(tag, "no user name set for favorites") + isFavoritesUpdated.value = true + return + } + val favoritesUserUrl = String.format(Locale.getDefault(), favoritesUrl, userName) + val scrapeFavorites : (Any?) -> JSONArray = { + JSONArray(URL(favoritesUserUrl).readText()) + } + val postFavorites : (Any?) -> Unit = { + val res = it as JSONArray + for (i in 0 until (res).length()) + { + val item = res.getJSONObject(i) + val artistTitle = item.getString("meta") + val id : Int? = if (item.isNull("tracks_id")) + null + else + item.getInt("tracks_id") + + val lastRequested : Int? = if (item.isNull("lastrequested")) null else item.getInt("lastrequested") + val lastPlayed : Int? = if (item.isNull("lastplayed")) null else item.getInt("lastplayed") + val requestCount : Int? = if (item.isNull("requestcount")) null else item.getInt("requestcount") + val isRequestable = (coolDown(lastPlayed, lastRequested, requestCount) < 0) + //Log.d(tag, "val : $id") + favoritesSongArray.add(Song(artistTitle, id ?: 0, isRequestable)) + } + Log.d(tag, "favorites : $favoritesSongArray") + isFavoritesUpdated.value = true + } + Async(scrapeFavorites, postFavorites, ActionOnError.NOTIFY) + } + + fun search(query: String) + { + responseArray.clear() + requestSongArray.clear() + localQuery = query + searchPage(query, 1) // the searchPage function is recursive to get all pages. + } + + private fun searchPage(query: String, pageNumber : Int) + { + val searchURL = String.format(Locale.getDefault(), searchUrl, query, pageNumber) + val scrape : (Any?) -> JSONObject = { + val res = URL(searchURL).readText() + val json = JSONObject(res) + json + } + val post : (Any?) -> Unit = { + val response = RequestResponse(it as JSONObject) + + responseArray.add(response) + for (i in 0 until response.songs.size) + { + requestSongArray.add(response.songs[i]) + } + isRequestResultUpdated.value = true + if (requestSongArray.size >= songThreshold) + { + isLoadMoreVisible = true + + } else { + if (response.currentPage < response.lastPage) + searchPage(query, pageNumber + 1) // recursive call to get the next page + else + finishSearch() + } + + } + Async(scrape, post, ActionOnError.NOTIFY) + } + + private fun finishSearch() + { + isLoadMoreVisible = false + } + + fun reset() + { + requestSongArray.clear() + responseArray.clear() + isRequestResultUpdated.value = false + songThreshold = songThresholdStep + } + + fun loadMore() + { + songThreshold += songThresholdStep + searchPage(localQuery, responseArray.last().currentPage + 1) + } + + + /** + * Scrape the website for the CSRF token required for requesting + * scrapeToken and postToken are the two lambas run by the Async() class. + */ + + private val scrapeToken : (Any?) -> Any? = { + val radioSearchUrl = "https://r-a-d.io/search" + var searchURL: URL? = null + var retVal: String? = null + var reader: BufferedReader? = null + + CookieHandler.setDefault(cookieManager) // it[0] ?? + + try { + searchURL = URL(radioSearchUrl) + } catch (e: MalformedURLException) { + e.printStackTrace() + } + + try { + reader = BufferedReader(InputStreamReader(searchURL!!.openStream(), "UTF-8")) + var line: String? + line = reader.readLine() + while (line != null) + { + line = line.trim { it <= ' ' } + val p = Pattern.compile("value=\"(\\w+)\"") + val m = p.matcher(line) + + if (line.startsWith(" (Unit) = { + token = it as String? + } + + /** + * Request the song with the CSRF token that was scraped + */ + private val requestSong: (Any?) -> Any? = { + val reqString = it as String + var response = "" + + try { + val reqURL = URL(reqString) + val conn = reqURL.openConnection() as HttpsURLConnection + val tokenObject = JSONObject() + + tokenObject.put("_token", token) + val requestBytes = tokenObject.toString().toByteArray() + + conn.requestMethod = "POST" + conn.doOutput = true + conn.doInput = true + conn.setChunkedStreamingMode(0) + conn.setRequestProperty("Content-Type", "application/json") + + val os = conn.outputStream + os.write(requestBytes) + + val responseCode = conn.responseCode + + if (responseCode == HttpsURLConnection.HTTP_OK) { + var line: String? + val br = BufferedReader(InputStreamReader( + conn.inputStream)) + line = br.readLine() + while (line != null) { + response += line + line = br.readLine() + } + } else { + response += "" + } + } catch (ex: IOException) { + ex.printStackTrace() + } catch (ex: JSONException) { + ex.printStackTrace() + } + + response + } + + private val postSong : (Any?) -> (Unit) = { + val response = JSONObject(it as String) + val key = response.names()!!.get(0) as String + val value = response.getString(key) + + snackBarText.postValue(addRequestMeta + value) + } + + + fun request(songID: Int?) { + val requestSongUrl = String.format(requestUrl, songID!!) + if (token == null) { + Async(scrapeToken, postToken, ActionOnError.NOTIFY) + } + Async(requestSong, postSong, ActionOnError.NOTIFY, requestSongUrl) + } + + fun raF() : Song { + // request a random favorite song. HELL YEAH + val requestableSongArray = ArrayList() + for (i in 0 until favoritesSongArray.size) + { + if (favoritesSongArray[i].isRequestable && (favoritesSongArray[i].id ?: 0) > 0) + requestableSongArray.add(favoritesSongArray[i]) + } + return if (requestableSongArray.isNotEmpty()) { + val songNbr = Random(System.currentTimeMillis()).nextInt(1, requestableSongArray.size) + requestableSongArray[songNbr] + } else { + Song("No song requestable - ") + } + } + + companion object { + val instance by lazy { + Requestor() + } + } + +} diff --git a/app/src/main/res/color/button_green.xml b/app/src/main/res/color/button_green.xml new file mode 100644 index 0000000..2550680 --- /dev/null +++ b/app/src/main/res/color/button_green.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/button_green_compat.xml b/app/src/main/res/color/button_green_compat.xml new file mode 100644 index 0000000..dbf46de --- /dev/null +++ b/app/src/main/res/color/button_green_compat.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/color/button_red.xml b/app/src/main/res/color/button_red.xml new file mode 100644 index 0000000..6d6afbf --- /dev/null +++ b/app/src/main/res/color/button_red.xml @@ -0,0 +1,8 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi-v24/ic_stat_new_message.xml b/app/src/main/res/drawable-anydpi-v24/ic_stat_new_message.xml new file mode 100644 index 0000000..b582d07 --- /dev/null +++ b/app/src/main/res/drawable-anydpi-v24/ic_stat_new_message.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable-anydpi/ic_alarm.xml b/app/src/main/res/drawable-anydpi/ic_alarm.xml new file mode 100644 index 0000000..3daccc8 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_alarm.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_av_timer.xml b/app/src/main/res/drawable-anydpi/ic_av_timer.xml new file mode 100644 index 0000000..f13692a --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_av_timer.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_bug.xml b/app/src/main/res/drawable-anydpi/ic_bug.xml new file mode 100644 index 0000000..8dca981 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_bug.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_chat_processing.xml b/app/src/main/res/drawable-anydpi/ic_chat_processing.xml new file mode 100644 index 0000000..aac6443 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_chat_processing.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_customize.xml b/app/src/main/res/drawable-anydpi/ic_customize.xml new file mode 100644 index 0000000..307d8e2 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_customize.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_newspaper.xml b/app/src/main/res/drawable-anydpi/ic_newspaper.xml new file mode 100644 index 0000000..6fd0430 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_newspaper.xml @@ -0,0 +1,10 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-anydpi/ic_notification.xml b/app/src/main/res/drawable-anydpi/ic_notification.xml new file mode 100644 index 0000000..0dbf589 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_notification.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_open_in_browser.xml b/app/src/main/res/drawable-anydpi/ic_open_in_browser.xml new file mode 100644 index 0000000..6f81348 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_open_in_browser.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-anydpi/ic_playlist_music.xml b/app/src/main/res/drawable-anydpi/ic_playlist_music.xml new file mode 100644 index 0000000..3c01620 --- /dev/null +++ b/app/src/main/res/drawable-anydpi/ic_playlist_music.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable-hdpi/ic_alarm.png b/app/src/main/res/drawable-hdpi/ic_alarm.png new file mode 100644 index 0000000..caf3951 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_alarm.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_av_timer.png b/app/src/main/res/drawable-hdpi/ic_av_timer.png new file mode 100644 index 0000000..367600a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_av_timer.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_bug.png b/app/src/main/res/drawable-hdpi/ic_bug.png new file mode 100644 index 0000000..ab4c961 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_bug.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_chat_processing.png b/app/src/main/res/drawable-hdpi/ic_chat_processing.png new file mode 100644 index 0000000..d1743c6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_chat_processing.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_customize.png b/app/src/main/res/drawable-hdpi/ic_customize.png new file mode 100644 index 0000000..362cebc Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_customize.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_newspaper.png b/app/src/main/res/drawable-hdpi/ic_newspaper.png new file mode 100644 index 0000000..de9bdab Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_newspaper.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_notification.png b/app/src/main/res/drawable-hdpi/ic_notification.png new file mode 100644 index 0000000..3a83a64 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_open_in_browser.png b/app/src/main/res/drawable-hdpi/ic_open_in_browser.png new file mode 100644 index 0000000..787c104 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_open_in_browser.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_pause.png b/app/src/main/res/drawable-hdpi/ic_pause.png new file mode 100644 index 0000000..2bb56d9 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_play.png b/app/src/main/res/drawable-hdpi/ic_play.png new file mode 100644 index 0000000..e141540 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_music.png b/app/src/main/res/drawable-hdpi/ic_playlist_music.png new file mode 100644 index 0000000..baf681c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_music.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_stat_new_message.png b/app/src/main/res/drawable-hdpi/ic_stat_new_message.png new file mode 100644 index 0000000..43d78bd Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_new_message.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_stat_now_playing.png b/app/src/main/res/drawable-hdpi/ic_stat_now_playing.png new file mode 100644 index 0000000..cb10246 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stat_now_playing.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_stop.png b/app/src/main/res/drawable-hdpi/ic_stop.png new file mode 100644 index 0000000..93715fe Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_high.png b/app/src/main/res/drawable-hdpi/ic_volume_high.png new file mode 100644 index 0000000..5728ffb Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_volume_high.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_low.png b/app/src/main/res/drawable-hdpi/ic_volume_low.png new file mode 100644 index 0000000..6e1c059 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_volume_low.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_medium.png b/app/src/main/res/drawable-hdpi/ic_volume_medium.png new file mode 100644 index 0000000..697fa09 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_volume_medium.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_volume_off.png b/app/src/main/res/drawable-hdpi/ic_volume_off.png new file mode 100644 index 0000000..d8338e5 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_volume_off.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_newspaper.png b/app/src/main/res/drawable-ldpi/ic_newspaper.png new file mode 100644 index 0000000..e474fd9 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_newspaper.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_pause.png b/app/src/main/res/drawable-ldpi/ic_pause.png new file mode 100644 index 0000000..765f2ea Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_play.png b/app/src/main/res/drawable-ldpi/ic_play.png new file mode 100644 index 0000000..5cb2141 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_play.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_stop.png b/app/src/main/res/drawable-ldpi/ic_stop.png new file mode 100644 index 0000000..b3b979d Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_volume_high.png b/app/src/main/res/drawable-ldpi/ic_volume_high.png new file mode 100644 index 0000000..fa5d2d0 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_volume_high.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_volume_low.png b/app/src/main/res/drawable-ldpi/ic_volume_low.png new file mode 100644 index 0000000..d9735a3 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_volume_low.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_volume_medium.png b/app/src/main/res/drawable-ldpi/ic_volume_medium.png new file mode 100644 index 0000000..5946114 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_volume_medium.png differ diff --git a/app/src/main/res/drawable-ldpi/ic_volume_off.png b/app/src/main/res/drawable-ldpi/ic_volume_off.png new file mode 100644 index 0000000..d840243 Binary files /dev/null and b/app/src/main/res/drawable-ldpi/ic_volume_off.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_alarm.png b/app/src/main/res/drawable-mdpi/ic_alarm.png new file mode 100644 index 0000000..9a7bd94 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_alarm.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_av_timer.png b/app/src/main/res/drawable-mdpi/ic_av_timer.png new file mode 100644 index 0000000..6a14527 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_av_timer.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_bug.png b/app/src/main/res/drawable-mdpi/ic_bug.png new file mode 100644 index 0000000..9d07b21 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_bug.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_chat_processing.png b/app/src/main/res/drawable-mdpi/ic_chat_processing.png new file mode 100644 index 0000000..c92ff3f Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_chat_processing.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_customize.png b/app/src/main/res/drawable-mdpi/ic_customize.png new file mode 100644 index 0000000..0b9feff Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_customize.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_newspaper.png b/app/src/main/res/drawable-mdpi/ic_newspaper.png new file mode 100644 index 0000000..f62f10c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_newspaper.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_notification.png b/app/src/main/res/drawable-mdpi/ic_notification.png new file mode 100644 index 0000000..a3a94d0 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_open_in_browser.png b/app/src/main/res/drawable-mdpi/ic_open_in_browser.png new file mode 100644 index 0000000..b668f95 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_open_in_browser.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_pause.png b/app/src/main/res/drawable-mdpi/ic_pause.png new file mode 100644 index 0000000..b2c2ae2 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_play.png b/app/src/main/res/drawable-mdpi/ic_play.png new file mode 100644 index 0000000..c9b916d Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_music.png b/app/src/main/res/drawable-mdpi/ic_playlist_music.png new file mode 100644 index 0000000..88eb7d4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_music.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_new_message.png b/app/src/main/res/drawable-mdpi/ic_stat_new_message.png new file mode 100644 index 0000000..e424146 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_new_message.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stat_now_playing.png b/app/src/main/res/drawable-mdpi/ic_stat_now_playing.png new file mode 100644 index 0000000..04bd4f6 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stat_now_playing.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_stop.png b/app/src/main/res/drawable-mdpi/ic_stop.png new file mode 100644 index 0000000..3d6917e Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_high.png b/app/src/main/res/drawable-mdpi/ic_volume_high.png new file mode 100644 index 0000000..033ef77 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_volume_high.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_low.png b/app/src/main/res/drawable-mdpi/ic_volume_low.png new file mode 100644 index 0000000..fcde2ff Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_volume_low.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_medium.png b/app/src/main/res/drawable-mdpi/ic_volume_medium.png new file mode 100644 index 0000000..21aad2c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_volume_medium.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_volume_off.png b/app/src/main/res/drawable-mdpi/ic_volume_off.png new file mode 100644 index 0000000..31621a4 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_volume_off.png differ diff --git a/app/src/main/res/drawable-v23/launch_screen.xml b/app/src/main/res/drawable-v23/launch_screen.xml new file mode 100644 index 0000000..13b4f47 --- /dev/null +++ b/app/src/main/res/drawable-v23/launch_screen.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable-xhdpi/ic_alarm.png b/app/src/main/res/drawable-xhdpi/ic_alarm.png new file mode 100644 index 0000000..c3d5a81 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_alarm.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_av_timer.png b/app/src/main/res/drawable-xhdpi/ic_av_timer.png new file mode 100644 index 0000000..fe792eb Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_av_timer.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_bug.png b/app/src/main/res/drawable-xhdpi/ic_bug.png new file mode 100644 index 0000000..eaefe48 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_bug.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_chat_processing.png b/app/src/main/res/drawable-xhdpi/ic_chat_processing.png new file mode 100644 index 0000000..46116df Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_chat_processing.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_customize.png b/app/src/main/res/drawable-xhdpi/ic_customize.png new file mode 100644 index 0000000..7a89035 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_customize.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_newspaper.png b/app/src/main/res/drawable-xhdpi/ic_newspaper.png new file mode 100644 index 0000000..3b6591d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_newspaper.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_notification.png b/app/src/main/res/drawable-xhdpi/ic_notification.png new file mode 100644 index 0000000..6612604 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_open_in_browser.png b/app/src/main/res/drawable-xhdpi/ic_open_in_browser.png new file mode 100644 index 0000000..4c673f6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_open_in_browser.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_pause.png b/app/src/main/res/drawable-xhdpi/ic_pause.png new file mode 100644 index 0000000..181d90f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_play.png b/app/src/main/res/drawable-xhdpi/ic_play.png new file mode 100644 index 0000000..2d181c0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_music.png b/app/src/main/res/drawable-xhdpi/ic_playlist_music.png new file mode 100644 index 0000000..2045bad Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_music.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_new_message.png b/app/src/main/res/drawable-xhdpi/ic_stat_new_message.png new file mode 100644 index 0000000..e10c3a9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_new_message.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stat_now_playing.png b/app/src/main/res/drawable-xhdpi/ic_stat_now_playing.png new file mode 100644 index 0000000..6e1736f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stat_now_playing.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_stop.png b/app/src/main/res/drawable-xhdpi/ic_stop.png new file mode 100644 index 0000000..ef399f6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_high.png b/app/src/main/res/drawable-xhdpi/ic_volume_high.png new file mode 100644 index 0000000..70e7a81 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_volume_high.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_low.png b/app/src/main/res/drawable-xhdpi/ic_volume_low.png new file mode 100644 index 0000000..c453d52 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_volume_low.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_medium.png b/app/src/main/res/drawable-xhdpi/ic_volume_medium.png new file mode 100644 index 0000000..49d34d0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_volume_medium.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_volume_off.png b/app/src/main/res/drawable-xhdpi/ic_volume_off.png new file mode 100644 index 0000000..1b91855 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_volume_off.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_alarm.png b/app/src/main/res/drawable-xxhdpi/ic_alarm.png new file mode 100644 index 0000000..4f7b0c3 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_alarm.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_av_timer.png b/app/src/main/res/drawable-xxhdpi/ic_av_timer.png new file mode 100644 index 0000000..46c9b52 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_av_timer.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_bug.png b/app/src/main/res/drawable-xxhdpi/ic_bug.png new file mode 100644 index 0000000..50b7b30 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_bug.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_chat_processing.png b/app/src/main/res/drawable-xxhdpi/ic_chat_processing.png new file mode 100644 index 0000000..bab7f06 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_chat_processing.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_customize.png b/app/src/main/res/drawable-xxhdpi/ic_customize.png new file mode 100644 index 0000000..a903b27 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_customize.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_newspaper.png b/app/src/main/res/drawable-xxhdpi/ic_newspaper.png new file mode 100644 index 0000000..bf6829a Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_newspaper.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_notification.png b/app/src/main/res/drawable-xxhdpi/ic_notification.png new file mode 100644 index 0000000..f4016de Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_notification.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_open_in_browser.png b/app/src/main/res/drawable-xxhdpi/ic_open_in_browser.png new file mode 100644 index 0000000..34d395e Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_open_in_browser.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_pause.png b/app/src/main/res/drawable-xxhdpi/ic_pause.png new file mode 100644 index 0000000..c142f9d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_play.png b/app/src/main/res/drawable-xxhdpi/ic_play.png new file mode 100644 index 0000000..23c6a0b Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_music.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_music.png new file mode 100644 index 0000000..a510a27 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_music.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_new_message.png b/app/src/main/res/drawable-xxhdpi/ic_stat_new_message.png new file mode 100644 index 0000000..fd75de1 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_new_message.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stat_now_playing.png b/app/src/main/res/drawable-xxhdpi/ic_stat_now_playing.png new file mode 100644 index 0000000..119d50f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stat_now_playing.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_stop.png b/app/src/main/res/drawable-xxhdpi/ic_stop.png new file mode 100644 index 0000000..7aeaa11 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_high.png b/app/src/main/res/drawable-xxhdpi/ic_volume_high.png new file mode 100644 index 0000000..301c634 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_volume_high.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_low.png b/app/src/main/res/drawable-xxhdpi/ic_volume_low.png new file mode 100644 index 0000000..0e358d2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_volume_low.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_medium.png b/app/src/main/res/drawable-xxhdpi/ic_volume_medium.png new file mode 100644 index 0000000..8438df9 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_volume_medium.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_volume_off.png b/app/src/main/res/drawable-xxhdpi/ic_volume_off.png new file mode 100644 index 0000000..7486506 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_volume_off.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_newspaper.png b/app/src/main/res/drawable-xxxhdpi/ic_newspaper.png new file mode 100644 index 0000000..0229d88 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_newspaper.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_pause.png b/app/src/main/res/drawable-xxxhdpi/ic_pause.png new file mode 100644 index 0000000..5216127 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_pause.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_play.png b/app/src/main/res/drawable-xxxhdpi/ic_play.png new file mode 100644 index 0000000..15c69e7 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_play.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stat_now_playing.png b/app/src/main/res/drawable-xxxhdpi/ic_stat_now_playing.png new file mode 100644 index 0000000..4d1b381 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stat_now_playing.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_stop.png b/app/src/main/res/drawable-xxxhdpi/ic_stop.png new file mode 100644 index 0000000..25f7e6b Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_stop.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_high.png b/app/src/main/res/drawable-xxxhdpi/ic_volume_high.png new file mode 100644 index 0000000..11bb24c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_volume_high.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_low.png b/app/src/main/res/drawable-xxxhdpi/ic_volume_low.png new file mode 100644 index 0000000..3be43d2 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_volume_low.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_medium.png b/app/src/main/res/drawable-xxxhdpi/ic_volume_medium.png new file mode 100644 index 0000000..739234a Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_volume_medium.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_volume_off.png b/app/src/main/res/drawable-xxxhdpi/ic_volume_off.png new file mode 100644 index 0000000..30769b9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_volume_off.png differ diff --git a/app/src/main/res/drawable/actionbar_logo.9.png b/app/src/main/res/drawable/actionbar_logo.9.png new file mode 100644 index 0000000..d71088c Binary files /dev/null and b/app/src/main/res/drawable/actionbar_logo.9.png differ diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..ea595ac --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/drawable/ic_radio_logo_round.xml b/app/src/main/res/drawable/ic_radio_logo_round.xml new file mode 100644 index 0000000..76263ae --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_logo_round.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_radiologo.xml b/app/src/main/res/drawable/ic_radiologo.xml new file mode 100644 index 0000000..7a21266 --- /dev/null +++ b/app/src/main/res/drawable/ic_radiologo.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/drawable/launch_screen.xml b/app/src/main/res/drawable/launch_screen.xml new file mode 100644 index 0000000..e15331c --- /dev/null +++ b/app/src/main/res/drawable/launch_screen.xml @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/lollipop_logo.png b/app/src/main/res/drawable/lollipop_logo.png new file mode 100644 index 0000000..637cff9 Binary files /dev/null and b/app/src/main/res/drawable/lollipop_logo.png differ diff --git a/app/src/main/res/drawable/news_bg.png b/app/src/main/res/drawable/news_bg.png new file mode 100644 index 0000000..aa08d79 Binary files /dev/null and b/app/src/main/res/drawable/news_bg.png differ diff --git a/app/src/main/res/drawable/normal_logo.png b/app/src/main/res/drawable/normal_logo.png new file mode 100644 index 0000000..9a286c8 Binary files /dev/null and b/app/src/main/res/drawable/normal_logo.png differ diff --git a/app/src/main/res/drawable/pause_small.png b/app/src/main/res/drawable/pause_small.png new file mode 100644 index 0000000..b633338 Binary files /dev/null and b/app/src/main/res/drawable/pause_small.png differ diff --git a/app/src/main/res/drawable/progress_bar_progress.xml b/app/src/main/res/drawable/progress_bar_progress.xml new file mode 100644 index 0000000..e6233e4 --- /dev/null +++ b/app/src/main/res/drawable/progress_bar_progress.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/seek_bar_progress.xml b/app/src/main/res/drawable/seek_bar_progress.xml new file mode 100644 index 0000000..ab7b54d --- /dev/null +++ b/app/src/main/res/drawable/seek_bar_progress.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/seek_bar_thumb.xml b/app/src/main/res/drawable/seek_bar_thumb.xml new file mode 100644 index 0000000..0a623c6 --- /dev/null +++ b/app/src/main/res/drawable/seek_bar_thumb.xml @@ -0,0 +1,25 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/splash_logo.png b/app/src/main/res/drawable/splash_logo.png new file mode 100644 index 0000000..414b494 Binary files /dev/null and b/app/src/main/res/drawable/splash_logo.png differ diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..7006b70 --- /dev/null +++ b/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_parameters.xml b/app/src/main/res/layout/activity_parameters.xml new file mode 100644 index 0000000..59839b4 --- /dev/null +++ b/app/src/main/res/layout/activity_parameters.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/button_load_more.xml b/app/src/main/res/layout/button_load_more.xml new file mode 100644 index 0000000..89b145d --- /dev/null +++ b/app/src/main/res/layout/button_load_more.xml @@ -0,0 +1,21 @@ + + + +