Initial commit

remotes/origin/devel
yattoz 2019-12-07 11:29:48 +01:00
révision 77d630896f
239 fichiers modifiés avec 8698 ajouts et 0 suppressions

14
.gitignore externe Normal file
Voir le fichier

@ -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

1
.idea/.name Normal file
Voir le fichier

@ -0,0 +1 @@
R-a-dio2

Voir le fichier

@ -0,0 +1,125 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<AndroidXmlCodeStyleSettings>
<option name="ARRANGEMENT_SETTINGS_MIGRATED_TO_191" value="true" />
</AndroidXmlCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>
<codeStyleSettings language="XML">
<indentOptions>
<option name="CONTINUATION_INDENT_SIZE" value="4" />
</indentOptions>
<arrangement>
<rules>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:android</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>xmlns:.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:id</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*:name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>name</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>style</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>^$</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>http://schemas.android.com/apk/res/android</XML_NAMESPACE>
</AND>
</match>
<order>ANDROID_ATTRIBUTE_ORDER</order>
</rule>
</section>
<section>
<rule>
<match>
<AND>
<NAME>.*</NAME>
<XML_ATTRIBUTE />
<XML_NAMESPACE>.*</XML_NAMESPACE>
</AND>
</match>
<order>BY_NAME</order>
</rule>
</section>
</rules>
</arrangement>
</codeStyleSettings>
<codeStyleSettings language="kotlin">
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</codeStyleSettings>
</code_scheme>
</component>

Voir le fichier

@ -0,0 +1,5 @@
<component name="ProjectCodeStyleConfiguration">
<state>
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
</state>
</component>

Voir le fichier

@ -0,0 +1,9 @@
<component name="ProjectDictionaryState">
<dictionary name="dict">
<words>
<w>debouncing</w>
<w>requestable</w>
<w>requestor</w>
</words>
</dictionary>
</component>

6
.idea/encodings.xml Normal file
Voir le fichier

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="PROJECT" charset="UTF-8" />
</component>
</project>

19
.idea/gradle.xml Normal file
Voir le fichier

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="distributionType" value="DEFAULT_WRAPPED" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
</set>
</option>
<option name="resolveModulePerSourceSet" value="false" />
<option name="testRunner" value="PLATFORM" />
</GradleProjectSettings>
</option>
</component>
</project>

9
.idea/misc.xml Normal file
Voir le fichier

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_1_8" project-jdk-name="1.8" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>

Voir le fichier

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="org.jetbrains.plugins.gradle.execution.test.runner.AllInPackageGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestClassGradleConfigurationProducer" />
<option value="org.jetbrains.plugins.gradle.execution.test.runner.TestMethodGradleConfigurationProducer" />
</set>
</option>
</component>
</project>

6
.idea/vcs.xml Normal file
Voir le fichier

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

674
LICENSE Normal file
Voir le fichier

@ -0,0 +1,674 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
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.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
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 <https://www.gnu.org/licenses/>.
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:
<program> Copyright (C) <year> <name of author>
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
<https://www.gnu.org/licenses/>.
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
<https://www.gnu.org/licenses/why-not-lgpl.html>.

50
README.md Normal file
Voir le fichier

