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 @@
+
+
+# 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.
+
+
+
+### 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("