Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fef23e47b |
@@ -1,37 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug report
|
|
||||||
about: Create a report to help us improve
|
|
||||||
labels: bug
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Steps to reproduce
|
|
||||||
|
|
||||||
1.
|
|
||||||
2.
|
|
||||||
3.
|
|
||||||
|
|
||||||
### Expected behaviour
|
|
||||||
- Tell us what should happen
|
|
||||||
|
|
||||||
### Actual behaviour
|
|
||||||
- Tell us what happens
|
|
||||||
|
|
||||||
### Environment data
|
|
||||||
|
|
||||||
Audiobookshelf Version:
|
|
||||||
|
|
||||||
- [ ] Android App?
|
|
||||||
- [ ] iOS App?
|
|
||||||
|
|
||||||
#### Android Issue
|
|
||||||
Android version:
|
|
||||||
|
|
||||||
Device model:
|
|
||||||
|
|
||||||
Stock or customized system:
|
|
||||||
|
|
||||||
#### iOS Issue
|
|
||||||
iOS Version:
|
|
||||||
|
|
||||||
iPhone model:
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature request
|
|
||||||
about: Suggest an idea for this project
|
|
||||||
labels: enhancement
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Is your feature request related to a problem? Please describe.
|
|
||||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
|
||||||
|
|
||||||
### Describe the solution you'd like
|
|
||||||
A clear and concise description of what you want to happen.
|
|
||||||
|
|
||||||
### Describe alternatives you've considered
|
|
||||||
A clear and concise description of any alternative solutions or features you've considered.
|
|
||||||
|
|
||||||
### Additional context
|
|
||||||
Add any other context or screenshots about the feature request here.
|
|
||||||
@@ -89,5 +89,4 @@ sw.*
|
|||||||
# Vim swap files
|
# Vim swap files
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
/resources/
|
/resources/
|
||||||
/android/app/release/
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
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.
|
|
||||||
|
|
||||||
Audiobookshelf Self-hosted audiobook server
|
|
||||||
Copyright (C) 2021 advplyr
|
|
||||||
|
|
||||||
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:
|
|
||||||
|
|
||||||
Audiobookshelf Copyright (C) 2021 advplyr
|
|
||||||
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,155 @@
|
|||||||
|
import { io } from 'socket.io-client'
|
||||||
|
import axios from 'axios'
|
||||||
|
import EventEmitter from 'events'
|
||||||
|
|
||||||
|
class Server extends EventEmitter {
|
||||||
|
constructor(store) {
|
||||||
|
super()
|
||||||
|
|
||||||
|
this.store = store
|
||||||
|
|
||||||
|
this.url = null
|
||||||
|
this.socket = null
|
||||||
|
|
||||||
|
this.user = null
|
||||||
|
this.connected = false
|
||||||
|
|
||||||
|
this.stream = null
|
||||||
|
}
|
||||||
|
|
||||||
|
get token() {
|
||||||
|
return this.user ? this.user.token : null
|
||||||
|
}
|
||||||
|
|
||||||
|
getAxiosConfig() {
|
||||||
|
return { headers: { Authorization: `Bearer ${this.token}` } }
|
||||||
|
}
|
||||||
|
|
||||||
|
getServerUrl(url) {
|
||||||
|
var urlObject = new URL(url)
|
||||||
|
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
|
||||||
|
}
|
||||||
|
|
||||||
|
setUser(user) {
|
||||||
|
this.user = user
|
||||||
|
this.store.commit('user/setUser', user)
|
||||||
|
if (user) {
|
||||||
|
localStorage.setItem('userToken', user.token)
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('userToken')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setServerUrl(url) {
|
||||||
|
this.url = url
|
||||||
|
localStorage.setItem('serverUrl', url)
|
||||||
|
this.store.commit('setServerUrl', url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(url, token) {
|
||||||
|
var serverUrl = this.getServerUrl(url)
|
||||||
|
var res = await this.ping(serverUrl)
|
||||||
|
if (!res || !res.success) {
|
||||||
|
this.url = null
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var authRes = await this.authorize(serverUrl, token)
|
||||||
|
if (!authRes || !authRes.user) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setServerUrl(serverUrl)
|
||||||
|
console.warn('Connect setting auth user', authRes)
|
||||||
|
this.setUser(authRes.user)
|
||||||
|
this.connectSocket()
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async check(url) {
|
||||||
|
var serverUrl = this.getServerUrl(url)
|
||||||
|
var res = await this.ping(serverUrl)
|
||||||
|
if (!res || !res.success) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return serverUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
async login(url, username, password) {
|
||||||
|
var serverUrl = this.getServerUrl(url)
|
||||||
|
var authUrl = serverUrl + '/login'
|
||||||
|
return axios.post(authUrl, { username, password }).then((res) => {
|
||||||
|
if (!res.data || !res.data.user) {
|
||||||
|
console.error(res.data.error)
|
||||||
|
return {
|
||||||
|
error: res.data.error || 'Unknown Error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setServerUrl(serverUrl)
|
||||||
|
this.setUser(res.data.user)
|
||||||
|
this.connectSocket()
|
||||||
|
return {
|
||||||
|
user: res.data.user
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('[Server] Server auth failed', error)
|
||||||
|
return {
|
||||||
|
error: 'Request Failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
logout() {
|
||||||
|
this.setUser(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
authorize(serverUrl, token) {
|
||||||
|
var authUrl = serverUrl + '/api/authorize'
|
||||||
|
return axios.post(authUrl, null, { headers: { Authorization: `Bearer ${token}` } }).then((res) => {
|
||||||
|
return res.data
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('[Server] Server auth failed', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ping(url) {
|
||||||
|
var pingUrl = url + '/ping'
|
||||||
|
console.log('[Server] Check server', pingUrl)
|
||||||
|
return axios.get(pingUrl).then((res) => {
|
||||||
|
return res.data
|
||||||
|
}).catch(error => {
|
||||||
|
console.error('Server check failed', error)
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
connectSocket() {
|
||||||
|
console.log('[SERVER] Connect Socket', this.url)
|
||||||
|
|
||||||
|
this.socket = io(this.url)
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('[Server] Socket Connected')
|
||||||
|
|
||||||
|
// Authenticate socket with token
|
||||||
|
this.socket.emit('auth', this.token)
|
||||||
|
|
||||||
|
this.connected = true
|
||||||
|
this.emit('connected', true)
|
||||||
|
})
|
||||||
|
this.socket.on('disconnect', () => {
|
||||||
|
console.log('[Server] Socket Disconnected')
|
||||||
|
})
|
||||||
|
this.socket.on('init', (data) => {
|
||||||
|
console.log('[Server] Initial socket data received', data)
|
||||||
|
if (data.stream) {
|
||||||
|
this.stream = data.stream
|
||||||
|
this.store.commit('setStreamAudiobook', data.stream.audiobook)
|
||||||
|
this.emit('initialStream', data.stream)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Server
|
||||||
@@ -1,36 +1,20 @@
|
|||||||
|
//apply plugin: 'com.android.application'
|
||||||
|
//apply plugin: 'kotlin-android'
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id 'com.android.application'
|
id 'com.android.application'
|
||||||
id 'kotlin-android'
|
id 'kotlin-android'
|
||||||
id 'kotlin-kapt'
|
id 'kotlin-kapt'
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
|
||||||
kotlinDaemonJvmArgs = [
|
|
||||||
"-Dfile.encoding=UTF-8",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
|
|
||||||
"--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
kotlinOptions {
|
|
||||||
freeCompilerArgs = ['-Xjvm-default=all']
|
|
||||||
}
|
|
||||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 69
|
versionCode 1
|
||||||
versionName "0.9.41-beta"
|
versionName "1.0"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -53,34 +37,20 @@ repositories {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
configurations.all {
|
|
||||||
resolutionStrategy {
|
|
||||||
force("com.google.android.gms:play-services-base:18.0.1")
|
|
||||||
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
|
||||||
force("androidx.core:core-ktx:$androidx_core_ktx_version")
|
|
||||||
force("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version")
|
|
||||||
force("androidx.media:media:$androidx_media_version")
|
|
||||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
|
||||||
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
|
||||||
|
|
||||||
// failOnVersionConflict()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
|
|
||||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
|
||||||
|
|
||||||
// testImplementation "junit:junit:$junitVersion"
|
testImplementation "junit:junit:$junitVersion"
|
||||||
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||||
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||||
implementation project(':capacitor-cordova-android-plugins')
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
|
||||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
implementation "androidx.core:core-ktx:+"
|
||||||
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
|
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||||
@@ -88,7 +58,6 @@ dependencies {
|
|||||||
|
|
||||||
implementation "androidx.media:media:$androidx_media_version"
|
implementation "androidx.media:media:$androidx_media_version"
|
||||||
|
|
||||||
// Exo Player
|
|
||||||
if (findProject(':exoplayer-library-core') != null) {
|
if (findProject(':exoplayer-library-core') != null) {
|
||||||
implementation project(':exoplayer-library-core')
|
implementation project(':exoplayer-library-core')
|
||||||
implementation project(':exoplayer-library-ui')
|
implementation project(':exoplayer-library-ui')
|
||||||
@@ -103,24 +72,18 @@ dependencies {
|
|||||||
implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayer_version"
|
implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayer_version"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Glide for images
|
|
||||||
implementation "com.github.bumptech.glide:glide:$glide_version"
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
kapt "com.github.bumptech.glide:compiler:$glide_version"
|
||||||
|
|
||||||
// Paper NoSQL Db
|
|
||||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
|
||||||
|
|
||||||
// Simple Storage
|
|
||||||
implementation "com.anggrayudi:storage:0.13.0"
|
|
||||||
|
|
||||||
// OK HTTP
|
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
|
||||||
|
|
||||||
// Jackson for JSON
|
|
||||||
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.1'
|
|
||||||
|
|
||||||
// FFMPEG-Kit
|
|
||||||
implementation 'com.arthenica:ffmpeg-kit-full:4.5.1'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
apply from: 'capacitor.build.gradle'
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
try {
|
||||||
|
def servicesJSON = file('google-services.json')
|
||||||
|
if (servicesJSON.text) {
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
}
|
||||||
|
} catch(Exception e) {
|
||||||
|
logger.warn("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,13 +9,8 @@ android {
|
|||||||
|
|
||||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation project(':capacitor-app')
|
|
||||||
implementation project(':capacitor-dialog')
|
implementation project(':capacitor-dialog')
|
||||||
implementation project(':capacitor-haptics')
|
implementation project(':capacitor-toast')
|
||||||
implementation project(':capacitor-network')
|
|
||||||
implementation project(':capacitor-status-bar')
|
|
||||||
implementation project(':capacitor-storage')
|
|
||||||
implementation project(':robingenz-capacitor-app-update')
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="com.audiobookshelf.app">
|
package="com.audiobookshelf.app">
|
||||||
|
|
||||||
@@ -8,9 +7,6 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
@@ -18,49 +14,31 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="true"
|
android:usesCleartextTraffic="true">
|
||||||
android:networkSecurityConfig="@xml/network_security_config"
|
|
||||||
android:requestLegacyExternalStorage="true">
|
|
||||||
|
|
||||||
|
|
||||||
<!--Used by Android Auto-->
|
|
||||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
|
||||||
android:resource="@drawable/icon" />
|
|
||||||
<meta-data
|
|
||||||
android:name="com.google.android.gms.car.application"
|
|
||||||
android:resource="@xml/automotive_app_desc"/>
|
|
||||||
|
|
||||||
<!-- Support for Cast -->
|
|
||||||
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
|
||||||
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||||
android:name="com.audiobookshelf.app.MainActivity"
|
android:name="com.audiobookshelf.app.MainActivity"
|
||||||
android:label="@string/title_activity_main"
|
android:label="@string/title_activity_main"
|
||||||
android:exported="true"
|
|
||||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||||
android:launchMode="singleTask">
|
android:launchMode="singleTask">
|
||||||
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
</intent-filter>
|
|
||||||
|
|
||||||
<!-- Register URL scheme -->
|
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.intent.action.VIEW" />
|
|
||||||
<category android:name="android.intent.category.DEFAULT" />
|
|
||||||
<category android:name="android.intent.category.BROWSABLE" />
|
|
||||||
<data android:scheme="@string/custom_url_scheme" />
|
|
||||||
</intent-filter>
|
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:exported="false"
|
||||||
|
android:grantUriPermissions="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@@ -69,12 +47,8 @@
|
|||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:exported="true"
|
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
android:name=".player.PlayerNotificationService">
|
android:name=".PlayerNotificationService">
|
||||||
<intent-filter>
|
|
||||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
|
||||||
</intent-filter>
|
|
||||||
</service>
|
</service>
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|||||||
@@ -1,30 +1,10 @@
|
|||||||
[
|
[
|
||||||
{
|
|
||||||
"pkg": "@capacitor/app",
|
|
||||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/dialog",
|
"pkg": "@capacitor/dialog",
|
||||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/haptics",
|
"pkg": "@capacitor/toast",
|
||||||
"classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin"
|
"classpath": "com.capacitorjs.plugins.toast.ToastPlugin"
|
||||||
},
|
|
||||||
{
|
|
||||||
"pkg": "@capacitor/network",
|
|
||||||
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"pkg": "@capacitor/status-bar",
|
|
||||||
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"pkg": "@capacitor/storage",
|
|
||||||
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"pkg": "@robingenz/capacitor-app-update",
|
|
||||||
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,38 @@
|
|||||||
|
package com.audiobookshelf.app
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
|
||||||
|
class Audiobook {
|
||||||
|
var id:String = "audiobook"
|
||||||
|
var token:String = ""
|
||||||
|
var playlistUrl:String = ""
|
||||||
|
var title:String = "No Title"
|
||||||
|
var author:String = "Unknown"
|
||||||
|
var series:String = ""
|
||||||
|
var cover:String = ""
|
||||||
|
var playWhenReady:Boolean = false
|
||||||
|
var startTime:Long = 0
|
||||||
|
var duration:Long = 0
|
||||||
|
|
||||||
|
var hasPlayerLoaded:Boolean = false
|
||||||
|
|
||||||
|
val playlistUri:Uri
|
||||||
|
val coverUri:Uri
|
||||||
|
|
||||||
|
constructor(jsondata:JSObject) {
|
||||||
|
id = jsondata.getString("id", "audiobook").toString()
|
||||||
|
title = jsondata.getString("title", "No Title").toString()
|
||||||
|
token = jsondata.getString("token", "").toString()
|
||||||
|
author = jsondata.getString("author", "Unknown").toString()
|
||||||
|
series = jsondata.getString("series", "").toString()
|
||||||
|
cover = jsondata.getString("cover", "").toString()
|
||||||
|
playlistUrl = jsondata.getString("playlistUrl", "").toString()
|
||||||
|
playWhenReady = jsondata.getBoolean("playWhenReady", false) == true
|
||||||
|
startTime = jsondata.getString("startTime", "0")!!.toLong()
|
||||||
|
duration = jsondata.getString("duration", "0")!!.toLong()
|
||||||
|
|
||||||
|
playlistUri = Uri.parse(playlistUrl)
|
||||||
|
coverUri = Uri.parse(cover)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
package com.audiobookshelf.app
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.android.gms.cast.CastMediaControlIntent
|
|
||||||
import com.google.android.gms.cast.framework.CastOptions
|
|
||||||
import com.google.android.gms.cast.framework.OptionsProvider
|
|
||||||
import com.google.android.gms.cast.framework.SessionProvider
|
|
||||||
import com.google.android.gms.cast.framework.media.CastMediaOptions
|
|
||||||
|
|
||||||
class CastOptionsProvider : OptionsProvider {
|
|
||||||
override fun getCastOptions(context: Context): CastOptions {
|
|
||||||
Log.d("CastOptionsProvider", "getCastOptions")
|
|
||||||
var appId = "FD1F76C5"
|
|
||||||
// var defaultId =CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
|
|
||||||
return CastOptions.Builder()
|
|
||||||
.setReceiverApplicationId(appId).setCastMediaOptions(
|
|
||||||
CastMediaOptions.Builder()
|
|
||||||
// We manage the media session and the notifications ourselves.
|
|
||||||
.setMediaSessionEnabled(false)
|
|
||||||
.setNotificationOptions(null)
|
|
||||||
.build()
|
|
||||||
)
|
|
||||||
.setStopReceiverApplicationWhenEndingSession(true).build()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,14 @@
|
|||||||
package com.audiobookshelf.app
|
package com.audiobookshelf.app
|
||||||
|
|
||||||
import android.Manifest
|
import android.content.ComponentName
|
||||||
import android.app.DownloadManager
|
import android.content.Context
|
||||||
import android.content.*
|
import android.content.Intent
|
||||||
import android.content.pm.PackageManager
|
import android.content.ServiceConnection
|
||||||
import android.os.*
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.core.app.ActivityCompat
|
import com.example.myapp.MyNativeAudio
|
||||||
import com.anggrayudi.storage.SimpleStorage
|
|
||||||
import com.anggrayudi.storage.SimpleStorageHelper
|
|
||||||
import com.audiobookshelf.app.data.AbsDatabase
|
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
|
||||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
|
||||||
import com.audiobookshelf.app.plugins.AbsFileSystem
|
|
||||||
import com.getcapacitor.BridgeActivity
|
import com.getcapacitor.BridgeActivity
|
||||||
import io.paperdb.Paper
|
|
||||||
|
|
||||||
|
|
||||||
class MainActivity : BridgeActivity() {
|
class MainActivity : BridgeActivity() {
|
||||||
private val tag = "MainActivity"
|
private val tag = "MainActivity"
|
||||||
@@ -26,149 +18,48 @@ class MainActivity : BridgeActivity() {
|
|||||||
private lateinit var mConnection : ServiceConnection
|
private lateinit var mConnection : ServiceConnection
|
||||||
|
|
||||||
lateinit var pluginCallback : () -> Unit
|
lateinit var pluginCallback : () -> Unit
|
||||||
lateinit var downloaderCallback : (String, Long) -> Unit
|
|
||||||
|
|
||||||
val storageHelper = SimpleStorageHelper(this)
|
|
||||||
val storage = SimpleStorage(this)
|
|
||||||
|
|
||||||
val REQUEST_PERMISSIONS = 1
|
|
||||||
var PERMISSIONS_ALL = arrayOf(
|
|
||||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
|
||||||
)
|
|
||||||
|
|
||||||
val broadcastReceiver = object: BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
when (intent?.action) {
|
|
||||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
|
||||||
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
|
|
||||||
downloaderCallback("complete", thisdlid)
|
|
||||||
}
|
|
||||||
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
|
|
||||||
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
|
|
||||||
downloaderCallback("clicked", thisdlid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
// TODO: Optimize using strict mode logs
|
|
||||||
// StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.Builder()
|
|
||||||
// .detectDiskReads()
|
|
||||||
// .detectDiskWrites().detectAll()
|
|
||||||
// .detectNetwork() // or .detectAll() for all detectable problems
|
|
||||||
// .penaltyLog()
|
|
||||||
// .build())
|
|
||||||
// StrictMode.setVmPolicy(VmPolicy.Builder()
|
|
||||||
// .detectLeakedSqlLiteObjects()
|
|
||||||
// .detectLeakedClosableObjects()
|
|
||||||
// .penaltyLog()
|
|
||||||
// .build())
|
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
Log.d(tag, "onCreate")
|
Log.d(tag, "onCreate")
|
||||||
|
registerPlugin(MyNativeAudio::class.java)
|
||||||
// Grant full storage access for testing
|
|
||||||
// var ss = SimpleStorage(this)
|
|
||||||
// ss.requestFullStorageAccess()
|
|
||||||
|
|
||||||
var permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
|
||||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
|
||||||
ActivityCompat.requestPermissions(this,
|
|
||||||
PERMISSIONS_ALL,
|
|
||||||
REQUEST_PERMISSIONS)
|
|
||||||
}
|
|
||||||
|
|
||||||
registerPlugin(AbsAudioPlayer::class.java)
|
|
||||||
registerPlugin(AbsDownloader::class.java)
|
|
||||||
registerPlugin(AbsFileSystem::class.java)
|
|
||||||
registerPlugin(AbsDatabase::class.java)
|
|
||||||
|
|
||||||
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
|
|
||||||
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
|
|
||||||
}
|
|
||||||
registerReceiver(broadcastReceiver, filter)
|
|
||||||
|
|
||||||
Paper.init(applicationContext)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
super.onDestroy()
|
|
||||||
unregisterReceiver(broadcastReceiver)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||||
super.onPostCreate(savedInstanceState)
|
super.onPostCreate(savedInstanceState)
|
||||||
Log.d(tag, "onPostCreate MainActivity")
|
|
||||||
|
|
||||||
mConnection = object : ServiceConnection {
|
mConnection = object : ServiceConnection {
|
||||||
override fun onServiceDisconnected(name: ComponentName) {
|
override fun onServiceDisconnected(name: ComponentName) {
|
||||||
Log.w(tag, "Service Disconnected $name")
|
Log.w(tag, "Service Disconnected")
|
||||||
mBounded = false
|
mBounded = false
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||||
Log.d(tag, "Service Connected $name")
|
Log.d(tag, "Service Connected")
|
||||||
|
|
||||||
|
|
||||||
mBounded = true
|
mBounded = true
|
||||||
val mLocalBinder = service as PlayerNotificationService.LocalBinder
|
val mLocalBinder = service as PlayerNotificationService.LocalBinder
|
||||||
foregroundService = mLocalBinder.getService()
|
foregroundService = mLocalBinder.getService()
|
||||||
|
|
||||||
// Let NativeAudio know foreground service is ready and setup event listener
|
// Let MyNativeAudio know foreground service is ready and setup event listener
|
||||||
if (pluginCallback != null) {
|
if (pluginCallback != null) {
|
||||||
pluginCallback()
|
pluginCallback()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Intent(this, PlayerNotificationService::class.java).also { intent ->
|
val startIntent = Intent(this, PlayerNotificationService::class.java)
|
||||||
Log.d(tag, "Binding PlayerNotificationService")
|
bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE);
|
||||||
bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
fun stopMyService() {
|
fun stopMyService() {
|
||||||
if (mBounded) {
|
if (mBounded) {
|
||||||
mConnection.let { unbindService(it) };
|
mConnection?.let { unbindService(it) };
|
||||||
mBounded = false;
|
mBounded = false;
|
||||||
}
|
}
|
||||||
val stopIntent = Intent(this, PlayerNotificationService::class.java)
|
val stopIntent = Intent(this, PlayerNotificationService::class.java)
|
||||||
stopService(stopIntent)
|
stopService(stopIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
|
|
||||||
downloaderCallback = cb
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSaveInstanceState(outState: Bundle) {
|
|
||||||
storageHelper.onSaveInstanceState(outState)
|
|
||||||
super.onSaveInstanceState(outState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
|
||||||
super.onRestoreInstanceState(savedInstanceState)
|
|
||||||
storageHelper.onRestoreInstanceState(savedInstanceState)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
|
||||||
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
|
||||||
storageHelper.storage.onActivityResult(requestCode, resultCode, data)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
|
||||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
||||||
Log.d(tag, "onRequestPermissionResult $requestCode")
|
|
||||||
permissions.forEach { Log.d(tag, "PERMISSION $it") }
|
|
||||||
grantResults.forEach { Log.d(tag, "GRANTREUSLTS $it") }
|
|
||||||
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
|
||||||
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
|
||||||
}
|
|
||||||
|
|
||||||
// override fun onUserInteraction() {
|
|
||||||
// super.onUserInteraction()
|
|
||||||
// Log.d(tag, "USER INTERACTION")
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package com.example.myapp
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.audiobookshelf.app.Audiobook
|
||||||
|
import com.audiobookshelf.app.MainActivity
|
||||||
|
import com.audiobookshelf.app.PlayerNotificationService
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.getcapacitor.Plugin
|
||||||
|
import com.getcapacitor.PluginCall
|
||||||
|
import com.getcapacitor.PluginMethod
|
||||||
|
import com.getcapacitor.annotation.CapacitorPlugin
|
||||||
|
|
||||||
|
@CapacitorPlugin(name = "MyNativeAudio")
|
||||||
|
class MyNativeAudio : Plugin() {
|
||||||
|
private val tag = "MyNativeAudio"
|
||||||
|
|
||||||
|
lateinit var mainActivity:MainActivity
|
||||||
|
lateinit var playerNotificationService: PlayerNotificationService
|
||||||
|
|
||||||
|
override fun load() {
|
||||||
|
mainActivity = (activity as MainActivity)
|
||||||
|
|
||||||
|
var foregroundServiceReady : () -> Unit = {
|
||||||
|
playerNotificationService = mainActivity.foregroundService
|
||||||
|
|
||||||
|
playerNotificationService.setCustomObjectListener(object: PlayerNotificationService.MyCustomObjectListener {
|
||||||
|
override fun onPlayingUpdate(isPlaying:Boolean) {
|
||||||
|
emit("onPlayingUpdate", isPlaying)
|
||||||
|
}
|
||||||
|
override fun onMetadata(metadata:JSObject) {
|
||||||
|
notifyListeners("onMetadata", metadata)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
mainActivity.pluginCallback = foregroundServiceReady
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(evtName: String, value:Any) {
|
||||||
|
var ret:JSObject = JSObject()
|
||||||
|
ret.put("value", value)
|
||||||
|
notifyListeners(evtName, ret)
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun initPlayer(call: PluginCall) {
|
||||||
|
if (!PlayerNotificationService.isStarted) {
|
||||||
|
Log.w(tag, "Starting foreground service --")
|
||||||
|
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
|
||||||
|
ContextCompat.startForegroundService(mainActivity, intent)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(tag, "Service already started --")
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
var audiobook:Audiobook = Audiobook(call.data)
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.initPlayer(audiobook)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun getCurrentTime(call: PluginCall) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
var currentTime = playerNotificationService.getCurrentTime()
|
||||||
|
Log.d(tag, "Get Current Time $currentTime")
|
||||||
|
val ret = JSObject()
|
||||||
|
ret.put("value", currentTime)
|
||||||
|
call.resolve(ret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun pausePlayer(call: PluginCall) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.pause()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun playPlayer(call: PluginCall) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.play()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun seekPlayer(call: PluginCall) {
|
||||||
|
var time:Long = call.getString("timeMs", "0")!!.toLong()
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.seekPlayer(time)
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@PluginMethod
|
||||||
|
fun seekForward10(call: PluginCall) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.seekForward10()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@PluginMethod
|
||||||
|
fun seekBackward10(call: PluginCall) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.seekBackward10()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@PluginMethod
|
||||||
|
fun terminateStream(call: PluginCall) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
playerNotificationService.terminateStream()
|
||||||
|
call.resolve()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,422 @@
|
|||||||
|
package com.audiobookshelf.app
|
||||||
|
|
||||||
|
import android.app.*
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Binder
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import com.google.android.exoplayer2.*
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
||||||
|
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
||||||
|
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||||
|
import com.google.android.exoplayer2.upstream.*
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
|
||||||
|
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||||
|
|
||||||
|
class PlayerNotificationService : Service() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
var isStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MyCustomObjectListener {
|
||||||
|
fun onPlayingUpdate(isPlaying: Boolean)
|
||||||
|
fun onMetadata(metadata: JSObject)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val tag = "PlayerService"
|
||||||
|
|
||||||
|
private lateinit var listener:MyCustomObjectListener
|
||||||
|
private lateinit var ctx:Context
|
||||||
|
private lateinit var mPlayer: SimpleExoPlayer
|
||||||
|
private lateinit var mediaSessionConnector: MediaSessionConnector
|
||||||
|
private lateinit var playerNotificationManager: PlayerNotificationManager
|
||||||
|
private lateinit var mediaSession: MediaSessionCompat
|
||||||
|
|
||||||
|
private val serviceJob = SupervisorJob()
|
||||||
|
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||||
|
private val binder = LocalBinder()
|
||||||
|
private val glideOptions = RequestOptions()
|
||||||
|
.fallback(R.drawable.icon)
|
||||||
|
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||||
|
|
||||||
|
private var notificationId = 10;
|
||||||
|
private var channelId = "audiobookshelf_channel"
|
||||||
|
private var channelName = "Audiobookshelf Channel"
|
||||||
|
|
||||||
|
private var currentAudiobook:Audiobook? = null
|
||||||
|
|
||||||
|
fun setCustomObjectListener(mylistener: MyCustomObjectListener) {
|
||||||
|
listener = mylistener
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Service related stuff
|
||||||
|
*/
|
||||||
|
override fun onBind(intent: Intent?): IBinder? {
|
||||||
|
Log.d(tag, "onBind")
|
||||||
|
return binder
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class LocalBinder : Binder() {
|
||||||
|
// Return this instance of LocalService so clients can call public methods
|
||||||
|
fun getService(): PlayerNotificationService = this@PlayerNotificationService
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopService(context: Context) {
|
||||||
|
val stopIntent = Intent(context, PlayerNotificationService::class.java)
|
||||||
|
context.stopService(stopIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
Log.d(tag, "onStartCommand $startId")
|
||||||
|
isStarted = true
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresApi(Build.VERSION_CODES.O)
|
||||||
|
private fun createNotificationChannel(channelId: String, channelName: String): String{
|
||||||
|
val chan = NotificationChannel(channelId,
|
||||||
|
channelName, NotificationManager.IMPORTANCE_HIGH)
|
||||||
|
chan.lightColor = Color.DKGRAY
|
||||||
|
chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||||
|
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
service.createNotificationChannel(chan)
|
||||||
|
return channelId
|
||||||
|
}
|
||||||
|
|
||||||
|
// detach player
|
||||||
|
override fun onDestroy() {
|
||||||
|
playerNotificationManager.setPlayer(null)
|
||||||
|
mPlayer.release()
|
||||||
|
mediaSession.release()
|
||||||
|
Log.d(tag, "onDestroy")
|
||||||
|
isStarted = false
|
||||||
|
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
//removing service when user swipe out our app
|
||||||
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
super.onTaskRemoved(rootIntent)
|
||||||
|
Log.d(tag, "onTaskRemoved")
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
ctx = this
|
||||||
|
|
||||||
|
channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
createNotificationChannel(channelId, channelName)
|
||||||
|
} else ""
|
||||||
|
|
||||||
|
mPlayer = SimpleExoPlayer.Builder(this).build()
|
||||||
|
setPlayerListeners()
|
||||||
|
|
||||||
|
val sessionActivityPendingIntent =
|
||||||
|
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
|
||||||
|
PendingIntent.getActivity(this, 0, sessionIntent, 0)
|
||||||
|
}
|
||||||
|
mediaSession = MediaSessionCompat(this, tag)
|
||||||
|
.apply {
|
||||||
|
setSessionActivity(sessionActivityPendingIntent)
|
||||||
|
isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "Media Session Set")
|
||||||
|
|
||||||
|
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
||||||
|
|
||||||
|
val builder = PlayerNotificationManager.Builder(
|
||||||
|
ctx,
|
||||||
|
notificationId,
|
||||||
|
channelId)
|
||||||
|
|
||||||
|
builder.setMediaDescriptionAdapter(DescriptionAdapter(mediaController))
|
||||||
|
|
||||||
|
builder.setNotificationListener(object : PlayerNotificationManager.NotificationListener {
|
||||||
|
override fun onNotificationPosted(
|
||||||
|
notificationId: Int,
|
||||||
|
notification: Notification,
|
||||||
|
onGoing: Boolean) {
|
||||||
|
|
||||||
|
// Start foreground service
|
||||||
|
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||||
|
startForeground(notificationId, notification)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNotificationCancelled(
|
||||||
|
notificationId: Int,
|
||||||
|
dismissedByUser: Boolean
|
||||||
|
) {
|
||||||
|
if (dismissedByUser) {
|
||||||
|
Log.d(tag, "onNotificationCancelled dismissed by user")
|
||||||
|
stopSelf()
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
playerNotificationManager = builder.build()
|
||||||
|
|
||||||
|
|
||||||
|
playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken)
|
||||||
|
playerNotificationManager.setUsePlayPauseActions(true)
|
||||||
|
playerNotificationManager.setUseNextAction(false)
|
||||||
|
playerNotificationManager.setUsePreviousAction(false)
|
||||||
|
playerNotificationManager.setUseChronometer(false)
|
||||||
|
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
|
|
||||||
|
// Unknown action
|
||||||
|
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||||
|
|
||||||
|
// Color is set based on the art - cannot override
|
||||||
|
// playerNotificationManager.setColor(Color.RED)
|
||||||
|
// playerNotificationManager.setColorized(true)
|
||||||
|
|
||||||
|
// Icon needs to be black and white
|
||||||
|
// playerNotificationManager.setSmallIcon(R.drawable.icon_32)
|
||||||
|
|
||||||
|
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
||||||
|
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
|
||||||
|
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(currentAudiobook!!.id)
|
||||||
|
.setTitle(currentAudiobook!!.title)
|
||||||
|
.setSubtitle(currentAudiobook!!.author)
|
||||||
|
.setMediaUri(currentAudiobook!!.playlistUri)
|
||||||
|
.setIconUri(currentAudiobook!!.coverUri)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
||||||
|
mediaSessionConnector.setPlayer(mPlayer)
|
||||||
|
|
||||||
|
//attach player to playerNotificationManager
|
||||||
|
playerNotificationManager.setPlayer(mPlayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class DescriptionAdapter(private val controller: MediaControllerCompat) :
|
||||||
|
PlayerNotificationManager.MediaDescriptionAdapter {
|
||||||
|
|
||||||
|
var currentIconUri: Uri? = null
|
||||||
|
var currentBitmap: Bitmap? = null
|
||||||
|
|
||||||
|
override fun createCurrentContentIntent(player: Player): PendingIntent? =
|
||||||
|
controller.sessionActivity
|
||||||
|
|
||||||
|
override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString()
|
||||||
|
|
||||||
|
override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString()
|
||||||
|
|
||||||
|
override fun getCurrentLargeIcon(
|
||||||
|
player: Player,
|
||||||
|
callback: PlayerNotificationManager.BitmapCallback
|
||||||
|
): Bitmap? {
|
||||||
|
val albumArtUri = controller.metadata.description.iconUri
|
||||||
|
|
||||||
|
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
||||||
|
// Cache the bitmap for the current audiobook so that successive calls to
|
||||||
|
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||||
|
currentIconUri = albumArtUri
|
||||||
|
serviceScope.launch {
|
||||||
|
currentBitmap = albumArtUri?.let {
|
||||||
|
resolveUriAsBitmap(it)
|
||||||
|
}
|
||||||
|
currentBitmap?.let { callback.onBitmap(it) }
|
||||||
|
}
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
currentBitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
// Block on downloading artwork.
|
||||||
|
Glide.with(ctx).applyDefaultRequestOptions(glideOptions)
|
||||||
|
.asBitmap()
|
||||||
|
.load(uri)
|
||||||
|
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setPlayerListeners() {
|
||||||
|
mPlayer.addListener(object : Player.Listener {
|
||||||
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
|
error.message?.let { Log.e(tag, it) }
|
||||||
|
error.localizedMessage?.let { Log.e(tag, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
|
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
|
||||||
|
Log.d(tag, "EVENT_TRACKS_CHANGED")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.contains(Player.EVENT_TIMELINE_CHANGED)) {
|
||||||
|
Log.d(tag, "EVENT_TIMELINE_CHANGED")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
|
||||||
|
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) {
|
||||||
|
Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + mPlayer.isLoading.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
||||||
|
if (mPlayer.playbackState == Player.STATE_READY) {
|
||||||
|
Log.d(tag, "STATE_READY : " + mPlayer.duration.toString())
|
||||||
|
|
||||||
|
if (!currentAudiobook!!.hasPlayerLoaded && currentAudiobook!!.startTime > 0) {
|
||||||
|
Log.d(tag, "Should seek to ${currentAudiobook!!.startTime}")
|
||||||
|
mPlayer.seekTo(currentAudiobook!!.startTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAudiobook!!.hasPlayerLoaded = true
|
||||||
|
sendClientMetadata("ready")
|
||||||
|
}
|
||||||
|
if (mPlayer.playbackState == Player.STATE_BUFFERING) {
|
||||||
|
Log.d(tag, "STATE_BUFFERING : " + mPlayer.currentPosition.toString())
|
||||||
|
sendClientMetadata("buffering")
|
||||||
|
}
|
||||||
|
if (mPlayer.playbackState == Player.STATE_ENDED) {
|
||||||
|
Log.d(tag, "STATE_ENDED")
|
||||||
|
sendClientMetadata("ended")
|
||||||
|
}
|
||||||
|
if (mPlayer.playbackState == Player.STATE_IDLE) {
|
||||||
|
Log.d(tag, "STATE_IDLE")
|
||||||
|
sendClientMetadata("idle")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) {
|
||||||
|
Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED")
|
||||||
|
}
|
||||||
|
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
|
||||||
|
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED")
|
||||||
|
}
|
||||||
|
if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) {
|
||||||
|
Log.d(tag, "EVENT IS PLAYING CHANGED")
|
||||||
|
if (listener != null) listener.onPlayingUpdate(player.isPlaying)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
User callable methods
|
||||||
|
*/
|
||||||
|
|
||||||
|
// fun initPlayer(token: String, playlistUri: String, playWhenReady: Boolean, currentTime: Long, title: String, artist: String, albumArt: String) {
|
||||||
|
fun initPlayer(audiobook: Audiobook) {
|
||||||
|
currentAudiobook = audiobook
|
||||||
|
|
||||||
|
Log.d(tag, "Init Player Audiobook ${currentAudiobook!!.playlistUrl} | ${currentAudiobook!!.title} | ${currentAudiobook!!.author}")
|
||||||
|
|
||||||
|
if (mPlayer.isPlaying) {
|
||||||
|
Log.d(tag, "Init Player audiobook already playing")
|
||||||
|
}
|
||||||
|
|
||||||
|
val metadata = MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentAudiobook!!.title)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentAudiobook!!.title)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentAudiobook!!.author)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, currentAudiobook!!.author)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentAudiobook!!.author)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAudiobook!!.series)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, currentAudiobook!!.cover)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, currentAudiobook!!.cover)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentAudiobook!!.id)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
mediaSession.setMetadata(metadata)
|
||||||
|
|
||||||
|
var mediaMetadata = MediaMetadata.Builder().build()
|
||||||
|
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.playlistUri).setMediaMetadata(mediaMetadata).build()
|
||||||
|
|
||||||
|
var dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||||
|
dataSourceFactory.setUserAgent(channelId)
|
||||||
|
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${currentAudiobook!!.token}"))
|
||||||
|
|
||||||
|
var mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
|
||||||
|
|
||||||
|
mPlayer.setMediaSource(mediaSource, true)
|
||||||
|
mPlayer.prepare()
|
||||||
|
mPlayer.playWhenReady = currentAudiobook!!.playWhenReady
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fun getCurrentTime() : Long {
|
||||||
|
return mPlayer.currentPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play() {
|
||||||
|
if (mPlayer.isPlaying) {
|
||||||
|
Log.d(tag, "Already playing")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mPlayer.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause() {
|
||||||
|
mPlayer.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekPlayer(time: Long) {
|
||||||
|
mPlayer.seekTo(time)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekForward10() {
|
||||||
|
mPlayer.seekTo(mPlayer.currentPosition + 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekBackward10() {
|
||||||
|
mPlayer.seekTo(mPlayer.currentPosition - 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun terminateStream() {
|
||||||
|
if (mPlayer.playbackState == Player.STATE_READY) {
|
||||||
|
mPlayer.clearMediaItems()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendClientMetadata(stateName: String) {
|
||||||
|
var metadata = JSObject()
|
||||||
|
var duration = mPlayer.duration
|
||||||
|
if (duration < 0) duration = 0
|
||||||
|
metadata.put("duration", duration)
|
||||||
|
metadata.put("currentTime", mPlayer.currentPosition)
|
||||||
|
metadata.put("stateName", stateName)
|
||||||
|
if (listener != null) listener.onMetadata(metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude
|
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
|
||||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
|
|
||||||
import com.fasterxml.jackson.databind.jsonschema.JsonSerializableSchema
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeStream(
|
|
||||||
val index:Int,
|
|
||||||
val codec_name:String,
|
|
||||||
val codec_long_name:String,
|
|
||||||
val channels:Int,
|
|
||||||
val channel_layout:String,
|
|
||||||
val duration:Double,
|
|
||||||
val bit_rate:Double
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeChapterTags(
|
|
||||||
val title:String
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeChapter(
|
|
||||||
val id:Int,
|
|
||||||
val start:Int,
|
|
||||||
val end:Int,
|
|
||||||
val tags:AudioProbeChapterTags?
|
|
||||||
) {
|
|
||||||
@JsonIgnore
|
|
||||||
fun getBookChapter():BookChapter {
|
|
||||||
var startS = start / 1000.0
|
|
||||||
var endS = end / 1000.0
|
|
||||||
var title = tags?.title ?: "Chapter $id"
|
|
||||||
return BookChapter(id, startS, endS, title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeFormatTags(
|
|
||||||
val artist:String?,
|
|
||||||
val album:String?,
|
|
||||||
val comment:String?,
|
|
||||||
val date:String?,
|
|
||||||
val genre:String?,
|
|
||||||
val title:String?
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioProbeFormat(
|
|
||||||
val filename:String,
|
|
||||||
val format_name:String,
|
|
||||||
val duration:Double,
|
|
||||||
val size:Long,
|
|
||||||
val bit_rate:Double,
|
|
||||||
val tags:AudioProbeFormatTags
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
class AudioProbeResult (
|
|
||||||
val streams:MutableList<AudioProbeStream>,
|
|
||||||
val chapters:MutableList<AudioProbeChapter>,
|
|
||||||
val format:AudioProbeFormat) {
|
|
||||||
|
|
||||||
val duration get() = format.duration
|
|
||||||
val size get() = format.size
|
|
||||||
val title get() = format.tags.title ?: format.filename.split("/").last()
|
|
||||||
val artist get() = format.tags.artist ?: ""
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getBookChapters(): List<BookChapter> {
|
|
||||||
if (chapters.isEmpty()) return mutableListOf()
|
|
||||||
return chapters.map { it.getBookChapter() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,357 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.fasterxml.jackson.annotation.*
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LibraryItem(
|
|
||||||
var id:String,
|
|
||||||
var ino:String,
|
|
||||||
var libraryId:String,
|
|
||||||
var folderId:String,
|
|
||||||
var path:String,
|
|
||||||
var relPath:String,
|
|
||||||
var mtimeMs:Long,
|
|
||||||
var ctimeMs:Long,
|
|
||||||
var birthtimeMs:Long,
|
|
||||||
var addedAt:Long,
|
|
||||||
var updatedAt:Long,
|
|
||||||
var lastScan:Long?,
|
|
||||||
var scanVersion:String?,
|
|
||||||
var isMissing:Boolean,
|
|
||||||
var isInvalid:Boolean,
|
|
||||||
var mediaType:String,
|
|
||||||
var media:MediaType,
|
|
||||||
var libraryFiles:MutableList<LibraryFile>?
|
|
||||||
) : LibraryItemWrapper() {
|
|
||||||
@get:JsonIgnore
|
|
||||||
val title get() = media.metadata.title
|
|
||||||
@get:JsonIgnore
|
|
||||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCoverUri():Uri {
|
|
||||||
if (media.coverPath == null) {
|
|
||||||
return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaMetadata(): MediaMetadataCompat {
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This auto-detects whether it is a Book or Podcast
|
|
||||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
|
||||||
@JsonSubTypes(
|
|
||||||
JsonSubTypes.Type(Book::class),
|
|
||||||
JsonSubTypes.Type(Podcast::class)
|
|
||||||
)
|
|
||||||
open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
|
||||||
@JsonIgnore
|
|
||||||
open fun getAudioTracks():List<AudioTrack> { return mutableListOf() }
|
|
||||||
@JsonIgnore
|
|
||||||
open fun setAudioTracks(audioTracks:MutableList<AudioTrack>) { }
|
|
||||||
@JsonIgnore
|
|
||||||
open fun addAudioTrack(audioTrack:AudioTrack) { }
|
|
||||||
@JsonIgnore
|
|
||||||
open fun removeAudioTrack(localFileId:String) { }
|
|
||||||
@JsonIgnore
|
|
||||||
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata(""),null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
class Podcast(
|
|
||||||
metadata:PodcastMetadata,
|
|
||||||
coverPath:String?,
|
|
||||||
var tags:MutableList<String>,
|
|
||||||
var episodes:MutableList<PodcastEpisode>?,
|
|
||||||
var autoDownloadEpisodes:Boolean
|
|
||||||
) : MediaType(metadata, coverPath) {
|
|
||||||
@JsonIgnore
|
|
||||||
override fun getAudioTracks():List<AudioTrack> {
|
|
||||||
var tracks = episodes?.map { it.audioTrack }
|
|
||||||
return tracks?.filterNotNull() ?: mutableListOf()
|
|
||||||
}
|
|
||||||
@JsonIgnore
|
|
||||||
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
|
||||||
// Remove episodes no longer there in tracks
|
|
||||||
episodes = episodes?.filter { ep ->
|
|
||||||
audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null
|
|
||||||
} as MutableList<PodcastEpisode>
|
|
||||||
|
|
||||||
// Add new episodes
|
|
||||||
audioTracks.forEach { at ->
|
|
||||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
|
||||||
var newEpisode = PodcastEpisode("local_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
|
|
||||||
episodes?.add(newEpisode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var index = 1
|
|
||||||
episodes?.forEach {
|
|
||||||
it.index = index
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@JsonIgnore
|
|
||||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
|
||||||
var newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,episodes?.size ?: 0 + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
|
||||||
episodes?.add(newEpisode)
|
|
||||||
|
|
||||||
var index = 1
|
|
||||||
episodes?.forEach {
|
|
||||||
it.index = index
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@JsonIgnore
|
|
||||||
override fun removeAudioTrack(localFileId:String) {
|
|
||||||
episodes?.removeIf { it.audioTrack?.localFileId == localFileId }
|
|
||||||
|
|
||||||
var index = 1
|
|
||||||
episodes?.forEach {
|
|
||||||
it.index = index
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@JsonIgnore
|
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode) {
|
|
||||||
var newEpisode = PodcastEpisode("local_" + episode.id,episodes?.size ?: 0 + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,audioTrack,audioTrack.duration,0, episode.id)
|
|
||||||
episodes?.add(newEpisode)
|
|
||||||
|
|
||||||
var index = 1
|
|
||||||
episodes?.forEach {
|
|
||||||
it.index = index
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
|
||||||
@JsonIgnore
|
|
||||||
override fun getLocalCopy(): Podcast {
|
|
||||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
class Book(
|
|
||||||
metadata:BookMetadata,
|
|
||||||
coverPath:String?,
|
|
||||||
var tags:List<String>,
|
|
||||||
var audioFiles:List<AudioFile>?,
|
|
||||||
var chapters:List<BookChapter>?,
|
|
||||||
var tracks:MutableList<AudioTrack>?,
|
|
||||||
var size:Long?,
|
|
||||||
var duration:Double?
|
|
||||||
) : MediaType(metadata, coverPath) {
|
|
||||||
@JsonIgnore
|
|
||||||
override fun getAudioTracks():List<AudioTrack> {
|
|
||||||
return tracks ?: mutableListOf()
|
|
||||||
}
|
|
||||||
@JsonIgnore
|
|
||||||
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
|
||||||
tracks = audioTracks
|
|
||||||
tracks?.sortBy { it.index }
|
|
||||||
// TODO: Is it necessary to calculate this each time? check if can remove safely
|
|
||||||
var totalDuration = 0.0
|
|
||||||
tracks?.forEach {
|
|
||||||
totalDuration += it.duration
|
|
||||||
}
|
|
||||||
duration = totalDuration
|
|
||||||
}
|
|
||||||
@JsonIgnore
|
|
||||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
|
||||||
tracks?.add(audioTrack)
|
|
||||||
|
|
||||||
var totalDuration = 0.0
|
|
||||||
tracks?.forEach {
|
|
||||||
totalDuration += it.duration
|
|
||||||
}
|
|
||||||
duration = totalDuration
|
|
||||||
}
|
|
||||||
@JsonIgnore
|
|
||||||
override fun removeAudioTrack(localFileId:String) {
|
|
||||||
tracks?.removeIf { it.localFileId == localFileId }
|
|
||||||
|
|
||||||
tracks?.sortBy { it.index }
|
|
||||||
|
|
||||||
var index = 1
|
|
||||||
var startOffset = 0.0
|
|
||||||
var totalDuration = 0.0
|
|
||||||
tracks?.forEach {
|
|
||||||
it.index = index
|
|
||||||
it.startOffset = startOffset
|
|
||||||
totalDuration += it.duration
|
|
||||||
|
|
||||||
index++
|
|
||||||
startOffset += it.duration
|
|
||||||
}
|
|
||||||
duration = totalDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
override fun getLocalCopy(): Book {
|
|
||||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This auto-detects whether it is a BookMetadata or PodcastMetadata
|
|
||||||
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
|
||||||
@JsonSubTypes(
|
|
||||||
JsonSubTypes.Type(BookMetadata::class),
|
|
||||||
JsonSubTypes.Type(PodcastMetadata::class)
|
|
||||||
)
|
|
||||||
open class MediaTypeMetadata(var title:String) {
|
|
||||||
@JsonIgnore
|
|
||||||
open fun getAuthorDisplayName():String { return "Unknown" }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
class BookMetadata(
|
|
||||||
title:String,
|
|
||||||
var subtitle:String?,
|
|
||||||
var authors:MutableList<Author>?,
|
|
||||||
var narrators:MutableList<String>?,
|
|
||||||
var genres:MutableList<String>,
|
|
||||||
var publishedYear:String?,
|
|
||||||
var publishedDate:String?,
|
|
||||||
var publisher:String?,
|
|
||||||
var description:String?,
|
|
||||||
var isbn:String?,
|
|
||||||
var asin:String?,
|
|
||||||
var language:String?,
|
|
||||||
var explicit:Boolean,
|
|
||||||
// In toJSONExpanded
|
|
||||||
var authorName:String?,
|
|
||||||
var authorNameLF:String?,
|
|
||||||
var narratorName:String?,
|
|
||||||
var seriesName:String?
|
|
||||||
) : MediaTypeMetadata(title) {
|
|
||||||
@JsonIgnore
|
|
||||||
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
class PodcastMetadata(
|
|
||||||
title:String,
|
|
||||||
var author:String?,
|
|
||||||
var feedUrl:String?,
|
|
||||||
var genres:MutableList<String>
|
|
||||||
) : MediaTypeMetadata(title) {
|
|
||||||
@JsonIgnore
|
|
||||||
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class Author(
|
|
||||||
var id:String,
|
|
||||||
var name:String,
|
|
||||||
var coverPath:String?
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class PodcastEpisode(
|
|
||||||
var id:String,
|
|
||||||
var index:Int,
|
|
||||||
var episode:String?,
|
|
||||||
var episodeType:String?,
|
|
||||||
var title:String?,
|
|
||||||
var subtitle:String?,
|
|
||||||
var description:String?,
|
|
||||||
var audioFile:AudioFile?,
|
|
||||||
var audioTrack:AudioTrack?,
|
|
||||||
var duration:Double?,
|
|
||||||
var size:Long?,
|
|
||||||
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LibraryFile(
|
|
||||||
var ino:String,
|
|
||||||
var metadata:FileMetadata
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class FileMetadata(
|
|
||||||
var filename:String,
|
|
||||||
var ext:String,
|
|
||||||
var path:String,
|
|
||||||
var relPath:String,
|
|
||||||
var size:Long?
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioFile(
|
|
||||||
var index:Int,
|
|
||||||
var ino:String,
|
|
||||||
var metadata:FileMetadata
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class Library(
|
|
||||||
var id:String,
|
|
||||||
var name:String,
|
|
||||||
var folders:MutableList<Folder>,
|
|
||||||
var icon:String,
|
|
||||||
var mediaType:String
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class Folder(
|
|
||||||
var id:String,
|
|
||||||
var fullPath:String
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class AudioTrack(
|
|
||||||
var index:Int,
|
|
||||||
var startOffset:Double,
|
|
||||||
var duration:Double,
|
|
||||||
var title:String,
|
|
||||||
var contentUrl:String,
|
|
||||||
var mimeType:String,
|
|
||||||
var metadata:FileMetadata?,
|
|
||||||
var isLocal:Boolean,
|
|
||||||
var localFileId:String?,
|
|
||||||
var audioProbeResult:AudioProbeResult?,
|
|
||||||
var serverIndex:Int? // Need to know if server track index is different
|
|
||||||
) {
|
|
||||||
|
|
||||||
@get:JsonIgnore
|
|
||||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
|
||||||
@get:JsonIgnore
|
|
||||||
val durationMs get() = (duration * 1000L).toLong()
|
|
||||||
@get:JsonIgnore
|
|
||||||
val endOffsetMs get() = startOffsetMs + durationMs
|
|
||||||
@get:JsonIgnore
|
|
||||||
val relPath get() = metadata?.relPath ?: ""
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getBookChapter():BookChapter {
|
|
||||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class BookChapter(
|
|
||||||
var id:Int,
|
|
||||||
var start:Double,
|
|
||||||
var end:Double,
|
|
||||||
var title:String?
|
|
||||||
)
|
|
||||||
@@ -1,226 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
|
||||||
import io.paperdb.Paper
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class DbManager {
|
|
||||||
val tag = "DbManager"
|
|
||||||
|
|
||||||
fun getDeviceData(): DeviceData {
|
|
||||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null)
|
|
||||||
}
|
|
||||||
fun saveDeviceData(deviceData:DeviceData) {
|
|
||||||
Paper.book("device").write("data", deviceData)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
|
||||||
var localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
|
||||||
Paper.book("localLibraryItems").allKeys.forEach {
|
|
||||||
var localLibraryItem:LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
|
||||||
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
|
|
||||||
localLibraryItems.add(localLibraryItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return localLibraryItems
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLocalLibraryItemsInFolder(folderId:String):List<LocalLibraryItem> {
|
|
||||||
var localLibraryItems = getLocalLibraryItems()
|
|
||||||
return localLibraryItems.filter {
|
|
||||||
it.folderId == folderId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLocalLibraryItemByLLId(libraryItemId:String):LocalLibraryItem? {
|
|
||||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? {
|
|
||||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeLocalLibraryItem(localLibraryItemId:String) {
|
|
||||||
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveLocalLibraryItems(localLibraryItems:List<LocalLibraryItem>) {
|
|
||||||
localLibraryItems.map {
|
|
||||||
Paper.book("localLibraryItems").write(it.id, it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveLocalLibraryItem(localLibraryItem:LocalLibraryItem) {
|
|
||||||
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveLocalFolder(localFolder:LocalFolder) {
|
|
||||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLocalFolder(folderId:String):LocalFolder? {
|
|
||||||
return Paper.book("localFolders").read(folderId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAllLocalFolders():List<LocalFolder> {
|
|
||||||
var localFolders:MutableList<LocalFolder> = mutableListOf()
|
|
||||||
Paper.book("localFolders").allKeys.forEach { localFolderId ->
|
|
||||||
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let {
|
|
||||||
localFolders.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return localFolders
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeLocalFolder(folderId:String) {
|
|
||||||
var localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
|
||||||
localLibraryItems.forEach {
|
|
||||||
Paper.book("localLibraryItems").delete(it.id)
|
|
||||||
}
|
|
||||||
Paper.book("localFolders").delete(folderId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) {
|
|
||||||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeDownloadItem(downloadItemId:String) {
|
|
||||||
Paper.book("downloadItems").delete(downloadItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
|
|
||||||
var downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
|
|
||||||
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
|
||||||
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
|
|
||||||
downloadItems.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return downloadItems
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveLocalMediaProgress(mediaProgress:LocalMediaProgress) {
|
|
||||||
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
|
||||||
}
|
|
||||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
|
||||||
fun getLocalMediaProgress(localMediaProgressId:String):LocalMediaProgress? {
|
|
||||||
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
|
||||||
}
|
|
||||||
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
|
||||||
var mediaProgress:MutableList<LocalMediaProgress> = mutableListOf()
|
|
||||||
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
|
|
||||||
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
|
|
||||||
mediaProgress.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mediaProgress
|
|
||||||
}
|
|
||||||
fun removeLocalMediaProgress(localMediaProgressId:String) {
|
|
||||||
Paper.book("localMediaProgress").delete(localMediaProgressId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeAllLocalMediaProgress() {
|
|
||||||
Paper.book("localMediaProgress").destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Make sure all local file ids still exist
|
|
||||||
fun cleanLocalLibraryItems() {
|
|
||||||
var localLibraryItems = getLocalLibraryItems()
|
|
||||||
|
|
||||||
localLibraryItems.forEach { lli ->
|
|
||||||
var hasUpates = false
|
|
||||||
|
|
||||||
// Check local files
|
|
||||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
|
||||||
var file = File(localFile.absolutePath)
|
|
||||||
if (!file.exists()) {
|
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
|
||||||
hasUpates = true
|
|
||||||
}
|
|
||||||
file.exists()
|
|
||||||
} as MutableList<LocalFile>
|
|
||||||
|
|
||||||
// Check audio tracks and episodes
|
|
||||||
if (lli.isPodcast) {
|
|
||||||
var podcast = lli.media as Podcast
|
|
||||||
podcast.episodes = podcast.episodes?.filter { ep ->
|
|
||||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}")
|
|
||||||
hasUpates = true
|
|
||||||
}
|
|
||||||
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
|
||||||
} as MutableList<PodcastEpisode>
|
|
||||||
} else {
|
|
||||||
var book = lli.media as Book
|
|
||||||
book.tracks = book.tracks?.filter { track ->
|
|
||||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}")
|
|
||||||
hasUpates = true
|
|
||||||
}
|
|
||||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
|
||||||
} as MutableList<AudioTrack>
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cover still there
|
|
||||||
lli.coverAbsolutePath?.let {
|
|
||||||
var coverFile = File(it)
|
|
||||||
|
|
||||||
if (!coverFile.exists()) {
|
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
|
||||||
lli.coverAbsolutePath = null
|
|
||||||
lli.coverContentUrl = null
|
|
||||||
hasUpates = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUpates) {
|
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
|
||||||
Paper.book("localLibraryItems").write(lli.id, lli)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any local media progress where the local media item is not found
|
|
||||||
fun cleanLocalMediaProgress() {
|
|
||||||
var localMediaProgress = getAllLocalMediaProgress()
|
|
||||||
var localLibraryItems = getLocalLibraryItems()
|
|
||||||
localMediaProgress.forEach {
|
|
||||||
var matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
|
||||||
if (matchingLLI == null) {
|
|
||||||
Log.d(tag, "cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing")
|
|
||||||
Paper.book("localMediaProgress").delete(it.id)
|
|
||||||
} else if (matchingLLI.isPodcast) {
|
|
||||||
if (it.localEpisodeId.isNullOrEmpty()) {
|
|
||||||
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing")
|
|
||||||
Paper.book("localMediaProgress").delete(it.id)
|
|
||||||
} else {
|
|
||||||
var podcast = matchingLLI.media as Podcast
|
|
||||||
var matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
|
|
||||||
if (matchingLEp == null) {
|
|
||||||
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing")
|
|
||||||
Paper.book("localMediaProgress").delete(it.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveLocalPlaybackSession(playbackSession:PlaybackSession) {
|
|
||||||
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
|
|
||||||
}
|
|
||||||
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
|
|
||||||
return Paper.book("localPlaybackSession").read(playbackSessionId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun saveObject(db:String, key:String, value:JSONObject) {
|
|
||||||
Log.d(tag, "Saving Object $key ${value.toString()}")
|
|
||||||
Paper.book(db).write(key, value)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadObject(db:String, key:String):JSONObject? {
|
|
||||||
var json: JSONObject? = Paper.book(db).read(key)
|
|
||||||
Log.d(tag, "Loaded Object $key $json")
|
|
||||||
return json
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
|
||||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
data class ServerConnectionConfig(
|
|
||||||
var id:String,
|
|
||||||
var index:Int,
|
|
||||||
var name:String,
|
|
||||||
var address:String,
|
|
||||||
var userId:String,
|
|
||||||
var username:String,
|
|
||||||
var token:String
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DeviceData(
|
|
||||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
|
||||||
var lastServerConnectionConfigId:String?,
|
|
||||||
var currentLocalPlaybackSession:PlaybackSession? // Stored to open up where left off for local media
|
|
||||||
) {
|
|
||||||
@JsonIgnore
|
|
||||||
fun getLastServerConnectionConfig():ServerConnectionConfig? {
|
|
||||||
return lastServerConnectionConfigId?.let { lsccid ->
|
|
||||||
return serverConnectionConfigs.find { it.id == lsccid }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LocalFile(
|
|
||||||
var id:String,
|
|
||||||
var filename:String?,
|
|
||||||
var contentUrl:String,
|
|
||||||
var basePath:String,
|
|
||||||
var absolutePath:String,
|
|
||||||
var simplePath:String,
|
|
||||||
var mimeType:String?,
|
|
||||||
var size:Long
|
|
||||||
) {
|
|
||||||
@JsonIgnore
|
|
||||||
fun isAudioFile():Boolean {
|
|
||||||
return mimeType?.startsWith("audio") == true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LocalFolder(
|
|
||||||
var id:String,
|
|
||||||
var name:String,
|
|
||||||
var contentUrl:String,
|
|
||||||
var basePath:String,
|
|
||||||
var absolutePath:String,
|
|
||||||
var simplePath:String,
|
|
||||||
var storageType:String,
|
|
||||||
var mediaType:String
|
|
||||||
)
|
|
||||||
|
|
||||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION)
|
|
||||||
@JsonSubTypes(
|
|
||||||
JsonSubTypes.Type(LibraryItem::class),
|
|
||||||
JsonSubTypes.Type(LocalLibraryItem::class)
|
|
||||||
)
|
|
||||||
open class LibraryItemWrapper()
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
data class FolderScanResult(
|
|
||||||
var itemsAdded:Int,
|
|
||||||
var itemsUpdated:Int,
|
|
||||||
var itemsRemoved:Int,
|
|
||||||
var itemsUpToDate:Int,
|
|
||||||
val localFolder:LocalFolder,
|
|
||||||
val localLibraryItems:List<LocalLibraryItem>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class LocalLibraryItemScanResult(
|
|
||||||
val updated:Boolean,
|
|
||||||
val localLibraryItem:LocalLibraryItem,
|
|
||||||
)
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LibraryCategory(
|
|
||||||
var id:String,
|
|
||||||
var label:String,
|
|
||||||
var type:String,
|
|
||||||
var entities:List<LibraryItemWrapper>,
|
|
||||||
var isLocal:Boolean
|
|
||||||
)
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LocalLibraryItem(
|
|
||||||
var id:String,
|
|
||||||
var folderId:String,
|
|
||||||
var basePath:String,
|
|
||||||
var absolutePath:String,
|
|
||||||
var contentUrl:String,
|
|
||||||
var isInvalid:Boolean,
|
|
||||||
var mediaType:String,
|
|
||||||
var media:MediaType,
|
|
||||||
var localFiles:MutableList<LocalFile>,
|
|
||||||
var coverContentUrl:String?,
|
|
||||||
var coverAbsolutePath:String?,
|
|
||||||
var isLocal:Boolean,
|
|
||||||
// If local library item is linked to a server item
|
|
||||||
var serverConnectionConfigId:String?,
|
|
||||||
var serverAddress:String?,
|
|
||||||
var serverUserId:String?,
|
|
||||||
var libraryItemId:String?
|
|
||||||
) : LibraryItemWrapper() {
|
|
||||||
@get:JsonIgnore
|
|
||||||
val title get() = media.metadata.title
|
|
||||||
@get:JsonIgnore
|
|
||||||
val authorName get() = media.metadata.getAuthorDisplayName()
|
|
||||||
@get:JsonIgnore
|
|
||||||
val isPodcast get() = mediaType == "podcast"
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCoverUri(): Uri {
|
|
||||||
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getDuration():Double {
|
|
||||||
var total = 0.0
|
|
||||||
var audioTracks = media.getAudioTracks()
|
|
||||||
audioTracks.forEach{ total += it.duration }
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
|
||||||
media.setAudioTracks(audioTracks)
|
|
||||||
localFiles = _localFiles
|
|
||||||
|
|
||||||
if (coverContentUrl != null) {
|
|
||||||
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
|
|
||||||
// Cover was removed
|
|
||||||
coverContentUrl = null
|
|
||||||
coverAbsolutePath = null
|
|
||||||
media.coverPath = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getPlaybackSession(episode:PodcastEpisode?):PlaybackSession {
|
|
||||||
var localEpisodeId = episode?.id
|
|
||||||
var sessionId = "play_local_${UUID.randomUUID()}"
|
|
||||||
|
|
||||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
|
||||||
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
|
||||||
var currentTime = mediaProgress?.currentTime ?: 0.0
|
|
||||||
|
|
||||||
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
|
|
||||||
var mediaMetadata = media.metadata
|
|
||||||
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
|
||||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
|
||||||
var authorName = mediaMetadata.getAuthorDisplayName()
|
|
||||||
if (episode != null) { // Get podcast episode audio track
|
|
||||||
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
|
||||||
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
|
||||||
}
|
|
||||||
|
|
||||||
var dateNow = System.currentTimeMillis()
|
|
||||||
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, chapters ?: mutableListOf(), mediaMetadata.title, authorName,null,getDuration(),PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun removeLocalFile(localFileId:String) {
|
|
||||||
localFiles.removeIf { it.id == localFileId }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaMetadata(): MediaMetadataCompat {
|
|
||||||
return MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, authorName)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
|
|
||||||
/*
|
|
||||||
Used as a helper class to generate LocalLibraryItem from scan results
|
|
||||||
*/
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LocalMediaItem(
|
|
||||||
var id:String,
|
|
||||||
var name: String,
|
|
||||||
var mediaType:String,
|
|
||||||
var folderId:String,
|
|
||||||
var contentUrl:String,
|
|
||||||
var simplePath: String,
|
|
||||||
var basePath:String,
|
|
||||||
var absolutePath:String,
|
|
||||||
var audioTracks:MutableList<AudioTrack>,
|
|
||||||
var localFiles:MutableList<LocalFile>,
|
|
||||||
var coverContentUrl:String?,
|
|
||||||
var coverAbsolutePath:String?
|
|
||||||
) {
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getDuration():Double {
|
|
||||||
var total = 0.0
|
|
||||||
audioTracks.forEach{ total += it.duration }
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getTotalSize():Long {
|
|
||||||
var total = 0L
|
|
||||||
localFiles.forEach { total += it.size }
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaMetadata():MediaTypeMetadata {
|
|
||||||
return if (mediaType == "book") {
|
|
||||||
BookMetadata(name,null, mutableListOf(), mutableListOf(), mutableListOf(),null,null,null,null,null,null,null,false,null,null,null,null)
|
|
||||||
} else {
|
|
||||||
PodcastMetadata(name,null,null, mutableListOf())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getAudiobookChapters():List<BookChapter> {
|
|
||||||
if (mediaType != "book" || audioTracks.isEmpty()) return mutableListOf()
|
|
||||||
if (audioTracks.size == 1) { // Single track audiobook look for chapters from ffprobe
|
|
||||||
return audioTracks[0].audioProbeResult?.getBookChapters() ?: mutableListOf()
|
|
||||||
}
|
|
||||||
// Multi-track make chapters from tracks
|
|
||||||
return audioTracks.map { it.getBookChapter() }
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getLocalLibraryItem():LocalLibraryItem {
|
|
||||||
var mediaMetadata = getMediaMetadata()
|
|
||||||
if (mediaType == "book") {
|
|
||||||
var chapters = getAudiobookChapters()
|
|
||||||
var book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration())
|
|
||||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
|
||||||
} else {
|
|
||||||
var podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false)
|
|
||||||
podcast.setAudioTracks(audioTracks) // Builds episodes from audio tracks
|
|
||||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false, mediaType, podcast,localFiles,coverContentUrl, coverAbsolutePath, true, null,null,null,null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class LocalMediaProgress(
|
|
||||||
var id:String,
|
|
||||||
var localLibraryItemId:String,
|
|
||||||
var localEpisodeId:String?,
|
|
||||||
var duration:Double,
|
|
||||||
var progress:Double, // 0 to 1
|
|
||||||
var currentTime:Double,
|
|
||||||
var isFinished:Boolean,
|
|
||||||
var lastUpdate:Long,
|
|
||||||
var startedAt:Long,
|
|
||||||
var finishedAt:Long?,
|
|
||||||
// For local lib items from server to support server sync
|
|
||||||
var serverConnectionConfigId:String?,
|
|
||||||
var serverAddress:String?,
|
|
||||||
var serverUserId:String?,
|
|
||||||
var libraryItemId:String?,
|
|
||||||
var episodeId:String?
|
|
||||||
) {
|
|
||||||
@JsonIgnore
|
|
||||||
fun updateIsFinished(finished:Boolean) {
|
|
||||||
if (isFinished != finished) { // If finished changed then set progress
|
|
||||||
progress = if (finished) 1.0 else 0.0
|
|
||||||
}
|
|
||||||
|
|
||||||
isFinished = finished
|
|
||||||
lastUpdate = System.currentTimeMillis()
|
|
||||||
finishedAt = if (isFinished) lastUpdate else null
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
|
|
||||||
currentTime = playbackSession.currentTime
|
|
||||||
progress = playbackSession.progress
|
|
||||||
lastUpdate = System.currentTimeMillis()
|
|
||||||
|
|
||||||
isFinished = playbackSession.progress >= 0.99
|
|
||||||
finishedAt = if (isFinished) lastUpdate else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
enum class PlayerState {
|
|
||||||
IDLE, BUFFERING, READY, ENDED
|
|
||||||
}
|
|
||||||
|
|
||||||
data class PlaybackMetadata(
|
|
||||||
val duration:Double,
|
|
||||||
val currentTime:Double,
|
|
||||||
val playerState:PlayerState
|
|
||||||
)
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
import com.google.android.exoplayer2.C
|
|
||||||
import com.google.android.exoplayer2.MediaItem
|
|
||||||
import com.google.android.exoplayer2.MediaMetadata
|
|
||||||
import com.google.android.gms.cast.MediaInfo
|
|
||||||
import com.google.android.gms.cast.MediaQueueItem
|
|
||||||
import com.google.android.gms.common.images.WebImage
|
|
||||||
|
|
||||||
// TODO: enum or something in kotlin?
|
|
||||||
val PLAYMETHOD_DIRECTPLAY = 0
|
|
||||||
val PLAYMETHOD_DIRECTSTREAM = 1
|
|
||||||
val PLAYMETHOD_TRANSCODE = 2
|
|
||||||
val PLAYMETHOD_LOCAL = 3
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
class PlaybackSession(
|
|
||||||
var id:String,
|
|
||||||
var userId:String?,
|
|
||||||
var libraryItemId:String?,
|
|
||||||
var episodeId:String?,
|
|
||||||
var mediaType:String,
|
|
||||||
var mediaMetadata:MediaTypeMetadata,
|
|
||||||
var chapters:List<BookChapter>,
|
|
||||||
var displayTitle: String?,
|
|
||||||
var displayAuthor: String?,
|
|
||||||
var coverPath:String?,
|
|
||||||
var duration:Double,
|
|
||||||
var playMethod:Int,
|
|
||||||
var startedAt:Long,
|
|
||||||
var updatedAt:Long,
|
|
||||||
var timeListening:Long,
|
|
||||||
var audioTracks:MutableList<AudioTrack>,
|
|
||||||
var currentTime:Double,
|
|
||||||
var libraryItem:LibraryItem?,
|
|
||||||
var localLibraryItem:LocalLibraryItem?,
|
|
||||||
var localEpisodeId:String?,
|
|
||||||
var serverConnectionConfigId:String?,
|
|
||||||
var serverAddress:String?,
|
|
||||||
var mediaPlayer:String?
|
|
||||||
) {
|
|
||||||
|
|
||||||
@get:JsonIgnore
|
|
||||||
val isHLS get() = playMethod == PLAYMETHOD_TRANSCODE
|
|
||||||
@get:JsonIgnore
|
|
||||||
val isDirectPlay get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
|
||||||
@get:JsonIgnore
|
|
||||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
|
||||||
@get:JsonIgnore
|
|
||||||
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
|
||||||
@get:JsonIgnore
|
|
||||||
val localLibraryItemId get() = localLibraryItem?.id ?: ""
|
|
||||||
@get:JsonIgnore
|
|
||||||
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
|
||||||
@get:JsonIgnore
|
|
||||||
val progress get() = currentTime / getTotalDuration()
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCurrentTrackIndex():Int {
|
|
||||||
for (i in 0..(audioTracks.size - 1)) {
|
|
||||||
var track = audioTracks[i]
|
|
||||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs) > currentTimeMs) {
|
|
||||||
return i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return audioTracks.size - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCurrentTrackTimeMs():Long {
|
|
||||||
var currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
|
||||||
var time = currentTime - currentTrack.startOffset
|
|
||||||
return (time * 1000L).toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getTrackStartOffsetMs(index:Int):Long {
|
|
||||||
var currentTrack = audioTracks[index]
|
|
||||||
return (currentTrack.startOffset * 1000L).toLong()
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getTotalDuration():Double {
|
|
||||||
var total = 0.0
|
|
||||||
audioTracks.forEach { total += it.duration }
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCoverUri(): Uri {
|
|
||||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
|
||||||
|
|
||||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
|
||||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getContentUri(audioTrack:AudioTrack): Uri {
|
|
||||||
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
|
||||||
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaMetadataCompat(): MediaMetadataCompat {
|
|
||||||
var metadataBuilder = MediaMetadataCompat.Builder()
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, "series")
|
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
|
||||||
return metadataBuilder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getExoMediaMetadata(audioTrack:AudioTrack): MediaMetadata {
|
|
||||||
var metadataBuilder = MediaMetadata.Builder()
|
|
||||||
.setTitle(displayTitle)
|
|
||||||
.setDisplayTitle(displayTitle)
|
|
||||||
.setArtist(displayAuthor)
|
|
||||||
.setAlbumArtist(displayAuthor)
|
|
||||||
.setSubtitle(displayAuthor)
|
|
||||||
|
|
||||||
var contentUri = this.getContentUri(audioTrack)
|
|
||||||
metadataBuilder.setMediaUri(contentUri)
|
|
||||||
|
|
||||||
return metadataBuilder.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getMediaItems():List<MediaItem> {
|
|
||||||
var mediaItems:MutableList<MediaItem> = mutableListOf()
|
|
||||||
|
|
||||||
for (audioTrack in audioTracks) {
|
|
||||||
var mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
|
||||||
var mediaUri = this.getContentUri(audioTrack)
|
|
||||||
var mimeType = audioTrack.mimeType
|
|
||||||
|
|
||||||
var queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
|
||||||
var mediaItem = MediaItem.Builder().setUri(mediaUri).setTag(queueItem).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
|
||||||
mediaItems.add(mediaItem)
|
|
||||||
}
|
|
||||||
return mediaItems
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getCastMediaMetadata(audioTrack:AudioTrack):com.google.android.gms.cast.MediaMetadata {
|
|
||||||
var castMetadata = com.google.android.gms.cast.MediaMetadata(com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER)
|
|
||||||
|
|
||||||
coverPath?.let {
|
|
||||||
castMetadata.addImage(WebImage(Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
|
|
||||||
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
|
||||||
return castMetadata
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getQueueItem(audioTrack:AudioTrack):MediaQueueItem {
|
|
||||||
var castMetadata = getCastMediaMetadata(audioTrack)
|
|
||||||
|
|
||||||
var mediaUri = getContentUri(audioTrack)
|
|
||||||
|
|
||||||
var mediaInfo = MediaInfo.Builder(mediaUri.toString()).apply {
|
|
||||||
setContentUrl(mediaUri.toString())
|
|
||||||
setContentType(audioTrack.mimeType)
|
|
||||||
setMetadata(castMetadata)
|
|
||||||
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
return MediaQueueItem.Builder(mediaInfo).apply {
|
|
||||||
setPlaybackDuration(audioTrack.duration)
|
|
||||||
}.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun clone():PlaybackSession {
|
|
||||||
return PlaybackSession(id,userId,libraryItemId,episodeId,mediaType,mediaMetadata,chapters,displayTitle,displayAuthor,coverPath,duration,playMethod,startedAt,updatedAt,timeListening,audioTracks,currentTime,libraryItem,localLibraryItem,localEpisodeId,serverConnectionConfigId,serverAddress, mediaPlayer)
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun syncData(syncData:MediaProgressSyncData) {
|
|
||||||
timeListening += syncData.timeListened
|
|
||||||
updatedAt = System.currentTimeMillis()
|
|
||||||
currentTime = syncData.currentTime
|
|
||||||
}
|
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
fun getNewLocalMediaProgress():LocalMediaProgress {
|
|
||||||
return LocalMediaProgress(localMediaProgressId,localLibraryItemId,localEpisodeId,getTotalDuration(),progress,currentTime,false,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
package com.audiobookshelf.app.device
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.data.DbManager
|
|
||||||
import com.audiobookshelf.app.data.DeviceData
|
|
||||||
import com.audiobookshelf.app.data.ServerConnectionConfig
|
|
||||||
|
|
||||||
object DeviceManager {
|
|
||||||
const val tag = "DeviceManager"
|
|
||||||
|
|
||||||
val dbManager:DbManager = DbManager()
|
|
||||||
var deviceData:DeviceData = dbManager.getDeviceData()
|
|
||||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
|
||||||
|
|
||||||
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
|
||||||
val serverAddress get() = serverConnectionConfig?.address ?: ""
|
|
||||||
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
|
||||||
val token get() = serverConnectionConfig?.token ?: ""
|
|
||||||
val isConnectedToServer get() = serverConnectionConfig != null
|
|
||||||
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
|
|
||||||
|
|
||||||
init {
|
|
||||||
Log.d(tag, "Device Manager Singleton invoked")
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBase64Id(id:String):String {
|
|
||||||
return android.util.Base64.encodeToString(id.toByteArray(), android.util.Base64.NO_WRAP)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
package com.audiobookshelf.app.device
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import com.anggrayudi.storage.file.*
|
|
||||||
import com.arthenica.ffmpegkit.FFmpegKitConfig
|
|
||||||
import com.arthenica.ffmpegkit.FFprobeKit
|
|
||||||
import com.arthenica.ffmpegkit.Level
|
|
||||||
import com.audiobookshelf.app.data.*
|
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
|
||||||
import com.getcapacitor.JSObject
|
|
||||||
|
|
||||||
class FolderScanner(var ctx: Context) {
|
|
||||||
private val tag = "FolderScanner"
|
|
||||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
|
|
||||||
|
|
||||||
private fun getLocalLibraryItemId(mediaItemId:String):String {
|
|
||||||
return "local_" + DeviceManager.getBase64Id(mediaItemId)
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class ItemScanResult {
|
|
||||||
ADDED, REMOVED, UPDATED, UPTODATE
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: CLEAN this monster! Divide into bite-size methods
|
|
||||||
fun scanForMediaItems(localFolder:LocalFolder, forceAudioProbe:Boolean):FolderScanResult? {
|
|
||||||
FFmpegKitConfig.enableLogCallback { log ->
|
|
||||||
if (log.level != Level.AV_LOG_STDERR) { // STDERR is filled with junk
|
|
||||||
Log.d(tag, "FFmpeg-Kit Log: (${log.level}) ${log.message}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localFolder.contentUrl))
|
|
||||||
|
|
||||||
if (df == null) {
|
|
||||||
Log.e(tag, "Folder Doc File Invalid $localFolder.contentUrl")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
var mediaItemsUpdated = 0
|
|
||||||
var mediaItemsAdded = 0
|
|
||||||
var mediaItemsRemoved = 0
|
|
||||||
var mediaItemsUpToDate = 0
|
|
||||||
|
|
||||||
// Search for files in media item folder
|
|
||||||
var foldersFound = df.search(false, DocumentFileType.FOLDER)
|
|
||||||
|
|
||||||
// Match folders found with local library items already saved in db
|
|
||||||
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
|
|
||||||
|
|
||||||
// Remove existing items no longer there
|
|
||||||
existingLocalLibraryItems = existingLocalLibraryItems.filter { lli ->
|
|
||||||
Log.d(tag, "scanForMediaItems Checking Existing LLI ${lli.id}")
|
|
||||||
var fileFound = foldersFound.find { f -> lli.id == getLocalLibraryItemId(f.id) }
|
|
||||||
if (fileFound == null) {
|
|
||||||
Log.d(tag, "Existing local library item is no longer in file system ${lli.media.metadata.title}")
|
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(lli.id)
|
|
||||||
mediaItemsRemoved++
|
|
||||||
}
|
|
||||||
fileFound != null
|
|
||||||
}
|
|
||||||
|
|
||||||
foldersFound.forEach { itemFolder ->
|
|
||||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
|
||||||
var existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
|
||||||
|
|
||||||
var result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
|
|
||||||
|
|
||||||
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++
|
|
||||||
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++
|
|
||||||
else if (result == ItemScanResult.ADDED) mediaItemsAdded++
|
|
||||||
else mediaItemsUpToDate++
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
|
|
||||||
|
|
||||||
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
|
|
||||||
var folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
|
|
||||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "No Media Items to save")
|
|
||||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, mutableListOf())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
|
||||||
var itemFolderName = itemFolder.name ?: ""
|
|
||||||
var itemId = getLocalLibraryItemId(itemFolder.id)
|
|
||||||
|
|
||||||
var existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
|
|
||||||
var existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
|
|
||||||
var isNewOrUpdated = existingItem == null
|
|
||||||
|
|
||||||
var audioTracks = mutableListOf<AudioTrack>()
|
|
||||||
var localFiles = mutableListOf<LocalFile>()
|
|
||||||
var index = 1
|
|
||||||
var startOffset = 0.0
|
|
||||||
var coverContentUrl:String? = null
|
|
||||||
var coverAbsolutePath:String? = null
|
|
||||||
|
|
||||||
var filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
|
||||||
|
|
||||||
var existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
|
||||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
|
||||||
}
|
|
||||||
if (existingLocalFilesRemoved.isNotEmpty()) {
|
|
||||||
Log.d(tag, "${existingLocalFilesRemoved.size} Local files were removed from local media item ${existingItem?.media?.metadata?.title}")
|
|
||||||
isNewOrUpdated = true
|
|
||||||
}
|
|
||||||
|
|
||||||
filesInFolder.forEach { file ->
|
|
||||||
var mimeType = file.mimeType ?: ""
|
|
||||||
var filename = file.name ?: ""
|
|
||||||
var isAudio = mimeType.startsWith("audio")
|
|
||||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
|
||||||
|
|
||||||
var localFileId = DeviceManager.getBase64Id(file.id)
|
|
||||||
|
|
||||||
var localFile = LocalFile(localFileId,filename,file.uri.toString(),file.getBasePath(ctx), file.getAbsolutePath(ctx),file.getSimplePath(ctx),mimeType,file.length())
|
|
||||||
localFiles.add(localFile)
|
|
||||||
|
|
||||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
|
||||||
|
|
||||||
if (isAudio) {
|
|
||||||
var audioTrackToAdd:AudioTrack? = null
|
|
||||||
|
|
||||||
var existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
|
||||||
if (existingAudioTrack != null) { // Update existing audio track
|
|
||||||
if (existingAudioTrack.index != index) {
|
|
||||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track index from ${existingAudioTrack.index} to $index")
|
|
||||||
existingAudioTrack.index = index
|
|
||||||
isNewOrUpdated = true
|
|
||||||
}
|
|
||||||
if (existingAudioTrack.startOffset != startOffset) {
|
|
||||||
Log.d(tag, "scanLibraryItemFolder Updating Audio track startOffset ${existingAudioTrack.startOffset} to $startOffset")
|
|
||||||
existingAudioTrack.startOffset = startOffset
|
|
||||||
isNewOrUpdated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingAudioTrack == null || forceAudioProbe) {
|
|
||||||
Log.d(tag, "scanLibraryItemFolder Scanning Audio File Path ${localFile.absolutePath} | ForceAudioProbe=${forceAudioProbe}")
|
|
||||||
|
|
||||||
// TODO: Make asynchronous
|
|
||||||
var audioProbeResult = probeAudioFile(localFile.absolutePath)
|
|
||||||
|
|
||||||
if (existingAudioTrack != null) {
|
|
||||||
// Update audio probe data on existing audio track
|
|
||||||
existingAudioTrack.audioProbeResult = audioProbeResult
|
|
||||||
audioTrackToAdd = existingAudioTrack
|
|
||||||
} else {
|
|
||||||
// Create new audio track
|
|
||||||
var track = AudioTrack(index, startOffset, audioProbeResult.duration, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
|
||||||
audioTrackToAdd = track
|
|
||||||
}
|
|
||||||
|
|
||||||
startOffset += audioProbeResult.duration
|
|
||||||
isNewOrUpdated = true
|
|
||||||
} else {
|
|
||||||
audioTrackToAdd = existingAudioTrack
|
|
||||||
}
|
|
||||||
|
|
||||||
startOffset += audioTrackToAdd.duration
|
|
||||||
index++
|
|
||||||
audioTracks.add(audioTrackToAdd)
|
|
||||||
} else {
|
|
||||||
var existingLocalFile = existingLocalFiles.find { elf -> elf.id == localFileId }
|
|
||||||
|
|
||||||
if (existingLocalFile == null) {
|
|
||||||
Log.d(tag, "scanLibraryItemFolder new local file found ${localFile.absolutePath}")
|
|
||||||
isNewOrUpdated = true
|
|
||||||
}
|
|
||||||
if (existingItem != null && existingItem.coverContentUrl == null) {
|
|
||||||
// Existing media item did not have a cover - cover found on scan
|
|
||||||
Log.d(tag, "scanLibraryItemFolder setting cover ${localFile.absolutePath}")
|
|
||||||
isNewOrUpdated = true
|
|
||||||
existingItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
existingItem.coverContentUrl = localFile.contentUrl
|
|
||||||
existingItem.media.coverPath = localFile.absolutePath
|
|
||||||
}
|
|
||||||
|
|
||||||
// First image file use as cover path
|
|
||||||
if (coverContentUrl == null) {
|
|
||||||
coverContentUrl = localFile.contentUrl
|
|
||||||
coverAbsolutePath = localFile.absolutePath
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingItem != null && audioTracks.isEmpty()) {
|
|
||||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} no longer has audio tracks - removing item")
|
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(existingItem.id)
|
|
||||||
return ItemScanResult.REMOVED
|
|
||||||
} else if (existingItem != null && !isNewOrUpdated) {
|
|
||||||
Log.d(tag, "Local library item ${existingItem.media.metadata.title} has no updates")
|
|
||||||
return ItemScanResult.UPTODATE
|
|
||||||
} else if (existingItem != null) {
|
|
||||||
Log.d(tag, "Updating local library item ${existingItem.media.metadata.title}")
|
|
||||||
existingItem.updateFromScan(audioTracks,localFiles)
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(existingItem)
|
|
||||||
return ItemScanResult.UPDATED
|
|
||||||
} else if (audioTracks.isNotEmpty()) {
|
|
||||||
Log.d(tag, "Found local media item named $itemFolderName with ${audioTracks.size} tracks and ${localFiles.size} local files")
|
|
||||||
var localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
|
|
||||||
var localLibraryItem = localMediaItem.getLocalLibraryItem()
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
|
||||||
return ItemScanResult.ADDED
|
|
||||||
} else {
|
|
||||||
return ItemScanResult.UPTODATE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Scan item after download and create local library item
|
|
||||||
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):LocalLibraryItem? {
|
|
||||||
var folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
|
||||||
var foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf()
|
|
||||||
|
|
||||||
var itemFolderId = ""
|
|
||||||
var itemFolderUrl = ""
|
|
||||||
var itemFolderBasePath = ""
|
|
||||||
var itemFolderAbsolutePath = ""
|
|
||||||
foldersFound.forEach {
|
|
||||||
if (it.name == downloadItem.itemTitle) {
|
|
||||||
itemFolderId = it.id
|
|
||||||
itemFolderUrl = it.uri.toString()
|
|
||||||
itemFolderBasePath = it.getBasePath(ctx)
|
|
||||||
itemFolderAbsolutePath = it.getAbsolutePath(ctx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (itemFolderUrl == "") {
|
|
||||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
|
||||||
|
|
||||||
if (df == null) {
|
|
||||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
var localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
|
||||||
Log.d(tag, "scanDownloadItem starting for ${downloadItem.itemFolderPath} | ${df.uri} | Item Folder Id:$itemFolderId | LLI Id:$localLibraryItemId")
|
|
||||||
|
|
||||||
// Search for files in media item folder
|
|
||||||
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
|
||||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
|
||||||
|
|
||||||
var localLibraryItem:LocalLibraryItem? = null
|
|
||||||
if (downloadItem.mediaType == "book") {
|
|
||||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true, downloadItem.serverConnectionConfigId, downloadItem.serverAddress, downloadItem.serverUserId, downloadItem.libraryItemId)
|
|
||||||
} else {
|
|
||||||
// Lookup or create podcast local library item
|
|
||||||
localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
|
||||||
if (localLibraryItem == null) {
|
|
||||||
Log.d(tag, "[FolderScanner] Podcast local library item not created yet for ${downloadItem.media.metadata.title}")
|
|
||||||
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, itemFolderBasePath, itemFolderAbsolutePath, itemFolderUrl, false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var audioTracks:MutableList<AudioTrack> = mutableListOf()
|
|
||||||
|
|
||||||
filesFound.forEach { docFile ->
|
|
||||||
var itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
|
||||||
itemPart.filename == docFile.name
|
|
||||||
}
|
|
||||||
if (itemPart == null) {
|
|
||||||
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
|
||||||
Log.e(tag, "scanDownloadItem: Item part not found for doc file ${docFile.name} | ${docFile.getAbsolutePath(ctx)} | ${docFile.uri}")
|
|
||||||
}
|
|
||||||
} else if (itemPart.audioTrack != null) { // Is audio track
|
|
||||||
var audioTrackFromServer = itemPart.audioTrack
|
|
||||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}")
|
|
||||||
|
|
||||||
var localFileId = DeviceManager.getBase64Id(docFile.id)
|
|
||||||
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
|
|
||||||
// TODO: Make asynchronous
|
|
||||||
var audioProbeResult = probeAudioFile(localFile.absolutePath)
|
|
||||||
|
|
||||||
// Create new audio track
|
|
||||||
var track = AudioTrack(audioTrackFromServer?.index ?: -1, audioTrackFromServer?.startOffset ?: 0.0, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer?.index ?: -1)
|
|
||||||
audioTracks.add(track)
|
|
||||||
|
|
||||||
Log.d(tag, "scanDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}")
|
|
||||||
|
|
||||||
// Add podcast episodes to library
|
|
||||||
itemPart.episode?.let { podcastEpisode ->
|
|
||||||
var podcast = localLibraryItem.media as Podcast
|
|
||||||
podcast.addEpisode(track, podcastEpisode)
|
|
||||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
|
||||||
}
|
|
||||||
} else { // Cover image
|
|
||||||
var localFileId = DeviceManager.getBase64Id(docFile.id)
|
|
||||||
var localFile = LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx),docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
|
||||||
|
|
||||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioTracks.isEmpty()) {
|
|
||||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
// For books sort audio tracks then set
|
|
||||||
if (downloadItem.mediaType == "book") {
|
|
||||||
audioTracks.sortBy { it.index }
|
|
||||||
|
|
||||||
var indexCheck = 1
|
|
||||||
var startOffset = 0.0
|
|
||||||
audioTracks.forEach { audioTrack ->
|
|
||||||
if (audioTrack.index != indexCheck || audioTrack.startOffset != startOffset) {
|
|
||||||
audioTrack.index = indexCheck
|
|
||||||
audioTrack.startOffset = startOffset
|
|
||||||
}
|
|
||||||
indexCheck++
|
|
||||||
startOffset += audioTrack.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
|
||||||
|
|
||||||
return localLibraryItem
|
|
||||||
}
|
|
||||||
|
|
||||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
|
||||||
var df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(localLibraryItem.contentUrl))
|
|
||||||
|
|
||||||
if (df == null) {
|
|
||||||
Log.e(tag, "Item Folder Doc File Invalid ${localLibraryItem.absolutePath}")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
Log.d(tag, "scanLocalLibraryItem starting for ${localLibraryItem.absolutePath} | ${df.uri}")
|
|
||||||
|
|
||||||
var wasUpdated = false
|
|
||||||
|
|
||||||
// Search for files in media item folder
|
|
||||||
var filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
|
||||||
Log.d(tag, "scanLocalLibraryItem ${filesFound.size} files found in ${localLibraryItem.absolutePath}")
|
|
||||||
|
|
||||||
filesFound.forEach {
|
|
||||||
try {
|
|
||||||
Log.d(tag, "Checking file found ${it.name} | ${it.id}")
|
|
||||||
}catch(e:Exception) {
|
|
||||||
Log.d(tag, "Check file found exception", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
|
||||||
|
|
||||||
// Remove any files no longer found in library item folder
|
|
||||||
var existingLocalFileIds = localLibraryItem.localFiles.map { it.id }
|
|
||||||
existingLocalFileIds.forEach { localFileId ->
|
|
||||||
Log.d(tag, "Checking local file id is there $localFileId")
|
|
||||||
if (filesFound.find { DeviceManager.getBase64Id(it.id) == localFileId } == null) {
|
|
||||||
Log.d(tag, "scanLocalLibraryItem file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
|
||||||
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
|
||||||
|
|
||||||
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
|
|
||||||
Log.d(tag, "scanLocalLibraryItem audio track file ${localFileId} was removed from ${localLibraryItem.absolutePath}")
|
|
||||||
localLibraryItem.media.removeAudioTrack(localFileId)
|
|
||||||
}
|
|
||||||
wasUpdated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
filesFound.forEach { docFile ->
|
|
||||||
var localFileId = DeviceManager.getBase64Id(docFile.id)
|
|
||||||
var existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
|
||||||
|
|
||||||
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
|
||||||
|
|
||||||
var localFile = existingLocalFile ?: LocalFile(localFileId,docFile.name,docFile.uri.toString(),docFile.getBasePath(ctx), docFile.getAbsolutePath(ctx),docFile.getSimplePath(ctx),docFile.mimeType,docFile.length())
|
|
||||||
if (existingLocalFile == null) {
|
|
||||||
localLibraryItem.localFiles.add(localFile)
|
|
||||||
Log.d(tag, "scanLocalLibraryItem new file found ${localFile.filename}")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (localFile.isAudioFile()) {
|
|
||||||
// TODO: Make asynchronous
|
|
||||||
var audioProbeResult = probeAudioFile(localFile.absolutePath)
|
|
||||||
|
|
||||||
var existingTrack = existingAudioTracks.find { audioTrack ->
|
|
||||||
audioTrack.localFileId == localFile.id
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingTrack == null) {
|
|
||||||
// Create new audio track
|
|
||||||
var lastTrack = existingAudioTracks.lastOrNull()
|
|
||||||
var startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
|
||||||
var track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
|
||||||
localLibraryItem.media.addAudioTrack(track)
|
|
||||||
wasUpdated = true
|
|
||||||
} else {
|
|
||||||
existingTrack.audioProbeResult = audioProbeResult
|
|
||||||
// TODO: Update data found from probe
|
|
||||||
wasUpdated = true
|
|
||||||
}
|
|
||||||
} else { // Check if cover is empty
|
|
||||||
if (localLibraryItem.coverContentUrl == null) {
|
|
||||||
Log.d(tag, "scanLocalLibraryItem setting cover for ${localLibraryItem.media.metadata.title}")
|
|
||||||
localLibraryItem.coverContentUrl = localFile.contentUrl
|
|
||||||
localLibraryItem.coverAbsolutePath = localFile.absolutePath
|
|
||||||
wasUpdated = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (wasUpdated) {
|
|
||||||
Log.d(tag, "Local library item was updated - saving it")
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "Local library item was up-to-date")
|
|
||||||
}
|
|
||||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun probeAudioFile(absolutePath:String):AudioProbeResult {
|
|
||||||
var session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
|
||||||
Log.d(tag, "FFprobe output ${JSObject(session.output)}")
|
|
||||||
|
|
||||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
|
||||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
|
||||||
return audioProbeResult
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,177 +0,0 @@
|
|||||||
package com.audiobookshelf.app.media
|
|
||||||
|
|
||||||
import android.bluetooth.BluetoothClass
|
|
||||||
import android.content.Context
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.data.*
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
|
||||||
import java.util.*
|
|
||||||
import io.paperdb.Paper
|
|
||||||
|
|
||||||
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|
||||||
val tag = "MediaManager"
|
|
||||||
|
|
||||||
var serverLibraryItems = listOf<LibraryItem>()
|
|
||||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
|
||||||
var serverLibraries = listOf<Library>()
|
|
||||||
|
|
||||||
fun initializeAndroidAuto() {
|
|
||||||
Log.d(tag, "Android Auto started when MainActivity was never started - initializing Paper")
|
|
||||||
Paper.init(ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
|
||||||
if (serverLibraryCategories.isNotEmpty()) {
|
|
||||||
cb(serverLibraryCategories)
|
|
||||||
} else {
|
|
||||||
apiHandler.getLibraryCategories(libraryId) {
|
|
||||||
serverLibraryCategories = it
|
|
||||||
cb(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
|
||||||
if (serverLibraryItems.isNotEmpty()) {
|
|
||||||
cb(serverLibraryItems)
|
|
||||||
} else {
|
|
||||||
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
|
||||||
serverLibraryItems = libraryItems
|
|
||||||
cb(libraryItems)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadLibraries(cb: (List<Library>) -> Unit) {
|
|
||||||
if (serverLibraries.isNotEmpty()) {
|
|
||||||
cb(serverLibraries)
|
|
||||||
} else {
|
|
||||||
apiHandler.getLibraries {
|
|
||||||
serverLibraries = it
|
|
||||||
cb(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Load currently listening category for local items
|
|
||||||
fun loadLocalCategory():List<LibraryCategory> {
|
|
||||||
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
|
||||||
var localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
|
||||||
var cats = mutableListOf<LibraryCategory>()
|
|
||||||
if (localBooks.isNotEmpty()) {
|
|
||||||
cats.add(LibraryCategory("local-books", "Local Books", "book", localBooks, true))
|
|
||||||
}
|
|
||||||
if (localPodcasts.isNotEmpty()) {
|
|
||||||
cats.add(LibraryCategory("local-podcasts", "Local Podcasts", "podcast", localPodcasts, true))
|
|
||||||
}
|
|
||||||
return cats
|
|
||||||
}
|
|
||||||
|
|
||||||
fun loadAndroidAutoItems(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
|
||||||
Log.d(tag, "Load android auto items for library id $libraryId")
|
|
||||||
var cats = mutableListOf<LibraryCategory>()
|
|
||||||
|
|
||||||
var localCategories = loadLocalCategory()
|
|
||||||
cats.addAll(localCategories)
|
|
||||||
|
|
||||||
// Connected to server and has internet - load other cats
|
|
||||||
if (apiHandler.isOnline() && (DeviceManager.isConnectedToServer || DeviceManager.hasLastServerConnectionConfig)) {
|
|
||||||
if (!DeviceManager.isConnectedToServer) {
|
|
||||||
DeviceManager.serverConnectionConfig = DeviceManager.deviceData.getLastServerConnectionConfig()
|
|
||||||
Log.d(tag, "Not connected to server, set last server \"${DeviceManager.serverAddress}\"")
|
|
||||||
}
|
|
||||||
|
|
||||||
loadLibraries { libraries ->
|
|
||||||
var library = libraries.find { it.id == libraryId } ?: libraries[0]
|
|
||||||
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
|
||||||
|
|
||||||
loadLibraryCategories(libraryId) { libraryCategories ->
|
|
||||||
|
|
||||||
// Only using book or podcast library categories for now
|
|
||||||
libraryCategories.forEach {
|
|
||||||
Log.d(tag, "Found library category ${it.label} with type ${it.type}")
|
|
||||||
if (it.type == library.mediaType) {
|
|
||||||
Log.d(tag, "Using library category ${it.id}")
|
|
||||||
cats.add(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadLibraryItems(libraryId) { libraryItems ->
|
|
||||||
var mainCat = LibraryCategory("library", "Library", library.mediaType, libraryItems, false)
|
|
||||||
cats.add(mainCat)
|
|
||||||
|
|
||||||
cb(cats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else { // Not connected/no internet sent downloaded cats only
|
|
||||||
cb(cats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFirstItem() : LibraryItemWrapper? {
|
|
||||||
if (serverLibraryItems.isNotEmpty()) {
|
|
||||||
return serverLibraryItems[0]
|
|
||||||
} else {
|
|
||||||
var localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
|
||||||
return if (localBooks.isNotEmpty()) return localBooks[0] else null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getById(id:String) : LibraryItemWrapper? {
|
|
||||||
if (id.startsWith("local")) {
|
|
||||||
return DeviceManager.dbManager.getLocalLibraryItem(id)
|
|
||||||
} else {
|
|
||||||
return serverLibraryItems.find { it.id == id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getFromSearch(query:String?) : LibraryItemWrapper? {
|
|
||||||
if (query.isNullOrEmpty()) return getFirstItem()
|
|
||||||
return serverLibraryItems.find {
|
|
||||||
it.title.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun play(libraryItemWrapper:LibraryItemWrapper, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
|
|
||||||
if (libraryItemWrapper is LocalLibraryItem) {
|
|
||||||
var localLibraryItem = libraryItemWrapper as LocalLibraryItem
|
|
||||||
cb(localLibraryItem.getPlaybackSession(null))
|
|
||||||
} else {
|
|
||||||
var libraryItem = libraryItemWrapper as LibraryItem
|
|
||||||
apiHandler.playLibraryItem(libraryItem.id,"",false, mediaPlayer) {
|
|
||||||
cb(it)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
|
||||||
val lhsLength = lhs.length + 1
|
|
||||||
val rhsLength = rhs.length + 1
|
|
||||||
|
|
||||||
var cost = Array(lhsLength) { it }
|
|
||||||
var newCost = Array(lhsLength) { 0 }
|
|
||||||
|
|
||||||
for (i in 1..rhsLength-1) {
|
|
||||||
newCost[0] = i
|
|
||||||
|
|
||||||
for (j in 1..lhsLength-1) {
|
|
||||||
val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1
|
|
||||||
|
|
||||||
val costReplace = cost[j - 1] + match
|
|
||||||
val costInsert = cost[j] + 1
|
|
||||||
val costDelete = newCost[j - 1] + 1
|
|
||||||
|
|
||||||
newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace)
|
|
||||||
}
|
|
||||||
|
|
||||||
val swap = cost
|
|
||||||
cost = newCost
|
|
||||||
newCost = swap
|
|
||||||
}
|
|
||||||
|
|
||||||
return cost[lhsLength - 1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.graphics.Bitmap
|
|
||||||
import android.net.Uri
|
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.bumptech.glide.Glide
|
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.bumptech.glide.request.RequestOptions
|
|
||||||
import com.google.android.exoplayer2.Player
|
|
||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
|
||||||
import kotlinx.coroutines.*
|
|
||||||
|
|
||||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
|
||||||
|
|
||||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
|
||||||
private val tag = "MediaDescriptionAdapter"
|
|
||||||
|
|
||||||
var currentIconUri: Uri? = null
|
|
||||||
var currentBitmap: Bitmap? = null
|
|
||||||
|
|
||||||
private val glideOptions = RequestOptions()
|
|
||||||
.fallback(R.drawable.icon)
|
|
||||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
|
||||||
private val serviceJob = SupervisorJob()
|
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
|
||||||
|
|
||||||
override fun createCurrentContentIntent(player: Player): PendingIntent? =
|
|
||||||
controller.sessionActivity
|
|
||||||
|
|
||||||
override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString()
|
|
||||||
|
|
||||||
override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString()
|
|
||||||
|
|
||||||
override fun getCurrentLargeIcon(
|
|
||||||
player: Player,
|
|
||||||
callback: PlayerNotificationManager.BitmapCallback
|
|
||||||
): Bitmap? {
|
|
||||||
val albumArtUri = controller.metadata.description.iconUri
|
|
||||||
|
|
||||||
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
|
||||||
// Cache the bitmap for the current audiobook so that successive calls to
|
|
||||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
|
||||||
currentIconUri = albumArtUri
|
|
||||||
Log.d(tag, "ART $currentIconUri")
|
|
||||||
serviceScope.launch {
|
|
||||||
currentBitmap = albumArtUri?.let {
|
|
||||||
resolveUriAsBitmap(it)
|
|
||||||
}
|
|
||||||
currentBitmap?.let { callback.onBitmap(it) }
|
|
||||||
}
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
currentBitmap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
|
||||||
return withContext(Dispatchers.IO) {
|
|
||||||
// Block on downloading artwork.
|
|
||||||
try {
|
|
||||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
|
||||||
.asBitmap()
|
|
||||||
.load(uri)
|
|
||||||
.placeholder(R.drawable.icon)
|
|
||||||
.error(R.drawable.icon)
|
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
|
||||||
.get()
|
|
||||||
} catch (e: Exception) {
|
|
||||||
e.printStackTrace()
|
|
||||||
|
|
||||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
|
||||||
.asBitmap()
|
|
||||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
|
||||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
|
||||||
.get()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.content.ContentResolver
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.AnyRes
|
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.audiobookshelf.app.data.LibraryCategory
|
|
||||||
import com.audiobookshelf.app.data.LibraryItem
|
|
||||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
|
||||||
|
|
||||||
|
|
||||||
class BrowseTree(
|
|
||||||
val context: Context,
|
|
||||||
libraryCategories: List<LibraryCategory>
|
|
||||||
) {
|
|
||||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get uri to drawable or any other resource type if u wish
|
|
||||||
* @param context - context
|
|
||||||
* @param drawableId - drawable res id
|
|
||||||
* @return - uri
|
|
||||||
*/
|
|
||||||
fun getUriToDrawable(context: Context,
|
|
||||||
@AnyRes drawableId: Int): Uri {
|
|
||||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
|
||||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
|
||||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
|
||||||
+ '/' + context.resources.getResourceEntryName(drawableId))
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
|
||||||
|
|
||||||
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Listening")
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
val allMetadata = MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Library Items")
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_books).toString())
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
|
||||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
// Server continue Listening cat
|
|
||||||
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
|
|
||||||
var continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
|
|
||||||
var libraryItem = liw as LibraryItem
|
|
||||||
libraryItem.getMediaMetadata()
|
|
||||||
}
|
|
||||||
if (continueListeningMediaMetadata.isNotEmpty()) {
|
|
||||||
rootList += continueReadingMetadata
|
|
||||||
}
|
|
||||||
continueListeningMediaMetadata.forEach {
|
|
||||||
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
|
|
||||||
children += it
|
|
||||||
mediaIdToChildren[CONTINUE_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rootList += allMetadata
|
|
||||||
rootList += downloadsMetadata
|
|
||||||
|
|
||||||
// Server library cat
|
|
||||||
libraryCategories.find { it.id == "library" }?.let { libraryCategory ->
|
|
||||||
var libraryMediaMetadata = libraryCategory.entities.map { libc ->
|
|
||||||
var libraryItem = libc as LibraryItem
|
|
||||||
libraryItem.getMediaMetadata()
|
|
||||||
}
|
|
||||||
libraryMediaMetadata.forEach {
|
|
||||||
val children = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
|
|
||||||
children += it
|
|
||||||
mediaIdToChildren[ALL_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
|
|
||||||
var localMediaMetadata = localBooksCat.entities.map { libc ->
|
|
||||||
var libraryItem = libc as LocalLibraryItem
|
|
||||||
libraryItem.getMediaMetadata()
|
|
||||||
}
|
|
||||||
localMediaMetadata.forEach {
|
|
||||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
|
||||||
children += it
|
|
||||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
|
||||||
}
|
|
||||||
|
|
||||||
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
|
|
||||||
}
|
|
||||||
|
|
||||||
const val AUTO_BROWSE_ROOT = "/"
|
|
||||||
const val ALL_ROOT = "__ALL__"
|
|
||||||
const val CONTINUE_ROOT = "__CONTINUE__"
|
|
||||||
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.app.Activity
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.appcompat.R
|
|
||||||
import androidx.mediarouter.app.MediaRouteChooserDialog
|
|
||||||
import androidx.mediarouter.media.MediaRouteSelector
|
|
||||||
import androidx.mediarouter.media.MediaRouter
|
|
||||||
import com.getcapacitor.PluginCall
|
|
||||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
|
||||||
import com.google.android.gms.cast.*
|
|
||||||
import com.google.android.gms.cast.framework.*
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
class CastManager constructor(val mainActivity:Activity) {
|
|
||||||
private val tag = "CastManager"
|
|
||||||
|
|
||||||
private var playerNotificationService:PlayerNotificationService? = null
|
|
||||||
private var newConnectionListener: SessionListener? = null
|
|
||||||
|
|
||||||
private fun switchToPlayer(useCastPlayer:Boolean) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService?.switchToPlayer(useCastPlayer)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private inner class CastSessionAvailabilityListener : SessionAvailabilityListener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a Cast session has started and the user wishes to control playback on a
|
|
||||||
* remote Cast receiver rather than play audio locally.
|
|
||||||
*/
|
|
||||||
override fun onCastSessionAvailable() {
|
|
||||||
Log.d(tag, "SessionAvailabilityListener: onCastSessionAvailable")
|
|
||||||
switchToPlayer(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a Cast session has ended and the user wishes to control playback locally.
|
|
||||||
*/
|
|
||||||
override fun onCastSessionUnavailable() {
|
|
||||||
Log.d(tag, "onCastSessionUnavailable")
|
|
||||||
switchToPlayer(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun requestSession(playerNotificationService: PlayerNotificationService, callback: RequestSessionCallback) {
|
|
||||||
this.playerNotificationService = playerNotificationService
|
|
||||||
|
|
||||||
mainActivity.runOnUiThread {
|
|
||||||
val session: CastSession? = getSession()
|
|
||||||
if (session == null) {
|
|
||||||
// show the "choose a connection" dialog
|
|
||||||
// Add the connection listener callback
|
|
||||||
listenForConnection(callback)
|
|
||||||
|
|
||||||
val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar)
|
|
||||||
builder.routeSelector = MediaRouteSelector.Builder()
|
|
||||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
|
||||||
.build()
|
|
||||||
builder.setCanceledOnTouchOutside(true)
|
|
||||||
builder.setOnCancelListener {
|
|
||||||
newConnectionListener?.let { ncl ->
|
|
||||||
getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java)
|
|
||||||
}
|
|
||||||
callback.onCancel()
|
|
||||||
}
|
|
||||||
builder.show()
|
|
||||||
} else {
|
|
||||||
// We are are already connected, so show the "connection options" Dialog
|
|
||||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
|
||||||
session.castDevice?.let {
|
|
||||||
builder.setTitle(it.friendlyName)
|
|
||||||
}
|
|
||||||
builder.setOnDismissListener { callback.onCancel() }
|
|
||||||
builder.setPositiveButton("Stop Casting") { _, _ -> endSession(true, null) }
|
|
||||||
builder.show()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class RequestSessionCallback : ConnectionCallback {
|
|
||||||
abstract fun onError(errorCode: Int)
|
|
||||||
abstract fun onCancel()
|
|
||||||
override fun onSessionEndedBeforeStart(errorCode: Int): Boolean {
|
|
||||||
onSessionStartFailed(errorCode)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionStartFailed(errorCode: Int): Boolean {
|
|
||||||
onError(errorCode)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) {
|
|
||||||
|
|
||||||
getSessionManager()!!.addSessionManagerListener(object : SessionListener() {
|
|
||||||
override fun onSessionEnded(castSession: CastSession, error: Int) {
|
|
||||||
getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java)
|
|
||||||
Log.d(tag, "CAST END SESSION")
|
|
||||||
pluginCall?.resolve()
|
|
||||||
}
|
|
||||||
}, CastSession::class.java)
|
|
||||||
getSessionManager()!!.endCurrentSession(stopCasting)
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
open class SessionListener : SessionManagerListener<CastSession> {
|
|
||||||
override fun onSessionStarting(castSession: CastSession) {}
|
|
||||||
override fun onSessionStarted(castSession: CastSession, sessionId: String) {}
|
|
||||||
override fun onSessionStartFailed(castSession: CastSession, error: Int) {}
|
|
||||||
override fun onSessionEnding(castSession: CastSession) {}
|
|
||||||
override fun onSessionEnded(castSession: CastSession, error: Int) {}
|
|
||||||
override fun onSessionResuming(castSession: CastSession, sessionId: String) {}
|
|
||||||
override fun onSessionResumed(castSession: CastSession, wasSuspended: Boolean) {}
|
|
||||||
override fun onSessionResumeFailed(castSession: CastSession, error: Int) {}
|
|
||||||
override fun onSessionSuspended(castSession: CastSession, reason: Int) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startRouteScan(connListener:ChromecastListener) {
|
|
||||||
var callback = object : ScanCallback() {
|
|
||||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
|
||||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
|
||||||
// if the routes have changed, we may have an available device
|
|
||||||
// If there is at least one device available
|
|
||||||
if (getContext().castState != CastState.NO_DEVICES_AVAILABLE) {
|
|
||||||
routes?.forEach { Log.d(tag, "CAST ROUTE ${it.description} | ${it.deviceType} | ${it.isBluetooth} | ${it.name}") }
|
|
||||||
|
|
||||||
// Stop the scan
|
|
||||||
stopRouteScan(this, null)
|
|
||||||
// Let the client know a receiver is available
|
|
||||||
connListener.onReceiverAvailableUpdate(true)
|
|
||||||
// Since we have a receiver we may also have an active session
|
|
||||||
var session = getSessionManager()?.currentCastSession
|
|
||||||
// If we do have a session
|
|
||||||
if (session != null) {
|
|
||||||
// Let the client know
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "No cast devices available")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
callback.setMediaRouter(getMediaRouter())
|
|
||||||
|
|
||||||
callback.onFilteredRouteUpdate()
|
|
||||||
|
|
||||||
getMediaRouter()?.addCallback(MediaRouteSelector.Builder()
|
|
||||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
|
||||||
.build(),
|
|
||||||
callback,
|
|
||||||
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal interface CastListener : Cast.MessageReceivedCallback {
|
|
||||||
fun onMediaLoaded(jsonMedia: JSONObject?)
|
|
||||||
fun onMediaUpdate(jsonMedia: JSONObject?)
|
|
||||||
fun onSessionUpdate(jsonSession: JSONObject?)
|
|
||||||
fun onSessionEnd(jsonSession: JSONObject?)
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class ChromecastListener : CastStateListener, CastListener {
|
|
||||||
abstract fun onReceiverAvailableUpdate(available: Boolean)
|
|
||||||
abstract fun onSessionRejoin(jsonSession: JSONObject?)
|
|
||||||
|
|
||||||
/** CastStateListener functions. */
|
|
||||||
override fun onCastStateChanged(state: Int) {
|
|
||||||
onReceiverAvailableUpdate(state != CastState.NO_DEVICES_AVAILABLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) {
|
|
||||||
Log.d(tag, "stopRouteScan")
|
|
||||||
if (callback == null) {
|
|
||||||
completionCallback?.run()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// mainActivity.runOnUiThread {
|
|
||||||
Log.d(tag, "Removing callback on media router")
|
|
||||||
callback.stop()
|
|
||||||
getMediaRouter()?.removeCallback(callback)
|
|
||||||
completionCallback?.run()
|
|
||||||
// }
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract class ScanCallback : MediaRouter.Callback() {
|
|
||||||
/**
|
|
||||||
* Called whenever a route is updated.
|
|
||||||
* @param routes the currently available routes
|
|
||||||
*/
|
|
||||||
abstract fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?)
|
|
||||||
|
|
||||||
/** records whether we have been stopped or not. */
|
|
||||||
private var stopped = false
|
|
||||||
|
|
||||||
/** Global mediaRouter object. */
|
|
||||||
private var mediaRouter: MediaRouter? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the mediaRouter object.
|
|
||||||
* @param router mediaRouter object
|
|
||||||
*/
|
|
||||||
fun setMediaRouter(router: MediaRouter?) {
|
|
||||||
mediaRouter = router
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Call this method when you wish to stop scanning.
|
|
||||||
* It is important that it is called, otherwise battery
|
|
||||||
* life will drain more quickly.
|
|
||||||
*/
|
|
||||||
fun stop() {
|
|
||||||
stopped = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onFilteredRouteUpdate() {
|
|
||||||
if (stopped || mediaRouter == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val outRoutes: MutableList<MediaRouter.RouteInfo> = ArrayList()
|
|
||||||
// Filter the routes
|
|
||||||
for (route in mediaRouter!!.routes) {
|
|
||||||
// We don't want default routes, or duplicate active routes
|
|
||||||
// or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32
|
|
||||||
val extras: Bundle? = route.extras
|
|
||||||
if (extras != null) {
|
|
||||||
CastDevice.getFromBundle(extras)
|
|
||||||
if (extras.getString("com.google.android.gms.cast.EXTRA_SESSION_ID") != null) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!route.isDefault
|
|
||||||
&& !route.description.equals("Google Cast Multizone Member")
|
|
||||||
&& route.playbackType == MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
|
|
||||||
outRoutes.add(route)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
onRouteUpdate(outRoutes)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
|
||||||
onFilteredRouteUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
|
||||||
onFilteredRouteUpdate()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
|
||||||
onFilteredRouteUpdate()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun listenForConnection(callback: ConnectionCallback) {
|
|
||||||
// We should only ever have one of these listeners active at a time, so remove previous
|
|
||||||
newConnectionListener?.let { ncl ->
|
|
||||||
getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java)
|
|
||||||
}
|
|
||||||
|
|
||||||
newConnectionListener = object : SessionListener() {
|
|
||||||
override fun onSessionStarted(castSession: CastSession, sessionId: String) {
|
|
||||||
Log.d(tag, "CAST SESSION STARTED ${castSession.castDevice?.friendlyName}")
|
|
||||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
|
||||||
|
|
||||||
val castContext = CastContext.getSharedInstance(mainActivity)
|
|
||||||
|
|
||||||
playerNotificationService?.let {
|
|
||||||
if (it.castPlayer == null) {
|
|
||||||
Log.d(tag, "Initializing castPlayer on session started - switch to cast player")
|
|
||||||
it.castPlayer = CastPlayer(castContext).apply {
|
|
||||||
addListener(PlayerListener(it))
|
|
||||||
setSessionAvailabilityListener(CastSessionAvailabilityListener())
|
|
||||||
}
|
|
||||||
switchToPlayer(true)
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "castPlayer is already initialized on session started")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionStartFailed(castSession: CastSession, error: Int) {
|
|
||||||
if (callback.onSessionStartFailed(error)) {
|
|
||||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionEnded(castSession: CastSession, error: Int) {
|
|
||||||
if (callback.onSessionEndedBeforeStart(error)) {
|
|
||||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newConnectionListener?.let {
|
|
||||||
Log.d(tag, "Add session manager listener")
|
|
||||||
getSessionManager()?.addSessionManagerListener(it, CastSession::class.java)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getContext(): CastContext {
|
|
||||||
return CastContext.getSharedInstance(mainActivity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSessionManager(): SessionManager? {
|
|
||||||
return getContext().sessionManager
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMediaRouter(): MediaRouter? {
|
|
||||||
return MediaRouter.getInstance(mainActivity)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSession(): CastSession? {
|
|
||||||
return getSessionManager()?.currentCastSession
|
|
||||||
}
|
|
||||||
|
|
||||||
internal interface ConnectionCallback {
|
|
||||||
/**
|
|
||||||
* Successfully joined a session on a route.
|
|
||||||
* @param jsonSession the session we joined
|
|
||||||
*/
|
|
||||||
fun onJoin(jsonSession: JSONObject?)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called if we received an error.
|
|
||||||
* @param errorCode You can find the error meaning here:
|
|
||||||
* https://developers.google.com/android/reference/com/google/android/gms/cast/CastStatusCodes
|
|
||||||
* @return true if we are done listening for join, false, if we to keep listening
|
|
||||||
*/
|
|
||||||
fun onSessionStartFailed(errorCode: Int): Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when we detect a session ended event before session started.
|
|
||||||
* See issues:
|
|
||||||
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/49
|
|
||||||
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
|
|
||||||
* @param errorCode error to output
|
|
||||||
* @return true if we are done listening for join, false, if we to keep listening
|
|
||||||
*/
|
|
||||||
fun onSessionEndedBeforeStart(errorCode: Int): Boolean
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,978 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.Surface
|
|
||||||
import android.view.SurfaceHolder
|
|
||||||
import android.view.SurfaceView
|
|
||||||
import android.view.TextureView
|
|
||||||
import com.google.android.exoplayer2.*
|
|
||||||
import com.google.android.exoplayer2.C.TrackType
|
|
||||||
import com.google.android.exoplayer2.MediaMetadata
|
|
||||||
import com.google.android.exoplayer2.Player.*
|
|
||||||
import com.google.android.exoplayer2.TracksInfo.TrackGroupInfo
|
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
|
||||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
|
||||||
import com.google.android.exoplayer2.source.TrackGroup
|
|
||||||
import com.google.android.exoplayer2.source.TrackGroupArray
|
|
||||||
import com.google.android.exoplayer2.text.Cue
|
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelection
|
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelectionArray
|
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelectionParameters
|
|
||||||
import com.google.android.exoplayer2.util.*
|
|
||||||
import com.google.android.exoplayer2.util.Util.castNonNull
|
|
||||||
import com.google.android.exoplayer2.video.VideoSize
|
|
||||||
import com.google.android.gms.cast.*
|
|
||||||
import com.google.android.gms.cast.framework.CastContext
|
|
||||||
import com.google.android.gms.cast.framework.CastSession
|
|
||||||
import com.google.android.gms.cast.framework.SessionManagerListener
|
|
||||||
import com.google.android.gms.cast.framework.media.RemoteMediaClient
|
|
||||||
import com.google.android.gms.common.api.PendingResult
|
|
||||||
import com.google.android.gms.common.api.ResultCallback
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
|
|
||||||
class CastPlayer(var castContext: CastContext) : BasePlayer() {
|
|
||||||
val tag = "CastPlayer"
|
|
||||||
|
|
||||||
private val RENDERER_COUNT = 3
|
|
||||||
private val RENDERER_INDEX_VIDEO = 0
|
|
||||||
private val RENDERER_INDEX_AUDIO = 1
|
|
||||||
private val RENDERER_INDEX_TEXT = 2
|
|
||||||
private val PROGRESS_REPORT_PERIOD_MS: Long = 1000
|
|
||||||
private val EMPTY_TRACK_SELECTION_ARRAY = TrackSelectionArray()
|
|
||||||
private val EMPTY_TRACK_ID_ARRAY = LongArray(0)
|
|
||||||
|
|
||||||
private val seekBackIncrementMs: Long = 5000L
|
|
||||||
private val seekForwardIncrementMs: Long = 5000L
|
|
||||||
private var myPlayWhenReady:Boolean = false
|
|
||||||
private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT
|
|
||||||
private var myAvailableCommands: Commands
|
|
||||||
private var myCurrentTracksInfo: TracksInfo
|
|
||||||
private var myCurrentTrackGroups: TrackGroupArray
|
|
||||||
private var myCurrentTrackSelections: TrackSelectionArray
|
|
||||||
|
|
||||||
var currentMediaItems:List<MediaItem> = mutableListOf()
|
|
||||||
var remoteMediaClient:RemoteMediaClient? = null
|
|
||||||
var sessionAvailabilityListener:SessionAvailabilityListener? = null
|
|
||||||
var myCurrentTimeline: CastTimeline
|
|
||||||
val timelineTracker: CastTimelineTracker
|
|
||||||
val period: Timeline.Period
|
|
||||||
var listeners: ListenerSet<Player.Listener>
|
|
||||||
|
|
||||||
/* package */
|
|
||||||
val PERMANENT_AVAILABLE_COMMANDS = Commands.Builder()
|
|
||||||
.addAll(
|
|
||||||
COMMAND_PLAY_PAUSE,
|
|
||||||
COMMAND_PREPARE,
|
|
||||||
COMMAND_STOP,
|
|
||||||
COMMAND_SEEK_TO_DEFAULT_POSITION,
|
|
||||||
COMMAND_SEEK_TO_MEDIA_ITEM,
|
|
||||||
COMMAND_SET_REPEAT_MODE,
|
|
||||||
COMMAND_SET_SPEED_AND_PITCH,
|
|
||||||
COMMAND_GET_CURRENT_MEDIA_ITEM,
|
|
||||||
COMMAND_GET_TIMELINE,
|
|
||||||
COMMAND_GET_MEDIA_ITEMS_METADATA,
|
|
||||||
COMMAND_SET_MEDIA_ITEMS_METADATA,
|
|
||||||
COMMAND_CHANGE_MEDIA_ITEMS,
|
|
||||||
COMMAND_GET_TRACK_INFOS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
var currentPlaybackState = Player.STATE_IDLE
|
|
||||||
var statusListener:StatusListener
|
|
||||||
|
|
||||||
val deviceInfo get() = castContext.sessionManager.currentCastSession?.castDevice.toString()
|
|
||||||
var lastReportedPositionMs = 0L
|
|
||||||
|
|
||||||
private var currentMediaItemIndex = 0
|
|
||||||
private var pendingMediaItemRemovalPosition:PositionInfo? = null
|
|
||||||
private var pendingSeekCount = 0
|
|
||||||
private var pendingSeekWindowIndex = 0
|
|
||||||
private var pendingSeekPositionMs = 0L
|
|
||||||
|
|
||||||
init {
|
|
||||||
Log.d(tag, "Init CastPlayer")
|
|
||||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
|
||||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
|
||||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
|
||||||
statusListener = StatusListener()
|
|
||||||
timelineTracker = CastTimelineTracker()
|
|
||||||
myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE
|
|
||||||
period = Timeline.Period()
|
|
||||||
myAvailableCommands = Commands.Builder().addAll(PERMANENT_AVAILABLE_COMMANDS).build()
|
|
||||||
|
|
||||||
listeners = ListenerSet(
|
|
||||||
Looper.getMainLooper(),
|
|
||||||
Clock.DEFAULT
|
|
||||||
) { listener: Player.Listener, flags: FlagSet? -> listener.onEvents(this, Player.Events(flags ?: FlagSet.Builder().build())) }
|
|
||||||
|
|
||||||
val sessionManager = castContext.sessionManager
|
|
||||||
sessionManager.addSessionManagerListener(statusListener, CastSession::class.java)
|
|
||||||
val session = sessionManager.currentCastSession
|
|
||||||
setRemoteMediaClient(session?.remoteMediaClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun load(mediaItems:List<MediaItem>, startIndex:Int, startTime:Long, playWhenReady:Boolean, playbackRate:Float, mediaType:String) {
|
|
||||||
Log.d(tag, "Load called")
|
|
||||||
|
|
||||||
if (remoteMediaClient == null) {
|
|
||||||
Log.d(tag, "Remote Media Client not set")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
currentMediaItems = mediaItems
|
|
||||||
|
|
||||||
var mediaQueueItems = mediaItems.map { toMediaQueueItem(it) }
|
|
||||||
|
|
||||||
var queueData = MediaQueueData.Builder().apply {
|
|
||||||
setItems(mediaQueueItems)
|
|
||||||
setQueueType(if (mediaType == "book") MediaQueueData.MEDIA_QUEUE_TYPE_AUDIO_BOOK else MediaQueueData.MEDIA_QUEUE_TYPE_PODCAST_SERIES)
|
|
||||||
setRepeatMode(MediaStatus.REPEAT_MODE_REPEAT_OFF)
|
|
||||||
setStartIndex(startIndex)
|
|
||||||
setStartTime(startTime)
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
var loadRequestData = MediaLoadRequestData.Builder().apply {
|
|
||||||
setPlaybackRate(playbackRate.toDouble())
|
|
||||||
setQueueData(queueData)
|
|
||||||
setAutoplay(playWhenReady)
|
|
||||||
setCurrentTime(startTime)
|
|
||||||
}.build()
|
|
||||||
|
|
||||||
remoteMediaClient?.load(loadRequestData)?.setResultCallback {
|
|
||||||
Log.d(tag, "Loaded cast player result ${it.status} | ${it.mediaError} | ${it.customData}")
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(tag, "Loaded cast player request data $loadRequestData")
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
|
|
||||||
// The MediaQueueItem you build is expected to be in the tag.
|
|
||||||
return (mediaItem.localConfiguration!!.tag as MediaQueueItem?)!!
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmName("setRemoteMediaClient1")
|
|
||||||
private fun setRemoteMediaClient(remoteMediaClient: RemoteMediaClient?) {
|
|
||||||
if (this.remoteMediaClient === remoteMediaClient) {
|
|
||||||
// Do nothing.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.remoteMediaClient != null) {
|
|
||||||
this.remoteMediaClient?.unregisterCallback(statusListener)
|
|
||||||
this.remoteMediaClient?.removeProgressListener(statusListener)
|
|
||||||
}
|
|
||||||
this.remoteMediaClient = remoteMediaClient
|
|
||||||
if (remoteMediaClient != null) {
|
|
||||||
sessionAvailabilityListener?.let {
|
|
||||||
it.onCastSessionAvailable()
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteMediaClient.registerCallback(statusListener)
|
|
||||||
remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS)
|
|
||||||
updateInternalStateAndNotifyIfChanged()
|
|
||||||
} else {
|
|
||||||
updateTimelineAndNotifyIfChanged()
|
|
||||||
sessionAvailabilityListener?.let {
|
|
||||||
it.onCastSessionUnavailable()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPlayerStateAndNotifyIfChanged(
|
|
||||||
playWhenReady: Boolean,
|
|
||||||
@PlayWhenReadyChangeReason playWhenReadyChangeReason: Int,
|
|
||||||
@Player.State playbackState: Int) {
|
|
||||||
|
|
||||||
val wasPlaying = this.currentPlaybackState == STATE_READY && this.playWhenReady
|
|
||||||
val playWhenReadyChanged = this.playWhenReady != playWhenReady
|
|
||||||
val playbackStateChanged = this.currentPlaybackState != playbackState
|
|
||||||
|
|
||||||
Log.d(tag, "setPlayerStateAndNotifyIfChanged newPlayWhenReady:$playWhenReady | playbackStateChanged:$playbackStateChanged | playWhenReadyChanged:$playWhenReadyChanged")
|
|
||||||
if (playWhenReadyChanged || playbackStateChanged) {
|
|
||||||
this.currentPlaybackState = playbackState
|
|
||||||
this.myPlayWhenReady = playWhenReady
|
|
||||||
|
|
||||||
if (playbackStateChanged) {
|
|
||||||
Log.d(tag, "CastPlayer About to emit onPlaybackStateChanged")
|
|
||||||
listeners.queueEvent(EVENT_PLAYBACK_STATE_CHANGED) {
|
|
||||||
it.onPlaybackStateChanged(playbackState)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playWhenReadyChanged) {
|
|
||||||
listeners.queueEvent(EVENT_PLAY_WHEN_READY_CHANGED) {
|
|
||||||
it.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val isPlaying = playbackState == STATE_READY && playWhenReady
|
|
||||||
if (wasPlaying != isPlaying) {
|
|
||||||
Log.d(tag, "CastPlayer About to emit onIsPlayingChanged $isPlaying")
|
|
||||||
listeners.queueEvent(EVENT_IS_PLAYING_CHANGED) {
|
|
||||||
it.onIsPlayingChanged(isPlaying)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "Is playing and was playing are equal $wasPlaying")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "setPlayerStateAndNotifyIfChanged No changes")
|
|
||||||
}
|
|
||||||
listeners.flushEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePlayerStateAndNotifyIfChanged() {
|
|
||||||
var newPlayWhenReadyValue = remoteMediaClient?.isPaused != true
|
|
||||||
|
|
||||||
@PlayWhenReadyChangeReason val playWhenReadyChangeReason = if (newPlayWhenReadyValue != playWhenReady) PLAY_WHEN_READY_CHANGE_REASON_REMOTE else PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST
|
|
||||||
// We do not mask the playback state, so try setting it regardless of the playWhenReady masking.
|
|
||||||
setPlayerStateAndNotifyIfChanged(
|
|
||||||
newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchPlaybackState(remoteMediaClient: RemoteMediaClient?): Int {
|
|
||||||
return when (remoteMediaClient?.playerState) {
|
|
||||||
MediaStatus.PLAYER_STATE_BUFFERING -> STATE_BUFFERING
|
|
||||||
MediaStatus.PLAYER_STATE_PLAYING, MediaStatus.PLAYER_STATE_PAUSED -> STATE_READY
|
|
||||||
MediaStatus.PLAYER_STATE_IDLE, MediaStatus.PLAYER_STATE_UNKNOWN -> STATE_IDLE
|
|
||||||
else -> STATE_IDLE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updatePlaybackRateAndNotifyIfChanged() {
|
|
||||||
val mediaStatus = remoteMediaClient!!.mediaStatus
|
|
||||||
val speed = mediaStatus?.playbackRate?.toFloat() ?: PlaybackParameters.DEFAULT.speed
|
|
||||||
if (speed > 0.0f) {
|
|
||||||
// Set the speed if not paused.
|
|
||||||
setPlaybackParametersAndNotifyIfChanged(PlaybackParameters(speed))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setPlaybackParametersAndNotifyIfChanged(playbackParameters: PlaybackParameters) {
|
|
||||||
if (this.playbackParameters == playbackParameters) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.playbackParameters = playbackParameters
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_PLAYBACK_PARAMETERS_CHANGED
|
|
||||||
) { listener -> listener.onPlaybackParametersChanged(playbackParameters) }
|
|
||||||
listeners.flushEvents()
|
|
||||||
updateAvailableCommandsAndNotifyIfChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateTimelineAndNotifyIfChanged(): Boolean {
|
|
||||||
val oldTimeline: Timeline = this.myCurrentTimeline
|
|
||||||
val oldWindowIndex = currentMediaItemIndex
|
|
||||||
var playingPeriodChanged = false
|
|
||||||
if (updateTimeline()) {
|
|
||||||
val timeline: Timeline = this.myCurrentTimeline
|
|
||||||
// Call onTimelineChanged.
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_TIMELINE_CHANGED
|
|
||||||
) { listener: Player.Listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) }
|
|
||||||
|
|
||||||
// Call onPositionDiscontinuity if required.
|
|
||||||
val currentTimeline = currentTimeline
|
|
||||||
var playingPeriodRemoved = false
|
|
||||||
if (!oldTimeline.isEmpty) {
|
|
||||||
val oldPeriodUid = castNonNull(oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid)
|
|
||||||
playingPeriodRemoved = currentTimeline.getIndexOfPeriod(oldPeriodUid) == C.INDEX_UNSET
|
|
||||||
}
|
|
||||||
if (playingPeriodRemoved) {
|
|
||||||
var oldPosition: PositionInfo = getCurrentPositionInfo()
|
|
||||||
if (pendingMediaItemRemovalPosition != null) {
|
|
||||||
pendingMediaItemRemovalPosition?.let {
|
|
||||||
oldPosition = it
|
|
||||||
}
|
|
||||||
pendingMediaItemRemovalPosition = null
|
|
||||||
} else {
|
|
||||||
// If the media item has been removed by another client, we don't know the removal
|
|
||||||
// position. We use the current position as a fallback.
|
|
||||||
oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true)
|
|
||||||
oldTimeline.getWindow(period.windowIndex, window)
|
|
||||||
oldPosition = PositionInfo(
|
|
||||||
window.uid,
|
|
||||||
period.windowIndex,
|
|
||||||
window.mediaItem,
|
|
||||||
period.uid,
|
|
||||||
period.windowIndex,
|
|
||||||
currentPosition,
|
|
||||||
contentPosition,
|
|
||||||
C.INDEX_UNSET,/* adGroupIndex= */
|
|
||||||
C.INDEX_UNSET) /* adIndexInAdGroup= */
|
|
||||||
}
|
|
||||||
|
|
||||||
val newPosition: PositionInfo = getCurrentPositionInfo()
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_POSITION_DISCONTINUITY
|
|
||||||
) { listener: Player.Listener ->
|
|
||||||
listener.onPositionDiscontinuity(
|
|
||||||
oldPosition, newPosition, DISCONTINUITY_REASON_REMOVE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Call onMediaItemTransition if required.
|
|
||||||
playingPeriodChanged = currentTimeline.isEmpty != oldTimeline.isEmpty || playingPeriodRemoved
|
|
||||||
if (playingPeriodChanged) {
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_MEDIA_ITEM_TRANSITION
|
|
||||||
) { listener: Player.Listener ->
|
|
||||||
listener.onMediaItemTransition(
|
|
||||||
currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
updateAvailableCommandsAndNotifyIfChanged()
|
|
||||||
}
|
|
||||||
return playingPeriodChanged
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateInternalStateAndNotifyIfChanged() {
|
|
||||||
if (remoteMediaClient == null) {
|
|
||||||
// There is no session. We leave the state of the player as it is now.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val oldWindowIndex = this.currentMediaItemIndex
|
|
||||||
val oldPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid else null
|
|
||||||
updatePlayerStateAndNotifyIfChanged()
|
|
||||||
// updateRepeatModeAndNotifyIfChanged( /* resultCallback= */null)
|
|
||||||
updatePlaybackRateAndNotifyIfChanged()
|
|
||||||
val playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged()
|
|
||||||
val currentTimeline = currentTimeline
|
|
||||||
currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline)
|
|
||||||
val currentPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true).uid else null
|
|
||||||
if (!playingPeriodChangedByTimelineChange
|
|
||||||
&& !Util.areEqual(oldPeriodUid, currentPeriodUid)
|
|
||||||
&& pendingSeekCount == 0) {
|
|
||||||
// Report discontinuity and media item auto transition.
|
|
||||||
currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true)
|
|
||||||
currentTimeline.getWindow(oldWindowIndex, window)
|
|
||||||
val windowDurationMs = window.durationMs
|
|
||||||
val oldPosition = PositionInfo(
|
|
||||||
window.uid,
|
|
||||||
period.windowIndex,
|
|
||||||
window.mediaItem,
|
|
||||||
period.uid,
|
|
||||||
period.windowIndex, /* positionMs= */
|
|
||||||
windowDurationMs, /* contentPositionMs= */
|
|
||||||
windowDurationMs, /* adGroupIndex= */
|
|
||||||
C.INDEX_UNSET, /* adIndexInAdGroup= */
|
|
||||||
C.INDEX_UNSET)
|
|
||||||
currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true)
|
|
||||||
currentTimeline.getWindow(currentMediaItemIndex, window)
|
|
||||||
val newPosition = PositionInfo(
|
|
||||||
window.uid,
|
|
||||||
period.windowIndex,
|
|
||||||
window.mediaItem,
|
|
||||||
period.uid,
|
|
||||||
period.windowIndex, /* positionMs= */
|
|
||||||
window.defaultPositionMs, /* contentPositionMs= */
|
|
||||||
window.defaultPositionMs, /* adGroupIndex= */
|
|
||||||
C.INDEX_UNSET, /* adIndexInAdGroup= */
|
|
||||||
C.INDEX_UNSET)
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_POSITION_DISCONTINUITY
|
|
||||||
) { listener: Listener ->
|
|
||||||
listener.onPositionDiscontinuity(
|
|
||||||
oldPosition, newPosition, DISCONTINUITY_REASON_AUTO_TRANSITION)
|
|
||||||
}
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_MEDIA_ITEM_TRANSITION
|
|
||||||
) { listener: Listener ->
|
|
||||||
listener.onMediaItemTransition(
|
|
||||||
currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_AUTO)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (updateTracksAndSelectionsAndNotifyIfChanged()) {
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksInfoChanged(currentTracksInfo) }
|
|
||||||
}
|
|
||||||
updateAvailableCommandsAndNotifyIfChanged()
|
|
||||||
listeners.flushEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Updates the internal tracks and selection and returns whether they have changed. */
|
|
||||||
@SuppressLint("WrongConstant")
|
|
||||||
private fun updateTracksAndSelectionsAndNotifyIfChanged(): Boolean {
|
|
||||||
if (remoteMediaClient == null) {
|
|
||||||
// There is no session. We leave the state of the player as it is now.
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val mediaStatus = getMediaStatus()
|
|
||||||
val mediaInfo = mediaStatus?.mediaInfo
|
|
||||||
val castMediaTracks = mediaInfo?.mediaTracks
|
|
||||||
if (castMediaTracks == null || castMediaTracks.isEmpty()) {
|
|
||||||
val hasChanged = !myCurrentTrackGroups.isEmpty
|
|
||||||
myCurrentTrackGroups = TrackGroupArray.EMPTY
|
|
||||||
myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY
|
|
||||||
myCurrentTracksInfo = TracksInfo.EMPTY
|
|
||||||
return hasChanged
|
|
||||||
}
|
|
||||||
var activeTrackIds = mediaStatus.activeTrackIds
|
|
||||||
if (activeTrackIds == null) {
|
|
||||||
activeTrackIds = EMPTY_TRACK_ID_ARRAY
|
|
||||||
}
|
|
||||||
val trackGroups = arrayOfNulls<TrackGroup>(castMediaTracks.size)
|
|
||||||
val trackSelections = arrayOfNulls<TrackSelection>(RENDERER_COUNT)
|
|
||||||
val trackGroupInfos = arrayOfNulls<TrackGroupInfo>(castMediaTracks.size)
|
|
||||||
for (i in castMediaTracks.indices) {
|
|
||||||
val mediaTrack = castMediaTracks[i]
|
|
||||||
trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack))
|
|
||||||
val id = mediaTrack.id
|
|
||||||
val trackType = MimeTypes.getTrackType(mediaTrack.contentType)
|
|
||||||
val rendererIndex: Int = getRendererIndexForTrackType(trackType)
|
|
||||||
val supported = rendererIndex != C.INDEX_UNSET
|
|
||||||
val selected = isTrackActive(id, activeTrackIds) && supported && trackSelections[rendererIndex] == null
|
|
||||||
if (selected) {
|
|
||||||
trackSelections[rendererIndex] = trackGroups[i]?.let { CastTrackSelection(it) }
|
|
||||||
}
|
|
||||||
val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE)
|
|
||||||
val trackSelected = booleanArrayOf(selected)
|
|
||||||
trackGroupInfos[i] = TrackGroupInfo(trackGroups[i]!!, trackSupport, trackType, trackSelected)
|
|
||||||
}
|
|
||||||
|
|
||||||
val tg = trackGroups.filterNotNull().toTypedArray()
|
|
||||||
var ts = trackSelections.filterNotNull().toTypedArray()
|
|
||||||
var tgi = trackGroupInfos.filterNotNull().toMutableList()
|
|
||||||
val newTrackGroups = TrackGroupArray(*tg)
|
|
||||||
val newTrackSelections = TrackSelectionArray(*ts)
|
|
||||||
val newTracksInfo = TracksInfo(tgi)
|
|
||||||
if (newTrackGroups != currentTrackGroups
|
|
||||||
|| newTrackSelections != currentTrackSelections
|
|
||||||
|| newTracksInfo != currentTracksInfo) {
|
|
||||||
myCurrentTrackSelections = newTrackSelections
|
|
||||||
myCurrentTrackGroups = newTrackGroups
|
|
||||||
myCurrentTracksInfo = newTracksInfo
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a [Format] instance containing all information contained in the given [ ] object.
|
|
||||||
*
|
|
||||||
* @param mediaTrack The [MediaTrack].
|
|
||||||
* @return The equivalent [Format].
|
|
||||||
*/
|
|
||||||
fun mediaTrackToFormat(mediaTrack: MediaTrack): Format {
|
|
||||||
return Format.Builder()
|
|
||||||
.setId(mediaTrack.contentId)
|
|
||||||
.setContainerMimeType(mediaTrack.contentType)
|
|
||||||
.setLanguage(mediaTrack.language)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getRendererIndexForTrackType(trackType: @TrackType Int): Int {
|
|
||||||
return if (trackType == C.TRACK_TYPE_VIDEO) RENDERER_INDEX_VIDEO else if (trackType == C.TRACK_TYPE_AUDIO) RENDERER_INDEX_AUDIO else if (trackType == C.TRACK_TYPE_TEXT) RENDERER_INDEX_TEXT else C.INDEX_UNSET
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun isTrackActive(id: Long, activeTrackIds: LongArray): Boolean {
|
|
||||||
for (activeTrackId in activeTrackIds) {
|
|
||||||
if (activeTrackId == id) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getCurrentPositionInfo(): PositionInfo {
|
|
||||||
val currentTimeline = currentTimeline
|
|
||||||
var newPeriodUid: Any? = null
|
|
||||||
var newWindowUid: Any? = null
|
|
||||||
var newMediaItem: MediaItem? = null
|
|
||||||
if (!currentTimeline.isEmpty) {
|
|
||||||
newPeriodUid = currentTimeline.getPeriod(currentPeriodIndex, period, /* setIds= */true).uid
|
|
||||||
newWindowUid = currentTimeline.getWindow(period.windowIndex, window).uid
|
|
||||||
newMediaItem = window.mediaItem
|
|
||||||
}
|
|
||||||
return PositionInfo(
|
|
||||||
newWindowUid,
|
|
||||||
currentMediaItemIndex,
|
|
||||||
newMediaItem,
|
|
||||||
newPeriodUid,
|
|
||||||
currentPeriodIndex,
|
|
||||||
currentPosition,
|
|
||||||
contentPosition,
|
|
||||||
C.INDEX_UNSET,/* adGroupIndex= */
|
|
||||||
C.INDEX_UNSET) /* adIndexInAdGroup= */
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateTimeline(): Boolean {
|
|
||||||
val oldTimeline = this.myCurrentTimeline
|
|
||||||
val status: MediaStatus? = getMediaStatus()
|
|
||||||
|
|
||||||
remoteMediaClient?.let {
|
|
||||||
this.myCurrentTimeline = if (status != null) timelineTracker.getCastTimeline(it) else CastTimeline.EMPTY_CAST_TIMELINE
|
|
||||||
}
|
|
||||||
|
|
||||||
val timelineChanged = oldTimeline != this.myCurrentTimeline
|
|
||||||
if (timelineChanged) {
|
|
||||||
currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, this.myCurrentTimeline)
|
|
||||||
Log.d(tag, "timelineChanged $currentMediaItemIndex")
|
|
||||||
}
|
|
||||||
return timelineChanged
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchCurrentWindowIndex(remoteMediaClient: RemoteMediaClient?, timeline: Timeline): Int {
|
|
||||||
if (remoteMediaClient == null) {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var currentWindowIndex = C.INDEX_UNSET
|
|
||||||
val currentItem = remoteMediaClient.currentItem
|
|
||||||
if (currentItem != null) {
|
|
||||||
currentWindowIndex = timeline.getIndexOfPeriod(currentItem.itemId)
|
|
||||||
}
|
|
||||||
if (currentWindowIndex == C.INDEX_UNSET) {
|
|
||||||
// The timeline is empty. Fall back to index 0.
|
|
||||||
currentWindowIndex = 0
|
|
||||||
}
|
|
||||||
return currentWindowIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getMediaStatus():MediaStatus? {
|
|
||||||
return remoteMediaClient?.mediaStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
@JvmName("setSessionAvailabilityListener1")
|
|
||||||
fun setSessionAvailabilityListener(listener: SessionAvailabilityListener) {
|
|
||||||
sessionAvailabilityListener = listener
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getApplicationLooper(): Looper {
|
|
||||||
return Looper.getMainLooper()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addListener(listener: Player.Listener) {
|
|
||||||
listeners.add(listener)
|
|
||||||
Log.d(tag, "addListener player listener $listener")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeListener(listener: Player.Listener) {
|
|
||||||
listeners.remove(listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>, resetPosition: Boolean) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setMediaItems(mediaItems: MutableList<MediaItem>, startWindowIndex: Int, startPositionMs: Long) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun addMediaItems(index: Int, mediaItems: MutableList<MediaItem>) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun removeMediaItems(fromIndex: Int, toIndex: Int) {
|
|
||||||
Log.d(tag, "removeMediaItems called not configured yet $fromIndex to $toIndex")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAvailableCommands(): Player.Commands {
|
|
||||||
return myAvailableCommands
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun prepare() {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPlaybackState(): Int {
|
|
||||||
return currentPlaybackState
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPlaybackSuppressionReason(): Int {
|
|
||||||
return Player.PLAYBACK_SUPPRESSION_REASON_NONE
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPlayerError(): PlaybackException? {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setPlayWhenReady(playWhenReady: Boolean) {
|
|
||||||
setPlayerStateAndNotifyIfChanged(
|
|
||||||
playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState)
|
|
||||||
|
|
||||||
listeners.flushEvents()
|
|
||||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult> = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause()
|
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
|
||||||
updatePlayerStateAndNotifyIfChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
pendingResult.setResultCallback(resultCb)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPlayWhenReady(): Boolean {
|
|
||||||
return myPlayWhenReady
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setRepeatMode(repeatMode: Int) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRepeatMode(): Int {
|
|
||||||
return Player.REPEAT_MODE_OFF
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getShuffleModeEnabled(): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isLoading(): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun seekTo(mediaItemIndex: Int, positionMs: Long) {
|
|
||||||
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
|
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
|
||||||
val statusCode: Int = it.status.statusCode
|
|
||||||
if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) {
|
|
||||||
Log.e(tag, "Seek failed. Error code $statusCode")
|
|
||||||
}
|
|
||||||
if (--pendingSeekCount == 0) {
|
|
||||||
currentMediaItemIndex = pendingSeekWindowIndex
|
|
||||||
Log.d(tag, "seekTo CB $currentMediaItemIndex")
|
|
||||||
pendingSeekWindowIndex = C.INDEX_UNSET
|
|
||||||
pendingSeekPositionMs = C.TIME_UNSET
|
|
||||||
|
|
||||||
// Playback state change will send metadata to client and stop seek loading
|
|
||||||
listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val mediaStatus = getMediaStatus()
|
|
||||||
|
|
||||||
// We assume the default position is 0. There is no support for seeking to the default position
|
|
||||||
// in RemoteMediaClient.
|
|
||||||
var positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0
|
|
||||||
if (mediaStatus != null) {
|
|
||||||
if (currentMediaItemIndex != mediaItemIndex) {
|
|
||||||
Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex")
|
|
||||||
remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb)
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal")
|
|
||||||
var mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build()
|
|
||||||
remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb)
|
|
||||||
}
|
|
||||||
val oldPosition = getCurrentPositionInfo()
|
|
||||||
pendingSeekCount++
|
|
||||||
pendingSeekWindowIndex = mediaItemIndex
|
|
||||||
pendingSeekPositionMs = positionMsFinal
|
|
||||||
val newPosition = getCurrentPositionInfo()
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_POSITION_DISCONTINUITY
|
|
||||||
) { listener: Player.Listener ->
|
|
||||||
listener.onPositionDiscontinuity(oldPosition, newPosition, DISCONTINUITY_REASON_SEEK)
|
|
||||||
}
|
|
||||||
if (oldPosition.mediaItemIndex != newPosition.mediaItemIndex) {
|
|
||||||
val mediaItem = currentTimeline.getWindow(mediaItemIndex, window).mediaItem
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_MEDIA_ITEM_TRANSITION
|
|
||||||
) { listener: Player.Listener -> listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK) }
|
|
||||||
}
|
|
||||||
updateAvailableCommandsAndNotifyIfChanged()
|
|
||||||
} else if (pendingSeekCount == 0) {
|
|
||||||
Log.w(tag, "seekTo Media Status is null")
|
|
||||||
// Playback state change will send metadata to client and stop seek loading
|
|
||||||
listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) }
|
|
||||||
}
|
|
||||||
listeners.flushEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateAvailableCommandsAndNotifyIfChanged() {
|
|
||||||
val previousAvailableCommands = availableCommands
|
|
||||||
myAvailableCommands = Util.getAvailableCommands(this,PERMANENT_AVAILABLE_COMMANDS)
|
|
||||||
if (availableCommands != previousAvailableCommands) {
|
|
||||||
listeners.queueEvent(
|
|
||||||
EVENT_AVAILABLE_COMMANDS_CHANGED
|
|
||||||
) { listener: Listener -> listener.onAvailableCommandsChanged(availableCommands) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSeekBackIncrement(): Long {
|
|
||||||
return seekBackIncrementMs
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSeekForwardIncrement(): Long {
|
|
||||||
return seekForwardIncrementMs
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getMaxSeekToPreviousPosition(): Long {
|
|
||||||
return C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
|
|
||||||
val actualPlaybackParameters = PlaybackParameters(playbackParameters.speed)
|
|
||||||
setPlaybackParametersAndNotifyIfChanged(actualPlaybackParameters)
|
|
||||||
listeners.flushEvents()
|
|
||||||
|
|
||||||
val pendingResult: PendingResult<RemoteMediaClient.MediaChannelResult>? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null)
|
|
||||||
|
|
||||||
var resultCb = ResultCallback<RemoteMediaClient.MediaChannelResult?> {
|
|
||||||
updatePlaybackRateAndNotifyIfChanged()
|
|
||||||
listeners.flushEvents()
|
|
||||||
}
|
|
||||||
pendingResult?.setResultCallback(resultCb)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPlaybackParameters(): PlaybackParameters {
|
|
||||||
return playbackParameters
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop() {
|
|
||||||
Log.d(tag, "stop CastPlayer")
|
|
||||||
currentPlaybackState = Player.STATE_IDLE
|
|
||||||
remoteMediaClient?.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun stop(reset: Boolean) {
|
|
||||||
stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun release() {
|
|
||||||
val sessionManager = castContext.sessionManager
|
|
||||||
sessionManager.removeSessionManagerListener(statusListener, CastSession::class.java)
|
|
||||||
sessionManager.endCurrentSession(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTrackGroups(): TrackGroupArray {
|
|
||||||
return this.myCurrentTrackGroups
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTrackSelections(): TrackSelectionArray {
|
|
||||||
return this.myCurrentTrackSelections
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTracksInfo(): TracksInfo {
|
|
||||||
return this.myCurrentTracksInfo
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTrackSelectionParameters(): TrackSelectionParameters {
|
|
||||||
return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {}
|
|
||||||
|
|
||||||
override fun getMediaMetadata(): MediaMetadata {
|
|
||||||
return MediaMetadata.EMPTY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPlaylistMetadata(): MediaMetadata {
|
|
||||||
return MediaMetadata.EMPTY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentTimeline(): Timeline {
|
|
||||||
return this.myCurrentTimeline
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentPeriodIndex(): Int {
|
|
||||||
return getCurrentMediaItemIndex()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentMediaItemIndex(): Int {
|
|
||||||
return if (pendingSeekWindowIndex != C.INDEX_UNSET) pendingSeekWindowIndex else currentMediaItemIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDuration(): Long {
|
|
||||||
return contentDuration
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentPosition(): Long {
|
|
||||||
return remoteMediaClient?.approximateStreamPosition ?: 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getBufferedPosition(): Long {
|
|
||||||
return currentPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTotalBufferedDuration(): Long {
|
|
||||||
return 0L
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isPlayingAd(): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentAdGroupIndex(): Int {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentAdIndexInAdGroup(): Int {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getContentPosition(): Long {
|
|
||||||
return currentPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getContentBufferedPosition(): Long {
|
|
||||||
return currentPosition
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getAudioAttributes(): AudioAttributes {
|
|
||||||
return AudioAttributes.DEFAULT
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setVolume(audioVolume: Float) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getVolume(): Float {
|
|
||||||
return 0F
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearVideoSurface() {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearVideoSurface(surface: Surface?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setVideoSurface(surface: Surface?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setVideoTextureView(textureView: TextureView?) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun clearVideoTextureView(textureView: TextureView?) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getVideoSize(): VideoSize {
|
|
||||||
return VideoSize.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getCurrentCues(): MutableList<Cue> {
|
|
||||||
return mutableListOf()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceInfo(): DeviceInfo {
|
|
||||||
return DeviceInfo.UNKNOWN
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getDeviceVolume(): Int {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun isDeviceMuted(): Boolean {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setDeviceVolume(volume: Int) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun increaseDeviceVolume() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun decreaseDeviceVolume() {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun setDeviceMuted(muted: Boolean) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
|
|
||||||
val TAG = "StatusListener"
|
|
||||||
|
|
||||||
// RemoteMediaClient.ProgressListener implementation.
|
|
||||||
override fun onProgressUpdated(progressMs: Long, unusedDurationMs: Long) {
|
|
||||||
lastReportedPositionMs = progressMs
|
|
||||||
}
|
|
||||||
|
|
||||||
// RemoteMediaClient.Callback implementation.
|
|
||||||
override fun onStatusUpdated() {
|
|
||||||
Log.d(TAG, "StatusListener status queue items " + remoteMediaClient?.mediaStatus?.queueItemCount)
|
|
||||||
if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_IDLE) {
|
|
||||||
Log.d(TAG, "StatusListener CastPlayer STATE_IDLE")
|
|
||||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_BUFFERING) {
|
|
||||||
Log.d(TAG, "StatusListener CastPlayer BUFFERING")
|
|
||||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_LOADING) {
|
|
||||||
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_LOADING")
|
|
||||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PAUSED) {
|
|
||||||
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PAUSED")
|
|
||||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING) {
|
|
||||||
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PLAYING")
|
|
||||||
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_UNKNOWN) {
|
|
||||||
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_UNKNOWN")
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInternalStateAndNotifyIfChanged()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMetadataUpdated() {}
|
|
||||||
override fun onQueueStatusUpdated() {
|
|
||||||
Log.d(TAG, "onQueueStatusUpdated")
|
|
||||||
updateTimelineAndNotifyIfChanged()
|
|
||||||
listeners.flushEvents()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPreloadStatusUpdated() {}
|
|
||||||
override fun onSendingRemoteMediaRequest() {}
|
|
||||||
override fun onAdBreakStatusUpdated() {}
|
|
||||||
|
|
||||||
// SessionManagerListener implementation.
|
|
||||||
override fun onSessionStarted(castSession: CastSession, s: String) {
|
|
||||||
Log.d(TAG, "StatusListener onSessionStarted")
|
|
||||||
setRemoteMediaClient(castSession.remoteMediaClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionResumed(castSession: CastSession, b: Boolean) {
|
|
||||||
setRemoteMediaClient(castSession.remoteMediaClient)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionEnded(castSession: CastSession, i: Int) {
|
|
||||||
Log.d(TAG, "StatusListener onSessionEnded")
|
|
||||||
setRemoteMediaClient(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionSuspended(castSession: CastSession, i: Int) {
|
|
||||||
setRemoteMediaClient(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionResumeFailed(castSession: CastSession, statusCode: Int) {
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
"Session resume failed. Error code "
|
|
||||||
+ statusCode)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionStarting(castSession: CastSession) {
|
|
||||||
Log.d(TAG, "StatusListener onSessionStarting")
|
|
||||||
// Do nothing.
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionStartFailed(castSession: CastSession, statusCode: Int) {
|
|
||||||
Log.e(
|
|
||||||
TAG,
|
|
||||||
("Session start failed. Error code "
|
|
||||||
+ statusCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionEnding(castSession: CastSession) {
|
|
||||||
// Do nothing.
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionResuming(castSession: CastSession, s: String) {
|
|
||||||
// Do nothing.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2017 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.util.Log
|
|
||||||
import android.util.SparseArray
|
|
||||||
import android.util.SparseIntArray
|
|
||||||
import com.google.android.exoplayer2.C
|
|
||||||
import com.google.android.exoplayer2.MediaItem
|
|
||||||
import com.google.android.exoplayer2.Timeline
|
|
||||||
import java.util.*
|
|
||||||
|
|
||||||
/** A [Timeline] for Cast media queues. */ /* package */
|
|
||||||
class CastTimeline(itemIds: IntArray, itemIdToData: SparseArray<ItemData>) : Timeline() {
|
|
||||||
/** Holds [Timeline] related data for a Cast media item. */
|
|
||||||
class ItemData(
|
|
||||||
/** The duration of the item in microseconds, or [C.TIME_UNSET] if unknown. */
|
|
||||||
val durationUs: Long,
|
|
||||||
/**
|
|
||||||
* The default start position of the item in microseconds, or [C.TIME_UNSET] if unknown.
|
|
||||||
*/
|
|
||||||
var defaultPositionUs: Long, isLive: Boolean) {
|
|
||||||
/** Whether the item is live content, or `false` if unknown. */
|
|
||||||
val isLive: Boolean = isLive
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a copy of this instance with the given values.
|
|
||||||
*
|
|
||||||
* @param durationUs The duration in microseconds, or [C.TIME_UNSET] if unknown.
|
|
||||||
* @param defaultPositionUs The default start position in microseconds, or [C.TIME_UNSET]
|
|
||||||
* if unknown.
|
|
||||||
* @param isLive Whether the item is live, or `false` if unknown.
|
|
||||||
*/
|
|
||||||
fun copyWithNewValues(durationUs: Long, defaultPositionUs: Long, isLive: Boolean): ItemData {
|
|
||||||
return if (durationUs == this.durationUs && defaultPositionUs == this.defaultPositionUs && isLive == this.isLive) {
|
|
||||||
this
|
|
||||||
} else ItemData(durationUs, defaultPositionUs, isLive)
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Holds no media information. */
|
|
||||||
val EMPTY = ItemData( /* durationUs= */
|
|
||||||
C.TIME_UNSET, /* defaultPositionUs= */
|
|
||||||
C.TIME_UNSET, /* isLive= */
|
|
||||||
false)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
private val idsToIndex: SparseIntArray
|
|
||||||
private val ids: IntArray
|
|
||||||
private val durationsUs: LongArray
|
|
||||||
private val defaultPositionsUs: LongArray
|
|
||||||
private val isLive: BooleanArray
|
|
||||||
|
|
||||||
// Timeline implementation.
|
|
||||||
override fun getWindowCount(): Int {
|
|
||||||
return ids.size
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window {
|
|
||||||
val durationUs = durationsUs[windowIndex]
|
|
||||||
val isDynamic = durationUs == C.TIME_UNSET
|
|
||||||
val mediaItem = MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build()
|
|
||||||
return window.set( /* uid= */
|
|
||||||
ids[windowIndex], /* mediaItem= */
|
|
||||||
mediaItem, /* manifest= */
|
|
||||||
null, /* presentationStartTimeMs= */
|
|
||||||
C.TIME_UNSET, /* windowStartTimeMs= */
|
|
||||||
C.TIME_UNSET, /* elapsedRealtimeEpochOffsetMs= */
|
|
||||||
C.TIME_UNSET, /* isSeekable= */
|
|
||||||
!isDynamic,
|
|
||||||
isDynamic,
|
|
||||||
if (isLive[windowIndex]) mediaItem.liveConfiguration else null,
|
|
||||||
defaultPositionsUs[windowIndex],
|
|
||||||
durationUs, /* firstPeriodIndex= */
|
|
||||||
windowIndex, /* lastPeriodIndex= */
|
|
||||||
windowIndex, /* positionInFirstPeriodUs= */
|
|
||||||
0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPeriodCount(): Int {
|
|
||||||
return ids.size
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
|
|
||||||
val id = ids[periodIndex]
|
|
||||||
return period.set(id, id, periodIndex, durationsUs[periodIndex], 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getIndexOfPeriod(uid: Any): Int {
|
|
||||||
return if (uid is Int) idsToIndex[uid, C.INDEX_UNSET] else C.INDEX_UNSET
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getUidOfPeriod(periodIndex: Int): Int {
|
|
||||||
return ids[periodIndex]
|
|
||||||
}
|
|
||||||
|
|
||||||
// equals and hashCode implementations.
|
|
||||||
override fun equals(other: Any?): Boolean {
|
|
||||||
if (this === other) {
|
|
||||||
return true
|
|
||||||
} else if (other !is CastTimeline) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val that = other
|
|
||||||
return (Arrays.equals(ids, that.ids)
|
|
||||||
&& Arrays.equals(durationsUs, that.durationsUs)
|
|
||||||
&& Arrays.equals(defaultPositionsUs, that.defaultPositionsUs)
|
|
||||||
&& Arrays.equals(isLive, that.isLive))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
var result = Arrays.hashCode(ids)
|
|
||||||
result = 31 * result + Arrays.hashCode(durationsUs)
|
|
||||||
result = 31 * result + Arrays.hashCode(defaultPositionsUs)
|
|
||||||
result = 31 * result + Arrays.hashCode(isLive)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** [Timeline] for a cast queue that has no items. */
|
|
||||||
val EMPTY_CAST_TIMELINE = CastTimeline(IntArray(0), SparseArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a Cast timeline from the given data.
|
|
||||||
*
|
|
||||||
* @param itemIds The ids of the items in the timeline.
|
|
||||||
* @param itemIdToData Maps item ids to [ItemData].
|
|
||||||
*/
|
|
||||||
init {
|
|
||||||
val itemCount = itemIds.size
|
|
||||||
idsToIndex = SparseIntArray(itemCount)
|
|
||||||
ids = itemIds.copyOf(itemCount)
|
|
||||||
durationsUs = LongArray(itemCount)
|
|
||||||
defaultPositionsUs = LongArray(itemCount)
|
|
||||||
isLive = BooleanArray(itemCount)
|
|
||||||
for (i in ids.indices) {
|
|
||||||
val id = ids[i]
|
|
||||||
idsToIndex.put(id, i)
|
|
||||||
val data = itemIdToData[id, ItemData.EMPTY]
|
|
||||||
durationsUs[i] = data.durationUs
|
|
||||||
defaultPositionsUs[i] = if (data.defaultPositionUs == C.TIME_UNSET) 0 else data.defaultPositionUs
|
|
||||||
isLive[i] = data.isLive
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2018 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.util.SparseArray
|
|
||||||
import com.google.android.exoplayer2.C
|
|
||||||
import com.google.android.gms.cast.MediaInfo
|
|
||||||
import com.google.android.gms.cast.framework.media.RemoteMediaClient
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates [CastTimelines][CastTimeline] from cast receiver app status updates.
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* This class keeps track of the duration reported by the current item to fill any missing
|
|
||||||
* durations in the media queue items [See internal: b/65152553].
|
|
||||||
*/
|
|
||||||
/* package */
|
|
||||||
class CastTimelineTracker {
|
|
||||||
private val itemIdToData: SparseArray<CastTimeline.ItemData>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a [CastTimeline] that represents the state of the given `remoteMediaClient`.
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* Returned timelines may contain values obtained from `remoteMediaClient` in previous
|
|
||||||
* invocations of this method.
|
|
||||||
*
|
|
||||||
* @param remoteMediaClient The Cast media client.
|
|
||||||
* @return A [CastTimeline] that represents the given `remoteMediaClient` status.
|
|
||||||
*/
|
|
||||||
fun getCastTimeline(remoteMediaClient: RemoteMediaClient): CastTimeline {
|
|
||||||
val itemIds = remoteMediaClient.mediaQueue.itemIds
|
|
||||||
if (itemIds.size > 0) {
|
|
||||||
// Only remove unused items when there is something in the queue to avoid removing all entries
|
|
||||||
// if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
|
|
||||||
removeUnusedItemDataEntries(itemIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Reset state when the app instance changes [Internal ref: b/129672468].
|
|
||||||
val mediaStatus = remoteMediaClient.mediaStatus
|
|
||||||
?: return CastTimeline.EMPTY_CAST_TIMELINE
|
|
||||||
val currentItemId = mediaStatus.currentItemId
|
|
||||||
updateItemData(
|
|
||||||
currentItemId, mediaStatus.mediaInfo, /* defaultPositionUs= */C.TIME_UNSET)
|
|
||||||
for (item in mediaStatus.queueItems) {
|
|
||||||
val defaultPositionUs = (item.startTime * C.MICROS_PER_SECOND).toLong()
|
|
||||||
updateItemData(item.itemId, item.media, defaultPositionUs)
|
|
||||||
}
|
|
||||||
return CastTimeline(itemIds, itemIdToData)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateItemData(itemId: Int, mediaInfo: MediaInfo?, defaultPositionUs: Long) {
|
|
||||||
var defaultPositionUs = defaultPositionUs
|
|
||||||
val previousData = itemIdToData[itemId, CastTimeline.ItemData.EMPTY]
|
|
||||||
var durationUs = getStreamDurationUs(mediaInfo)
|
|
||||||
if (durationUs == C.TIME_UNSET) {
|
|
||||||
durationUs = previousData.durationUs
|
|
||||||
}
|
|
||||||
val isLive = if (mediaInfo == null) previousData.isLive else mediaInfo.streamType == MediaInfo.STREAM_TYPE_LIVE
|
|
||||||
if (defaultPositionUs == C.TIME_UNSET) {
|
|
||||||
defaultPositionUs = previousData.defaultPositionUs
|
|
||||||
}
|
|
||||||
itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive))
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getStreamDurationUs(mediaInfo: MediaInfo?): Long {
|
|
||||||
if (mediaInfo == null) {
|
|
||||||
return C.TIME_UNSET
|
|
||||||
}
|
|
||||||
val durationMs = mediaInfo.streamDuration
|
|
||||||
return if (durationMs != MediaInfo.UNKNOWN_DURATION) msToUs(durationMs) else C.TIME_UNSET
|
|
||||||
}
|
|
||||||
|
|
||||||
fun msToUs(timeMs: Long): Long {
|
|
||||||
return if (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) timeMs else timeMs * 1000
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun removeUnusedItemDataEntries(itemIds: IntArray) {
|
|
||||||
val scratchItemIds = HashSet<Int>( /* initialCapacity= */itemIds.size * 2)
|
|
||||||
for (id in itemIds) {
|
|
||||||
scratchItemIds.add(id)
|
|
||||||
}
|
|
||||||
var index = 0
|
|
||||||
while (index < itemIdToData.size()) {
|
|
||||||
if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
|
|
||||||
itemIdToData.removeAt(index)
|
|
||||||
} else {
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
itemIdToData = SparseArray()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2021 The Android Open Source Project
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import com.google.android.exoplayer2.C
|
|
||||||
import com.google.android.exoplayer2.Format
|
|
||||||
import com.google.android.exoplayer2.source.TrackGroup
|
|
||||||
import com.google.android.exoplayer2.trackselection.TrackSelection
|
|
||||||
import com.google.android.exoplayer2.util.Assertions
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [TrackSelection] that only selects the first track of the provided [TrackGroup].
|
|
||||||
*
|
|
||||||
*
|
|
||||||
* This relies on [CastPlayer] track groups only having one track.
|
|
||||||
*/
|
|
||||||
/* package */
|
|
||||||
internal class CastTrackSelection
|
|
||||||
/** @param trackGroup The [TrackGroup] from which the first track will only be selected.
|
|
||||||
*/(private val trackGroup: TrackGroup) : TrackSelection {
|
|
||||||
override fun getType(): Int {
|
|
||||||
return TrackSelection.TYPE_UNSET
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getTrackGroup(): TrackGroup {
|
|
||||||
return trackGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun length(): Int {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getFormat(index: Int): Format {
|
|
||||||
Assertions.checkArgument(index == 0)
|
|
||||||
return trackGroup.getFormat(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getIndexInTrackGroup(index: Int): Int {
|
|
||||||
return if (index == 0) 0 else C.INDEX_UNSET
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun indexOf(format: Format): Int {
|
|
||||||
return if (format === trackGroup.getFormat(0)) 0 else C.INDEX_UNSET
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun indexOf(indexInTrackGroup: Int): Int {
|
|
||||||
return if (indexInTrackGroup == 0) 0 else C.INDEX_UNSET
|
|
||||||
}
|
|
||||||
|
|
||||||
// Object overrides.
|
|
||||||
override fun hashCode(): Int {
|
|
||||||
return System.identityHashCode(trackGroup)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Track groups are compared by identity not value, as distinct groups may have the same value.
|
|
||||||
override fun equals(obj: Any?): Boolean {
|
|
||||||
if (this === obj) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
if (obj == null || javaClass != obj.javaClass) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
val other = obj as CastTrackSelection
|
|
||||||
return trackGroup === other.trackGroup
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.data.LocalMediaProgress
|
|
||||||
import com.audiobookshelf.app.data.PlaybackSession
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
data class MediaProgressSyncData(
|
|
||||||
var timeListened:Long, // seconds
|
|
||||||
var duration:Double, // seconds
|
|
||||||
var currentTime:Double // seconds
|
|
||||||
)
|
|
||||||
|
|
||||||
class MediaProgressSyncer(playerNotificationService:PlayerNotificationService, apiHandler: ApiHandler) {
|
|
||||||
private val tag = "MediaProgressSync"
|
|
||||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
|
||||||
private val apiHandler = apiHandler
|
|
||||||
|
|
||||||
private var listeningTimerTask: TimerTask? = null
|
|
||||||
var listeningTimerRunning:Boolean = false
|
|
||||||
|
|
||||||
private var lastSyncTime:Long = 0
|
|
||||||
|
|
||||||
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
|
||||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
|
||||||
|
|
||||||
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
|
||||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
|
||||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
|
||||||
val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
|
||||||
|
|
||||||
fun start() {
|
|
||||||
if (listeningTimerRunning) {
|
|
||||||
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
|
||||||
if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
|
|
||||||
Log.d(tag, "Playback session changed, reset timer")
|
|
||||||
currentLocalMediaProgress = null
|
|
||||||
listeningTimerTask?.cancel()
|
|
||||||
lastSyncTime = 0L
|
|
||||||
} else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
listeningTimerRunning = true
|
|
||||||
lastSyncTime = System.currentTimeMillis()
|
|
||||||
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
|
|
||||||
|
|
||||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
|
||||||
var currentTime = playerNotificationService.getCurrentTimeSeconds()
|
|
||||||
sync(currentTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stop() {
|
|
||||||
if (!listeningTimerRunning) return
|
|
||||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
|
||||||
|
|
||||||
var currentTime = playerNotificationService.getCurrentTimeSeconds()
|
|
||||||
sync(currentTime)
|
|
||||||
reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sync(currentTime:Double) {
|
|
||||||
var diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
|
||||||
if (diffSinceLastSync < 1000L) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var listeningTimeToAdd = diffSinceLastSync / 1000L
|
|
||||||
lastSyncTime = System.currentTimeMillis()
|
|
||||||
|
|
||||||
var syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
|
||||||
|
|
||||||
currentPlaybackSession?.syncData(syncData)
|
|
||||||
if (currentIsLocal) {
|
|
||||||
// Save local progress sync
|
|
||||||
currentPlaybackSession?.let {
|
|
||||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
|
||||||
saveLocalProgress(it)
|
|
||||||
|
|
||||||
// Send sync to server also if connected to this server and local item belongs to this server
|
|
||||||
if (it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
|
||||||
apiHandler.sendLocalProgressSync(it) {
|
|
||||||
Log.d(tag, "Local progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
apiHandler.sendProgressSync(currentSessionId, syncData) {
|
|
||||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
|
||||||
if (currentLocalMediaProgress == null) {
|
|
||||||
var mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
|
||||||
if (mediaProgress == null) {
|
|
||||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
|
||||||
} else {
|
|
||||||
currentLocalMediaProgress = mediaProgress
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
|
||||||
}
|
|
||||||
currentLocalMediaProgress?.let {
|
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
|
||||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
|
||||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${(it.progress * 100).roundToInt()}%")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun reset() {
|
|
||||||
listeningTimerTask?.cancel()
|
|
||||||
listeningTimerTask = null
|
|
||||||
listeningTimerRunning = false
|
|
||||||
currentPlaybackSession = null
|
|
||||||
currentLocalMediaProgress = null
|
|
||||||
lastSyncTime = 0L
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.Message
|
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
|
||||||
import android.util.Log
|
|
||||||
import android.view.KeyEvent
|
|
||||||
import com.audiobookshelf.app.data.LibraryItem
|
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
|
|
||||||
class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() {
|
|
||||||
var tag = "MediaSessionCallback"
|
|
||||||
|
|
||||||
private var mediaButtonClickCount: Int = 0
|
|
||||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
|
||||||
var seekAmount: Long = 20000 //ms
|
|
||||||
|
|
||||||
override fun onPrepare() {
|
|
||||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
|
||||||
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.preparePlayer(it,true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlay() {
|
|
||||||
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
|
|
||||||
playerNotificationService.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareFromSearch(query: String?, extras: Bundle?) {
|
|
||||||
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
|
||||||
super.onPrepareFromSearch(query, extras)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
|
||||||
Log.d(tag, "ON PLAY FROM SEARCH $query")
|
|
||||||
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.preparePlayer(it,true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPause() {
|
|
||||||
Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT")
|
|
||||||
playerNotificationService.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStop() {
|
|
||||||
playerNotificationService.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSkipToPrevious() {
|
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSkipToNext() {
|
|
||||||
playerNotificationService.seekForward(seekAmount)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFastForward() {
|
|
||||||
playerNotificationService.seekForward(seekAmount)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRewind() {
|
|
||||||
playerNotificationService.seekForward(seekAmount)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSeekTo(pos: Long) {
|
|
||||||
playerNotificationService.seekPlayer(pos)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
|
||||||
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
|
||||||
var libraryItemWrapper: LibraryItemWrapper? = null
|
|
||||||
if (mediaId.isNullOrEmpty()) {
|
|
||||||
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
|
|
||||||
} else {
|
|
||||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
|
||||||
}
|
|
||||||
|
|
||||||
libraryItemWrapper?.let { li ->
|
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.preparePlayer(it,true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
|
||||||
return handleCallMediaButton(mediaButtonEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleCallMediaButton(intent: Intent): Boolean {
|
|
||||||
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
|
||||||
var keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
|
|
||||||
if (keyEvent?.getAction() == KeyEvent.ACTION_UP) {
|
|
||||||
when (keyEvent?.getKeyCode()) {
|
|
||||||
KeyEvent.KEYCODE_HEADSETHOOK -> {
|
|
||||||
if (0 == mediaButtonClickCount) {
|
|
||||||
if (playerNotificationService.mPlayer.isPlaying)
|
|
||||||
playerNotificationService.pause()
|
|
||||||
else
|
|
||||||
playerNotificationService.play()
|
|
||||||
}
|
|
||||||
handleMediaButtonClickCount()
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
|
||||||
if (0 == mediaButtonClickCount) {
|
|
||||||
playerNotificationService.play()
|
|
||||||
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
|
||||||
}
|
|
||||||
handleMediaButtonClickCount()
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
|
||||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
|
||||||
handleMediaButtonClickCount()
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
|
||||||
playerNotificationService.seekForward(seekAmount)
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
|
||||||
playerNotificationService.closePlayback()
|
|
||||||
}
|
|
||||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
|
||||||
if (playerNotificationService.mPlayer.isPlaying) {
|
|
||||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
|
||||||
handleMediaButtonClickCount()
|
|
||||||
} else {
|
|
||||||
if (0 == mediaButtonClickCount) {
|
|
||||||
playerNotificationService.play()
|
|
||||||
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
|
||||||
}
|
|
||||||
handleMediaButtonClickCount()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> {
|
|
||||||
Log.d(tag, "KeyCode:${keyEvent.getKeyCode()}")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleMediaButtonClickCount() {
|
|
||||||
mediaButtonClickCount++
|
|
||||||
if (1 == mediaButtonClickCount) {
|
|
||||||
Timer().schedule(mediaButtonClickTimeout) {
|
|
||||||
mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount)
|
|
||||||
mediaButtonClickCount = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak")
|
|
||||||
object : Handler(){
|
|
||||||
override fun handleMessage(msg: Message) {
|
|
||||||
super.handleMessage(msg)
|
|
||||||
if (2 == msg.what) {
|
|
||||||
playerNotificationService.seekBackward(seekAmount)
|
|
||||||
playerNotificationService.play()
|
|
||||||
}
|
|
||||||
else if (msg.what >= 3) {
|
|
||||||
playerNotificationService.seekForward(seekAmount)
|
|
||||||
playerNotificationService.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.ResultReceiver
|
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.data.LibraryItem
|
|
||||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
|
||||||
import com.google.android.exoplayer2.Player
|
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
|
||||||
|
|
||||||
class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer {
|
|
||||||
var tag = "MediaSessionPlaybackPreparer"
|
|
||||||
|
|
||||||
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
|
|
||||||
Log.d(tag, "ON COMMAND $command")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getSupportedPrepareActions(): Long {
|
|
||||||
return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
|
|
||||||
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
|
||||||
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
|
|
||||||
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepare(playWhenReady: Boolean) {
|
|
||||||
Log.d(tag, "ON PREPARE $playWhenReady")
|
|
||||||
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.preparePlayer(it,playWhenReady)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
|
||||||
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
|
||||||
|
|
||||||
var libraryItemWrapper: LibraryItemWrapper? = playerNotificationService.mediaManager.getById(mediaId)
|
|
||||||
libraryItemWrapper?.let { li ->
|
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.preparePlayer(it,playWhenReady)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
|
||||||
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
|
||||||
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
|
||||||
playerNotificationService.mediaManager.play(li, playerNotificationService.getMediaPlayer()) {
|
|
||||||
Log.d(tag, "About to prepare player with ${it.displayTitle}")
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.preparePlayer(it,playWhenReady)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
|
|
||||||
Log.d(tag, "ON PREPARE FROM URI $uri")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.data.PlayerState
|
|
||||||
import com.google.android.exoplayer2.PlaybackException
|
|
||||||
import com.google.android.exoplayer2.Player
|
|
||||||
|
|
||||||
class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener {
|
|
||||||
var tag = "PlayerListener"
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
var lastPauseTime: Long = 0 //ms
|
|
||||||
}
|
|
||||||
|
|
||||||
private var onSeekBack: Boolean = false
|
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
|
||||||
var errorMessage = error.message ?: "Unknown Error"
|
|
||||||
Log.e(tag, "onPlayerError $errorMessage")
|
|
||||||
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onEvents(player: Player, events: Player.Events) {
|
|
||||||
Log.d(tag, "onEvents ${player.deviceInfo} | ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
|
|
||||||
|
|
||||||
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
|
|
||||||
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) {
|
|
||||||
Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.currentPlayer.isLoading)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
|
|
||||||
Log.d(tag, "EVENT_PLAYBACK_STATE_CHANGED MediaPlayer = ${playerNotificationService.getMediaPlayer()}")
|
|
||||||
|
|
||||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) {
|
|
||||||
Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration)
|
|
||||||
|
|
||||||
if (lastPauseTime == 0L) {
|
|
||||||
lastPauseTime = -1;
|
|
||||||
}
|
|
||||||
playerNotificationService.sendClientMetadata(PlayerState.READY)
|
|
||||||
}
|
|
||||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) {
|
|
||||||
Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.currentPlayer.currentPosition)
|
|
||||||
playerNotificationService.sendClientMetadata(PlayerState.BUFFERING)
|
|
||||||
}
|
|
||||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
|
|
||||||
Log.d(tag, "STATE_ENDED")
|
|
||||||
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
|
|
||||||
}
|
|
||||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
|
|
||||||
Log.d(tag, "STATE_IDLE")
|
|
||||||
playerNotificationService.sendClientMetadata(PlayerState.IDLE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) {
|
|
||||||
Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
|
|
||||||
}
|
|
||||||
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
|
|
||||||
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
|
|
||||||
}
|
|
||||||
if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) {
|
|
||||||
Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}")
|
|
||||||
|
|
||||||
if (player.isPlaying) {
|
|
||||||
if (lastPauseTime > 0) {
|
|
||||||
if (onSeekBack) onSeekBack = false
|
|
||||||
else {
|
|
||||||
var backTime = calcPauseSeekBackTime()
|
|
||||||
if (backTime > 0) {
|
|
||||||
if (backTime >= playerNotificationService.mPlayer.currentPosition) backTime = playerNotificationService.mPlayer.currentPosition - 500
|
|
||||||
Log.d(tag, "SeekBackTime $backTime")
|
|
||||||
onSeekBack = true
|
|
||||||
playerNotificationService.seekBackward(backTime)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else lastPauseTime = System.currentTimeMillis()
|
|
||||||
|
|
||||||
// Start/stop progress sync interval
|
|
||||||
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
|
|
||||||
if (player.isPlaying) {
|
|
||||||
playerNotificationService.mediaProgressSyncer.start()
|
|
||||||
} else {
|
|
||||||
playerNotificationService.mediaProgressSyncer.stop()
|
|
||||||
}
|
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun calcPauseSeekBackTime() : Long {
|
|
||||||
if (lastPauseTime <= 0) return 0
|
|
||||||
var time: Long = System.currentTimeMillis() - lastPauseTime
|
|
||||||
var seekback: Long
|
|
||||||
if (time < 60000) seekback = 0
|
|
||||||
else if (time < 120000) seekback = 10000
|
|
||||||
else if (time < 300000) seekback = 15000
|
|
||||||
else if (time < 1800000) seekback = 20000
|
|
||||||
else if (time < 3600000) seekback = 25000
|
|
||||||
else seekback = 29500
|
|
||||||
return seekback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.util.Log
|
|
||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
|
||||||
|
|
||||||
class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener {
|
|
||||||
var tag = "PlayerNotificationListener"
|
|
||||||
|
|
||||||
override fun onNotificationPosted(
|
|
||||||
notificationId: Int,
|
|
||||||
notification: Notification,
|
|
||||||
onGoing: Boolean) {
|
|
||||||
|
|
||||||
// Start foreground service
|
|
||||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
|
||||||
playerNotificationService.startForeground(notificationId, notification)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onNotificationCancelled(
|
|
||||||
notificationId: Int,
|
|
||||||
dismissedByUser: Boolean
|
|
||||||
) {
|
|
||||||
if (dismissedByUser) {
|
|
||||||
Log.d(tag, "onNotificationCancelled dismissed by user")
|
|
||||||
playerNotificationService.stopSelf()
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "onNotificationCancelled not dismissed by user")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.app.*
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.graphics.Color
|
|
||||||
import android.hardware.Sensor
|
|
||||||
import android.hardware.SensorManager
|
|
||||||
import android.os.*
|
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
|
||||||
import androidx.media.utils.MediaConstants
|
|
||||||
import com.audiobookshelf.app.data.*
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.media.MediaManager
|
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
|
||||||
import com.google.android.exoplayer2.*
|
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
|
||||||
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
|
|
||||||
import com.google.android.exoplayer2.source.MediaSource
|
|
||||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource
|
|
||||||
import com.google.android.exoplayer2.source.hls.HlsMediaSource
|
|
||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
|
||||||
import com.google.android.exoplayer2.upstream.*
|
|
||||||
import io.paperdb.Paper
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
|
|
||||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
|
||||||
|
|
||||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
var isStarted = false
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClientEventEmitter {
|
|
||||||
fun onPlaybackSession(playbackSession:PlaybackSession)
|
|
||||||
fun onPlaybackClosed()
|
|
||||||
fun onPlayingUpdate(isPlaying: Boolean)
|
|
||||||
fun onMetadata(metadata: PlaybackMetadata)
|
|
||||||
fun onPrepare(audiobookId: String, playWhenReady: Boolean)
|
|
||||||
fun onSleepTimerEnded(currentPosition: Long)
|
|
||||||
fun onSleepTimerSet(sleepTimeRemaining: Int)
|
|
||||||
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
|
|
||||||
fun onPlaybackFailed(errorMessage:String)
|
|
||||||
fun onMediaPlayerChanged(mediaPlayer:String)
|
|
||||||
}
|
|
||||||
|
|
||||||
private val tag = "PlayerService"
|
|
||||||
private val binder = LocalBinder()
|
|
||||||
|
|
||||||
var clientEventEmitter:ClientEventEmitter? = null
|
|
||||||
|
|
||||||
private lateinit var ctx:Context
|
|
||||||
private lateinit var mediaSessionConnector: MediaSessionConnector
|
|
||||||
private lateinit var playerNotificationManager: PlayerNotificationManager
|
|
||||||
private lateinit var mediaSession: MediaSessionCompat
|
|
||||||
private lateinit var transportControls:MediaControllerCompat.TransportControls
|
|
||||||
|
|
||||||
lateinit var mediaManager: MediaManager
|
|
||||||
lateinit var apiHandler: ApiHandler
|
|
||||||
|
|
||||||
lateinit var mPlayer: ExoPlayer
|
|
||||||
lateinit var currentPlayer:Player
|
|
||||||
var castPlayer:CastPlayer? = null
|
|
||||||
|
|
||||||
lateinit var sleepTimerManager:SleepTimerManager
|
|
||||||
lateinit var mediaProgressSyncer:MediaProgressSyncer
|
|
||||||
|
|
||||||
private var notificationId = 10;
|
|
||||||
private var channelId = "audiobookshelf_channel"
|
|
||||||
private var channelName = "Audiobookshelf Channel"
|
|
||||||
|
|
||||||
private var currentPlaybackSession:PlaybackSession? = null
|
|
||||||
|
|
||||||
var isAndroidAuto = false
|
|
||||||
|
|
||||||
// The following are used for the shake detection
|
|
||||||
private var isShakeSensorRegistered:Boolean = false
|
|
||||||
private var mSensorManager: SensorManager? = null
|
|
||||||
private var mAccelerometer: Sensor? = null
|
|
||||||
private var mShakeDetector: ShakeDetector? = null
|
|
||||||
private var shakeSensorUnregisterTask:TimerTask? = null
|
|
||||||
|
|
||||||
/*
|
|
||||||
Service related stuff
|
|
||||||
*/
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
|
||||||
Log.d(tag, "onBind")
|
|
||||||
|
|
||||||
// Android Auto Media Browser Service
|
|
||||||
if (SERVICE_INTERFACE == intent.action) {
|
|
||||||
Log.d(tag, "Is Media Browser Service")
|
|
||||||
return super.onBind(intent);
|
|
||||||
}
|
|
||||||
return binder
|
|
||||||
}
|
|
||||||
|
|
||||||
inner class LocalBinder : Binder() {
|
|
||||||
// Return this instance of LocalService so clients can call public methods
|
|
||||||
fun getService(): PlayerNotificationService = this@PlayerNotificationService
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopService(context: Context) {
|
|
||||||
val stopIntent = Intent(context, PlayerNotificationService::class.java)
|
|
||||||
context.stopService(stopIntent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
isStarted = true
|
|
||||||
Log.d(tag, "onStartCommand $startId")
|
|
||||||
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStart(intent: Intent?, startId: Int) {
|
|
||||||
Log.d(tag, "onStart $startId")
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.O)
|
|
||||||
private fun createNotificationChannel(channelId: String, channelName: String): String {
|
|
||||||
val chan = NotificationChannel(channelId,
|
|
||||||
channelName, NotificationManager.IMPORTANCE_LOW)
|
|
||||||
chan.lightColor = Color.DKGRAY
|
|
||||||
chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
|
||||||
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
||||||
service.createNotificationChannel(chan)
|
|
||||||
return channelId
|
|
||||||
}
|
|
||||||
|
|
||||||
// detach player
|
|
||||||
override fun onDestroy() {
|
|
||||||
playerNotificationManager.setPlayer(null)
|
|
||||||
mPlayer.release()
|
|
||||||
castPlayer?.release()
|
|
||||||
mediaSession.release()
|
|
||||||
mediaProgressSyncer.reset()
|
|
||||||
Log.d(tag, "onDestroy")
|
|
||||||
isStarted = false
|
|
||||||
|
|
||||||
super.onDestroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
//removing service when user swipe out our app
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
super.onTaskRemoved(rootIntent)
|
|
||||||
Log.d(tag, "onTaskRemoved")
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
ctx = this
|
|
||||||
|
|
||||||
// Initialize player
|
|
||||||
var customLoadControl:LoadControl = DefaultLoadControl.Builder().setBufferDurationsMs(
|
|
||||||
1000 * 20, // 20s min buffer
|
|
||||||
1000 * 45, // 45s max buffer
|
|
||||||
1000 * 5, // 5s playback start
|
|
||||||
1000 * 20 // 20s playback rebuffer
|
|
||||||
).build()
|
|
||||||
|
|
||||||
mPlayer = ExoPlayer.Builder(this)
|
|
||||||
.setLoadControl(customLoadControl)
|
|
||||||
.setSeekBackIncrementMs(10000)
|
|
||||||
.setSeekForwardIncrementMs(10000)
|
|
||||||
.build()
|
|
||||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
|
||||||
mPlayer.addListener(PlayerListener(this))
|
|
||||||
var audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
|
||||||
mPlayer.setAudioAttributes(audioAttributes, true)
|
|
||||||
|
|
||||||
currentPlayer = mPlayer
|
|
||||||
|
|
||||||
// Initialize API
|
|
||||||
apiHandler = ApiHandler(ctx)
|
|
||||||
|
|
||||||
// Initialize sleep timer
|
|
||||||
sleepTimerManager = SleepTimerManager(this)
|
|
||||||
|
|
||||||
// Initialize Media Progress Syncer
|
|
||||||
mediaProgressSyncer = MediaProgressSyncer(this, apiHandler)
|
|
||||||
|
|
||||||
// Initialize shake sensor
|
|
||||||
Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}")
|
|
||||||
initSensor()
|
|
||||||
|
|
||||||
// Initialize media manager
|
|
||||||
mediaManager = MediaManager(apiHandler, ctx)
|
|
||||||
|
|
||||||
channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
createNotificationChannel(channelId, channelName)
|
|
||||||
} else ""
|
|
||||||
|
|
||||||
val sessionActivityPendingIntent =
|
|
||||||
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
|
|
||||||
PendingIntent.getActivity(this, 0, sessionIntent, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaSession = MediaSessionCompat(this, tag)
|
|
||||||
.apply {
|
|
||||||
setSessionActivity(sessionActivityPendingIntent)
|
|
||||||
isActive = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
|
||||||
|
|
||||||
// This is for Media Browser
|
|
||||||
sessionToken = mediaSession.sessionToken
|
|
||||||
|
|
||||||
val builder = PlayerNotificationManager.Builder(
|
|
||||||
ctx,
|
|
||||||
notificationId,
|
|
||||||
channelId)
|
|
||||||
|
|
||||||
builder.setMediaDescriptionAdapter(AbMediaDescriptionAdapter(mediaController, this))
|
|
||||||
builder.setNotificationListener(PlayerNotificationListener(this))
|
|
||||||
|
|
||||||
playerNotificationManager = builder.build()
|
|
||||||
playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken)
|
|
||||||
playerNotificationManager.setUsePlayPauseActions(true)
|
|
||||||
playerNotificationManager.setUseNextAction(false)
|
|
||||||
playerNotificationManager.setUsePreviousAction(false)
|
|
||||||
playerNotificationManager.setUseChronometer(false)
|
|
||||||
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
|
||||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
|
||||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
|
||||||
playerNotificationManager.setUseRewindActionInCompactView(true)
|
|
||||||
|
|
||||||
// Unknown action
|
|
||||||
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
|
||||||
|
|
||||||
transportControls = mediaController.transportControls
|
|
||||||
|
|
||||||
mediaSessionConnector = MediaSessionConnector(mediaSession)
|
|
||||||
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
|
|
||||||
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
|
|
||||||
return MediaDescriptionCompat.Builder()
|
|
||||||
.setMediaId(currentPlaybackSession!!.id)
|
|
||||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
|
||||||
.setSubtitle(currentPlaybackSession!!.displayAuthor)
|
|
||||||
.setIconUri(currentPlaybackSession!!.getCoverUri()).build()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mediaSessionConnector.setEnabledPlaybackActions(
|
|
||||||
PlaybackStateCompat.ACTION_PLAY_PAUSE
|
|
||||||
or PlaybackStateCompat.ACTION_PLAY
|
|
||||||
or PlaybackStateCompat.ACTION_PAUSE
|
|
||||||
or PlaybackStateCompat.ACTION_SEEK_TO
|
|
||||||
or PlaybackStateCompat.ACTION_FAST_FORWARD
|
|
||||||
or PlaybackStateCompat.ACTION_REWIND
|
|
||||||
or PlaybackStateCompat.ACTION_STOP
|
|
||||||
)
|
|
||||||
mediaSessionConnector.setQueueNavigator(queueNavigator)
|
|
||||||
mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this))
|
|
||||||
mediaSessionConnector.setPlayer(mPlayer)
|
|
||||||
|
|
||||||
//attach player to playerNotificationManager
|
|
||||||
playerNotificationManager.setPlayer(mPlayer)
|
|
||||||
mediaSession.setCallback(MediaSessionCallback(this))
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
User callable methods
|
|
||||||
*/
|
|
||||||
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean) {
|
|
||||||
playbackSession.mediaPlayer = getMediaPlayer()
|
|
||||||
|
|
||||||
if (playbackSession.mediaPlayer == "cast-player" && playbackSession.isLocal) {
|
|
||||||
Log.w(tag, "Cannot cast local media item - switching player")
|
|
||||||
currentPlaybackSession = null
|
|
||||||
switchToPlayer(false)
|
|
||||||
playbackSession.mediaPlayer = getMediaPlayer()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playbackSession.mediaPlayer == "cast-player") {
|
|
||||||
// If cast-player is the first player to be used
|
|
||||||
mediaSessionConnector.setPlayer(castPlayer)
|
|
||||||
playerNotificationManager.setPlayer(castPlayer)
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlaybackSession = playbackSession
|
|
||||||
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
|
||||||
|
|
||||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
|
||||||
|
|
||||||
var metadata = playbackSession.getMediaMetadataCompat()
|
|
||||||
mediaSession.setMetadata(metadata)
|
|
||||||
var mediaItems = playbackSession.getMediaItems()
|
|
||||||
|
|
||||||
if (mediaItems.isEmpty()) {
|
|
||||||
Log.e(tag, "Invalid playback session no media items to play")
|
|
||||||
currentPlaybackSession = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mPlayer == currentPlayer) {
|
|
||||||
var mediaSource:MediaSource
|
|
||||||
|
|
||||||
var dataSourceFactory = DefaultHttpDataSource.Factory()
|
|
||||||
dataSourceFactory.setUserAgent(channelId)
|
|
||||||
if (playbackSession.isLocal) {
|
|
||||||
Log.d(tag, "Playing Local Item")
|
|
||||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
|
||||||
} else if (!playbackSession.isHLS) {
|
|
||||||
Log.d(tag, "Direct Playing Item")
|
|
||||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "Playing HLS Item")
|
|
||||||
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${DeviceManager.token}"))
|
|
||||||
mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
|
||||||
}
|
|
||||||
mPlayer.setMediaSource(mediaSource)
|
|
||||||
|
|
||||||
|
|
||||||
// Add remaining media items if multi-track
|
|
||||||
if (mediaItems.size > 1) {
|
|
||||||
currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size))
|
|
||||||
Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}")
|
|
||||||
|
|
||||||
var currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
|
||||||
var currentTrackTime = playbackSession.getCurrentTrackTimeMs()
|
|
||||||
Log.d(tag, "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime")
|
|
||||||
currentPlayer.seekTo(currentTrackIndex, currentTrackTime)
|
|
||||||
} else {
|
|
||||||
currentPlayer.seekTo(playbackSession.currentTimeMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(tag, "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}")
|
|
||||||
currentPlayer.playWhenReady = playWhenReady
|
|
||||||
currentPlayer.setPlaybackSpeed(1f) // TODO: Playback speed should come from settings
|
|
||||||
currentPlayer.prepare()
|
|
||||||
|
|
||||||
} else if (castPlayer != null) {
|
|
||||||
var currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
|
||||||
var currentTrackTime = playbackSession.getCurrentTrackTimeMs()
|
|
||||||
var mediaType = playbackSession.mediaType
|
|
||||||
Log.d(tag, "Loading cast player $currentTrackIndex $currentTrackTime $mediaType")
|
|
||||||
|
|
||||||
castPlayer?.load(mediaItems, currentTrackIndex, currentTrackTime, playWhenReady, 1f, mediaType)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handlePlayerPlaybackError(errorMessage:String) {
|
|
||||||
// On error and was attempting to direct play - fallback to transcode
|
|
||||||
currentPlaybackSession?.let { playbackSession ->
|
|
||||||
if (playbackSession.isDirectPlay) {
|
|
||||||
var mediaPlayer = getMediaPlayer()
|
|
||||||
Log.d(tag, "Fallback to transcode $mediaPlayer")
|
|
||||||
|
|
||||||
var libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
|
||||||
var episodeId = playbackSession.episodeId
|
|
||||||
apiHandler.playLibraryItem(libraryItemId, episodeId, true, mediaPlayer) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
preparePlayer(it, true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
|
||||||
closePlayback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun switchToPlayer(useCastPlayer: Boolean) {
|
|
||||||
var wasPlaying = currentPlayer.isPlaying
|
|
||||||
if (useCastPlayer) {
|
|
||||||
if (currentPlayer == castPlayer) {
|
|
||||||
Log.d(tag, "switchToPlayer: Already using Cast Player " + castPlayer?.deviceInfo)
|
|
||||||
return
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "switchToPlayer: Switching to cast player from exo player stop exo player")
|
|
||||||
mPlayer.stop()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (currentPlayer == mPlayer) {
|
|
||||||
Log.d(tag, "switchToPlayer: Already using Exo Player " + mPlayer.deviceInfo)
|
|
||||||
return
|
|
||||||
} else if (castPlayer != null) {
|
|
||||||
Log.d(tag, "switchToPlayer: Switching to exo player from cast player stop cast player")
|
|
||||||
castPlayer?.stop()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlayer = if (useCastPlayer) {
|
|
||||||
Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo)
|
|
||||||
mediaSessionConnector.setPlayer(castPlayer)
|
|
||||||
playerNotificationManager.setPlayer(castPlayer)
|
|
||||||
castPlayer as CastPlayer
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "switchToPlayer: Using ExoPlayer")
|
|
||||||
mediaSessionConnector.setPlayer(mPlayer)
|
|
||||||
playerNotificationManager.setPlayer(mPlayer)
|
|
||||||
mPlayer
|
|
||||||
}
|
|
||||||
|
|
||||||
clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer())
|
|
||||||
|
|
||||||
if (currentPlaybackSession == null) {
|
|
||||||
Log.d(tag, "switchToPlayer: No Current playback session")
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlaybackSession?.let {
|
|
||||||
Log.d(tag, "switchToPlayer: Preparing current playback session ${it.displayTitle}")
|
|
||||||
if (wasPlaying) { // media is paused when switching players
|
|
||||||
clientEventEmitter?.onPlayingUpdate(false)
|
|
||||||
}
|
|
||||||
preparePlayer(it, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentTime() : Long {
|
|
||||||
return if (currentPlayer.mediaItemCount > 1) {
|
|
||||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
|
||||||
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
|
|
||||||
currentPlayer.currentPosition + currentTrackStartOffset
|
|
||||||
} else {
|
|
||||||
currentPlayer.currentPosition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentTimeSeconds() : Double {
|
|
||||||
return getCurrentTime() / 1000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getBufferedTime() : Long {
|
|
||||||
return if (currentPlayer.mediaItemCount > 1) {
|
|
||||||
val windowIndex = currentPlayer.currentMediaItemIndex
|
|
||||||
val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L
|
|
||||||
currentPlayer.bufferedPosition + currentTrackStartOffset
|
|
||||||
} else {
|
|
||||||
currentPlayer.bufferedPosition
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getBufferedTimeSeconds() : Double {
|
|
||||||
return getBufferedTime() / 1000.0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getDuration() : Long {
|
|
||||||
return currentPlayer.duration
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentBookTitle() : String? {
|
|
||||||
return currentPlaybackSession?.displayTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
|
|
||||||
return currentPlaybackSession?.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentPlaybackSessionId() :String? {
|
|
||||||
return currentPlaybackSession?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
fun play() {
|
|
||||||
if (currentPlayer.isPlaying) {
|
|
||||||
Log.d(tag, "Already playing")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
currentPlayer.volume = 1F
|
|
||||||
if (currentPlayer == castPlayer) {
|
|
||||||
Log.d(tag, "CAST Player set on play ${currentPlayer.isLoading} || ${currentPlayer.duration} | ${currentPlayer.currentPosition}")
|
|
||||||
}
|
|
||||||
|
|
||||||
currentPlayer.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun pause() {
|
|
||||||
currentPlayer.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playPause():Boolean {
|
|
||||||
return if (currentPlayer.isPlaying) {
|
|
||||||
pause()
|
|
||||||
false
|
|
||||||
} else {
|
|
||||||
play()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekPlayer(time: Long) {
|
|
||||||
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time")
|
|
||||||
if (currentPlayer.mediaItemCount > 1) {
|
|
||||||
currentPlaybackSession?.currentTime = time / 1000.0
|
|
||||||
var newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
|
|
||||||
var newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
|
|
||||||
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
|
|
||||||
} else {
|
|
||||||
currentPlayer.seekTo(time)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekForward(amount: Long) {
|
|
||||||
currentPlayer.seekTo(currentPlayer.currentPosition + amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun seekBackward(amount: Long) {
|
|
||||||
currentPlayer.seekTo(currentPlayer.currentPosition - amount)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setPlaybackSpeed(speed: Float) {
|
|
||||||
currentPlayer.setPlaybackSpeed(speed)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun closePlayback() {
|
|
||||||
currentPlayer.clearMediaItems()
|
|
||||||
currentPlayer.stop()
|
|
||||||
currentPlaybackSession = null
|
|
||||||
clientEventEmitter?.onPlaybackClosed()
|
|
||||||
PlayerListener.lastPauseTime = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendClientMetadata(playerState: PlayerState) {
|
|
||||||
var duration = currentPlaybackSession?.getTotalDuration() ?: 0.0
|
|
||||||
clientEventEmitter?.onMetadata(PlaybackMetadata(duration, getCurrentTimeSeconds(), playerState))
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getMediaPlayer():String {
|
|
||||||
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
|
||||||
//
|
|
||||||
private val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
|
|
||||||
private val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
|
|
||||||
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
|
|
||||||
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
|
|
||||||
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
|
|
||||||
private val VALID_MEDIA_BROWSERS = mutableListOf<String>(ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
|
|
||||||
|
|
||||||
private val AUTO_MEDIA_ROOT = "/"
|
|
||||||
private val ALL_ROOT = "__ALL__"
|
|
||||||
private lateinit var browseTree:BrowseTree
|
|
||||||
|
|
||||||
|
|
||||||
// Only allowing android auto or similar to access media browser service
|
|
||||||
// normal loading of audiobooks is handled in webview (not natively)
|
|
||||||
private fun isValid(packageName: String, uid: Int) : Boolean {
|
|
||||||
Log.d(tag, "onGetRoot: Checking package $packageName with uid $uid")
|
|
||||||
if (!VALID_MEDIA_BROWSERS.contains(packageName)) {
|
|
||||||
Log.d(tag, "onGetRoot: package $packageName not valid for the media browser service")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot? {
|
|
||||||
// Verify that the specified package is allowed to access your content
|
|
||||||
return if (!isValid(clientPackageName, clientUid)) {
|
|
||||||
// No further calls will be made to other media browsing methods.
|
|
||||||
null
|
|
||||||
} else {
|
|
||||||
if (!isStarted) {
|
|
||||||
Log.d(tag, "AA Not yet started")
|
|
||||||
mediaManager.initializeAndroidAuto()
|
|
||||||
isStarted = true
|
|
||||||
}
|
|
||||||
|
|
||||||
isAndroidAuto = true
|
|
||||||
|
|
||||||
val extras = Bundle()
|
|
||||||
extras.putBoolean(
|
|
||||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true)
|
|
||||||
extras.putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
|
||||||
extras.putInt(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE,
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM)
|
|
||||||
|
|
||||||
BrowserRoot(AUTO_MEDIA_ROOT, extras)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoadChildren(parentMediaId: String, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
|
||||||
Log.d(tag, "ON LOAD CHILDREN $parentMediaId")
|
|
||||||
|
|
||||||
var flag = if (parentMediaId == AUTO_MEDIA_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
|
||||||
|
|
||||||
result.detach()
|
|
||||||
|
|
||||||
mediaManager.loadAndroidAutoItems("main") { libraryCategories ->
|
|
||||||
browseTree = BrowseTree(this, libraryCategories)
|
|
||||||
val children = browseTree[parentMediaId]?.map { item ->
|
|
||||||
MediaBrowserCompat.MediaItem(item.description, flag)
|
|
||||||
}
|
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: For using sub menus. Check if this is the root menu:
|
|
||||||
// if (AUTO_MEDIA_ROOT == parentMediaId) {
|
|
||||||
// build the MediaItem objects for the top level,
|
|
||||||
// and put them in the mediaItems list
|
|
||||||
// } else {
|
|
||||||
// examine the passed parentMediaId to see which submenu we're at,
|
|
||||||
// and put the children of that menu in the mediaItems list
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
|
||||||
result.detach()
|
|
||||||
mediaManager.loadAndroidAutoItems("main") { libraryCategories ->
|
|
||||||
browseTree = BrowseTree(this, libraryCategories)
|
|
||||||
val children = browseTree[ALL_ROOT]?.map { item ->
|
|
||||||
MediaBrowserCompat.MediaItem(item.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
|
||||||
}
|
|
||||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// SHAKE SENSOR
|
|
||||||
//
|
|
||||||
private fun initSensor() {
|
|
||||||
// ShakeDetector initialization
|
|
||||||
mSensorManager = getSystemService(SENSOR_SERVICE) as SensorManager
|
|
||||||
mAccelerometer = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
|
|
||||||
|
|
||||||
mShakeDetector = ShakeDetector()
|
|
||||||
mShakeDetector!!.setOnShakeListener(object : ShakeDetector.OnShakeListener {
|
|
||||||
override fun onShake(count: Int) {
|
|
||||||
Log.d(tag, "PHONE SHAKE! $count")
|
|
||||||
sleepTimerManager.handleShake()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shake sensor used for sleep timer
|
|
||||||
fun registerSensor() {
|
|
||||||
if (isShakeSensorRegistered) {
|
|
||||||
Log.w(tag, "Shake sensor already registered")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shakeSensorUnregisterTask?.cancel()
|
|
||||||
|
|
||||||
Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}")
|
|
||||||
var success = mSensorManager!!.registerListener(
|
|
||||||
mShakeDetector,
|
|
||||||
mAccelerometer,
|
|
||||||
SensorManager.SENSOR_DELAY_UI
|
|
||||||
)
|
|
||||||
if (success) isShakeSensorRegistered = true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unregisterSensor() {
|
|
||||||
if (!isShakeSensorRegistered) return
|
|
||||||
|
|
||||||
// Unregister shake sensor after wake up expiration
|
|
||||||
shakeSensorUnregisterTask?.cancel()
|
|
||||||
shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
Log.d(tag, "wake time expired: Unregistering shake sensor")
|
|
||||||
mSensorManager!!.unregisterListener(mShakeDetector)
|
|
||||||
isShakeSensorRegistered = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.hardware.Sensor
|
|
||||||
import android.hardware.SensorEvent
|
|
||||||
import android.hardware.SensorEventListener
|
|
||||||
import android.hardware.SensorManager
|
|
||||||
import kotlin.math.sqrt
|
|
||||||
|
|
||||||
class ShakeDetector : SensorEventListener {
|
|
||||||
private var mListener: OnShakeListener? = null
|
|
||||||
private var mShakeTimestamp: Long = 0
|
|
||||||
private var mShakeCount = 0
|
|
||||||
fun setOnShakeListener(listener: OnShakeListener?) {
|
|
||||||
mListener = listener
|
|
||||||
}
|
|
||||||
|
|
||||||
interface OnShakeListener {
|
|
||||||
fun onShake(count: Int)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onAccuracyChanged(
|
|
||||||
sensor: Sensor,
|
|
||||||
accuracy: Int
|
|
||||||
) { // ignore
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSensorChanged(event: SensorEvent) {
|
|
||||||
if (mListener != null) {
|
|
||||||
val x = event.values[0]
|
|
||||||
val y = event.values[1]
|
|
||||||
val z = event.values[2]
|
|
||||||
val gX = x / SensorManager.GRAVITY_EARTH
|
|
||||||
val gY = y / SensorManager.GRAVITY_EARTH
|
|
||||||
val gZ = z / SensorManager.GRAVITY_EARTH
|
|
||||||
// gForce will be close to 1 when there is no movement.
|
|
||||||
val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ)
|
|
||||||
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
|
|
||||||
val now = System.currentTimeMillis()
|
|
||||||
// ignore shake events too close to each other (500ms)
|
|
||||||
if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// reset the shake count after 3 seconds of no shakes
|
|
||||||
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
|
|
||||||
mShakeCount = 0
|
|
||||||
}
|
|
||||||
mShakeTimestamp = now
|
|
||||||
mShakeCount++
|
|
||||||
mListener!!.onShake(mShakeCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/*
|
|
||||||
* The gForce that is necessary to register as shake.
|
|
||||||
* Must be greater than 1G (one earth gravity unit).
|
|
||||||
* You can install "G-Force", by Blake La Pierre
|
|
||||||
* from the Google Play Store and run it to see how
|
|
||||||
* many G's it takes to register a shake
|
|
||||||
*/
|
|
||||||
private const val SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
|
|
||||||
private const val SHAKE_SLOP_TIME_MS = 500
|
|
||||||
private const val SHAKE_COUNT_RESET_TIME_MS = 3000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
package com.audiobookshelf.app.player
|
|
||||||
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.concurrent.schedule
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
const val SLEEP_EXTENSION_TIME = 900000L // 15m
|
|
||||||
|
|
||||||
class SleepTimerManager constructor(playerNotificationService:PlayerNotificationService) {
|
|
||||||
private val tag = "SleepTimerManager"
|
|
||||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
|
||||||
|
|
||||||
private var sleepTimerTask:TimerTask? = null
|
|
||||||
private var sleepTimerRunning:Boolean = false
|
|
||||||
private var sleepTimerEndTime:Long = 0L
|
|
||||||
private var sleepTimerLength:Long = 0L
|
|
||||||
private var sleepTimerElapsed:Long = 0L
|
|
||||||
private var sleepTimerExtensionTime:Long = 0L
|
|
||||||
private var sleepTimerFinishedAt:Long = 0L
|
|
||||||
|
|
||||||
private fun getCurrentTime():Long {
|
|
||||||
return playerNotificationService.getCurrentTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getDuration():Long {
|
|
||||||
return playerNotificationService.getDuration()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getIsPlaying():Boolean {
|
|
||||||
return playerNotificationService.currentPlayer.isPlaying
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun setVolume(volume:Float) {
|
|
||||||
playerNotificationService.currentPlayer.volume = volume
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pause() {
|
|
||||||
playerNotificationService.currentPlayer.pause()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun play() {
|
|
||||||
playerNotificationService.currentPlayer.play()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
|
||||||
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
|
||||||
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
|
||||||
}
|
|
||||||
// For chapter end timer
|
|
||||||
if (sleepTimerEndTime <= 0) return 0
|
|
||||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
|
||||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
|
||||||
sleepTimerTask?.cancel()
|
|
||||||
sleepTimerRunning = false
|
|
||||||
sleepTimerFinishedAt = 0L
|
|
||||||
sleepTimerElapsed = 0L
|
|
||||||
|
|
||||||
// Register shake sensor
|
|
||||||
playerNotificationService.registerSensor()
|
|
||||||
|
|
||||||
var currentTime = getCurrentTime()
|
|
||||||
if (isChapterTime) {
|
|
||||||
if (currentTime > time) {
|
|
||||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sleepTimerEndTime = time
|
|
||||||
sleepTimerLength = 0
|
|
||||||
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
|
|
||||||
|
|
||||||
if (sleepTimerEndTime > getDuration()) {
|
|
||||||
sleepTimerEndTime = getDuration()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
sleepTimerLength = time
|
|
||||||
sleepTimerEndTime = 0L
|
|
||||||
sleepTimerExtensionTime = time
|
|
||||||
|
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
|
||||||
sleepTimerLength = getDuration() - getCurrentTime()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
|
||||||
|
|
||||||
sleepTimerRunning = true
|
|
||||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
if (getIsPlaying()) {
|
|
||||||
sleepTimerElapsed += 1000L
|
|
||||||
|
|
||||||
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
|
||||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
|
||||||
|
|
||||||
if (sleepTimeSecondsRemaining > 0) {
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sleepTimeSecondsRemaining <= 0) {
|
|
||||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
|
||||||
pause()
|
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
|
||||||
clearSleepTimer()
|
|
||||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
|
||||||
} else if (sleepTimeSecondsRemaining <= 30) {
|
|
||||||
// Start fading out audio
|
|
||||||
var volume = sleepTimeSecondsRemaining / 30F
|
|
||||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
|
||||||
setVolume(volume)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
fun clearSleepTimer() {
|
|
||||||
sleepTimerTask?.cancel()
|
|
||||||
sleepTimerTask = null
|
|
||||||
sleepTimerEndTime = 0
|
|
||||||
sleepTimerRunning = false
|
|
||||||
playerNotificationService.unregisterSensor()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getSleepTimerTime():Long? {
|
|
||||||
return sleepTimerEndTime
|
|
||||||
}
|
|
||||||
|
|
||||||
fun cancelSleepTimer() {
|
|
||||||
Log.d(tag, "Canceling Sleep Timer")
|
|
||||||
clearSleepTimer()
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extendSleepTime() {
|
|
||||||
if (!sleepTimerRunning) return
|
|
||||||
setVolume(1F)
|
|
||||||
if (sleepTimerEndTime == 0L) {
|
|
||||||
sleepTimerLength += sleepTimerExtensionTime
|
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
|
||||||
} else {
|
|
||||||
sleepTimerEndTime += sleepTimerExtensionTime
|
|
||||||
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
|
|
||||||
}
|
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkShouldExtendSleepTimer() {
|
|
||||||
if (!sleepTimerRunning) {
|
|
||||||
if (sleepTimerFinishedAt <= 0L) return
|
|
||||||
|
|
||||||
var finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
|
||||||
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
|
|
||||||
{
|
|
||||||
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
|
|
||||||
sleepTimerFinishedAt = 0L
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
|
|
||||||
setSleepTimer(newSleepTime, false)
|
|
||||||
play()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Only extend if within 30 seconds of finishing
|
|
||||||
var sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
|
||||||
if (sleepTimeRemaining <= 30) extendSleepTime()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleShake() {
|
|
||||||
Log.d(tag, "HANDLE SHAKE HERE")
|
|
||||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun increaseSleepTime(time: Long) {
|
|
||||||
Log.d(tag, "Increase Sleep time $time")
|
|
||||||
if (!sleepTimerRunning) return
|
|
||||||
|
|
||||||
if (sleepTimerEndTime == 0L) {
|
|
||||||
sleepTimerLength += time
|
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
|
||||||
} else {
|
|
||||||
var newSleepEndTime = sleepTimerEndTime + time
|
|
||||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
|
||||||
getDuration()
|
|
||||||
} else {
|
|
||||||
newSleepEndTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(1F)
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
fun decreaseSleepTime(time: Long) {
|
|
||||||
Log.d(tag, "Decrease Sleep time $time")
|
|
||||||
if (!sleepTimerRunning) return
|
|
||||||
|
|
||||||
|
|
||||||
if (sleepTimerEndTime == 0L) {
|
|
||||||
sleepTimerLength -= time
|
|
||||||
if (sleepTimerLength <= 0) sleepTimerLength = 1000L
|
|
||||||
} else {
|
|
||||||
var newSleepEndTime = sleepTimerEndTime - time
|
|
||||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
|
||||||
// End sleep timer in 1 second
|
|
||||||
getCurrentTime() + 1000
|
|
||||||
} else {
|
|
||||||
newSleepEndTime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setVolume(1F)
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
|
||||||
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.audiobookshelf.app.MainActivity
|
|
||||||
import com.audiobookshelf.app.data.*
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.player.CastManager
|
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.getcapacitor.*
|
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
|
||||||
import com.google.android.gms.cast.CastDevice
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsAudioPlayer")
|
|
||||||
class AbsAudioPlayer : Plugin() {
|
|
||||||
private val tag = "AbsAudioPlayer"
|
|
||||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
|
||||||
lateinit var apiHandler:ApiHandler
|
|
||||||
lateinit var castManager:CastManager
|
|
||||||
|
|
||||||
lateinit var playerNotificationService: PlayerNotificationService
|
|
||||||
|
|
||||||
private var isCastAvailable:Boolean = false
|
|
||||||
|
|
||||||
override fun load() {
|
|
||||||
mainActivity = (activity as MainActivity)
|
|
||||||
apiHandler = ApiHandler(mainActivity)
|
|
||||||
|
|
||||||
initCastManager()
|
|
||||||
|
|
||||||
var foregroundServiceReady : () -> Unit = {
|
|
||||||
playerNotificationService = mainActivity.foregroundService
|
|
||||||
|
|
||||||
playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter {
|
|
||||||
override fun onPlaybackSession(playbackSession: PlaybackSession) {
|
|
||||||
notifyListeners("onPlaybackSession", JSObject(jacksonMapper.writeValueAsString(playbackSession)))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlaybackClosed() {
|
|
||||||
emit("onPlaybackClosed", true)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayingUpdate(isPlaying: Boolean) {
|
|
||||||
emit("onPlayingUpdate", isPlaying)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMetadata(metadata: PlaybackMetadata) {
|
|
||||||
notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata)))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("audiobookId", audiobookId)
|
|
||||||
jsobj.put("playWhenReady", playWhenReady)
|
|
||||||
notifyListeners("onPrepareMedia", jsobj)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSleepTimerEnded(currentPosition: Long) {
|
|
||||||
emit("onSleepTimerEnded", currentPosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSleepTimerSet(sleepTimeRemaining: Int) {
|
|
||||||
emit("onSleepTimerSet", sleepTimeRemaining)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
|
||||||
notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlaybackFailed(errorMessage: String) {
|
|
||||||
emit("onPlaybackFailed", errorMessage)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMediaPlayerChanged(mediaPlayer:String) {
|
|
||||||
emit("onMediaPlayerChanged", mediaPlayer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
mainActivity.pluginCallback = foregroundServiceReady
|
|
||||||
}
|
|
||||||
|
|
||||||
fun emit(evtName: String, value: Any) {
|
|
||||||
var ret = JSObject()
|
|
||||||
ret.put("value", value)
|
|
||||||
notifyListeners(evtName, ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun initCastManager() {
|
|
||||||
var connListener = object: CastManager.ChromecastListener() {
|
|
||||||
override fun onReceiverAvailableUpdate(available: Boolean) {
|
|
||||||
Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available")
|
|
||||||
isCastAvailable = available
|
|
||||||
emit("onCastAvailableUpdate", available)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionRejoin(jsonSession: JSONObject?) {
|
|
||||||
Log.d(tag, "ChromecastListener: CAST onSessionRejoin")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMediaLoaded(jsonMedia: JSONObject?) {
|
|
||||||
Log.d(tag, "ChromecastListener: CAST onMediaLoaded")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMediaUpdate(jsonMedia: JSONObject?) {
|
|
||||||
Log.d(tag, "ChromecastListener: CAST onMediaUpdate")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionUpdate(jsonSession: JSONObject?) {
|
|
||||||
Log.d(tag, "ChromecastListener: CAST onSessionUpdate")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSessionEnd(jsonSession: JSONObject?) {
|
|
||||||
Log.d(tag, "ChromecastListener: CAST onSessionEnd")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
|
|
||||||
Log.d(tag, "ChromecastListener: CAST onMessageReceived")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
castManager = CastManager(mainActivity)
|
|
||||||
castManager.startRouteScan(connListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun prepareLibraryItem(call: PluginCall) {
|
|
||||||
// Need to make sure the player service has been started
|
|
||||||
if (!PlayerNotificationService.isStarted) {
|
|
||||||
Log.w(tag, "prepareLibraryItem: PlayerService not started - Starting foreground service --")
|
|
||||||
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
|
|
||||||
ContextCompat.startForegroundService(mainActivity, intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var libraryItemId = call.getString("libraryItemId", "").toString()
|
|
||||||
var episodeId = call.getString("episodeId", "").toString()
|
|
||||||
var playWhenReady = call.getBoolean("playWhenReady") == true
|
|
||||||
|
|
||||||
if (libraryItemId.isEmpty()) {
|
|
||||||
Log.e(tag, "Invalid call to play library item no library item id")
|
|
||||||
return call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (libraryItemId.startsWith("local")) { // Play local media item
|
|
||||||
DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let {
|
|
||||||
var episode: PodcastEpisode? = null
|
|
||||||
if (!episodeId.isNullOrEmpty()) {
|
|
||||||
var podcastMedia = it.media as Podcast
|
|
||||||
episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId }
|
|
||||||
if (episode == null) {
|
|
||||||
Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId")
|
|
||||||
return call.resolve(JSObject())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
|
||||||
var playbackSession = it.getPlaybackSession(episode)
|
|
||||||
playerNotificationService.preparePlayer(playbackSession, playWhenReady)
|
|
||||||
}
|
|
||||||
return call.resolve(JSObject())
|
|
||||||
}
|
|
||||||
} else { // Play library item from server
|
|
||||||
var mediaPlayer = playerNotificationService.getMediaPlayer()
|
|
||||||
|
|
||||||
apiHandler.playLibraryItem(libraryItemId, episodeId, false, mediaPlayer) {
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
|
|
||||||
playerNotificationService.preparePlayer(it, playWhenReady)
|
|
||||||
}
|
|
||||||
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getCurrentTime(call: PluginCall) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
var currentTime = playerNotificationService.getCurrentTimeSeconds()
|
|
||||||
var bufferedTime = playerNotificationService.getBufferedTimeSeconds()
|
|
||||||
val ret = JSObject()
|
|
||||||
ret.put("value", currentTime)
|
|
||||||
ret.put("bufferedTime", bufferedTime)
|
|
||||||
call.resolve(ret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun pausePlayer(call: PluginCall) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.pause()
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun playPlayer(call: PluginCall) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.play()
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun playPause(call: PluginCall) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
var playing = playerNotificationService.playPause()
|
|
||||||
call.resolve(JSObject("{\"playing\":$playing}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun seek(call: PluginCall) {
|
|
||||||
var time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun seekForward(call: PluginCall) {
|
|
||||||
var amount:Int = call.getInt("value", 0) ?: 0
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.seekForward(amount * 1000L) // convert to ms
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun seekBackward(call: PluginCall) {
|
|
||||||
var amount:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.seekBackward(amount * 1000L) // convert to ms
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun setPlaybackSpeed(call: PluginCall) {
|
|
||||||
var playbackSpeed:Float = call.getFloat("value", 1.0f) ?: 1.0f
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.setPlaybackSpeed(playbackSpeed)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun closePlayback(call: PluginCall) {
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.closePlayback()
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun setSleepTimer(call: PluginCall) {
|
|
||||||
var time:Long = call.getString("time", "360000")!!.toLong()
|
|
||||||
var isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
var success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
|
|
||||||
val ret = JSObject()
|
|
||||||
ret.put("success", success)
|
|
||||||
call.resolve(ret)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getSleepTimerTime(call: PluginCall) {
|
|
||||||
var time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
|
|
||||||
val ret = JSObject()
|
|
||||||
ret.put("value", time)
|
|
||||||
call.resolve(ret)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun increaseSleepTime(call: PluginCall) {
|
|
||||||
var time:Long = call.getString("time", "300000")!!.toLong()
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.sleepTimerManager.increaseSleepTime(time)
|
|
||||||
val ret = JSObject()
|
|
||||||
ret.put("success", true)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun decreaseSleepTime(call: PluginCall) {
|
|
||||||
var time:Long = call.getString("time", "300000")!!.toLong()
|
|
||||||
|
|
||||||
Handler(Looper.getMainLooper()).post() {
|
|
||||||
playerNotificationService.sleepTimerManager.decreaseSleepTime(time)
|
|
||||||
val ret = JSObject()
|
|
||||||
ret.put("success", true)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun cancelSleepTimer(call: PluginCall) {
|
|
||||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun requestSession(call: PluginCall) {
|
|
||||||
// Need to make sure the player service has been started
|
|
||||||
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
|
|
||||||
call.resolve()
|
|
||||||
castManager.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() {
|
|
||||||
override fun onError(errorCode: Int) {
|
|
||||||
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCancel() {
|
|
||||||
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onJoin(jsonSession: JSONObject?) {
|
|
||||||
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getIsCastAvailable(call: PluginCall) {
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("value", isCastAvailable)
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,325 +0,0 @@
|
|||||||
package com.audiobookshelf.app.data
|
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.MainActivity
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.getcapacitor.*
|
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsDatabase")
|
|
||||||
class AbsDatabase : Plugin() {
|
|
||||||
val tag = "AbsDatabase"
|
|
||||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
|
||||||
lateinit var apiHandler: ApiHandler
|
|
||||||
|
|
||||||
data class LocalMediaProgressPayload(val value:List<LocalMediaProgress>)
|
|
||||||
data class LocalLibraryItemsPayload(val value:List<LocalLibraryItem>)
|
|
||||||
data class LocalFoldersPayload(val value:List<LocalFolder>)
|
|
||||||
|
|
||||||
override fun load() {
|
|
||||||
mainActivity = (activity as MainActivity)
|
|
||||||
apiHandler = ApiHandler(mainActivity)
|
|
||||||
|
|
||||||
DeviceManager.dbManager.cleanLocalMediaProgress()
|
|
||||||
DeviceManager.dbManager.cleanLocalLibraryItems()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getDeviceData(call:PluginCall) {
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var deviceData = DeviceManager.dbManager.getDeviceData()
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getLocalFolders(call:PluginCall) {
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var folders = DeviceManager.dbManager.getAllLocalFolders()
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getLocalFolder(call:PluginCall) {
|
|
||||||
var folderId = call.getString("folderId", "").toString()
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
|
|
||||||
var folderObj = jacksonMapper.writeValueAsString(it)
|
|
||||||
call.resolve(JSObject(folderObj))
|
|
||||||
} ?: call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getLocalLibraryItem(call:PluginCall) {
|
|
||||||
var id = call.getString("id", "").toString()
|
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
|
|
||||||
if (localLibraryItem == null) {
|
|
||||||
call.resolve()
|
|
||||||
} else {
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getLocalLibraryItemByLLId(call:PluginCall) {
|
|
||||||
var libraryItemId = call.getString("libraryItemId", "").toString()
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId)
|
|
||||||
if (localLibraryItem == null) {
|
|
||||||
call.resolve()
|
|
||||||
} else {
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getLocalLibraryItems(call:PluginCall) {
|
|
||||||
var mediaType = call.getString("mediaType", "").toString()
|
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getLocalLibraryItemsInFolder(call:PluginCall) {
|
|
||||||
var folderId = call.getString("folderId", "").toString()
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
|
||||||
var serverConnectionConfigId = call.getString("id", "").toString()
|
|
||||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId }
|
|
||||||
|
|
||||||
var userId = call.getString("userId", "").toString()
|
|
||||||
var username = call.getString("username", "").toString()
|
|
||||||
var token = call.getString("token", "").toString()
|
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
if (serverConnectionConfig == null) { // New Server Connection
|
|
||||||
var serverAddress = call.getString("address", "").toString()
|
|
||||||
|
|
||||||
// Create new server connection config
|
|
||||||
var sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
|
||||||
var sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
|
||||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token)
|
|
||||||
|
|
||||||
// Add and save
|
|
||||||
DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!)
|
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfig?.id
|
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
|
||||||
} else {
|
|
||||||
var shouldSave = false
|
|
||||||
if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != token) {
|
|
||||||
serverConnectionConfig?.userId = userId
|
|
||||||
serverConnectionConfig?.username = username
|
|
||||||
serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})"
|
|
||||||
serverConnectionConfig?.token = token
|
|
||||||
shouldSave = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set last connection config
|
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConnectionConfigId) {
|
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfigId
|
|
||||||
shouldSave = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldSave) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
|
||||||
}
|
|
||||||
|
|
||||||
DeviceManager.serverConnectionConfig = serverConnectionConfig
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.serverConnectionConfig)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun removeServerConnectionConfig(call:PluginCall) {
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString()
|
|
||||||
DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList<ServerConnectionConfig>
|
|
||||||
if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) {
|
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
|
||||||
}
|
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
|
||||||
if (DeviceManager.serverConnectionConfig?.id == serverConnectionConfigId) {
|
|
||||||
DeviceManager.serverConnectionConfig = null
|
|
||||||
}
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun logout(call:PluginCall) {
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
DeviceManager.serverConnectionConfig = null
|
|
||||||
DeviceManager.deviceData.lastServerConnectionConfigId = null
|
|
||||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun getAllLocalMediaProgress(call:PluginCall) {
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun removeLocalMediaProgress(call:PluginCall) {
|
|
||||||
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
|
||||||
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun syncLocalMediaProgressWithServer(call:PluginCall) {
|
|
||||||
if (DeviceManager.serverConnectionConfig == null) {
|
|
||||||
Log.e(tag, "syncLocalMediaProgressWithServer not connected to server")
|
|
||||||
return call.resolve()
|
|
||||||
}
|
|
||||||
apiHandler.syncMediaProgress {
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun updateLocalMediaProgressFinished(call:PluginCall) {
|
|
||||||
var localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
|
||||||
var isFinished = call.getBoolean("isFinished", false) == true
|
|
||||||
Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished")
|
|
||||||
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
|
||||||
if (localMediaProgress == null) {
|
|
||||||
Log.e(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId")
|
|
||||||
call.resolve(JSObject("{\"error\":\"Progress not found\"}"))
|
|
||||||
} else {
|
|
||||||
localMediaProgress.updateIsFinished(isFinished)
|
|
||||||
|
|
||||||
var lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
|
|
||||||
Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring")
|
|
||||||
|
|
||||||
// Send update to server media progress is linked to a server and user is logged into that server
|
|
||||||
localMediaProgress.serverConnectionConfigId?.let { configId ->
|
|
||||||
if (DeviceManager.serverConnectionConfigId == configId) {
|
|
||||||
var libraryItemId = localMediaProgress.libraryItemId ?: ""
|
|
||||||
var episodeId = localMediaProgress.episodeId ?: ""
|
|
||||||
var updatePayload = JSObject()
|
|
||||||
updatePayload.put("isFinished", isFinished)
|
|
||||||
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
|
|
||||||
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("local", true)
|
|
||||||
jsobj.put("server", true)
|
|
||||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
|
||||||
call.resolve(jsobj)
|
|
||||||
// call.resolve(JSObject("{\"local\":true,\"server\":true,\"localMediaProgress\":$lmpstring}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
|
|
||||||
// call.resolve(JSObject("{\"local\":true,\"localMediaProgress\":$lmpstring}}"))
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("local", true)
|
|
||||||
jsobj.put("server", false)
|
|
||||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun updateLocalTrackOrder(call:PluginCall) {
|
|
||||||
var localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
|
||||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
|
||||||
if (localLibraryItem == null) {
|
|
||||||
call.resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
|
|
||||||
|
|
||||||
var tracks:JSArray = call.getArray("tracks") ?: JSArray()
|
|
||||||
Log.d(tag, "updateLocalTrackOrder $tracks")
|
|
||||||
|
|
||||||
var index = 1
|
|
||||||
var hasUpdates = false
|
|
||||||
for (i in 0 until tracks.length()) {
|
|
||||||
var track = tracks.getJSONObject(i)
|
|
||||||
var localFileId = track.getString("localFileId")
|
|
||||||
|
|
||||||
var existingTrack = audioTracks.find{ it.localFileId == localFileId }
|
|
||||||
if (existingTrack != null) {
|
|
||||||
Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index")
|
|
||||||
if (existingTrack.index != index) hasUpdates = true
|
|
||||||
existingTrack.index = index++
|
|
||||||
} else {
|
|
||||||
Log.e(tag, "Audio track with local file id not found")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasUpdates) {
|
|
||||||
Log.d(tag, "Save library item track orders")
|
|
||||||
localLibraryItem.media.setAudioTracks(audioTracks)
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "No tracks need to be updated")
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Generic Webview calls to db
|
|
||||||
//
|
|
||||||
@PluginMethod
|
|
||||||
fun saveFromWebview(call: PluginCall) {
|
|
||||||
var db = call.getString("db", "").toString()
|
|
||||||
var key = call.getString("key", "").toString()
|
|
||||||
var value = call.getObject("value")
|
|
||||||
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
if (db == "" || key == "" || value == null) {
|
|
||||||
Log.d(tag, "saveFromWebview Invalid key/value")
|
|
||||||
} else {
|
|
||||||
var json = value as JSONObject
|
|
||||||
DeviceManager.dbManager.saveObject(db, key, json)
|
|
||||||
}
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun loadFromWebview(call:PluginCall) {
|
|
||||||
var db = call.getString("db", "").toString()
|
|
||||||
var key = call.getString("key", "").toString()
|
|
||||||
if (db == "" || key == "") {
|
|
||||||
Log.d(tag, "loadFromWebview Invalid Key")
|
|
||||||
call.resolve()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var json = DeviceManager.dbManager.loadObject(db, key)
|
|
||||||
var jsobj = JSObject.fromJSONObject(json)
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
|
||||||
|
|
||||||
import android.app.DownloadManager
|
|
||||||
import android.content.Context
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.MainActivity
|
|
||||||
import com.audiobookshelf.app.data.*
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.getcapacitor.JSObject
|
|
||||||
import com.getcapacitor.Plugin
|
|
||||||
import com.getcapacitor.PluginCall
|
|
||||||
import com.getcapacitor.PluginMethod
|
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsDownloader")
|
|
||||||
class AbsDownloader : Plugin() {
|
|
||||||
private val tag = "AbsDownloader"
|
|
||||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
|
||||||
lateinit var downloadManager: DownloadManager
|
|
||||||
lateinit var apiHandler: ApiHandler
|
|
||||||
lateinit var folderScanner: FolderScanner
|
|
||||||
|
|
||||||
data class DownloadItemPart(
|
|
||||||
val id: String,
|
|
||||||
val filename: String,
|
|
||||||
val destinationPath:String,
|
|
||||||
val itemTitle: String,
|
|
||||||
val serverPath: String,
|
|
||||||
val localFolderName: String,
|
|
||||||
val localFolderId: String,
|
|
||||||
val audioTrack: AudioTrack?,
|
|
||||||
val episode:PodcastEpisode?,
|
|
||||||
var completed:Boolean,
|
|
||||||
@JsonIgnore val uri: Uri,
|
|
||||||
@JsonIgnore val destinationUri: Uri,
|
|
||||||
var downloadId: Long?,
|
|
||||||
var progress: Long
|
|
||||||
) {
|
|
||||||
@JsonIgnore
|
|
||||||
fun getDownloadRequest(): DownloadManager.Request {
|
|
||||||
val dlRequest = DownloadManager.Request(uri)
|
|
||||||
dlRequest.setTitle(filename)
|
|
||||||
dlRequest.setDescription("Downloading to $localFolderName for book $itemTitle")
|
|
||||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
|
||||||
dlRequest.setDestinationUri(destinationUri)
|
|
||||||
return dlRequest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
data class DownloadItem(
|
|
||||||
val id: String,
|
|
||||||
val libraryItemId:String,
|
|
||||||
val episodeId:String?,
|
|
||||||
val serverConnectionConfigId:String,
|
|
||||||
val serverAddress:String,
|
|
||||||
val serverUserId:String,
|
|
||||||
val mediaType: String,
|
|
||||||
val itemFolderPath:String,
|
|
||||||
val localFolder: LocalFolder,
|
|
||||||
val itemTitle: String,
|
|
||||||
val media:MediaType,
|
|
||||||
val downloadItemParts: MutableList<DownloadItemPart>
|
|
||||||
)
|
|
||||||
|
|
||||||
var downloadQueue: MutableList<DownloadItem> = mutableListOf()
|
|
||||||
|
|
||||||
override fun load() {
|
|
||||||
mainActivity = (activity as MainActivity)
|
|
||||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
|
||||||
folderScanner = FolderScanner(mainActivity)
|
|
||||||
apiHandler = ApiHandler(mainActivity)
|
|
||||||
|
|
||||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun downloadLibraryItem(call: PluginCall) {
|
|
||||||
val libraryItemId = call.data.getString("libraryItemId").toString()
|
|
||||||
var episodeId = call.data.getString("episodeId").toString()
|
|
||||||
if (episodeId == "null") episodeId = ""
|
|
||||||
var localFolderId = call.data.getString("localFolderId").toString()
|
|
||||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
|
||||||
|
|
||||||
var downloadId = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
|
||||||
if (downloadQueue.find { it.id == downloadId } != null) {
|
|
||||||
Log.d(tag, "Download already started for this media entity $downloadId")
|
|
||||||
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
|
|
||||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
|
||||||
|
|
||||||
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
|
||||||
if (localFolder != null) {
|
|
||||||
|
|
||||||
if (!episodeId.isNullOrEmpty() && libraryItem.mediaType != "podcast") {
|
|
||||||
Log.e(tag, "Library item is not a podcast but episode was requested")
|
|
||||||
call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
|
||||||
} else if (!episodeId.isNullOrEmpty()) {
|
|
||||||
var podcast = libraryItem.media as Podcast
|
|
||||||
var episode = podcast.episodes?.find { podcastEpisode ->
|
|
||||||
podcastEpisode.id == episodeId
|
|
||||||
}
|
|
||||||
if (episode == null) {
|
|
||||||
call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}"))
|
|
||||||
} else {
|
|
||||||
startLibraryItemDownload(libraryItem, localFolder, episode)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
startLibraryItemDownload(libraryItem, localFolder, null)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean folder path so it can be used in URL
|
|
||||||
fun cleanRelPath(relPath: String): String {
|
|
||||||
var cleanedRelPath = relPath.replace("\\", "/").replace("%", "%25").replace("#", "%23")
|
|
||||||
return if (cleanedRelPath.startsWith("/")) cleanedRelPath.substring(1) else cleanedRelPath
|
|
||||||
}
|
|
||||||
|
|
||||||
// Item filenames could be the same if they are in subfolders, this will make them unique
|
|
||||||
fun getFilenameFromRelPath(relPath: String): String {
|
|
||||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
|
||||||
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
|
||||||
if (libraryItem.mediaType == "book") {
|
|
||||||
var bookTitle = libraryItem.media.metadata.title
|
|
||||||
var tracks = libraryItem.media.getAudioTracks()
|
|
||||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
|
||||||
var itemFolderPath = localFolder.absolutePath + "/" + bookTitle
|
|
||||||
var downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf())
|
|
||||||
|
|
||||||
// Create download item part for each audio track
|
|
||||||
tracks.forEach { audioTrack ->
|
|
||||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
|
||||||
var destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
|
|
||||||
var destinationFile = File("$itemFolderPath/$destinationFilename")
|
|
||||||
|
|
||||||
if (destinationFile.exists()) {
|
|
||||||
Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
|
||||||
destinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
var destinationUri = Uri.fromFile(destinationFile)
|
|
||||||
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}")
|
|
||||||
Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri")
|
|
||||||
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, audioTrack, null, false, downloadUri, destinationUri, null, 0)
|
|
||||||
|
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
|
||||||
|
|
||||||
var dlRequest = downloadItemPart.getDownloadRequest()
|
|
||||||
var downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
downloadItemPart.downloadId = downloadId
|
|
||||||
}
|
|
||||||
|
|
||||||
if (downloadItem.downloadItemParts.isNotEmpty()) {
|
|
||||||
// Add cover download item
|
|
||||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
|
||||||
var serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg"
|
|
||||||
var destinationFilename = "cover.jpg"
|
|
||||||
var destinationFile = File("$itemFolderPath/$destinationFilename")
|
|
||||||
|
|
||||||
if (destinationFile.exists()) {
|
|
||||||
Log.d(tag, "Cover already exists, removing it from ${destinationFile.absolutePath}")
|
|
||||||
destinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
var destinationUri = Uri.fromFile(destinationFile)
|
|
||||||
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}")
|
|
||||||
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, bookTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0)
|
|
||||||
|
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
|
||||||
|
|
||||||
var dlRequest = downloadItemPart.getDownloadRequest()
|
|
||||||
var downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
downloadItemPart.downloadId = downloadId
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Cannot create new text file here but can download here... ??
|
|
||||||
// var abmetadataFile = File(itemFolderPath, "abmetadata.abs")
|
|
||||||
// abmetadataFile.createNewFileIfPossible()
|
|
||||||
// abmetadataFile.writeText(getAbMetadataText(libraryItem))
|
|
||||||
|
|
||||||
downloadQueue.add(downloadItem)
|
|
||||||
startWatchingDownloads(downloadItem)
|
|
||||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Podcast episode download
|
|
||||||
|
|
||||||
var podcastTitle = libraryItem.media.metadata.title
|
|
||||||
var audioTrack = episode?.audioTrack
|
|
||||||
Log.d(tag, "Starting podcast episode download")
|
|
||||||
var itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
|
||||||
var downloadItemId = "${libraryItem.id}-${episode?.id}"
|
|
||||||
var downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, libraryItem.media, mutableListOf())
|
|
||||||
|
|
||||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
|
|
||||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
|
||||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
|
|
||||||
var destinationFile = File("$itemFolderPath/$destinationFilename")
|
|
||||||
if (destinationFile.exists()) {
|
|
||||||
Log.d(tag, "Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
|
||||||
destinationFile.delete()
|
|
||||||
}
|
|
||||||
|
|
||||||
var destinationUri = Uri.fromFile(destinationFile)
|
|
||||||
var downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}")
|
|
||||||
Log.d(tag, "Audio File Destination Uri $destinationUri | Download URI $downloadUri")
|
|
||||||
var downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, audioTrack, episode,false, downloadUri, destinationUri, null, 0)
|
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
|
||||||
|
|
||||||
var dlRequest = downloadItemPart.getDownloadRequest()
|
|
||||||
var downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
downloadItemPart.downloadId = downloadId
|
|
||||||
|
|
||||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
|
||||||
serverPath = "/api/items/${libraryItem.id}/cover?format=jpeg"
|
|
||||||
destinationFilename = "cover.jpg"
|
|
||||||
destinationFile = File("$itemFolderPath/$destinationFilename")
|
|
||||||
|
|
||||||
if (destinationFile.exists()) {
|
|
||||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
|
||||||
} else {
|
|
||||||
destinationUri = Uri.fromFile(destinationFile)
|
|
||||||
downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}&token=${DeviceManager.token}")
|
|
||||||
downloadItemPart = DownloadItemPart(DeviceManager.getBase64Id(destinationFile.absolutePath), destinationFilename, destinationFile.absolutePath, podcastTitle, serverPath, localFolder.name, localFolder.id, null,null, false, downloadUri, destinationUri, null, 0)
|
|
||||||
|
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
|
||||||
|
|
||||||
dlRequest = downloadItemPart.getDownloadRequest()
|
|
||||||
downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
downloadItemPart.downloadId = downloadId
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadQueue.add(downloadItem)
|
|
||||||
startWatchingDownloads(downloadItem)
|
|
||||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun startWatchingDownloads(downloadItem: DownloadItem) {
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
while (downloadItem.downloadItemParts.find { !it.completed } != null) { // While some item is not completed
|
|
||||||
var numPartsBefore = downloadItem.downloadItemParts.size
|
|
||||||
checkDownloads(downloadItem)
|
|
||||||
|
|
||||||
// Keep database updated as item parts finish downloading
|
|
||||||
if (downloadItem.downloadItemParts.size > 0 && downloadItem.downloadItemParts.size != numPartsBefore) {
|
|
||||||
Log.d(tag, "Save download item on num parts changed from $numPartsBefore to ${downloadItem.downloadItemParts.size}")
|
|
||||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
|
||||||
}
|
|
||||||
|
|
||||||
notifyListeners("onItemDownloadUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
|
|
||||||
delay(500)
|
|
||||||
}
|
|
||||||
|
|
||||||
var localLibraryItem = folderScanner.scanDownloadItem(downloadItem)
|
|
||||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
|
||||||
downloadQueue.remove(downloadItem)
|
|
||||||
|
|
||||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
|
|
||||||
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("libraryItemId", downloadItem.id)
|
|
||||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
|
||||||
if (localLibraryItem != null) {
|
|
||||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
|
||||||
}
|
|
||||||
notifyListeners("onItemDownloadComplete", jsobj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun checkDownloads(downloadItem: DownloadItem) {
|
|
||||||
var itemParts = downloadItem.downloadItemParts.map { it }
|
|
||||||
for (downloadItemPart in itemParts) {
|
|
||||||
if (downloadItemPart.downloadId != null) {
|
|
||||||
var dlid = downloadItemPart.downloadId!!
|
|
||||||
val query = DownloadManager.Query().setFilterById(dlid)
|
|
||||||
downloadManager.query(query).use {
|
|
||||||
if (it.moveToFirst()) {
|
|
||||||
val totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
|
||||||
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
|
||||||
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
|
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
|
||||||
|
|
||||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
|
|
||||||
// downloadItem.downloadItemParts.remove(downloadItemPart)
|
|
||||||
downloadItemPart.completed = true
|
|
||||||
} else if (downloadStatus == DownloadManager.STATUS_FAILED) {
|
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
|
|
||||||
downloadItem.downloadItemParts.remove(downloadItemPart)
|
|
||||||
// downloadItemPart.completed = true
|
|
||||||
} else {
|
|
||||||
//update progress
|
|
||||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
|
||||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
|
|
||||||
downloadItemPart.progress = percentProgress
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
|
|
||||||
downloadItem.downloadItemParts.remove(downloadItemPart)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
package com.audiobookshelf.app.plugins
|
|
||||||
|
|
||||||
import android.database.Cursor
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.documentfile.provider.DocumentFile
|
|
||||||
import com.anggrayudi.storage.SimpleStorage
|
|
||||||
import com.anggrayudi.storage.callback.FolderPickerCallback
|
|
||||||
import com.anggrayudi.storage.callback.StorageAccessCallback
|
|
||||||
import com.anggrayudi.storage.file.*
|
|
||||||
import com.audiobookshelf.app.MainActivity
|
|
||||||
import com.audiobookshelf.app.data.LocalFolder
|
|
||||||
import com.audiobookshelf.app.data.LocalLibraryItem
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.getcapacitor.*
|
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.GlobalScope
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsFileSystem")
|
|
||||||
class AbsFileSystem : Plugin() {
|
|
||||||
private val TAG = "AbsFileSystem"
|
|
||||||
private val tag = "AbsFileSystem"
|
|
||||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
|
|
||||||
lateinit var mainActivity: MainActivity
|
|
||||||
|
|
||||||
override fun load() {
|
|
||||||
mainActivity = (activity as MainActivity)
|
|
||||||
|
|
||||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
|
||||||
override fun onRootPathNotSelected(
|
|
||||||
requestCode: Int,
|
|
||||||
rootPath: String,
|
|
||||||
uri: Uri,
|
|
||||||
selectedStorageType: StorageType,
|
|
||||||
expectedStorageType: StorageType
|
|
||||||
) {
|
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCanceledByUser(requestCode: Int) {
|
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
|
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
|
||||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun selectFolder(call: PluginCall) {
|
|
||||||
var mediaType = call.data.getString("mediaType", "book").toString()
|
|
||||||
|
|
||||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
|
||||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
|
||||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
|
||||||
var absolutePath = folder.getAbsolutePath(activity)
|
|
||||||
var storageType = folder.getStorageType(activity)
|
|
||||||
var simplePath = folder.getSimplePath(activity)
|
|
||||||
var basePath = folder.getBasePath(activity)
|
|
||||||
var folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
|
|
||||||
|
|
||||||
var localFolder = LocalFolder(folderId, folder.name ?: "", folder.uri.toString(),basePath,absolutePath, simplePath, storageType.toString(), mediaType)
|
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalFolder(localFolder)
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
|
|
||||||
Log.e(TAG, "STORAGE ACCESS DENIED")
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("error", "Access Denied")
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
|
||||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("error", "Permission Denied")
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
mainActivity.storage.openFolderPicker(6)
|
|
||||||
}
|
|
||||||
|
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
|
||||||
@PluginMethod
|
|
||||||
fun requestStoragePermission(call: PluginCall) {
|
|
||||||
Log.d(TAG, "Request Storage Permissions")
|
|
||||||
mainActivity.storageHelper.requestStorageAccess()
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun checkStoragePermission(call: PluginCall) {
|
|
||||||
var res: Boolean
|
|
||||||
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
|
||||||
res = SimpleStorage.hasStoragePermission(context)
|
|
||||||
Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "checkStoragePermission: Has permission on Android 10 or up")
|
|
||||||
res = true
|
|
||||||
}
|
|
||||||
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("value", res)
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun checkFolderPermissions(call: PluginCall) {
|
|
||||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
|
||||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
|
||||||
|
|
||||||
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
|
||||||
|
|
||||||
var jsobj = JSObject()
|
|
||||||
jsobj.put("value", hasAccess)
|
|
||||||
call.resolve(jsobj)
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun scanFolder(call: PluginCall) {
|
|
||||||
var folderId = call.data.getString("folderId", "").toString()
|
|
||||||
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
|
||||||
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
|
|
||||||
|
|
||||||
var folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
|
|
||||||
folder?.let {
|
|
||||||
var folderScanner = FolderScanner(context)
|
|
||||||
var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
|
||||||
if (folderScanResult == null) {
|
|
||||||
Log.d(TAG, "NO Scan DATA")
|
|
||||||
return call.resolve(JSObject())
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(folderScanResult)}")
|
|
||||||
return call.resolve(JSObject(jacksonMapper.writeValueAsString(folderScanResult)))
|
|
||||||
}
|
|
||||||
} ?: call.resolve(JSObject())
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun removeFolder(call: PluginCall) {
|
|
||||||
var folderId = call.data.getString("folderId", "").toString()
|
|
||||||
DeviceManager.dbManager.removeLocalFolder(folderId)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun removeLocalLibraryItem(call: PluginCall) {
|
|
||||||
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
|
||||||
call.resolve()
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun scanLocalLibraryItem(call: PluginCall) {
|
|
||||||
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
|
||||||
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
|
||||||
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
|
||||||
var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
|
||||||
localLibraryItem?.let {
|
|
||||||
var folderScanner = FolderScanner(context)
|
|
||||||
var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
|
||||||
if (scanResult == null) {
|
|
||||||
Log.d(TAG, "NO Scan DATA")
|
|
||||||
call.resolve(JSObject())
|
|
||||||
} else {
|
|
||||||
Log.d(TAG, "Scan DATA ${jacksonMapper.writeValueAsString(scanResult)}")
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(scanResult)))
|
|
||||||
}
|
|
||||||
} ?: call.resolve(JSObject())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun deleteItem(call: PluginCall) {
|
|
||||||
var localLibraryItemId = call.data.getString("id", "").toString()
|
|
||||||
var absolutePath = call.data.getString("absolutePath", "").toString()
|
|
||||||
var contentUrl = call.data.getString("contentUrl", "").toString()
|
|
||||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
|
||||||
|
|
||||||
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
|
||||||
var success = docfile?.delete() == true
|
|
||||||
if (success) {
|
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
|
||||||
}
|
|
||||||
call.resolve(JSObject("{\"success\":$success}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
@PluginMethod
|
|
||||||
fun deleteTrackFromItem(call: PluginCall) {
|
|
||||||
var localLibraryItemId = call.data.getString("id", "").toString()
|
|
||||||
var trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
|
|
||||||
var contentUrl = call.data.getString("trackContentUrl", "").toString()
|
|
||||||
Log.d(tag, "deleteTrackFromItem $contentUrl")
|
|
||||||
|
|
||||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
|
||||||
if (localLibraryItem == null) {
|
|
||||||
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
|
|
||||||
return call.resolve(JSObject("{\"success\":false}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
|
||||||
var success = docfile?.delete() == true
|
|
||||||
if (success) {
|
|
||||||
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
|
|
||||||
localLibraryItem.removeLocalFile(trackLocalFileId)
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
|
||||||
} else {
|
|
||||||
call.resolve(JSObject("{\"success\":false}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
package com.audiobookshelf.app.server
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import android.net.ConnectivityManager
|
|
||||||
import android.net.NetworkCapabilities
|
|
||||||
import android.util.Log
|
|
||||||
import com.audiobookshelf.app.data.*
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
|
||||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
|
||||||
import com.getcapacitor.JSArray
|
|
||||||
import com.getcapacitor.JSObject
|
|
||||||
import okhttp3.*
|
|
||||||
import okhttp3.MediaType.Companion.toMediaType
|
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.io.IOException
|
|
||||||
|
|
||||||
class ApiHandler(var ctx:Context) {
|
|
||||||
val tag = "ApiHandler"
|
|
||||||
|
|
||||||
private var client = OkHttpClient()
|
|
||||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
|
||||||
|
|
||||||
var storageSharedPreferences: SharedPreferences? = null
|
|
||||||
|
|
||||||
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
|
||||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
|
|
||||||
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
|
|
||||||
|
|
||||||
fun getRequest(endpoint:String, cb: (JSObject) -> Unit) {
|
|
||||||
val request = Request.Builder()
|
|
||||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
|
||||||
.build()
|
|
||||||
makeRequest(request, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun postRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
|
||||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
|
||||||
val request = Request.Builder().post(requestBody)
|
|
||||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
|
||||||
.build()
|
|
||||||
makeRequest(request, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
|
||||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
|
||||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
|
||||||
val request = Request.Builder().patch(requestBody)
|
|
||||||
.url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}")
|
|
||||||
.build()
|
|
||||||
makeRequest(request, cb)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun isOnline(): Boolean {
|
|
||||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
|
||||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
|
||||||
if (capabilities != null) {
|
|
||||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
|
||||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
|
|
||||||
return true
|
|
||||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
|
||||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
|
|
||||||
return true
|
|
||||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
|
||||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
fun makeRequest(request:Request, cb: (JSObject) -> Unit) {
|
|
||||||
client.newCall(request).enqueue(object : Callback {
|
|
||||||
override fun onFailure(call: Call, e: IOException) {
|
|
||||||
Log.d(tag, "FAILURE TO CONNECT")
|
|
||||||
e.printStackTrace()
|
|
||||||
cb(JSObject())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onResponse(call: Call, response: Response) {
|
|
||||||
response.use {
|
|
||||||
if (!it.isSuccessful) throw IOException("Unexpected code $response")
|
|
||||||
|
|
||||||
var bodyString = it.body!!.string()
|
|
||||||
if (bodyString == "OK") {
|
|
||||||
cb(JSObject())
|
|
||||||
} else {
|
|
||||||
var jsonObj = JSObject()
|
|
||||||
if (bodyString.startsWith("[")) {
|
|
||||||
var array = JSArray(bodyString)
|
|
||||||
jsonObj.put("value", array)
|
|
||||||
} else {
|
|
||||||
jsonObj = JSObject(bodyString)
|
|
||||||
}
|
|
||||||
cb(jsonObj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLibraries(cb: (List<Library>) -> Unit) {
|
|
||||||
val mapper = jacksonMapper
|
|
||||||
getRequest("/api/libraries") {
|
|
||||||
val libraries = mutableListOf<Library>()
|
|
||||||
if (it.has("value")) {
|
|
||||||
var array = it.getJSONArray("value")
|
|
||||||
for (i in 0 until array.length()) {
|
|
||||||
val library = mapper.readValue<Library>(array.get(i).toString())
|
|
||||||
libraries.add(library)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cb(libraries)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLibraryItem(libraryItemId:String, cb: (LibraryItem) -> Unit) {
|
|
||||||
getRequest("/api/items/$libraryItemId?expanded=1") {
|
|
||||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
|
||||||
cb(libraryItem)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLibraryItems(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
|
||||||
getRequest("/api/libraries/$libraryId/items?limit=100&minified=1") {
|
|
||||||
val items = mutableListOf<LibraryItem>()
|
|
||||||
if (it.has("results")) {
|
|
||||||
var array = it.getJSONArray("results")
|
|
||||||
for (i in 0 until array.length()) {
|
|
||||||
val item = jacksonMapper.readValue<LibraryItem>(array.get(i).toString())
|
|
||||||
items.add(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cb(items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
|
||||||
getRequest("/api/libraries/$libraryId/personalized") {
|
|
||||||
val items = mutableListOf<LibraryCategory>()
|
|
||||||
if (it.has("value")) {
|
|
||||||
var array = it.getJSONArray("value")
|
|
||||||
for (i in 0 until array.length()) {
|
|
||||||
var jsobj = array.get(i) as JSONObject
|
|
||||||
|
|
||||||
var type = jsobj.get("type").toString()
|
|
||||||
// Only support for podcast and book in android auto
|
|
||||||
if (type == "podcast" || type == "book") {
|
|
||||||
jsobj.put("isLocal", false)
|
|
||||||
val item = jacksonMapper.readValue<LibraryCategory>(jsobj.toString())
|
|
||||||
items.add(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cb(items)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun playLibraryItem(libraryItemId:String, episodeId:String?, forceTranscode:Boolean, mediaPlayer:String, cb: (PlaybackSession) -> Unit) {
|
|
||||||
var payload = JSObject()
|
|
||||||
payload.put("mediaPlayer", mediaPlayer)
|
|
||||||
|
|
||||||
// Only if direct play fails do we force transcode
|
|
||||||
if (!forceTranscode) payload.put("forceDirectPlay", true)
|
|
||||||
else payload.put("forceTranscode", true)
|
|
||||||
|
|
||||||
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
|
||||||
postRequest(endpoint, payload) {
|
|
||||||
it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id)
|
|
||||||
it.put("serverAddress", DeviceManager.serverAddress)
|
|
||||||
val playbackSession = jacksonMapper.readValue<PlaybackSession>(it.toString())
|
|
||||||
cb(playbackSession)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: () -> Unit) {
|
|
||||||
var payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
|
||||||
|
|
||||||
postRequest("/api/session/$sessionId/sync", payload) {
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
|
|
||||||
var payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
|
||||||
|
|
||||||
postRequest("/api/session/local", payload) {
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
|
|
||||||
if (!isOnline()) {
|
|
||||||
Log.d(tag, "Error not online")
|
|
||||||
cb(LocalMediaProgressSyncResultsPayload(0,0,0))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all local media progress connected to items on the current connected server
|
|
||||||
var localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
|
|
||||||
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
var localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
|
|
||||||
|
|
||||||
if (localMediaProgress.isNotEmpty()) {
|
|
||||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
|
||||||
var payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
|
||||||
postRequest("/api/me/sync-local-progress", payload) {
|
|
||||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it.toString()}")
|
|
||||||
|
|
||||||
if (it.toString() == "{}") {
|
|
||||||
Log.e(tag, "Progress sync received empty object")
|
|
||||||
} else {
|
|
||||||
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
|
|
||||||
|
|
||||||
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
|
|
||||||
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
|
|
||||||
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
|
|
||||||
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
|
|
||||||
// Update all local media progress
|
|
||||||
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
|
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cb(localSyncResultsPayload)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log.d(tag, "No local media progress to sync")
|
|
||||||
cb(localSyncResultsPayload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) {
|
|
||||||
Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload")
|
|
||||||
var endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
|
||||||
patchRequest(endpoint,updatePayload) {
|
|
||||||
Log.d(tag, "updateMediaProgress patched progress")
|
|
||||||
cb()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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">
|
|
||||||
<group android:scaleX="1.127451"
|
|
||||||
android:scaleY="1.127451"
|
|
||||||
android:translateX="-1.5294118"
|
|
||||||
android:translateY="-1.5294118">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6z"/>
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,12l-2.5,-1.5L15,12L15,4h5v8z"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<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">
|
|
||||||
<group android:scaleX="1.127451"
|
|
||||||
android:scaleY="1.127451"
|
|
||||||
android:translateX="-1.5294118"
|
|
||||||
android:translateY="-1.5294118">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M20.13,5.41l-1.41,-1.41l-9.19,9.19l-4.25,-4.24l-1.41,1.41l5.66,5.66z"/>
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M5,18h14v2h-14z"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<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">
|
|
||||||
<group android:scaleX="1.127451"
|
|
||||||
android:scaleY="1.127451"
|
|
||||||
android:translateX="-1.5294118"
|
|
||||||
android:translateY="-1.5294118">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M12,3v9.28c-0.47,-0.17 -0.97,-0.28 -1.5,-0.28C8.01,12 6,14.01 6,16.5S8.01,21 10.5,21c2.31,0 4.2,-1.75 4.45,-4H15V6h4V3h-7z"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<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">
|
|
||||||
<group android:scaleX="1.127451"
|
|
||||||
android:scaleY="1.127451"
|
|
||||||
android:translateX="-1.5294118"
|
|
||||||
android:translateY="-1.5294118">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<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">
|
|
||||||
<group android:scaleX="1.127451"
|
|
||||||
android:scaleY="1.127451"
|
|
||||||
android:translateX="-1.5294118"
|
|
||||||
android:translateY="-1.5294118">
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
|
||||||
<path
|
|
||||||
android:fillColor="@android:color/white"
|
|
||||||
android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
|
||||||
</group>
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 276 B |
|
Before Width: | Height: | Size: 215 B |
|
Before Width: | Height: | Size: 462 B |
|
Before Width: | Height: | Size: 250 B |
|
Before Width: | Height: | Size: 444 B |
|
Before Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 199 B |
|
Before Width: | Height: | Size: 176 B |
|
Before Width: | Height: | Size: 335 B |
|
Before Width: | Height: | Size: 186 B |
|
Before Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 309 B |
|
Before Width: | Height: | Size: 355 B |
|
Before Width: | Height: | Size: 591 B |
|
Before Width: | Height: | Size: 289 B |
|
Before Width: | Height: | Size: 598 B |
|
Before Width: | Height: | Size: 430 B |
|
Before Width: | Height: | Size: 448 B |
|
Before Width: | Height: | Size: 869 B |
|
Before Width: | Height: | Size: 477 B |
|
Before Width: | Height: | Size: 843 B |
@@ -1,7 +0,0 @@
|
|||||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
|
||||||
android:viewportHeight="24" android:viewportWidth="24"
|
|
||||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
|
||||||
</vector>
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
|
||||||
android:viewportHeight="24" android:viewportWidth="24"
|
|
||||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
|
||||||
<path android:fillColor="@android:color/white" android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
|
||||||
</vector>
|
|
||||||
|
Before Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
<background android:drawable="@color/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||