@ -0,0 +1,50 @@
![icon](https://r-a-d.io/assets/logo_image_small.png)![logo](https://r-a-d.io/assets/logotitle_2.png)
# R/a/dio, the brand-new Android app!
R/a/dio is a webradio that was founded 7+ years ago with the intention of bringing you a stream of (mostly) high quality anime music, and we keep that up to this day! You can always drop by and visit our website https://r-a-d.io for more information.
![the gif](./demo.gif)
### Features
- Listen to R/a/dio!
- Fine-tune the volume in the app to go lower than the lowest volume of Android!
- Adapt to all screens: small phone, big tablet, horizontal, vertical, split screen!
- Start and stop the stream by headphones plugging/unplugging, or with a bluetooth headset!
- Check last played and queued tracks!
- Request any title to the AFK streamer Hanyuu-sama!
- Display and request your favorite tracks! There's even a random-request button (**.ra f**) available!
- Chat in the IRC with the embedded WebIRC!
- Wake up with the sound of R/a/dio with the built-in Alarm Clock feature! Don't worry: if there is no network, it will play a default sound instead.
- Never miss a stream with the Streamer Notification Service! (**Warning: this feature polls the server regularly and consumes battery. It is MUCH MORE RECOMMENDED to register to Hanyuu-sama's updates on Twitter and use the Twitter app. But if for some reason you don't (or can't) use the Twitter App, this should get you covered.**)
- Supports lastFM, LibreFM and Listenbrainz scrobblers with [Pano Scrobbler](https://play.google.com/store/apps/details?id=com.arn.scrobble)
- **[v1.1]** Snooze the alarm! You can set up the snooze duration you want, or avoid being tempted and disable snooze altogether. When ringing, a special notification design will display bigger buttons with text instead of icons.
- **[v1.1]** Sleep with the sound of R/a/dio! You can set up a timer to stop the app after any amount of minutes. When the timer approaches, the sound will gradually fade out.
As always, thanks for listening!
# Releases
## Release 1.1.0
Incoming! The date will be announced when it reaches the Play Store. The version number may be different, probably a v2.0.0 considering it's a full rewrite of the previous app.
Features added:
- Alarm snooze
- Sleep timer
- Special notification design for alarm
- Added pull-to-refresh on favorites
- Added link to help on how to use favorites in IRC
- Added settings to control API fetch frequency when playback is stopped
Bug fixes:
- Better handling of queue, avoid duplicates
- Should handle correctly when the stream is down ("ed" playing)
- Update the streamer picture in notification at startup
- UI updates correctly when app is opened after alarm wake-up
- Backup alarm sound is now correctly triggered only when necessary
## Release 1.0
- will probably stay undisclosed, as the release will surely be based on what I call 1.1.0

1
app/.gitignore externe Normal file
Voir le fichier

@ -0,0 +1 @@
/build

110
app/build.gradle Normal file
Voir le fichier

@ -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
}

21
app/proguard-rules.pro externe Normal file
Voir le fichier

@ -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

Voir le fichier

@ -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)
}
}

Voir le fichier

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="io.r_a_d.radio2">
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <!-- needed for API 28 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.SET_ALARM"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme.Launcher"
tools:ignore="AllowBackup,GoogleAppIndexingWarning">
<service
android:name=".RadioService"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<service android:name=".streamerNotificationService.StreamerMonitorService"
android:enabled="true"
android:exported="true"/>
<receiver android:name=".BootBroadcastReceiver"
android:directBootAware="true"
tools:targetApi="n">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="io.r_a_d.radio2.PLAY_OR_FALLBACK" />
<action android:name="io.r_a_d.radio2.NOTIFY" /> <!-- this is for Snooze -->
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
</receiver>
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenSize"
android:label="@string/app_name"
android:launchMode="singleTask"
android:screenOrientation="fullSensor">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ParametersActivity"
android:configChanges="orientation|screenSize"
android:launchMode="singleTask"
android:parentActivityName=".MainActivity"
android:screenOrientation="fullSensor" />
<receiver android:name="androidx.media.session.MediaButtonReceiver">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
</application>
</manifest>

Voir le fichier

@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<!-- seems like the font cannot be changed in KiwiIRC without writing a full theme, so loading the CSS below is useless-->
<!--
<link rel="stylesheet" href="stylesheet.css" type="text/css" charset="utf-8" /> -->
</head>
<body>
<div style="height: auto">
<iframe src="https://kiwiirc.com/nextclient/?theme=dark#irc://irc.rizon.net:+6697/#r/a/dio"
style="border:0; width:100%; height:100%; position: absolute; top: 0px; left: 0px">
</iframe>
</div>
</body>
</html>

Fichier binaire non affiché.

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 22 KiB

Voir le fichier

@ -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
}

Voir le fichier

@ -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<Any, Void, Any>() {
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)
}
}
}

Voir le fichier

@ -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<BottomNavigationView>(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<BottomNavigationView>(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)
}
}
}

Voir le fichier

@ -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)
}
}

Voir le fichier

@ -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)
}
}
}

Voir le fichier

@ -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<NavController>? = null
private var isTimerStarted = false
/**
* Called on first creation and when restoring state.
*/
private fun setupBottomNavigationBar() {
val bottomNavigationView = findViewById<BottomNavigationView>(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
}
}
}
*/
}

Voir le fichier

