Initial commit
|
|
@ -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
|
||||
|
|
@ -0,0 +1 @@
|
|||
R-a-dio2
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<component name="ProjectCodeStyleConfiguration">
|
||||
<state>
|
||||
<option name="USE_PER_PROJECT_SETTINGS" value="true" />
|
||||
</state>
|
||||
</component>
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="dict">
|
||||
<words>
|
||||
<w>debouncing</w>
|
||||
<w>requestable</w>
|
||||
<w>requestor</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
|
|
@ -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>.
|
||||
|
|
@ -0,0 +1,50 @@
|
|||

|
||||
|
||||
# R/a/dio, the brand-new Android app!
|
||||
|
||||
R/a/dio is a webradio that was founded 7+ years ago with the intention of bringing you a stream of (mostly) high quality anime music, and we keep that up to this day! You can always drop by and visit our website https://r-a-d.io for more information.
|
||||
|
||||

|
||||
|
||||
### Features
|
||||
|
||||
- Listen to R/a/dio!
|
||||
- Fine-tune the volume in the app to go lower than the lowest volume of Android!
|
||||
- Adapt to all screens: small phone, big tablet, horizontal, vertical, split screen!
|
||||
- Start and stop the stream by headphones plugging/unplugging, or with a bluetooth headset!
|
||||
- Check last played and queued tracks!
|
||||
- Request any title to the AFK streamer Hanyuu-sama!
|
||||
- Display and request your favorite tracks! There's even a random-request button (**.ra f**) available!
|
||||
- Chat in the IRC with the embedded WebIRC!
|
||||
- Wake up with the sound of R/a/dio with the built-in Alarm Clock feature! Don't worry: if there is no network, it will play a default sound instead.
|
||||
- Never miss a stream with the Streamer Notification Service! (**Warning: this feature polls the server regularly and consumes battery. It is MUCH MORE RECOMMENDED to register to Hanyuu-sama's updates on Twitter and use the Twitter app. But if for some reason you don't (or can't) use the Twitter App, this should get you covered.**)
|
||||
- Supports lastFM, LibreFM and Listenbrainz scrobblers with [Pano Scrobbler](https://play.google.com/store/apps/details?id=com.arn.scrobble)
|
||||
- **[v1.1]** Snooze the alarm! You can set up the snooze duration you want, or avoid being tempted and disable snooze altogether. When ringing, a special notification design will display bigger buttons with text instead of icons.
|
||||
- **[v1.1]** Sleep with the sound of R/a/dio! You can set up a timer to stop the app after any amount of minutes. When the timer approaches, the sound will gradually fade out.
|
||||
|
||||
As always, thanks for listening!
|
||||
|
||||
|
||||
# Releases
|
||||
|
||||
## Release 1.1.0
|
||||
|
||||
Incoming! The date will be announced when it reaches the Play Store. The version number may be different, probably a v2.0.0 considering it's a full rewrite of the previous app.
|
||||
|
||||
Features added:
|
||||
- Alarm snooze
|
||||
- Sleep timer
|
||||
- Special notification design for alarm
|
||||
- Added pull-to-refresh on favorites
|
||||
- Added link to help on how to use favorites in IRC
|
||||
- Added settings to control API fetch frequency when playback is stopped
|
||||
|
||||
Bug fixes:
|
||||
- Better handling of queue, avoid duplicates
|
||||
- Should handle correctly when the stream is down ("ed" playing)
|
||||
- Update the streamer picture in notification at startup
|
||||
- UI updates correctly when app is opened after alarm wake-up
|
||||
- Backup alarm sound is now correctly triggered only when necessary
|
||||
|
||||
## Release 1.0
|
||||
- will probably stay undisclosed, as the release will surely be based on what I call 1.1.0
|
||||
|
|
@ -0,0 +1 @@
|
|||
/build
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
Después Anchura: | Altura: | Tamaño: 22 KiB |
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
*/
|
||||
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
*/
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -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.
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
Después Anchura: | Altura: | Tamaño: 605 B |
|
Después Anchura: | Altura: | Tamaño: 251 B |
|
Después Anchura: | Altura: | Tamaño: 297 B |
|
Después Anchura: | Altura: | Tamaño: 204 B |
|
Después Anchura: | Altura: | Tamaño: 187 B |
|
Después Anchura: | Altura: | Tamaño: 640 B |
|
Después Anchura: | Altura: | Tamaño: 273 B |
|
Después Anchura: | Altura: | Tamaño: 213 B |
|
Después Anchura: | Altura: | Tamaño: 402 B |
|
Después Anchura: | Altura: | Tamaño: 655 B |
|
Después Anchura: | Altura: | Tamaño: 208 B |
|
Después Anchura: | Altura: | Tamaño: 460 B |
|
Después Anchura: | Altura: | Tamaño: 630 B |
|
Después Anchura: | Altura: | Tamaño: 396 B |
|
Después Anchura: | Altura: | Tamaño: 1.0 KiB |
|
Después Anchura: | Altura: | Tamaño: 3.4 KiB |
|
Después Anchura: | Altura: | Tamaño: 3.5 KiB |
|
Después Anchura: | Altura: | Tamaño: 1.1 KiB |
|
Después Anchura: | Altura: | Tamaño: 485 B |
|
Después Anchura: | Altura: | Tamaño: 369 B |
|
Después Anchura: | Altura: | Tamaño: 518 B |