@ -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<Int>,
fragmentManager: FragmentManager,
containerId: Int,
intent: Intent
): LiveData<NavController> {
// Map of tags
val graphIdToTagMap = SparseArray<String>()
// Result. Mutable live data with the selected controlled
val selectedNavController = MutableLiveData<NavController>()
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<Int>,
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<String>,
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"

Voir le fichier

@ -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()
}
}

Voir le fichier

@ -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()
}
}

Voir le fichier

@ -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<String> = 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<Int> = Observer {
setVolume(it)
}
private val isMutedObserver: Observer<Boolean> = Observer {
setVolume(if (it) null else -1)
}
private val isPlayingObserver: Observer<Boolean> = Observer {
if (it)
beginPlaying()
else
stopPlaying()
}
private val startTimeObserver = Observer<Long> {
// 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<String> {
PlayerStore.instance.initApi()
nowPlayingNotification.update(this) // should update the streamer icon
}
private val streamerPictureObserver = Observer<Bitmap> {
nowPlayingNotification.update(this)
}
// ##################################################
// ############## LIFECYCLE CALLBACKS ###############
// ##################################################
override fun onLoadChildren(
parentId: String,
result: Result<MutableList<MediaBrowserCompat.MediaItem>>
) {
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 <API21)
if (PlayerStore.instance.isServiceStarted.value!!) {
val keyEvent =
mediaButtonEvent?.getParcelableExtra<KeyEvent>(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.")
}
}
}

Voir le fichier

@ -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()
}
}
}

Voir le fichier

@ -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

Voir le fichier

@ -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<String>? = 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<String>? = 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<Int>()
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))
}
}
}

Voir le fichier

@ -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<Long?> = 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
}
}

Voir le fichier

@ -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<Boolean> = MutableLiveData()
val isServiceStarted: MutableLiveData<Boolean> = MutableLiveData()
val volume: MutableLiveData<Int> = MutableLiveData()
val playbackState: MutableLiveData<Int> = MutableLiveData()
val currentTime: MutableLiveData<Long> = MutableLiveData()
val streamerPicture: MutableLiveData<Bitmap> = MutableLiveData()
val streamerName: MutableLiveData<String> = MutableLiveData()
val currentSong : Song = Song()
val currentSongBackup: Song = Song()
val lp : ArrayList<Song> = ArrayList()
val queue : ArrayList<Song> = ArrayList()
val isQueueUpdated: MutableLiveData<Boolean> = MutableLiveData()
val isLpUpdated: MutableLiveData<Boolean> = MutableLiveData()
val isMuted : MutableLiveData<Boolean> = MutableLiveData()
val listenersCount: MutableLiveData<Int> = 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()
}
}
}

Voir le fichier

@ -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<String> = MutableLiveData()
val artist: MutableLiveData<String> = MutableLiveData()
val type: MutableLiveData<Int> = MutableLiveData()
val startTime: MutableLiveData<Long> = MutableLiveData()
val stopTime: MutableLiveData<Long> = 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
}
}

Voir le fichier

@ -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<Preference>("timeSet")
val isWakingUp = findPreference<SwitchPreferenceCompat>("isWakingUp")
val alarmDays = findPreference<MultiSelectListPreference>("alarmDays")
val snoozeDuration = findPreference<ListPreference>("snoozeDuration")
fun updateIsWakingUpSummary(preference: SwitchPreferenceCompat?, newValue: Boolean? = true, forceTime: Int? = null, forceDays: Set<String>? = 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<String>?)
{
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<String>()
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<String>)
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
}
}

Voir le fichier

@ -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<EditTextPreference>("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<SwitchPreferenceCompat>("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<SwitchPreferenceCompat>("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<Preference>("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<ListPreference>("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
}
}
}

Voir le fichier

@ -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<Preference>("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
}
}
}

Voir le fichier

@ -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<EditTextPreference>("sleepDuration")
val isSleeping = findPreference<SwitchPreferenceCompat>("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
}
}
}
}

Voir le fichier

@ -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<Preference>("streamerMonitorPeriodPref")
val streamerNotification = preferenceScreen.findPreference<Preference>("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
}
}
}

Voir le fichier

@ -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()
}
}

Voir le fichier

@ -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) {
}
}

Voir le fichier

@ -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<String> = 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()
}
}

Voir le fichier

@ -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()
}
}

Voir le fichier

@ -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<String>()
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.
}
}
}

Voir le fichier

@ -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<WebView>(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
}
}

Voir le fichier

@ -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
}

Voir le fichier

@ -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")
}
}

Voir le fichier

@ -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"
}
}

Voir le fichier

@ -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<News>
/*,
context: Context,
resource: Int,
objects: Array<out Song>*/
) : RecyclerView.Adapter<NewsAdapter.MyViewHolder>() /*ArrayAdapter<Song>(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<TextView>(R.id.news_title)
val text = holder.itemView.findViewById<TextView>(R.id.news_text)
val author = holder.itemView.findViewById<TextView>(R.id.news_author)
val header = holder.itemView.findViewById<TextView>(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
}
*/
}

Voir le fichier

@ -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<RecyclerView>(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)
}
}

Voir le fichier

@ -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<News> = 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)
}
}

Voir le fichier

@ -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<TextView>(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<ConstraintLayout>(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<ImageButton>(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)
}
}
}

Voir le fichier

@ -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
}
}
}

Voir le fichier

@ -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<String?> = 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()
}
}

Voir le fichier

@ -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<Fragment>()
private val fragmentTitleList = ArrayList<String>()
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]
}
}

Voir le fichier

@ -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<Boolean> {
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<RecyclerView>(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()
}
}

Voir le fichier

@ -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<Boolean> {
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<Song>(listOf((Song("No queue - "))))
else
PlayerStore.instance.queue
)
recyclerView = root.findViewById<RecyclerView>(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()
}
}

Voir le fichier

@ -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<Song>
/*,
context: Context,
resource: Int,
objects: Array<out Song>*/
) : RecyclerView.Adapter<SongAdaptater.MyViewHolder>() /*ArrayAdapter<Song>(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
}
*/
}

Voir le fichier

@ -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.
}

Voir le fichier

@ -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<Boolean> = 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<RecyclerView>(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()
}
}

Voir le fichier

@ -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<Boolean> {
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<RecyclerView>(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()
}
}

Voir le fichier

@ -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<Song> = 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
}
}

Voir le fichier

@ -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<Song>
/*,
context: Context,
resource: Int,
objects: Array<out Song>*/
) : RecyclerView.Adapter<RequestSongAdapter.MyViewHolder>() /*ArrayAdapter<Song>(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<TextView>(R.id.request_song_artist)
val title = holder.itemView.findViewById<TextView>(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<Song>()
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()
}
}

Voir le fichier

@ -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<String?> = MutableLiveData()
private var responseArray : ArrayList<RequestResponse> = ArrayList()
val requestSongArray : ArrayList<Song> = ArrayList()
val favoritesSongArray : ArrayList<Song> = ArrayList()
val isRequestResultUpdated : MutableLiveData<Boolean> = MutableLiveData()
val isFavoritesUpdated : MutableLiveData<Boolean> = 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("<form")) {
if (m.find()) {
retVal = m.group(1)
break
}
}
line = reader.readLine()
}
} catch (e: IOException) {
e.printStackTrace()
} finally {
if (reader != null) try {
reader.close()
} catch (ignored: IOException) {
}
}
retVal
}
private val postToken : (Any?) -> (Unit) = {
token = it as String?
}
/**
* Request the song with the CSRF token that was scraped
*/
private val requestSong: (Any?) -> Any? = {
val reqString = it as String
var response = ""
try {
val reqURL = URL(reqString)
val conn = reqURL.openConnection() as HttpsURLConnection
val tokenObject = JSONObject()
tokenObject.put("_token", token)
val requestBytes = tokenObject.toString().toByteArray()
conn.requestMethod = "POST"
conn.doOutput = true
conn.doInput = true
conn.setChunkedStreamingMode(0)
conn.setRequestProperty("Content-Type", "application/json")
val os = conn.outputStream
os.write(requestBytes)
val responseCode = conn.responseCode
if (responseCode == HttpsURLConnection.HTTP_OK) {
var line: String?
val br = BufferedReader(InputStreamReader(
conn.inputStream))
line = br.readLine()
while (line != null) {
response += line
line = br.readLine()
}
} else {
response += ""
}
} catch (ex: IOException) {
ex.printStackTrace()
} catch (ex: JSONException) {
ex.printStackTrace()
}
response
}
private val postSong : (Any?) -> (Unit) = {
val response = JSONObject(it as String)
val key = response.names()!!.get(0) as String
val value = response.getString(key)
snackBarText.postValue(addRequestMeta + value)
}
fun request(songID: Int?) {
val requestSongUrl = String.format(requestUrl, songID!!)
if (token == null) {
Async(scrapeToken, postToken, ActionOnError.NOTIFY)
}
Async(requestSong, postSong, ActionOnError.NOTIFY, requestSongUrl)
}
fun raF() : Song {
// request a random favorite song. HELL YEAH
val requestableSongArray = ArrayList<Song>()
for (i in 0 until favoritesSongArray.size)
{
if (favoritesSongArray[i].isRequestable && (favoritesSongArray[i].id ?: 0) > 0)
requestableSongArray.add(favoritesSongArray[i])
}
return if (requestableSongArray.isNotEmpty()) {
val songNbr = Random(System.currentTimeMillis()).nextInt(1, requestableSongArray.size)
requestableSongArray[songNbr]
} else {
Song("No song requestable - ")
}
}
companion object {
val instance by lazy {
Requestor()
}
}
}

Voir le fichier

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true"
android:color="@color/reqButtonNormal"/> <!-- pressed -->
<item android:state_focused="true"
android:color="@color/reqButtonNormal"/> <!-- focused -->
<item android:color="@color/reqButtonNormal"/> <!-- default -->
</selector>

Voir le fichier

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true"
android:color="@color/reqButtonPressed"/> <!-- pressed -->
<item android:state_focused="true"
android:color="@color/reqButtonNormal"/> <!-- focused -->
<item android:color="@color/reqButtonNormal"/> <!-- default -->
</selector>

Voir le fichier

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_pressed="true"
android:color="@color/reqButtonDisabled"/> <!-- pressed -->
<item android:state_focused="true"
android:color="@color/reqButtonDisabled"/> <!-- focused -->
<item android:color="@color/reqButtonDisabled"/> <!-- default -->
</selector>

Voir le fichier

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="26.086956"
android:viewportHeight="26.086956"
android:tint="#FFFFFF">
<group android:translateX="1.0434783"
android:translateY="1.0434783">
<path
android:fillColor="#FF000000"
android:pathData="M6,18c0,0.55 0.45,1 1,1h1v3.5c0,0.83 0.67,1.5 1.5,1.5s1.5,-0.67 1.5,-1.5L11,19h2v3.5c0,0.83 0.67,1.5 1.5,1.5s1.5,-0.67 1.5,-1.5L16,19h1c0.55,0 1,-0.45 1,-1L18,8L6,8v10zM3.5,8C2.67,8 2,8.67 2,9.5v7c0,0.83 0.67,1.5 1.5,1.5S5,17.33 5,16.5v-7C5,8.67 4.33,8 3.5,8zM20.5,8c-0.83,0 -1.5,0.67 -1.5,1.5v7c0,0.83 0.67,1.5 1.5,1.5s1.5,-0.67 1.5,-1.5v-7c0,-0.83 -0.67,-1.5 -1.5,-1.5zM15.53,2.16l1.3,-1.3c0.2,-0.2 0.2,-0.51 0,-0.71 -0.2,-0.2 -0.51,-0.2 -0.71,0l-1.48,1.48C13.85,1.23 12.95,1 12,1c-0.96,0 -1.86,0.23 -2.66,0.63L7.85,0.15c-0.2,-0.2 -0.51,-0.2 -0.71,0 -0.2,0.2 -0.2,0.51 0,0.71l1.31,1.31C6.97,3.26 6,5.01 6,7h12c0,-1.99 -0.97,-3.75 -2.47,-4.84zM10,5L9,5L9,4h1v1zM15,5h-1L14,4h1v1z"/>
</group>
</vector>

Voir le fichier

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path
android:fillColor="#FF000000"
android:pathData="M22,5.72l-4.6,-3.86 -1.29,1.53 4.6,3.86L22,5.72zM7.88,3.39L6.6,1.86 2,5.71l1.29,1.53 4.59,-3.85zM12.5,8L11,8v6l4.75,2.85 0.75,-1.23 -4,-2.37L12.5,8zM12,4c-4.97,0 -9,4.03 -9,9s4.02,9 9,9c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,20c-3.87,0 -7,-3.13 -7,-7s3.13,-7 7,-7 7,3.13 7,7 -3.13,7 -7,7z"/>
</vector>

Voir le fichier

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path
android:fillColor="#FF000000"
android:pathData="M7,13c1.66,0 3,-1.34 3,-3S8.66,7 7,7s-3,1.34 -3,3 1.34,3 3,3zM19,7h-8v7L3,14L3,5L1,5v15h2v-3h18v3h2v-9c0,-2.21 -1.79,-4 -4,-4z"/>
</vector>

Voir le fichier

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path
android:fillColor="#FF000000"
android:pathData="M20,8h-2.81c-0.45,-0.78 -1.07,-1.45 -1.82,-1.96L17,4.41 15.59,3l-2.17,2.17C12.96,5.06 12.49,5 12,5c-0.49,0 -0.96,0.06 -1.41,0.17L8.41,3 7,4.41l1.62,1.63C7.88,6.55 7.26,7.22 6.81,8L4,8v2h2.09c-0.05,0.33 -0.09,0.66 -0.09,1v1L4,12v2h2v1c0,0.34 0.04,0.67 0.09,1L4,16v2h2.81c1.04,1.79 2.97,3 5.19,3s4.15,-1.21 5.19,-3L20,18v-2h-2.09c0.05,-0.33 0.09,-0.66 0.09,-1v-1h2v-2h-2v-1c0,-0.34 -0.04,-0.67 -0.09,-1L20,10L20,8zM14,16h-4v-2h4v2zM14,12h-4v-2h4v2z"/>
</vector>

Voir le fichier

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path
android:fillColor="#FF000000"
android:pathData="M20,2L4,2c-1.1,0 -1.99,0.9 -1.99,2L2,22l4,-4h14c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM9,11L7,11L7,9h2v2zM13,11h-2L11,9h2v2zM17,11h-2L15,9h2v2z"/>
</vector>

Voir le fichier

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path
android:fillColor="#FF000000"
android:pathData="M3,17v2h6v-2L3,17zM3,5v2h10L13,5L3,5zM13,21v-2h8v-2h-8v-2h-2v6h2zM7,9v2L3,11v2h4v2h2L9,9L7,9zM21,13v-2L11,11v2h10zM15,9h2L17,7h4L21,5h-4L17,3h-2v6z"/>
</vector>

Voir le fichier

@ -0,0 +1,10 @@
<!-- drawable/newspaper.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path android:fillColor="#FF000000" android:pathData="M20,11H4V8H20M20,15H13V13H20M20,19H13V17H20M11,19H4V13H11M20.33,4.67L18.67,3L17,4.67L15.33,3L13.67,4.67L12,3L10.33,4.67L8.67,3L7,4.67L5.33,3L3.67,4.67L2,3V19A2,2 0 0,0 4,21H20A2,2 0 0,0 22,19V3L20.33,4.67Z" />
</vector>

Voir le fichier

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path
android:fillColor="#FF000000"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z"/>
</vector>

Voir le fichier

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path
android:fillColor="#FF000000"
android:pathData="M19,4L5,4c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h4v-2L5,18L5,8h14v10h-4v2h4c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.89,-2 -2,-2zM12,10l-4,4h3v6h2v-6h3l-4,-4z"/>
</vector>

Voir le fichier

@ -0,0 +1,11 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF"
android:alpha="0.8">
<path
android:fillColor="#FF000000"
android:pathData="M15,6L3,6v2h12L15,6zM15,10L3,10v2h12v-2zM3,16h8v-2L3,14v2zM17,6v8.18c-0.31,-0.11 -0.65,-0.18 -1,-0.18 -1.66,0 -3,1.34 -3,3s1.34,3 3,3 3,-1.34 3,-3L19,8h3L22,6h-5z"/>
</vector>

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 605 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 251 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 297 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 204 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 187 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 640 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 273 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 213 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 402 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 655 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 208 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 460 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 630 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 396 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 1.0 KiB

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 3.4 KiB

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 3.5 KiB

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 1.1 KiB

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 485 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 369 B

Fichier binaire non affiché.

Après

Largeur:  |  Hauteur:  |  Taille: 518 B

Certains fichiers ne sont pas affichés car ce diff contient trop de modifications Voir plus