Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48313b60c1 | ||
|
|
3be13025a8 | ||
|
|
505717e580 | ||
|
|
b4bb6bf81b | ||
|
|
6a8d901ce1 | ||
|
|
68f3f0e276 | ||
|
|
ddf8e66a08 | ||
|
|
5b7a492a48 | ||
|
|
9be1ee1843 | ||
|
|
b5c6acc2bc | ||
|
|
39b137c7f1 | ||
|
|
b16a7342aa | ||
|
|
3056c55d2f | ||
|
|
6b416e6fc1 | ||
|
|
e4c6093a82 | ||
|
|
3c1120ea48 | ||
|
|
a41e26e4c6 | ||
|
|
317dc366e3 | ||
|
|
cd4c280950 | ||
|
|
0ca8de5916 | ||
|
|
3840f2f344 | ||
|
|
f353a44fac | ||
|
|
a06796d9ca | ||
|
|
a69054fefa | ||
|
|
6805d7eb96 | ||
|
|
c77d0f1944 | ||
|
|
c457c8eaf7 | ||
|
|
a391db5dc2 | ||
|
|
2180e2f649 | ||
|
|
fc6d16bdd9 | ||
|
|
815529129a | ||
|
|
bad8d10a18 | ||
|
|
a4412da3ed | ||
|
|
870774b408 | ||
|
|
d7dcaa22a6 | ||
|
|
cc0b2943dd | ||
|
|
50ee0b2265 | ||
|
|
910b3a2a17 | ||
|
|
916da91ccb | ||
|
|
d70a99254a | ||
|
|
dcf5bb61a2 | ||
|
|
aa65bb10c1 | ||
|
|
158448f8a2 | ||
|
|
f4be9b3e26 | ||
|
|
15d68ca285 | ||
|
|
ad12e6a19d | ||
|
|
1f1b2fe85a | ||
|
|
480df58ce4 | ||
|
|
2decf532b2 | ||
|
|
3ba87419ae | ||
|
|
1c78af37fa | ||
|
|
2b5373aedd | ||
|
|
99bf960b8a | ||
|
|
c4aca22c28 | ||
|
|
58bd0e0cee | ||
|
|
c1c56f8f52 | ||
|
|
f930ba1941 | ||
|
|
251116a5ce | ||
|
|
06739c0401 | ||
|
|
92f069d1e6 | ||
|
|
62c5042ecc | ||
|
|
604b086c0b | ||
|
|
0e0b356f6b | ||
|
|
573768e2b2 | ||
|
|
236fd09c94 | ||
|
|
cb6cb5f637 | ||
|
|
eb916a3ab0 | ||
|
|
61b8ca1510 | ||
|
|
5d2da97dc5 | ||
|
|
d626686614 | ||
|
|
60ee33cb72 | ||
|
|
8d2498e96d | ||
|
|
1794484bed | ||
|
|
f2b6331843 | ||
|
|
8d5f33245f | ||
|
|
1ee544b842 | ||
|
|
0fd9463c7c | ||
|
|
ad5edf3aee | ||
|
|
d5fafd8cab | ||
|
|
92256f2fed | ||
|
|
5fa8d4c989 | ||
|
|
6b75f79f00 | ||
|
|
b0c9f29d90 | ||
|
|
7721afc116 | ||
|
|
bc85e9bbfa | ||
|
|
e8b6602fe5 | ||
|
|
0c49bcfe3f | ||
|
|
aecb6a8dd2 | ||
|
|
718947522e | ||
|
|
2f8ca51447 | ||
|
|
4fa6dd2616 | ||
|
|
736e57fafd | ||
|
|
30d86279a5 | ||
|
|
8bbfcdeb82 | ||
|
|
d23cf62264 | ||
|
|
73d5b19d2b | ||
|
|
1959351125 | ||
|
|
0708133779 | ||
|
|
9a81fc3688 | ||
|
|
ac71d39265 | ||
|
|
4203654ec8 | ||
|
|
9701c767b2 | ||
|
|
0223df4f9e | ||
|
|
7549385404 | ||
|
|
3f2d0ed8b1 | ||
|
|
e4a5927e07 | ||
|
|
a3aac4da75 | ||
|
|
1e9453e501 | ||
|
|
bec3f5841e | ||
|
|
068762912f | ||
|
|
01e85d0e91 | ||
|
|
a67c19f30f | ||
|
|
394363c8cb | ||
|
|
7fd51ebcc1 | ||
|
|
15b7fbce47 | ||
|
|
e03f878865 | ||
|
|
cffa7f5344 | ||
|
|
8411428241 | ||
|
|
ae4678cf24 | ||
|
|
68e565ebe2 | ||
|
|
d99f4406b7 | ||
|
|
2064cd8380 | ||
|
|
415ff65561 | ||
|
|
be885009ad | ||
|
|
d64dd63ea4 | ||
|
|
72f10e5f42 | ||
|
|
511386e80a | ||
|
|
b219756cb7 | ||
|
|
31c753ffcc | ||
|
|
2dd822e04d | ||
|
|
d18972e2f3 | ||
|
|
81ca757c77 | ||
|
|
7189588c2b | ||
|
|
1fed00ca81 | ||
|
|
a63022a669 | ||
|
|
843f6ca606 | ||
|
|
52fd8ac5e8 | ||
|
|
d5b69be7c1 | ||
|
|
729e959506 | ||
|
|
a0a7fd1e14 | ||
|
|
0b4c999018 | ||
|
|
c5a9677ac6 | ||
|
|
dbe0b7ea69 | ||
|
|
32df099e8a | ||
|
|
612c48174c | ||
|
|
e8e7a241c6 | ||
|
|
9263028c56 | ||
|
|
114dbd24bc | ||
|
|
4d0d1eb88f | ||
|
|
b72aba36b8 | ||
|
|
9d6a467517 | ||
|
|
441bc24e2d | ||
|
|
033f066568 | ||
|
|
49ad1ef601 | ||
|
|
a17b588e29 | ||
|
|
f65f7b01c3 | ||
|
|
379612d874 | ||
|
|
25bccdfce6 | ||
|
|
f974fc6e54 | ||
|
|
4d71bc48aa | ||
|
|
df1ce6d91f | ||
|
|
ae65bed352 | ||
|
|
2f8e30accb | ||
|
|
13665af0ed | ||
|
|
c4c6377925 | ||
|
|
493d7aecc9 | ||
|
|
f7516889e4 | ||
|
|
760d05cf1b | ||
|
|
3f2b3f2e9c | ||
|
|
6cea0ba03d | ||
|
|
066050700c | ||
|
|
448f7855af | ||
|
|
ae195e7b58 | ||
|
|
03c3f37e1e | ||
|
|
363bfd206c | ||
|
|
29fd29728a | ||
|
|
9a7b2e8172 | ||
|
|
0339f731ea | ||
|
|
7fbc2dddbe | ||
|
|
7a9f6ff2ca | ||
|
|
6c65c8a33e | ||
|
|
736af85855 | ||
|
|
4abd1c8b49 | ||
|
|
ccba8dc3c7 | ||
|
|
331a3b4a1a | ||
|
|
24d124e2e8 | ||
|
|
021538820a | ||
|
|
0bc8b32ef5 | ||
|
|
239938e069 | ||
|
|
6db2c7805d | ||
|
|
eed8a29c9b | ||
|
|
6e8d84496b | ||
|
|
782f11ff28 | ||
|
|
6de4626239 | ||
|
|
a0ee82bb31 | ||
|
|
c0ac3b7bb5 | ||
|
|
2f4b3050fd | ||
|
|
bf3a97523d | ||
|
|
c94e57f55e | ||
|
|
ef65b4c278 | ||
|
|
e5c8d5d4d4 | ||
|
|
abf140bd21 | ||
|
|
12a153d423 | ||
|
|
d9e4469089 | ||
|
|
526fca98b9 | ||
|
|
9ad351f0d7 | ||
|
|
f309e1fcf2 | ||
|
|
105451ebf1 | ||
|
|
119bfd6c98 | ||
|
|
ee942c6704 | ||
|
|
12de187b7a | ||
|
|
77ef0c119b | ||
|
|
cc744bb975 | ||
|
|
4b834cb5c1 | ||
|
|
9fd3dc6978 | ||
|
|
4f8b13b23d | ||
|
|
0b9b0341b5 | ||
|
|
f57f0e4e0d | ||
|
|
7a091dd428 | ||
|
|
f70f707100 | ||
|
|
314dc960f2 | ||
|
|
94b9dbb8b3 | ||
|
|
a259883979 | ||
|
|
a8de03b82d | ||
|
|
4fc70cd3dd | ||
|
|
f98aea71cb | ||
|
|
461733854a | ||
|
|
b849882664 | ||
|
|
03312390cb | ||
|
|
4f1f0e4db7 | ||
|
|
73602a51ce | ||
|
|
40b9d4a012 | ||
|
|
2d1e4b9836 | ||
|
|
e998a03571 | ||
|
|
d0b7a958aa | ||
|
|
d8d02984fd | ||
|
|
84bab5de1b | ||
|
|
37b5554b39 | ||
|
|
704318071e | ||
|
|
4d4d3730bd | ||
|
|
61433ef678 | ||
|
|
926c17543f | ||
|
|
f76128c2b3 | ||
|
|
a42f94e367 | ||
|
|
f9be51184d | ||
|
|
52ec8b4234 | ||
|
|
2b692309fa | ||
|
|
6d8479ac7d | ||
|
|
999315ca91 | ||
|
|
f5088d0384 | ||
|
|
979b74f516 | ||
|
|
9253a9bf6e | ||
|
|
69127944a3 | ||
|
|
fd4974dfce | ||
|
|
06f4c933ca | ||
|
|
a4ce1dbe1d | ||
|
|
abb41979f4 | ||
|
|
6b59ad5bd3 | ||
|
|
a30fe74da2 | ||
|
|
6157b5923a | ||
|
|
cdcf152049 | ||
|
|
16da0c909f | ||
|
|
1a555eab63 | ||
|
|
4cdcbf79d7 | ||
|
|
c07b527a1d | ||
|
|
a2031f1d88 | ||
|
|
42fec4831e |
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
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>.
|
||||
@@ -1,304 +0,0 @@
|
||||
import { io } from 'socket.io-client'
|
||||
import { Storage } from '@capacitor/storage'
|
||||
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.initialized = false
|
||||
|
||||
this.stream = null
|
||||
|
||||
this.isConnectingSocket = false
|
||||
}
|
||||
|
||||
get token() {
|
||||
return this.user ? this.user.token : null
|
||||
}
|
||||
|
||||
getAxiosConfig() {
|
||||
return { headers: { Authorization: `Bearer ${this.token}` } }
|
||||
}
|
||||
|
||||
getServerUrl(url) {
|
||||
if (!url) return null
|
||||
try {
|
||||
var urlObject = new URL(url)
|
||||
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
|
||||
} catch (error) {
|
||||
console.error('Invalid URL', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
setUser(user) {
|
||||
this.user = user
|
||||
this.store.commit('user/setUser', user)
|
||||
if (user) {
|
||||
// this.store.commit('user/setSettings', user.settings)
|
||||
Storage.set({ key: 'token', value: user.token })
|
||||
} else {
|
||||
Storage.remove({ key: 'token' })
|
||||
}
|
||||
}
|
||||
|
||||
setServerUrl(url) {
|
||||
this.url = url
|
||||
this.store.commit('setServerUrl', url)
|
||||
|
||||
if (url) {
|
||||
Storage.set({ key: 'serverUrl', value: url })
|
||||
} else {
|
||||
Storage.remove({ key: 'serverUrl' })
|
||||
}
|
||||
}
|
||||
|
||||
async connect(url, token) {
|
||||
if (this.connected) {
|
||||
console.warn('[SOCKET] Connection already established for ' + this.url)
|
||||
return { success: true }
|
||||
}
|
||||
if (!url) {
|
||||
console.error('Invalid url to connect')
|
||||
return {
|
||||
error: 'Invalid URL'
|
||||
}
|
||||
}
|
||||
|
||||
var serverUrl = this.getServerUrl(url)
|
||||
var res = await this.ping(serverUrl)
|
||||
|
||||
if (!res || !res.success) {
|
||||
return {
|
||||
error: res ? res.error : 'Unknown Error'
|
||||
}
|
||||
}
|
||||
var authRes = await this.authorize(serverUrl, token)
|
||||
if (!authRes || authRes.error) {
|
||||
return {
|
||||
error: authRes ? authRes.error : 'Authorization Error'
|
||||
}
|
||||
}
|
||||
|
||||
this.setServerUrl(serverUrl)
|
||||
|
||||
this.setUser(authRes.user)
|
||||
this.connectSocket()
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async check(url) {
|
||||
var serverUrl = this.getServerUrl(url)
|
||||
if (!serverUrl) {
|
||||
return {
|
||||
error: 'Invalid server url'
|
||||
}
|
||||
}
|
||||
var res = await this.ping(serverUrl)
|
||||
if (!res || res.error) {
|
||||
return {
|
||||
error: res ? res.error : 'Ping Failed'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
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)
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.setUser(null)
|
||||
this.stream = null
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
}
|
||||
this.emit('logout')
|
||||
}
|
||||
|
||||
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)
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ping(url) {
|
||||
var pingUrl = url + '/ping'
|
||||
console.log('[Server] Check server', pingUrl)
|
||||
return axios.get(pingUrl, { timeout: 1000 }).then((res) => {
|
||||
return res.data
|
||||
}).catch(error => {
|
||||
console.error('Server check failed', error)
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
connectSocket() {
|
||||
if (this.socket && !this.connected) {
|
||||
this.socket.connect()
|
||||
console.log('[SOCKET] Submitting connect')
|
||||
return
|
||||
}
|
||||
if (this.connected || this.socket) {
|
||||
if (this.socket) console.error('[SOCKET] Socket already established', this.url)
|
||||
else console.error('[SOCKET] Already connected to socket', this.url)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[SOCKET] Connect Socket', this.url)
|
||||
|
||||
const socketOptions = {
|
||||
transports: ['websocket'],
|
||||
upgrade: false,
|
||||
// reconnectionAttempts: 3
|
||||
}
|
||||
this.socket = io(this.url, socketOptions)
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[SOCKET] Socket Connected ' + this.socket.id)
|
||||
|
||||
// Authenticate socket with token
|
||||
this.socket.emit('auth', this.token)
|
||||
this.connected = true
|
||||
this.emit('connected', true)
|
||||
this.store.commit('setSocketConnected', true)
|
||||
})
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('[SOCKET] Socket Disconnected: ' + reason)
|
||||
this.connected = false
|
||||
this.emit('connected', false)
|
||||
this.emit('initialized', false)
|
||||
this.initialized = false
|
||||
this.store.commit('setSocketConnected', false)
|
||||
|
||||
// this.socket.removeAllListeners()
|
||||
// if (this.socket.io && this.socket.io.removeAllListeners) {
|
||||
// console.log(`[SOCKET] Removing ALL IO listeners`)
|
||||
// this.socket.io.removeAllListeners()
|
||||
// }
|
||||
})
|
||||
this.socket.on('init', (data) => {
|
||||
console.log('[SOCKET] Initial socket data received', data)
|
||||
if (data.stream) {
|
||||
this.stream = data.stream
|
||||
this.store.commit('setStreamAudiobook', data.stream.audiobook)
|
||||
this.emit('initialStream', data.stream)
|
||||
}
|
||||
if (data.serverSettings) {
|
||||
this.store.commit('setServerSettings', data.serverSettings)
|
||||
}
|
||||
this.initialized = true
|
||||
this.emit('initialized', true)
|
||||
})
|
||||
|
||||
this.socket.on('user_updated', (user) => {
|
||||
if (this.user && user.id === this.user.id) {
|
||||
this.setUser(user)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('current_user_audiobook_update', (payload) => {
|
||||
this.emit('currentUserAudiobookUpdate', payload)
|
||||
})
|
||||
|
||||
this.socket.on('show_error_toast', (payload) => {
|
||||
this.emit('show_error_toast', payload)
|
||||
})
|
||||
this.socket.on('show_success_toast', (payload) => {
|
||||
this.emit('show_success_toast', payload)
|
||||
})
|
||||
|
||||
this.socket.onAny((evt, args) => {
|
||||
console.log(`[SOCKET] ${this.socket.id}: ${evt} ${JSON.stringify(args)}`)
|
||||
})
|
||||
|
||||
this.socket.on('connect_error', (err) => {
|
||||
console.error('[SOCKET] connection failed', err)
|
||||
this.emit('socketConnectionFailed', err)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_attempt", (attempt) => {
|
||||
console.log(`[SOCKET] Reconnect Attempt ${this.socket.id}: ${attempt}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_error", (err) => {
|
||||
console.log(`[SOCKET] Reconnect Error ${this.socket.id}: ${err}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_failed", () => {
|
||||
console.log(`[SOCKET] Reconnect Failed ${this.socket.id}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect", () => {
|
||||
console.log(`[SOCKET] Reconnect Success ${this.socket.id}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default Server
|
||||
@@ -4,6 +4,22 @@ plugins {
|
||||
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 {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = ['-Xjvm-default=all']
|
||||
@@ -13,8 +29,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 56
|
||||
versionName "0.9.36-beta"
|
||||
versionCode 82
|
||||
versionName "0.9.51-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -37,23 +53,34 @@ repositories {
|
||||
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 {
|
||||
implementation "com.anggrayudi:storage:0.13.0"
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
|
||||
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
|
||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
// testImplementation "junit:junit:$junitVersion"
|
||||
// androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
// androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
implementation "androidx.core:core-ktx:1.6.0"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||
@@ -61,6 +88,7 @@ dependencies {
|
||||
|
||||
implementation "androidx.media:media:$androidx_media_version"
|
||||
|
||||
// Exo Player
|
||||
if (findProject(':exoplayer-library-core') != null) {
|
||||
implementation project(':exoplayer-library-core')
|
||||
implementation project(':exoplayer-library-ui')
|
||||
@@ -75,9 +103,24 @@ dependencies {
|
||||
implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayer_version"
|
||||
}
|
||||
|
||||
|
||||
// Glide for images
|
||||
implementation "com.github.bumptech.glide:glide:$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-min:4.5.1'
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
@@ -9,14 +9,12 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capacitor-storage')
|
||||
implementation project(':robingenz-capacitor-app-update')
|
||||
implementation project(':capacitor-data-storage-sqlite')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
<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"
|
||||
android:installLocation="preferExternal"
|
||||
package="com.audiobookshelf.app">
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
@@ -70,7 +72,7 @@
|
||||
<service
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".PlayerNotificationService">
|
||||
android:name=".player.PlayerNotificationService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@capacitor-community/sqlite",
|
||||
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
@@ -11,6 +7,10 @@
|
||||
"pkg": "@capacitor/dialog",
|
||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/haptics",
|
||||
"classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/network",
|
||||
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
|
||||
@@ -22,13 +22,5 @@
|
||||
{
|
||||
"pkg": "@capacitor/storage",
|
||||
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@robingenz/capacitor-app-update",
|
||||
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "capacitor-data-storage-sqlite",
|
||||
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import java.io.File
|
||||
|
||||
|
||||
@CapacitorPlugin(name = "AudioDownloader")
|
||||
class AudioDownloader : Plugin() {
|
||||
private val tag = "AudioDownloader"
|
||||
|
||||
lateinit var mainActivity:MainActivity
|
||||
lateinit var downloadManager:DownloadManager
|
||||
|
||||
// data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) {
|
||||
// fun toJSObject() : JSObject {
|
||||
// var obj = JSObject()
|
||||
// obj.put("uri", this.uri)
|
||||
// obj.put("name", this.name)
|
||||
// obj.put("size", this.size)
|
||||
// obj.put("coverUrl", this.coverUrl)
|
||||
// return obj
|
||||
// }
|
||||
// }
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
|
||||
if (evt == "complete") {
|
||||
}
|
||||
if (evt == "clicked") {
|
||||
Log.d(tag, "Clicked $id back in the audiodownloader")
|
||||
}
|
||||
}
|
||||
mainActivity.registerBroadcastReceiver(recieverEvent)
|
||||
|
||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
||||
}
|
||||
|
||||
|
||||
// @PluginMethod
|
||||
// fun load(call: PluginCall) {
|
||||
// var audiobookUrls = call.data.getJSONArray("audiobookUrls")
|
||||
// var len = audiobookUrls?.length()
|
||||
// if (len == null) {
|
||||
// len = 0
|
||||
// }
|
||||
// Log.d(tag, "CALLED LOAD $len")
|
||||
// var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
|
||||
//
|
||||
// (0 until len).forEach {
|
||||
// var jsobj = audiobookUrls.get(it) as JSONObject
|
||||
// var audiobookUrl = jsobj.get("contentUrl").toString()
|
||||
// var coverUrl = jsobj.get("coverUrl").toString()
|
||||
// var storageId = ""
|
||||
// if(jsobj.has("storageId")) jsobj.get("storageId").toString()
|
||||
//
|
||||
// var basePath = ""
|
||||
// if(jsobj.has("basePath")) jsobj.get("basePath").toString()
|
||||
//
|
||||
// var coverBasePath = ""
|
||||
// if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString()
|
||||
//
|
||||
// Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl")
|
||||
//
|
||||
// var audiobookFile: DocumentFile? = null
|
||||
// var coverFile: DocumentFile? = null
|
||||
//
|
||||
// // Android 9 OR Below use storage id and base path
|
||||
// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||
// audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath)
|
||||
// if (coverUrl != null && coverUrl != "") {
|
||||
// coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath)
|
||||
// }
|
||||
// } else {
|
||||
// // Android 10 and up manually deleting will still load the file causing crash
|
||||
// var exists = checkUriExists(Uri.parse(audiobookUrl))
|
||||
// if (exists) {
|
||||
// Log.d(tag, "Audiobook exists")
|
||||
// audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl))
|
||||
// } else {
|
||||
// Log.e(tag, "Audiobook does not exist")
|
||||
// }
|
||||
//
|
||||
// var coverExists = checkUriExists(Uri.parse(coverUrl))
|
||||
// if (coverExists) {
|
||||
// Log.d(tag, "Cover Exists")
|
||||
// coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
|
||||
// } else if (coverUrl != null && coverUrl != "") {
|
||||
// Log.e(tag, "Cover does not exist")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (audiobookFile == null) {
|
||||
// Log.e(tag, "Audiobook was not found $audiobookUrl")
|
||||
// } else {
|
||||
// Log.d(tag, "Audiobook File Found StorageId:${audiobookFile.getStorageId(context)} | AbsolutePath:${audiobookFile.getAbsolutePath(context)} | BasePath:${audiobookFile.getBasePath(context)}")
|
||||
//
|
||||
// var _name = audiobookFile.name
|
||||
// if (_name == null) _name = ""
|
||||
//
|
||||
// var size = audiobookFile.length()
|
||||
//
|
||||
// if (audiobookFile.uri.toString() !== audiobookUrl) {
|
||||
// Log.d(tag, "Audiobook URI ${audiobookFile.uri} is different from $audiobookUrl => using the latter")
|
||||
// }
|
||||
//
|
||||
// // Use existing URI's - bug happening where new uri is different from initial
|
||||
// var abItem = AudiobookItem(Uri.parse(audiobookUrl), _name, size, coverUrl)
|
||||
//
|
||||
// Log.d(tag, "Setting AB ITEM ${abItem.name} | ${abItem.size} | ${abItem.uri} | ${abItem.coverUrl}")
|
||||
//
|
||||
// audiobookItems.add(abItem)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Log.d(tag, "Load Finished ${audiobookItems.size} found")
|
||||
//
|
||||
// var audiobookObjs:List<JSObject> = audiobookItems.map{ it.toJSObject() }
|
||||
// var mediaItemNoticePayload = JSObject()
|
||||
// mediaItemNoticePayload.put("items", audiobookObjs)
|
||||
// notifyListeners("onMediaLoaded", mediaItemNoticePayload)
|
||||
// }
|
||||
|
||||
@PluginMethod
|
||||
fun download(call: PluginCall) {
|
||||
var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
|
||||
var url = call.data.getString("downloadUrl", "unknown").toString()
|
||||
var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString()
|
||||
var title = call.data.getString("title", "Audiobook").toString()
|
||||
var filename = call.data.getString("filename", "audiobook.mp3").toString()
|
||||
var coverFilename = call.data.getString("coverFilename", "cover.png").toString()
|
||||
var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString()
|
||||
var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!!
|
||||
Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl")
|
||||
|
||||
var dlfilename = audiobookId + "." + File(filename).extension
|
||||
var coverdlfilename = audiobookId + "." + File(coverFilename).extension
|
||||
Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename")
|
||||
|
||||
var canWriteToFolder = folder.canWrite()
|
||||
if (!canWriteToFolder) {
|
||||
Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}")
|
||||
val ret = JSObject()
|
||||
ret.put("error", "Cannot write to ${folder.baseName}")
|
||||
call.resolve(ret)
|
||||
return
|
||||
}
|
||||
|
||||
var dlRequest = DownloadManager.Request(Uri.parse(url))
|
||||
dlRequest.setTitle("Ab: $title")
|
||||
dlRequest.setDescription("Downloading to ${folder.name}")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename)
|
||||
|
||||
var audiobookDownloadId = downloadManager.enqueue(dlRequest)
|
||||
var coverDownloadId:Long? = null
|
||||
|
||||
if (coverDownloadUrl != "") {
|
||||
var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl))
|
||||
coverDlRequest.setTitle("Cover: $title")
|
||||
coverDlRequest.setDescription("Downloading to ${folder.name}")
|
||||
coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
|
||||
coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename)
|
||||
coverDownloadId = downloadManager.enqueue(coverDlRequest)
|
||||
}
|
||||
|
||||
var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long ->
|
||||
if (id == audiobookDownloadId) {
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("progress", prog)
|
||||
notifyListeners("onDownloadProgress", jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
var coverDocFile:DocumentFile? = null
|
||||
|
||||
var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean ->
|
||||
Log.d(tag, "RECEIVER DONE $id, SUCCES? $success")
|
||||
var docfile:DocumentFile? = null
|
||||
|
||||
// Download was complete, now find downloaded file
|
||||
if (id == coverDownloadId) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
|
||||
Log.d(tag, "Move Cover File ${docfile?.name}")
|
||||
|
||||
// For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename
|
||||
// check if this was the case
|
||||
if (docfile?.name == null) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title")
|
||||
Log.d(tag, "Cover File name attempt 2 ${docfile?.name}")
|
||||
}
|
||||
} else if (id == audiobookDownloadId) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
|
||||
Log.d(tag, "Move Audiobook File ${docfile?.name}")
|
||||
|
||||
if (docfile?.name == null) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title")
|
||||
Log.d(tag, "File name attempt 2 ${docfile?.name}")
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for moving the downloaded file
|
||||
var callback = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode:ErrorCode) {
|
||||
Log.e(tag, "FAILED MOVE FILE $errorCode")
|
||||
|
||||
docfile?.delete()
|
||||
coverDocFile?.delete()
|
||||
|
||||
if (id == audiobookDownloadId) {
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("error", "Move failed")
|
||||
notifyListeners("onDownloadFailed", jsobj)
|
||||
}
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
var resultDocFile = result as DocumentFile
|
||||
var simplePath = resultDocFile.getSimplePath(context)
|
||||
var storageId = resultDocFile.getStorageId(context)
|
||||
var size = resultDocFile.length()
|
||||
Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath")
|
||||
|
||||
var abFolder = folder.findFolder(title)
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("downloadId", id)
|
||||
jsobj.put("storageId", storageId)
|
||||
jsobj.put("storageType", resultDocFile.getStorageType(context))
|
||||
jsobj.put("folderUrl", abFolder?.uri)
|
||||
jsobj.put("folderName", abFolder?.name)
|
||||
jsobj.put("downloadFolderUrl", downloadFolderUrl)
|
||||
jsobj.put("contentUrl", resultDocFile.uri)
|
||||
jsobj.put("basePath", resultDocFile.getBasePath(context))
|
||||
jsobj.put("filename", filename)
|
||||
jsobj.put("simplePath", simplePath)
|
||||
jsobj.put("size", size)
|
||||
|
||||
if (resultDocFile.name == filename) {
|
||||
Log.d(tag, "Audiobook Finishing Moving")
|
||||
} else if (resultDocFile.name == coverFilename) {
|
||||
coverDocFile = docfile
|
||||
Log.d(tag, "Audiobook Cover Finished Moving")
|
||||
jsobj.put("isCover", true)
|
||||
}
|
||||
notifyListeners("onDownloadComplete", jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
// After file is downloaded, move the files into an audiobook directory inside the user selected folder
|
||||
if (id == coverDownloadId) {
|
||||
docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback)
|
||||
} else if (id == audiobookDownloadId) {
|
||||
docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback)
|
||||
}
|
||||
}
|
||||
|
||||
var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver)
|
||||
progressUpdater.run()
|
||||
if (coverDownloadId != null) {
|
||||
var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver)
|
||||
coverProgressUpdater.run()
|
||||
}
|
||||
|
||||
val ret = JSObject()
|
||||
ret.put("audiobookDownloadId", audiobookDownloadId)
|
||||
ret.put("coverDownloadId", coverDownloadId)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() {
|
||||
private val query: DownloadManager.Query = DownloadManager.Query()
|
||||
private var totalBytes: Int = 0
|
||||
private var TAG = "DownloadProgressUpdater"
|
||||
|
||||
init {
|
||||
query.setFilterById(this.downloadId)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
Log.d(TAG, "RUN FOR ID $downloadId")
|
||||
var keepRunning = true
|
||||
var increment = 0
|
||||
while (keepRunning) {
|
||||
Thread.sleep(500)
|
||||
increment++
|
||||
|
||||
if (increment % 4 == 0) {
|
||||
Log.d(TAG, "Loop $increment : $downloadId")
|
||||
}
|
||||
|
||||
manager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
//get total bytes of the file
|
||||
if (totalBytes <= 0) {
|
||||
totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
||||
if (totalBytes <= 0) {
|
||||
Log.e(TAG, "Download Is 0 Bytes $downloadId")
|
||||
doneReceiver(downloadId, false)
|
||||
keepRunning = false
|
||||
this.interrupt()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
||||
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
|
||||
|
||||
if (increment % 4 == 0) {
|
||||
Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes")
|
||||
}
|
||||
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
doneReceiver(downloadId, true)
|
||||
} else {
|
||||
doneReceiver(downloadId, false)
|
||||
}
|
||||
keepRunning = false
|
||||
this.interrupt()
|
||||
} else {
|
||||
//update progress
|
||||
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
|
||||
receiver(downloadId, percentProgress)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "NOT FOUND IN QUERY")
|
||||
keepRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.getcapacitor.JSObject
|
||||
|
||||
class Audiobook {
|
||||
var id:String
|
||||
var ino:String
|
||||
var libraryId:String
|
||||
var folderId:String
|
||||
var book:Book
|
||||
var duration:Float
|
||||
var size:Long
|
||||
var numTracks:Int
|
||||
var isMissing:Boolean
|
||||
var isInvalid:Boolean
|
||||
var path:String
|
||||
|
||||
var isDownloaded:Boolean = false
|
||||
var downloadFolderUrl:String = ""
|
||||
var folderUrl:String = ""
|
||||
var contentUrl:String = ""
|
||||
var filename:String = ""
|
||||
var localCoverUrl:String = ""
|
||||
var localCover:String = ""
|
||||
|
||||
var serverUrl:String = ""
|
||||
var token:String = ""
|
||||
|
||||
constructor(jsobj: JSObject, serverUrl:String, token:String) {
|
||||
this.serverUrl = serverUrl
|
||||
this.token = token
|
||||
|
||||
id = jsobj.getString("id", "").toString()
|
||||
ino = jsobj.getString("ino", "").toString()
|
||||
libraryId = jsobj.getString("libraryId", "").toString()
|
||||
folderId = jsobj.getString("folderId", "").toString()
|
||||
|
||||
var bookJsObj = jsobj.getJSObject("book")
|
||||
book = bookJsObj?.let { Book(it) }!!
|
||||
|
||||
duration = jsobj.getDouble("duration").toFloat()
|
||||
size = jsobj.getLong("size")
|
||||
numTracks = jsobj.getInteger("numTracks")!!
|
||||
isMissing = jsobj.getBoolean("isMissing")
|
||||
isInvalid = jsobj.getBoolean("isInvalid")
|
||||
path = jsobj.getString("path", "").toString()
|
||||
|
||||
isDownloaded = jsobj.getBoolean("isDownloaded")
|
||||
if (isDownloaded) {
|
||||
downloadFolderUrl = jsobj.getString("downloadFolderUrl", "").toString()
|
||||
folderUrl = jsobj.getString("folderUrl", "").toString()
|
||||
contentUrl = jsobj.getString("contentUrl", "").toString()
|
||||
filename = jsobj.getString("filename", "").toString()
|
||||
localCover = jsobj.getString("localCover", "").toString()
|
||||
localCoverUrl = jsobj.getString("localCoverUrl", "").toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun getCover():Uri {
|
||||
if (isDownloaded) {
|
||||
// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse(localCoverUrl)
|
||||
}
|
||||
if (book.cover == "" || serverUrl == "" || token == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}")
|
||||
}
|
||||
|
||||
fun getDurationLong():Long {
|
||||
return duration.toLong() * 1000L
|
||||
}
|
||||
|
||||
fun toMediaMetadata():MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, book.title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, book.title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, book.authorFL)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, book.authorFL)
|
||||
|
||||
// val extras = Bundle()
|
||||
// if (isDownloaded) {
|
||||
// extras.putLong(
|
||||
// MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
// MediaDescriptionCompat.STATUS_DOWNLOADED)
|
||||
// }
|
||||
// extras.putInt(
|
||||
// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED)
|
||||
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, RESOURCE_ROOT_URI +
|
||||
// context.resources.getResourceEntryName(R.drawable.notification_bg_low_normal))
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite
|
||||
import okhttp3.*
|
||||
import org.json.JSONArray
|
||||
import java.io.IOException
|
||||
|
||||
class AudiobookManager {
|
||||
var tag = "AudiobookManager"
|
||||
|
||||
interface OnStreamData {
|
||||
fun onStreamReady(asd:AudiobookStreamData)
|
||||
}
|
||||
|
||||
var hasLoaded = false
|
||||
var isLoading = false
|
||||
var ctx: Context
|
||||
var serverUrl = ""
|
||||
var token = ""
|
||||
private var client:OkHttpClient
|
||||
|
||||
var localMediaManager:LocalMediaManager
|
||||
|
||||
var audiobooks:MutableList<Audiobook> = mutableListOf()
|
||||
var audiobooksInProgress:MutableList<Audiobook> = mutableListOf()
|
||||
|
||||
var storageSharedPreferences: SharedPreferences? = null
|
||||
|
||||
constructor(_ctx:Context, _client:OkHttpClient) {
|
||||
ctx = _ctx
|
||||
client = _client
|
||||
|
||||
localMediaManager = LocalMediaManager(ctx)
|
||||
}
|
||||
|
||||
fun init() {
|
||||
storageSharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
|
||||
serverUrl = storageSharedPreferences?.getString("serverUrl", "").toString()
|
||||
Log.d(tag, "SHARED PREF SERVERURL $serverUrl")
|
||||
token = storageSharedPreferences?.getString("token", "").toString()
|
||||
Log.d(tag, "SHARED PREF TOKEN $token")
|
||||
}
|
||||
|
||||
fun getPlaybackRate() : Float {
|
||||
if (storageSharedPreferences != null) {
|
||||
var userSettings = storageSharedPreferences?.getString("userSettings", "").toString()
|
||||
if (userSettings != "") {
|
||||
var json = JSObject(userSettings)
|
||||
var playbackRate = json.getString("playbackRate", "1")
|
||||
if (playbackRate != null) {
|
||||
return playbackRate.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1f
|
||||
}
|
||||
|
||||
fun loadCategories(cb: (() -> Unit)) {
|
||||
Log.d(tag, "LOAD Categories $serverUrl | $token")
|
||||
var url = "$serverUrl/api/libraries/main/categories"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var results = JSONArray(bodyString)
|
||||
// var results = resJson.getJSONArray("results")
|
||||
|
||||
var totalShelves = results.length() - 1
|
||||
Log.d(tag, "Got categories $totalShelves")
|
||||
for (i in 0..totalShelves) {
|
||||
var shelfobj = results.get(i)
|
||||
var jsobj = JSObject(shelfobj.toString())
|
||||
var shelfId = jsobj.getString("id", "")
|
||||
Log.d(tag, "Category shelf id $shelfId")
|
||||
if (shelfId == "continue-reading") {
|
||||
var entities = jsobj.getJSONArray("entities")
|
||||
var totalEntities = entities.length() - 1
|
||||
Log.d(tag, "Shelf total entities $totalEntities")
|
||||
for (y in 0..totalEntities) {
|
||||
var abobj = entities.get(y)
|
||||
Log.d(tag, "Shelf category ab id $y = ${abobj.toString()}")
|
||||
var abjsobj = JSObject(abobj.toString())
|
||||
abjsobj.put("isDownloaded", false)
|
||||
var audiobook = Audiobook(abjsobj, serverUrl, token)
|
||||
if (audiobook.isMissing || audiobook.isInvalid || audiobook.numTracks <= 0) {
|
||||
Log.d(tag, "Not an audiobook or invalid/missing")
|
||||
} else {
|
||||
var audiobookExists = audiobooksInProgress.find { it.id == audiobook.id }
|
||||
if (audiobookExists == null) {
|
||||
audiobooksInProgress.add(audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(tag, "${audiobooksInProgress.size} Audiobooks In Progress Loaded")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadAudiobooks(cb: (() -> Unit)) {
|
||||
Log.d(tag, "Load Audiobooks: $serverUrl | $token")
|
||||
if (serverUrl == "" || token == "") {
|
||||
Log.d(tag, "Load Audiobooks: No Server or Token set")
|
||||
cb()
|
||||
return
|
||||
} else if (!serverUrl.startsWith("http")) {
|
||||
Log.e(tag, "Load Audiobooks: Invalid server url $serverUrl")
|
||||
cb()
|
||||
return
|
||||
}
|
||||
|
||||
// First load currently reading
|
||||
loadCategories() {
|
||||
// Then load all
|
||||
var url = "$serverUrl/api/libraries/main/books/all?sort=book.title"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "Load Audiobooks: FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var resJson = JSObject(bodyString)
|
||||
var results = resJson.getJSONArray("results")
|
||||
|
||||
var totalBooks = results.length() - 1
|
||||
for (i in 0..totalBooks) {
|
||||
var abobj = results.get(i)
|
||||
var jsobj = JSObject(abobj.toString())
|
||||
|
||||
jsobj.put("isDownloaded", false)
|
||||
var audiobook = Audiobook(jsobj, serverUrl, token)
|
||||
|
||||
if (audiobook.isMissing || audiobook.isInvalid) {
|
||||
Log.d(tag, "Audiobook ${audiobook.book.title} is missing or invalid")
|
||||
} else if (audiobook.numTracks <= 0) {
|
||||
Log.d(tag, "Audiobook ${audiobook.book.title} has audio tracks")
|
||||
} else {
|
||||
var audiobookExists = audiobooks.find { it.id == audiobook.id }
|
||||
if (audiobookExists == null) {
|
||||
audiobooks.add(audiobook)
|
||||
} else {
|
||||
Log.d(tag, "Audiobook already there from downloaded")
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(tag, "${audiobooks.size} Audiobooks Loaded")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
isLoading = true
|
||||
hasLoaded = true
|
||||
|
||||
localMediaManager.loadLocalAudio()
|
||||
|
||||
// Load downloads from sql db
|
||||
var db = CapacitorDataStorageSqlite(ctx)
|
||||
db.openStore("storage", "downloads", false, "no-encryption", 1)
|
||||
var keyvalues = db.keysvalues()
|
||||
keyvalues.forEach {
|
||||
Log.d(tag, "keyvalue ${it.getString("key")} | ${it.getString("value")}")
|
||||
|
||||
var dlobj = JSObject(it.getString("value"))
|
||||
if (dlobj.has("audiobook")) {
|
||||
var abobj = dlobj.getJSObject("audiobook")!!
|
||||
abobj.put("isDownloaded", true)
|
||||
abobj.put("contentUrl", dlobj.getString("contentUrl", "").toString())
|
||||
abobj.put("filename", dlobj.getString("filename", "").toString())
|
||||
abobj.put("folderUrl", dlobj.getString("folderUrl", "").toString())
|
||||
abobj.put("downloadFolderUrl", dlobj.getString("downloadFolderUrl", "").toString())
|
||||
abobj.put("localCoverUrl", dlobj.getString("coverUrl", "").toString())
|
||||
abobj.put("localCover", dlobj.getString("cover", "").toString())
|
||||
|
||||
var audiobook = Audiobook(abobj, serverUrl, token)
|
||||
audiobooks.add(audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openStream(audiobook:Audiobook, streamListener:OnStreamData) {
|
||||
var url = "$serverUrl/api/books/${audiobook.id}/stream"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var playbackRate = getPlaybackRate()
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var stream = JSObject(bodyString)
|
||||
var streamId = stream.getString("streamId", "").toString()
|
||||
var startTime = stream.getDouble("startTime")
|
||||
var streamUrl = stream.getString("streamUrl", "").toString()
|
||||
|
||||
var startTimeLong = (startTime * 1000).toLong()
|
||||
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", streamId)
|
||||
abStreamDataObj.put("audiobookId", audiobook.id)
|
||||
abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl")
|
||||
abStreamDataObj.put("title", audiobook.book.title)
|
||||
abStreamDataObj.put("author", audiobook.book.authorFL)
|
||||
abStreamDataObj.put("token", token)
|
||||
abStreamDataObj.put("cover", audiobook.getCover())
|
||||
abStreamDataObj.put("duration", audiobook.getDurationLong())
|
||||
abStreamDataObj.put("startTime", startTimeLong)
|
||||
abStreamDataObj.put("playbackSpeed", playbackRate)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", false)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Log.d(tag, "Stream Ready on Main Looper")
|
||||
streamListener.onStreamReady(audiobookStreamData)
|
||||
}
|
||||
|
||||
Log.d(tag, "Init Player Stream")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData {
|
||||
var playbackRate = getPlaybackRate()
|
||||
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", "download")
|
||||
abStreamDataObj.put("audiobookId", audiobook.id)
|
||||
abStreamDataObj.put("contentUrl", audiobook.contentUrl)
|
||||
abStreamDataObj.put("title", audiobook.book.title)
|
||||
abStreamDataObj.put("author", audiobook.book.authorFL)
|
||||
abStreamDataObj.put("token", null)
|
||||
abStreamDataObj.put("cover", audiobook.getCover())
|
||||
abStreamDataObj.put("duration", audiobook.getDurationLong())
|
||||
abStreamDataObj.put("startTime", 0)
|
||||
abStreamDataObj.put("playbackSpeed", playbackRate)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", true)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
return audiobookStreamData
|
||||
}
|
||||
|
||||
fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData {
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", "local")
|
||||
abStreamDataObj.put("audiobookId", local.id)
|
||||
abStreamDataObj.put("contentUrl", local.uri.toString())
|
||||
abStreamDataObj.put("title", local.name)
|
||||
abStreamDataObj.put("author", "")
|
||||
abStreamDataObj.put("token", null)
|
||||
abStreamDataObj.put("cover", local.coverUri)
|
||||
abStreamDataObj.put("duration", local.duration)
|
||||
abStreamDataObj.put("startTime", 0)
|
||||
abStreamDataObj.put("playbackSpeed", 1)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", true)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
return audiobookStreamData
|
||||
}
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
fun searchForAudiobook(query:String):Audiobook? {
|
||||
var closestDistance = 99
|
||||
var closestMatch:Audiobook? = null
|
||||
audiobooks.forEach {
|
||||
var dist = levenshtein(it.book.title, query)
|
||||
Log.d(tag, "LEVENSHTEIN $dist")
|
||||
if (dist < closestDistance) {
|
||||
closestDistance = dist
|
||||
closestMatch = it
|
||||
}
|
||||
}
|
||||
if (closestMatch != null) {
|
||||
Log.d(tag, "Closest Search is ${closestMatch?.book?.title} with distance $closestDistance")
|
||||
if (closestDistance < 2) {
|
||||
return closestMatch
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getFirstAudiobook():Audiobook? {
|
||||
if (audiobooks.isEmpty()) return null
|
||||
return audiobooks[0]
|
||||
}
|
||||
|
||||
fun getFirstLocal(): LocalMediaManager.LocalAudio? {
|
||||
if (localMediaManager.localAudioFiles.isEmpty()) return null
|
||||
return localMediaManager.localAudioFiles[0]
|
||||
}
|
||||
|
||||
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
|
||||
fun getAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
|
||||
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
|
||||
if (audiobooks.isEmpty()) {
|
||||
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
} else {
|
||||
audiobooks.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
}
|
||||
return mediaMetadata
|
||||
}
|
||||
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
|
||||
fun getDownloadedAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
|
||||
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
|
||||
if (audiobooks.isEmpty()) {
|
||||
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
} else {
|
||||
audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } }
|
||||
}
|
||||
return mediaMetadata
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSObject
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
/*
|
||||
* Normal progress sync is handled in webview, but when using android auto webview may not be open.
|
||||
* If webview is not open sync progress every 5s. Webview can be closed at any time so interval is always set.
|
||||
*/
|
||||
class AudiobookProgressSyncer constructor(playerNotificationService:PlayerNotificationService, client: OkHttpClient) {
|
||||
private val tag = "AudiobookProgressSync"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
private val client:OkHttpClient = client
|
||||
|
||||
private var listeningTimerTask: TimerTask? = null
|
||||
var listeningTimerRunning:Boolean = false
|
||||
|
||||
private var webviewOpenOnStart:Boolean = false
|
||||
private var listeningBookTitle:String? = ""
|
||||
private var listeningBookIsLocal:Boolean = false
|
||||
private var listeningBookId:String? = ""
|
||||
private var listeningStreamId:String? = ""
|
||||
|
||||
private var lastPlaybackTime:Long = 0
|
||||
private var lastUpdateTime:Long = 0
|
||||
|
||||
fun start() {
|
||||
if (listeningTimerRunning) {
|
||||
Log.d(tag, "start: Timer already running for $listeningBookTitle")
|
||||
if (playerNotificationService.getCurrentBookTitle() != listeningBookTitle) {
|
||||
Log.d(tag, "start: Changed audiobook stream - resetting timer")
|
||||
listeningTimerTask?.cancel()
|
||||
}
|
||||
}
|
||||
listeningTimerRunning = true
|
||||
|
||||
webviewOpenOnStart = playerNotificationService.getIsWebviewOpen()
|
||||
listeningBookTitle = playerNotificationService.getCurrentBookTitle()
|
||||
listeningBookIsLocal = playerNotificationService.getCurrentBookIsLocal()
|
||||
listeningBookId = playerNotificationService.getCurrentBookId()
|
||||
listeningStreamId = playerNotificationService.getCurrentStreamId()
|
||||
|
||||
lastPlaybackTime = playerNotificationService.getCurrentTime()
|
||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
// Webview was closed while android auto is open - switch to native sync
|
||||
if (!playerNotificationService.getIsWebviewOpen() && webviewOpenOnStart) {
|
||||
Log.d(tag, "Listening Timer: webview closed Switching to native sync tracking")
|
||||
webviewOpenOnStart = false
|
||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
||||
}
|
||||
if (!webviewOpenOnStart && playerNotificationService.currentPlayer.isPlaying) {
|
||||
sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!listeningTimerRunning) return
|
||||
Log.d(tag, "stop: Stopping listening for $listeningBookTitle")
|
||||
|
||||
if (!webviewOpenOnStart) {
|
||||
sync()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
listeningBookTitle = ""
|
||||
listeningBookId = ""
|
||||
listeningBookIsLocal = false
|
||||
listeningStreamId = ""
|
||||
}
|
||||
|
||||
fun sync() {
|
||||
var currTime = System.currentTimeMillis() / 1000L
|
||||
var elapsed = currTime - lastUpdateTime
|
||||
lastUpdateTime = currTime
|
||||
|
||||
if (!listeningBookIsLocal) {
|
||||
Log.d(tag, "ListeningTimer: Sending sync data to server: elapsed $elapsed | $listeningStreamId | $listeningBookId")
|
||||
|
||||
// Send sync data only for streaming books
|
||||
var syncData: JSObject = JSObject()
|
||||
syncData.put("timeListened", elapsed)
|
||||
syncData.put("currentTime", playerNotificationService.getCurrentTime())
|
||||
syncData.put("streamId", listeningStreamId)
|
||||
syncData.put("audiobookId", listeningBookId)
|
||||
sendStreamSyncData(syncData) {
|
||||
Log.d(tag, "Stream sync done")
|
||||
}
|
||||
} else if (listeningStreamId == "download") {
|
||||
// TODO: Save downloaded audiobook progress & send to server if connected
|
||||
Log.d(tag, "ListeningTimer: Is listening download")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendStreamSyncData(payload:JSObject, cb: (() -> Unit)) {
|
||||
var serverUrl = playerNotificationService.getServerUrl()
|
||||
var token = playerNotificationService.getUserToken()
|
||||
|
||||
if (serverUrl == "" || token == "") {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "Sync Stream $serverUrl | $token")
|
||||
var url = "$serverUrl/api/syncStream"
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSObject
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.lang.Exception
|
||||
|
||||
class AudiobookStreamData {
|
||||
var id:String = "unset"
|
||||
var audiobookId:String = ""
|
||||
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 playbackSpeed:Float = 1f
|
||||
var duration:Long = 0
|
||||
var tracks:MutableList<String> = mutableListOf()
|
||||
|
||||
var isLocal:Boolean = false
|
||||
var contentUrl:String = ""
|
||||
|
||||
var hasPlayerLoaded:Boolean = false
|
||||
|
||||
var playlistUri:Uri = Uri.EMPTY
|
||||
var coverUri:Uri = Uri.EMPTY
|
||||
var contentUri:Uri = Uri.EMPTY // For Local only
|
||||
|
||||
constructor(jsondata:JSObject) {
|
||||
id = jsondata.getString("id", "unset").toString()
|
||||
audiobookId = jsondata.getString("audiobookId", "").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
|
||||
|
||||
if (jsondata.has("startTime")) {
|
||||
startTime = jsondata.getString("startTime", "0")!!.toLong()
|
||||
}
|
||||
|
||||
if (jsondata.has("duration")) {
|
||||
duration = jsondata.getString("duration", "0")!!.toLong()
|
||||
}
|
||||
|
||||
if (jsondata.has("playbackSpeed")) {
|
||||
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
|
||||
}
|
||||
|
||||
|
||||
// Local data
|
||||
isLocal = jsondata.getBoolean("isLocal", false) == true
|
||||
contentUrl = jsondata.getString("contentUrl", "").toString()
|
||||
|
||||
if (playlistUrl != "") {
|
||||
playlistUri = Uri.parse(playlistUrl)
|
||||
}
|
||||
if (cover != "" && cover != null) {
|
||||
coverUri = Uri.parse(cover)
|
||||
} else {
|
||||
coverUri = Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
cover = coverUri.toString()
|
||||
}
|
||||
|
||||
if (contentUrl != "") {
|
||||
contentUri = Uri.parse(contentUrl)
|
||||
}
|
||||
|
||||
// Tracks for cast
|
||||
try {
|
||||
var tracksTest = jsondata.getJSONArray("tracks")
|
||||
Log.d("AudiobookStreamData", "Load tracks from json array ${tracksTest.length()}")
|
||||
for (i in 0 until tracksTest.length()) {
|
||||
var track = tracksTest.get(i)
|
||||
Log.d("AudiobookStreamData", "Extracting track $track")
|
||||
tracks.add(track as String)
|
||||
}
|
||||
} catch(e:Exception) {
|
||||
Log.d("AudiobookStreamData", "No tracks found $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCover() {
|
||||
coverUri = Uri.EMPTY
|
||||
cover = ""
|
||||
}
|
||||
|
||||
fun getMediaMetadataCompat():MediaMetadataCompat {
|
||||
var metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, series)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
|
||||
// if (cover != "") {
|
||||
// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, cover)
|
||||
// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, cover)
|
||||
// }
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
fun getMediaMetadata():MediaMetadata {
|
||||
var metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(author)
|
||||
.setAlbumArtist(author)
|
||||
.setSubtitle(author)
|
||||
|
||||
// if (coverUri != Uri.EMPTY) {
|
||||
// metadataBuilder.setArtworkUri(coverUri)
|
||||
// }
|
||||
if (playlistUri != Uri.EMPTY) {
|
||||
metadataBuilder.setMediaUri(playlistUri)
|
||||
}
|
||||
if (contentUri != Uri.EMPTY) {
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
}
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
fun getMimeType():String {
|
||||
return if (isLocal) {
|
||||
MimeTypes.BASE_TYPE_AUDIO
|
||||
} else {
|
||||
MimeTypes.APPLICATION_M3U8
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaUri():Uri {
|
||||
return if (isLocal) {
|
||||
contentUri
|
||||
} else {
|
||||
Uri.parse("$playlistUrl?token=$token")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCastQueue():ArrayList<MediaItem> {
|
||||
var mediaQueue: java.util.ArrayList<MediaItem> = java.util.ArrayList<MediaItem>()
|
||||
|
||||
for (i in 0 until tracks.size) {
|
||||
var track = tracks[i]
|
||||
var metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(author)
|
||||
.setAlbumArtist(author)
|
||||
.setSubtitle(author)
|
||||
.setTrackNumber(i + 1)
|
||||
|
||||
if (coverUri != Uri.EMPTY) {
|
||||
metadataBuilder.setArtworkUri(coverUri)
|
||||
}
|
||||
|
||||
var mimeType = MimeTypes.BASE_TYPE_AUDIO
|
||||
|
||||
var mediaMetadata = metadataBuilder.build()
|
||||
var mediaItem = MediaItem.Builder().setUri(Uri.parse(track)).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||
mediaQueue.add(mediaItem)
|
||||
}
|
||||
|
||||
return mediaQueue
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import com.getcapacitor.JSObject
|
||||
|
||||
class Book {
|
||||
var title:String
|
||||
var subtitle:String
|
||||
var author:String
|
||||
var authorFL:String
|
||||
var narrator:String
|
||||
var series:String
|
||||
var volumeNumber:String
|
||||
var publisher:String
|
||||
var description:String
|
||||
var publishYear:String
|
||||
var language:String
|
||||
var cover:String
|
||||
var coverFullPath:String
|
||||
var genres:String
|
||||
var lastUpdate:Long
|
||||
|
||||
constructor(jsobj: JSObject) {
|
||||
title = jsobj.getString("title", "").toString()
|
||||
subtitle = jsobj.getString("subtitle", "").toString()
|
||||
author = jsobj.getString("author", "").toString()
|
||||
authorFL = jsobj.getString("authorFL", "").toString()
|
||||
narrator = jsobj.getString("narrator", "").toString()
|
||||
series = jsobj.getString("series", "").toString()
|
||||
volumeNumber = jsobj.getString("volumeNumber", "").toString()
|
||||
publisher = jsobj.getString("publisher", "").toString()
|
||||
description = jsobj.getString("description", "").toString()
|
||||
publishYear = jsobj.getString("publishYear", "").toString()
|
||||
language = jsobj.getString("language", "").toString()
|
||||
cover = jsobj.getString("cover", "").toString()
|
||||
coverFullPath = jsobj.getString("coverFullPath", "").toString()
|
||||
genres = jsobj.getString("genres", "").toString()
|
||||
lastUpdate = jsobj.getLong("lastUpdate")
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
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
|
||||
|
||||
|
||||
class BrowseTree(
|
||||
val context: Context,
|
||||
audiobooksInProgress: List<Audiobook>,
|
||||
audiobookMetadata: List<MediaMetadataCompat>,
|
||||
downloadedMetadata: List<MediaMetadataCompat>
|
||||
) {
|
||||
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, "Reading")
|
||||
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, "Audiobooks")
|
||||
|
||||
var resource = getUriToDrawable(context, R.drawable.exo_icon_books).toString()
|
||||
Log.d("BrowseTree", "RESOURCE $resource")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, resource)
|
||||
}.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()
|
||||
|
||||
// val localsMetadata = MediaMetadataCompat.Builder().apply {
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples")
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||
// }.build()
|
||||
|
||||
if (audiobooksInProgress.isNotEmpty()) {
|
||||
rootList += continueReadingMetadata
|
||||
}
|
||||
rootList += allMetadata
|
||||
rootList += downloadsMetadata
|
||||
// rootList += localsMetadata
|
||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||
|
||||
audiobooksInProgress.forEach { audiobook ->
|
||||
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
|
||||
children += audiobook.toMediaMetadata()
|
||||
mediaIdToChildren[CONTINUE_ROOT] = children
|
||||
}
|
||||
|
||||
audiobookMetadata.forEach {
|
||||
val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
|
||||
allChildren += it
|
||||
mediaIdToChildren[ALL_ROOT] = allChildren
|
||||
}
|
||||
|
||||
downloadedMetadata.forEach {
|
||||
val allChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
allChildren += it
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = allChildren
|
||||
}
|
||||
|
||||
// localAudio.forEach { local ->
|
||||
// val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf()
|
||||
// localChildren += local.toMediaMetadata()
|
||||
// mediaIdToChildren[LOCAL_ROOT] = localChildren
|
||||
// }
|
||||
// Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}")
|
||||
}
|
||||
|
||||
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__"
|
||||
//const val LOCAL_ROOT = "__LOCAL__"
|
||||
@@ -11,8 +11,10 @@ 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(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).setCastMediaOptions(
|
||||
.setReceiverApplicationId(appId).setCastMediaOptions(
|
||||
CastMediaOptions.Builder()
|
||||
// We manage the media session and the notifications ourselves.
|
||||
.setMediaSessionEnabled(false)
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.AnyRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import com.bumptech.glide.Glide
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class LocalMediaManager {
|
||||
private var ctx: Context
|
||||
val tag = "LocalAudioManager"
|
||||
|
||||
constructor(ctx: Context) {
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
data class LocalAudio(val uri: Uri,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val duration: Int,
|
||||
val size: Int,
|
||||
val coverUri: Uri?
|
||||
) {
|
||||
|
||||
fun toMediaMetadata(): MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||
|
||||
if (coverUri != null) {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
val localAudioFiles = mutableListOf<LocalAudio>()
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
|
||||
fun loadLocalAudio() {
|
||||
localAudioFiles.clear()
|
||||
|
||||
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Anthem/AnthemSample.m4b"), "anthem_sample", "Anthem", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
|
||||
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Legend of Sleepy Hollow/LegendOfSleepyHollowSample.m4b"), "sleepy_hollow", "Legend of Sleepy Hollow", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
|
||||
|
||||
// TODO: No longer reading in local audio files - just use samples
|
||||
// if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
// Log.e(tag, "Permission not granted to read from external storage")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// val collection =
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// MediaStore.Audio.Media.getContentUri(
|
||||
// MediaStore.VOLUME_EXTERNAL
|
||||
// )
|
||||
// } else {
|
||||
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
// }
|
||||
//
|
||||
// val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE)
|
||||
// val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null)
|
||||
//
|
||||
// audioCursor?.use { cursor ->
|
||||
// // Cache column indices.
|
||||
// val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||
// val nameColumn =
|
||||
// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
|
||||
// val durationColumn =
|
||||
// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||
// val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
|
||||
//
|
||||
// while (cursor.moveToNext()) {
|
||||
// // Get values of columns for a given video.
|
||||
// val id = cursor.getLong(idColumn)
|
||||
// val name = cursor.getString(nameColumn)
|
||||
// val duration = cursor.getInt(durationColumn)
|
||||
// val size = cursor.getInt(sizeColumn)
|
||||
//
|
||||
// val contentUri: Uri = ContentUris.withAppendedId(
|
||||
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
// id
|
||||
// )
|
||||
// Log.d(tag, "Found local audio file $name")
|
||||
// localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Log.d(tag, "${localAudioFiles.size} Local Audio Files found")
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.*
|
||||
import android.os.*
|
||||
import android.Manifest
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.audiobookshelf.app.data.AbsDatabase
|
||||
import com.audiobookshelf.app.data.DbManager
|
||||
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 io.paperdb.Paper
|
||||
|
||||
|
||||
class MainActivity : BridgeActivity() {
|
||||
@@ -17,47 +30,58 @@ class MainActivity : BridgeActivity() {
|
||||
private lateinit var mConnection : ServiceConnection
|
||||
|
||||
lateinit var pluginCallback : () -> Unit
|
||||
lateinit var downloaderCallback : (String, Long) -> Unit
|
||||
|
||||
val storageHelper = SimpleStorageHelper(this)
|
||||
val storage = SimpleStorage(this)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val REQUEST_PERMISSIONS = 1
|
||||
var PERMISSIONS_ALL = arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
Log.d(tag, "onCreate")
|
||||
registerPlugin(MyNativeAudio::class.java)
|
||||
registerPlugin(AudioDownloader::class.java)
|
||||
registerPlugin(StorageManager::class.java)
|
||||
|
||||
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
|
||||
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
|
||||
DbManager.initialize(applicationContext)
|
||||
|
||||
// Grant full storage access for testing
|
||||
// var ss = SimpleStorage(this)
|
||||
// ss.requestFullStorageAccess()
|
||||
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
PERMISSIONS_ALL,
|
||||
REQUEST_PERMISSIONS)
|
||||
}
|
||||
registerReceiver(broadcastReceiver, filter)
|
||||
|
||||
registerPlugin(AbsAudioPlayer::class.java)
|
||||
registerPlugin(AbsDownloader::class.java)
|
||||
registerPlugin(AbsFileSystem::class.java)
|
||||
registerPlugin(AbsDatabase::class.java)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unregisterReceiver(broadcastReceiver)
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
super.onPostCreate(savedInstanceState)
|
||||
Log.d(tag, "onPostCreate MainActivity")
|
||||
|
||||
mConnection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
@@ -68,36 +92,33 @@ class MainActivity : BridgeActivity() {
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
Log.d(tag, "Service Connected $name")
|
||||
|
||||
|
||||
mBounded = true
|
||||
val mLocalBinder = service as PlayerNotificationService.LocalBinder
|
||||
foregroundService = mLocalBinder.getService()
|
||||
|
||||
// Let MyNativeAudio know foreground service is ready and setup event listener
|
||||
// Let NativeAudio know foreground service is ready and setup event listener
|
||||
if (pluginCallback != null) {
|
||||
pluginCallback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val startIntent = Intent(this, PlayerNotificationService::class.java)
|
||||
bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE);
|
||||
Intent(this, PlayerNotificationService::class.java).also { intent ->
|
||||
Log.d(tag, "Binding PlayerNotificationService")
|
||||
bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun stopMyService() {
|
||||
if (mBounded) {
|
||||
mConnection?.let { unbindService(it) };
|
||||
mConnection.let { unbindService(it) };
|
||||
mBounded = false;
|
||||
}
|
||||
val stopIntent = Intent(this, PlayerNotificationService::class.java)
|
||||
stopService(stopIntent)
|
||||
}
|
||||
|
||||
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
|
||||
downloaderCallback = cb
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
@@ -122,9 +143,4 @@ class MainActivity : BridgeActivity() {
|
||||
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
||||
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
// override fun onUserInteraction() {
|
||||
// super.onUserInteraction()
|
||||
// Log.d(tag, "USER INTERACTION")
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.capacitorjs.plugins.app.AppPlugin
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import org.json.JSONObject
|
||||
|
||||
@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.setBridge(bridge)
|
||||
|
||||
playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener {
|
||||
override fun onPlayingUpdate(isPlaying: Boolean) {
|
||||
emit("onPlayingUpdate", isPlaying)
|
||||
}
|
||||
|
||||
override fun onMetadata(metadata: JSObject) {
|
||||
notifyListeners("onMetadata", 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(sleepTimerEndTime: Long) {
|
||||
emit("onSleepTimerSet", sleepTimerEndTime)
|
||||
}
|
||||
})
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
var jsobj = JSObject()
|
||||
|
||||
var audiobookStreamData:AudiobookStreamData = AudiobookStreamData(call.data)
|
||||
if (audiobookStreamData.playlistUrl == "" && audiobookStreamData.contentUrl == "") {
|
||||
Log.e(tag, "Invalid URL for init audio player")
|
||||
|
||||
jsobj.put("success", false)
|
||||
return call.resolve(jsobj)
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.initPlayer(audiobookStreamData)
|
||||
jsobj.put("success", true)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getCurrentTime(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var currentTime = playerNotificationService.getCurrentTime()
|
||||
var bufferedTime = playerNotificationService.getBufferedTime()
|
||||
val ret = JSObject()
|
||||
ret.put("value", currentTime)
|
||||
ret.put("bufferedTime", bufferedTime)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getStreamSyncData(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var isPlaying = playerNotificationService.getPlayStatus()
|
||||
var lastPauseTime = playerNotificationService.getTheLastPauseTime()
|
||||
Log.d(tag, "Get Last Pause Time $lastPauseTime")
|
||||
var currentTime = playerNotificationService.getCurrentTime()
|
||||
//if (!isPlaying) currentTime -= playerNotificationService.calcPauseSeekBackTime()
|
||||
var id = playerNotificationService.getCurrentAudiobookId()
|
||||
Log.d(tag, "Get Current id $id")
|
||||
var duration = playerNotificationService.getDuration()
|
||||
Log.d(tag, "Get duration $duration")
|
||||
val ret = JSObject()
|
||||
ret.put("lastPauseTime", lastPauseTime)
|
||||
ret.put("currentTime", currentTime)
|
||||
ret.put("isPlaying", isPlaying)
|
||||
ret.put("id", id)
|
||||
ret.put("duration", duration)
|
||||
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 seekForward(call: PluginCall) {
|
||||
var amount:Long = call.getString("amount", "0")!!.toLong()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekForward(amount)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekBackward(call: PluginCall) {
|
||||
var amount:Long = call.getString("amount", "0")!!.toLong()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekBackward(amount)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPlaybackSpeed(call: PluginCall) {
|
||||
var playbackSpeed:Float = call.getFloat("speed", 1.0f)!!
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.setPlaybackSpeed(playbackSpeed)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun terminateStream(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.terminateStream()
|
||||
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) {
|
||||
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
|
||||
|
||||
playerNotificationService.castManager.requestSession(mainActivity, 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.hardware.SensorManager
|
||||
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 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 <= 0) return 0
|
||||
var sleepTimeRemaining = sleepTimerEndTime - getCurrentTime()
|
||||
return ((sleepTimeRemaining / 1000).toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
fun getIsSleepTimerRunning():Boolean {
|
||||
return sleepTimerRunning
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 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
|
||||
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
|
||||
} else {
|
||||
sleepTimerEndTime = currentTime + time
|
||||
sleepTimerExtensionTime = time
|
||||
}
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
}
|
||||
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
|
||||
sleepTimerRunning = true
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (getIsPlaying()) {
|
||||
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
Log.d(tag, "Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
|
||||
playerNotificationService.listener?.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.listener?.onSleepTimerSet(0)
|
||||
}
|
||||
|
||||
private fun extendSleepTime() {
|
||||
if (!sleepTimerRunning) return
|
||||
setVolume(1F)
|
||||
sleepTimerEndTime += sleepTimerExtensionTime
|
||||
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
|
||||
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
|
||||
var newSleepEndTime = sleepTimerEndTime + time
|
||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||
getDuration()
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
setVolume(1F)
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Decrease Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
var newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
setVolume(1F)
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
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.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "StorageManager")
|
||||
class StorageManager : Plugin() {
|
||||
private val TAG = "StorageManager"
|
||||
|
||||
lateinit var mainActivity:MainActivity
|
||||
|
||||
data class MediaFile(val uri: Uri, val name: String, val simplePath: String, val size: Long, val type: String, val isAudio: Boolean) {
|
||||
fun toJSObject() : JSObject {
|
||||
var obj = JSObject()
|
||||
obj.put("uri", this.uri)
|
||||
obj.put("name", this.name)
|
||||
obj.put("simplePath", this.simplePath)
|
||||
obj.put("size", this.size)
|
||||
obj.put("type", this.type)
|
||||
obj.put("isAudio", this.isAudio)
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaFolder(val uri: Uri, val name: String, val simplePath: String, val mediaFiles:List<MediaFile>) {
|
||||
fun toJSObject() : JSObject {
|
||||
var obj = JSObject()
|
||||
obj.put("uri", this.uri)
|
||||
obj.put("name", this.name)
|
||||
obj.put("simplePath", this.simplePath)
|
||||
obj.put("files", this.mediaFiles.map { it.toJSObject() })
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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 storageId = folder.getStorageId(activity)
|
||||
var storageType = folder.getStorageType(activity)
|
||||
var simplePath = folder.getSimplePath(activity)
|
||||
var basePath = folder.getBasePath(activity)
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("uri", folder.uri)
|
||||
jsobj.put("absolutePath", absolutePath)
|
||||
jsobj.put("storageId", storageId)
|
||||
jsobj.put("storageType", storageType)
|
||||
jsobj.put("simplePath", simplePath)
|
||||
jsobj.put("basePath", basePath)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
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 = false
|
||||
|
||||
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||
res = SimpleStorage.hasStoragePermission(context)
|
||||
Log.d(TAG, "Check Storage Access $res")
|
||||
} else {
|
||||
Log.d(TAG, "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 searchFolder(call: PluginCall) {
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Searching folder $folderUrl")
|
||||
|
||||
var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(TAG, "Folder Doc File Invalid $folderUrl")
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("folders", JSArray())
|
||||
jsobj.put("files", JSArray())
|
||||
call.resolve(jsobj)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}")
|
||||
|
||||
var mediaFolders = mutableListOf<MediaFolder>()
|
||||
var foldersFound = df.search(false, DocumentFileType.FOLDER)
|
||||
|
||||
foldersFound.forEach {
|
||||
Log.d(TAG, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri}")
|
||||
var folderName = it.name ?: ""
|
||||
var mediaFiles = mutableListOf<MediaFile>()
|
||||
|
||||
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
filesInFolder.forEach { it2 ->
|
||||
var mimeType = it2?.mimeType ?: ""
|
||||
var filename = it2?.name ?: ""
|
||||
var isAudio = mimeType.startsWith("audio")
|
||||
Log.d(TAG, "Found $mimeType file $filename in folder $folderName")
|
||||
var imageFile = MediaFile(it2.uri, filename, it2.getSimplePath(context), it2.length(), mimeType, isAudio)
|
||||
mediaFiles.add(imageFile)
|
||||
}
|
||||
if (mediaFiles.size > 0) {
|
||||
mediaFolders.add(MediaFolder(it.uri, folderName, it.getSimplePath(context), mediaFiles))
|
||||
}
|
||||
}
|
||||
|
||||
// Files in root dir
|
||||
var rootMediaFiles = mutableListOf<MediaFile>()
|
||||
var mediaFilesFound:List<DocumentFile> = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
mediaFilesFound.forEach {
|
||||
Log.d(TAG, "Folder Root File Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri} | ${it.mimeType}")
|
||||
var mimeType = it?.mimeType ?: ""
|
||||
var filename = it?.name ?: ""
|
||||
var isAudio = mimeType.startsWith("audio")
|
||||
Log.d(TAG, "Found $mimeType file $filename in root folder")
|
||||
var imageFile = MediaFile(it.uri, filename, it.getSimplePath(context), it.length(), mimeType, isAudio)
|
||||
rootMediaFiles.add(imageFile)
|
||||
}
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("folders", mediaFolders.map{ it.toJSObject() })
|
||||
jsobj.put("files", rootMediaFiles.map{ it.toJSObject() })
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
|
||||
@PluginMethod
|
||||
fun delete(call: PluginCall) {
|
||||
var url = call.data.getString("url", "").toString()
|
||||
var coverUrl = call.data.getString("coverUrl", "").toString()
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
|
||||
if (folderUrl != "") {
|
||||
Log.d(TAG, "CALLED DELETE FOLDER: $folderUrl")
|
||||
var folder = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
|
||||
var success = folder?.deleteRecursively(context)
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("success", success)
|
||||
call.resolve()
|
||||
} else {
|
||||
// Older audiobooks did not store a folder url, use cover and audiobook url
|
||||
var abExists = checkUriExists(Uri.parse(url))
|
||||
if (abExists) {
|
||||
var abfile = DocumentFileCompat.fromUri(context, Uri.parse(url))
|
||||
abfile?.delete()
|
||||
}
|
||||
|
||||
var coverExists = checkUriExists(Uri.parse(coverUrl))
|
||||
if (coverExists) {
|
||||
var coverfile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
|
||||
coverfile?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun checkUriExists(uri: Uri?): Boolean {
|
||||
if (uri == null) return false
|
||||
val resolver = context.contentResolver
|
||||
var cursor: Cursor? = null
|
||||
return try {
|
||||
cursor = resolver.query(uri, null, null, null, null)
|
||||
//cursor null: content Uri was invalid or some other error occurred
|
||||
//cursor.moveToFirst() false: Uri was ok but no entry found.
|
||||
(cursor != null && cursor.moveToFirst())
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
} finally {
|
||||
try {
|
||||
cursor?.close()
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@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:Long,
|
||||
val end:Long,
|
||||
val tags:AudioProbeChapterTags?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
val startS = start / 1000.0
|
||||
val endS = end / 1000.0
|
||||
val 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() }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
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>?,
|
||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||
) : 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 checkHasTracks():Boolean {
|
||||
return if (mediaType == "podcast") {
|
||||
((media as Podcast).numEpisodes ?: 0) > 0
|
||||
} else {
|
||||
((media as Book).numTracks ?: 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
var numEpisodes:Int?
|
||||
) : MediaType(metadata, coverPath) {
|
||||
@JsonIgnore
|
||||
override fun getAudioTracks():List<AudioTrack> {
|
||||
val 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) {
|
||||
val newEpisode = PodcastEpisode("local_ep_" + 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) {
|
||||
val 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):PodcastEpisode {
|
||||
val 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++
|
||||
}
|
||||
return newEpisode
|
||||
}
|
||||
|
||||
// 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, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@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?,
|
||||
var numTracks:Int?
|
||||
) : 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, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(libraryItem:LibraryItemWrapper): MediaMetadataCompat {
|
||||
var coverUri:Uri = Uri.EMPTY
|
||||
val podcast = if(libraryItem is LocalLibraryItem) {
|
||||
coverUri = libraryItem.getCoverUri()
|
||||
libraryItem.media as Podcast
|
||||
} else {
|
||||
coverUri = (libraryItem as LibraryItem).getCoverUri()
|
||||
(libraryItem as LibraryItem).media as Podcast
|
||||
}
|
||||
|
||||
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, podcast.metadata.getAuthorDisplayName())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@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
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaMetadata(): MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@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?
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val startMs get() = (start * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endMs get() = (end * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class MediaProgress(
|
||||
var id:String,
|
||||
var libraryItemId:String,
|
||||
var episodeId:String?,
|
||||
var duration:Double, // seconds
|
||||
var progress:Double, // 0 to 1
|
||||
var currentTime:Double,
|
||||
var isFinished:Boolean,
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
)
|
||||
|
||||
// Helper class
|
||||
data class LibraryItemWithEpisode(
|
||||
var libraryItemWrapper:LibraryItemWrapper,
|
||||
var episode:PodcastEpisode
|
||||
)
|
||||
@@ -0,0 +1,241 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import io.paperdb.Paper
|
||||
import java.io.File
|
||||
|
||||
class DbManager {
|
||||
val tag = "DbManager"
|
||||
|
||||
companion object {
|
||||
var isDbInitialized = false
|
||||
|
||||
fun initialize(ctx: Context) {
|
||||
if (isDbInitialized) return
|
||||
Paper.init(ctx)
|
||||
isDbInitialized = true
|
||||
Log.i("DbManager", "Initialized Paper db")
|
||||
}
|
||||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
|
||||
}
|
||||
fun saveDeviceData(deviceData:DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
||||
Paper.book("localLibraryItems").allKeys.forEach {
|
||||
val 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> {
|
||||
val 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 getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
|
||||
var podcastEpisode:PodcastEpisode? = null
|
||||
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
podcastEpisode != null
|
||||
}
|
||||
return if (localLibraryItem != null) {
|
||||
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
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> {
|
||||
val 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) {
|
||||
val 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> {
|
||||
val 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> {
|
||||
val 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() {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
|
||||
localLibraryItems.forEach { lli ->
|
||||
var hasUpates = false
|
||||
|
||||
// Check local files
|
||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||
|
||||
val 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) {
|
||||
val 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 {
|
||||
val 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 {
|
||||
val 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() {
|
||||
val localMediaProgress = getAllLocalMediaProgress()
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
localMediaProgress.forEach {
|
||||
val 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 {
|
||||
val podcast = matchingLLI.media as Podcast
|
||||
val 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
var name:String,
|
||||
var address:String,
|
||||
var userId:String,
|
||||
var username:String,
|
||||
var token:String,
|
||||
var customHeaders:Map<String, String>?
|
||||
)
|
||||
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
fun default():DeviceSettings {
|
||||
return DeviceSettings(false, 10, 10)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
||||
}
|
||||
|
||||
data class DeviceData(
|
||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId:String?,
|
||||
var currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
|
||||
var deviceSettings:DeviceSettings?
|
||||
) {
|
||||
@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 {
|
||||
if (mimeType == "application/octet-stream") return true
|
||||
if (mimeType == "video/mp4") return true
|
||||
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()
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceInfo(
|
||||
var manufacturer:String,
|
||||
var model:String,
|
||||
var brand:String,
|
||||
var sdkVersion:Int,
|
||||
var clientVersion: String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class PlayItemRequestPayload(
|
||||
var mediaPlayer:String,
|
||||
var forceDirectPlay:Boolean,
|
||||
var forceTranscode:Boolean,
|
||||
var deviceInfo:DeviceInfo
|
||||
)
|
||||
@@ -0,0 +1,15 @@
|
||||
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,
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
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
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
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
|
||||
val audioTracks = media.getAudioTracks()
|
||||
audioTracks.forEach{ total += it.duration }
|
||||
return total
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
||||
localFiles = _localFiles
|
||||
media.setAudioTracks(audioTracks)
|
||||
|
||||
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 {
|
||||
val localEpisodeId = episode?.id
|
||||
val sessionId = "play_local_${UUID.randomUUID()}"
|
||||
|
||||
// Get current progress for local media
|
||||
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
||||
val currentTime = mediaProgress?.currentTime ?: 0.0
|
||||
|
||||
// TODO: Clean up add mediaType methods for displayTitle and displayAuthor
|
||||
val mediaMetadata = media.metadata
|
||||
val chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||
val 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}")
|
||||
}
|
||||
|
||||
val 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(ctx: Context): MediaMetadataCompat {
|
||||
val coverUri = getCoverUri()
|
||||
|
||||
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, coverUri.toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, authorName)
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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 {
|
||||
val mediaMetadata = getMediaMetadata()
|
||||
if (mediaType == "book") {
|
||||
val chapters = getAudiobookChapters()
|
||||
val book = Book(mediaMetadata as BookMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), chapters,audioTracks,getTotalSize(),getDuration(),audioTracks.size)
|
||||
return LocalLibraryItem(id, folderId, basePath,absolutePath, contentUrl, false,mediaType, book, localFiles, coverContentUrl, coverAbsolutePath,true,null,null,null,null)
|
||||
} else {
|
||||
val podcast = Podcast(mediaMetadata as PodcastMetadata, coverAbsolutePath, mutableListOf(), mutableListOf(), false, 0)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||
isFinished = serverMediaProgress.isFinished
|
||||
progress = serverMediaProgress.progress
|
||||
currentTime = serverMediaProgress.currentTime
|
||||
duration = serverMediaProgress.duration
|
||||
lastUpdate = serverMediaProgress.lastUpdate
|
||||
finishedAt = serverMediaProgress.finishedAt
|
||||
startedAt = serverMediaProgress.startedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
enum class PlayerState {
|
||||
IDLE, BUFFERING, READY, ENDED
|
||||
}
|
||||
|
||||
data class PlaybackMetadata(
|
||||
val duration:Double,
|
||||
val currentTime:Double,
|
||||
val playerState:PlayerState
|
||||
)
|
||||
@@ -0,0 +1,212 @@
|
||||
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.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
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 totalDurationMs get() = (getTotalDuration() * 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()
|
||||
@get:JsonIgnore
|
||||
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackIndex():Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return audioTracks.size - 1
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getChapterForTime(time:Long):BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time >= it.startMs && it.endMs > time}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackTimeMs():Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
val time = currentTime - currentTrack.startOffset
|
||||
return (time * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getTrackStartOffsetMs(index:Int):Long {
|
||||
val 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 {
|
||||
val 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 {
|
||||
val metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(displayTitle)
|
||||
.setDisplayTitle(displayTitle)
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
|
||||
val contentUri = this.getContentUri(audioTrack)
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaItems():List<MediaItem> {
|
||||
val mediaItems:MutableList<MediaItem> = mutableListOf()
|
||||
|
||||
for (audioTrack in audioTracks) {
|
||||
val mediaMetadata = this.getExoMediaMetadata(audioTrack)
|
||||
val mediaUri = this.getContentUri(audioTrack)
|
||||
val mimeType = audioTrack.mimeType
|
||||
|
||||
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
||||
val 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 {
|
||||
val 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 {
|
||||
val castMetadata = getCastMediaMetadata(audioTrack)
|
||||
|
||||
val mediaUri = getContentUri(audioTrack)
|
||||
|
||||
val 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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.URL_SAFE or android.util.Base64.NO_WRAP)
|
||||
}
|
||||
|
||||
fun getServerConnectionConfig(id:String?):ServerConnectionConfig? {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
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())
|
||||
|
||||
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
|
||||
|
||||
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}")
|
||||
}
|
||||
}
|
||||
|
||||
val 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
|
||||
val 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}")
|
||||
val 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}")
|
||||
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||
|
||||
val 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 {
|
||||
val itemFolderName = itemFolder.name ?: ""
|
||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
|
||||
val existingLocalFiles = existingItem?.localFiles ?: mutableListOf()
|
||||
val existingAudioTracks = existingItem?.media?.getAudioTracks() ?: mutableListOf()
|
||||
var isNewOrUpdated = existingItem == null
|
||||
|
||||
val audioTracks = mutableListOf<AudioTrack>()
|
||||
val localFiles = mutableListOf<LocalFile>()
|
||||
var index = 1
|
||||
var startOffset = 0.0
|
||||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
|
||||
val 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 ->
|
||||
val mimeType = file.mimeType ?: ""
|
||||
val filename = file.name ?: ""
|
||||
val isAudio = mimeType.startsWith("audio") || mimeType == "video/mp4"
|
||||
Log.d(tag, "Found $mimeType file $filename in folder $itemFolderName")
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(file.id)
|
||||
|
||||
val 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
|
||||
|
||||
val 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
|
||||
val 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
|
||||
val track = AudioTrack(index, startOffset, audioProbeResult?.duration ?: 0.0, filename, localFile.contentUrl, mimeType, null, true, localFileId, audioProbeResult, null)
|
||||
audioTrackToAdd = track
|
||||
}
|
||||
|
||||
startOffset += audioProbeResult?.duration ?: 0.0
|
||||
isNewOrUpdated = true
|
||||
} else {
|
||||
audioTrackToAdd = existingAudioTrack
|
||||
}
|
||||
|
||||
startOffset += audioTrackToAdd.duration
|
||||
index++
|
||||
audioTracks.add(audioTrackToAdd)
|
||||
} else {
|
||||
val 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")
|
||||
val localMediaItem = LocalMediaItem(itemId, itemFolderName, localFolder.mediaType, localFolder.id, itemFolder.uri.toString(), itemFolder.getSimplePath(ctx), itemFolder.getBasePath(ctx), itemFolder.getAbsolutePath(ctx),audioTracks,localFiles,coverContentUrl,coverAbsolutePath)
|
||||
val 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):DownloadItemScanResult? {
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val 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
|
||||
}
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||
return null
|
||||
}
|
||||
|
||||
val 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
|
||||
// m4b files showing as mimeType application/octet-stream on Android 10 and earlier see #154
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val 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
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}")
|
||||
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val 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
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer.index)
|
||||
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 ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val newEpisode = podcast.addEpisode(track, podcastEpisode)
|
||||
localEpisodeId = newEpisode.id
|
||||
Log.d(tag, "scanDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}")
|
||||
}
|
||||
} else { // Cover image
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val 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)
|
||||
}
|
||||
|
||||
val downloadItemScanResult = DownloadItemScanResult(localLibraryItem,null)
|
||||
|
||||
// If library item had media progress then make local media progress and save
|
||||
downloadItem.userMediaProgress?.let { mediaProgress ->
|
||||
val localMediaProgressId = if (downloadItem.episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val newLocalMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = false,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = downloadItem.serverConnectionConfigId,
|
||||
serverAddress = downloadItem.serverAddress,
|
||||
serverUserId = downloadItem.serverUserId,
|
||||
libraryItemId = downloadItem.libraryItemId,
|
||||
episodeId = downloadItem.episodeId)
|
||||
Log.d(tag, "scanLibraryItemFolder: Saving local media progress ${newLocalMediaProgress.id} at progress ${newLocalMediaProgress.progress}")
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(newLocalMediaProgress)
|
||||
|
||||
downloadItemScanResult.localMediaProgress = newLocalMediaProgress
|
||||
}
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
return downloadItemScanResult
|
||||
}
|
||||
|
||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
||||
val 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
|
||||
val filesFound = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
val existingAudioTracks = localLibraryItem.media.getAudioTracks()
|
||||
|
||||
// Remove any files no longer found in library item folder
|
||||
val 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 ->
|
||||
val localFileId = DeviceManager.getBase64Id(docFile.id)
|
||||
val existingLocalFile = localLibraryItem.localFiles.find { it.id == localFileId }
|
||||
|
||||
if (existingLocalFile == null || (existingLocalFile.isAudioFile() && forceAudioProbe)) {
|
||||
|
||||
val 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
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
val existingTrack = existingAudioTracks.find { audioTrack ->
|
||||
audioTrack.localFileId == localFile.id
|
||||
}
|
||||
|
||||
if (existingTrack == null) {
|
||||
// Create new audio track
|
||||
val lastTrack = existingAudioTracks.lastOrNull()
|
||||
val startOffset = (lastTrack?.startOffset ?: 0.0) + (lastTrack?.duration ?: 0.0)
|
||||
val track = AudioTrack(existingAudioTracks.size, startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, null)
|
||||
localLibraryItem.media.addAudioTrack(track)
|
||||
Log.d(tag, "Added New Audio Track ${track.title}")
|
||||
wasUpdated = true
|
||||
} else {
|
||||
existingTrack.audioProbeResult = audioProbeResult
|
||||
// TODO: Update data found from probe
|
||||
|
||||
Log.d(tag, "Updated Audio Track Probe Data ${existingTrack.title}")
|
||||
|
||||
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? {
|
||||
val 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 probeObject = JSObject(session.output)
|
||||
if (!probeObject.has("streams")) { // Check if output is empty
|
||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath is empty")
|
||||
return null
|
||||
} else {
|
||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||
return audioProbeResult
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import java.util.*
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val tag = "MediaManager"
|
||||
|
||||
var serverLibraryItems = listOf<LibraryItem>()
|
||||
var selectedLibraryId = ""
|
||||
|
||||
var selectedLibraryItemWrapper:LibraryItemWrapper? = null
|
||||
var selectedPodcast:Podcast? = null
|
||||
var selectedLibraryItemId:String? = null
|
||||
var serverPodcastEpisodes = listOf<PodcastEpisode>()
|
||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
||||
var serverLibraries = listOf<Library>()
|
||||
var serverConfigIdUsed:String? = null
|
||||
|
||||
fun getIsLibrary(id:String) : Boolean {
|
||||
return serverLibraries.find { it.id == id } != null
|
||||
}
|
||||
|
||||
fun checkResetServerItems() {
|
||||
// When opening android auto need to check if still connected to server
|
||||
// and reset any server data already set
|
||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||
|
||||
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
serverPodcastEpisodes = listOf()
|
||||
serverLibraryCategories = listOf()
|
||||
serverLibraries = listOf()
|
||||
serverLibraryItems = listOf()
|
||||
selectedLibraryId = ""
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraryCategories(libraryId:String, cb: (List<LibraryCategory>) -> Unit) {
|
||||
if (serverLibraryCategories.isNotEmpty()) {
|
||||
cb(serverLibraryCategories)
|
||||
} else {
|
||||
apiHandler.getLibraryCategories(libraryId) {
|
||||
serverLibraryCategories = it
|
||||
cb(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
|
||||
if (serverLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
|
||||
cb(serverLibraryItems)
|
||||
} else {
|
||||
apiHandler.getLibraryItems(libraryId) { libraryItems ->
|
||||
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
|
||||
if (libraryItemsWithAudio.isNotEmpty()) selectedLibraryId = libraryId
|
||||
|
||||
serverLibraryItems = libraryItemsWithAudio
|
||||
cb(libraryItemsWithAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
|
||||
if (libraryItemId.startsWith("local")) {
|
||||
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
||||
} else {
|
||||
Log.d(tag, "loadLibraryItem: $libraryItemId")
|
||||
apiHandler.getLibraryItem(libraryItemId) { libraryItem ->
|
||||
Log.d(tag, "loadLibraryItem: Got library item $libraryItem")
|
||||
cb(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
|
||||
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
||||
|
||||
selectedLibraryItemWrapper = libraryItemWrapper
|
||||
|
||||
libraryItemWrapper?.let {
|
||||
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
|
||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||
serverPodcastEpisodes = listOf()
|
||||
cb(mutableListOf())
|
||||
} else {
|
||||
val podcast = libraryItemWrapper.media as Podcast
|
||||
serverPodcastEpisodes = podcast.episodes ?: listOf()
|
||||
selectedLibraryItemId = libraryItemWrapper.id
|
||||
selectedPodcast = podcast
|
||||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
}
|
||||
} else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes
|
||||
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
|
||||
serverPodcastEpisodes = listOf()
|
||||
cb(mutableListOf())
|
||||
} else {
|
||||
val podcast = libraryItemWrapper.media as Podcast
|
||||
serverPodcastEpisodes = podcast.episodes ?: listOf()
|
||||
selectedLibraryItemId = libraryItemWrapper.id
|
||||
selectedPodcast = podcast
|
||||
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
MediaBrowserCompat.MediaItem(podcastEpisode.getMediaMetadata(libraryItemWrapper).description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraries(cb: (List<Library>) -> Unit) {
|
||||
if (serverLibraries.isNotEmpty()) {
|
||||
cb(serverLibraries)
|
||||
} else {
|
||||
apiHandler.getLibraries {
|
||||
serverLibraries = it
|
||||
cb(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
|
||||
var successfulPing = false
|
||||
suspendCoroutine<Boolean> { cont ->
|
||||
apiHandler.pingServer(config) {
|
||||
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
|
||||
successfulPing = it
|
||||
cont.resume(it)
|
||||
}
|
||||
}
|
||||
return successfulPing
|
||||
}
|
||||
|
||||
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
if (!apiHandler.isOnline()) cb(false)
|
||||
else {
|
||||
coroutineScope {
|
||||
var hasValidConn = false
|
||||
|
||||
// First check if the current selected config is pingable
|
||||
DeviceManager.serverConnectionConfig?.let {
|
||||
hasValidConn = checkServerConnection(it)
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn")
|
||||
}
|
||||
|
||||
if (!hasValidConn) {
|
||||
// Loop through available configs and check if can connect
|
||||
for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) {
|
||||
val result = checkServerConnection(config)
|
||||
|
||||
if (result) {
|
||||
hasValidConn = true
|
||||
DeviceManager.serverConnectionConfig = config
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cb(hasValidConn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Load currently listening category for local items
|
||||
fun loadLocalCategory():List<LibraryCategory> {
|
||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||
val 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(cb: (List<LibraryCategory>) -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
val cats = mutableListOf<LibraryCategory>()
|
||||
|
||||
val localCategories = loadLocalCategory()
|
||||
cats.addAll(localCategories)
|
||||
|
||||
// Check if any valid server connection if not use locally downloaded books
|
||||
checkSetValidServerConnectionConfig { isConnected ->
|
||||
if (isConnected) {
|
||||
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
|
||||
|
||||
loadLibraries { libraries ->
|
||||
val library = libraries[0]
|
||||
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
|
||||
|
||||
loadLibraryCategories(library.id) { 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)
|
||||
}
|
||||
}
|
||||
|
||||
cb(cats)
|
||||
}
|
||||
}
|
||||
} else { // Not connected/no internet sent downloaded cats only
|
||||
cb(cats)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getFirstItem() : LibraryItemWrapper? {
|
||||
if (serverLibraryItems.isNotEmpty()) {
|
||||
return serverLibraryItems[0]
|
||||
} else {
|
||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||
return if (localBooks.isNotEmpty()) return localBooks[0] else null
|
||||
}
|
||||
}
|
||||
|
||||
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
|
||||
if (id.startsWith("local")) {
|
||||
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||
} else {
|
||||
val podcastEpisode = serverPodcastEpisodes.find { it.id == id }
|
||||
return if (podcastEpisode != null && selectedLibraryItemWrapper != null) {
|
||||
LibraryItemWithEpisode(selectedLibraryItemWrapper!!, podcastEpisode)
|
||||
} 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, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||
if (libraryItemWrapper is LocalLibraryItem) {
|
||||
val localLibraryItem = libraryItemWrapper as LocalLibraryItem
|
||||
cb(localLibraryItem.getPlaybackSession(episode))
|
||||
} else {
|
||||
val libraryItem = libraryItemWrapper as LibraryItem
|
||||
apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "",playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
cb(null)
|
||||
} else {
|
||||
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,10 +1,11 @@
|
||||
package com.audiobookshelf.app
|
||||
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
|
||||
@@ -14,16 +15,12 @@ import kotlinx.coroutines.*
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
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)
|
||||
|
||||
@@ -59,9 +56,8 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
|
||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Block on downloading artwork.
|
||||
try {
|
||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
@@ -71,7 +67,7 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
||||
Glide.with(playerNotificationService)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
@@ -0,0 +1,112 @@
|
||||
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.Library
|
||||
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>,
|
||||
libraries: List<Library>
|
||||
) {
|
||||
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 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()
|
||||
|
||||
val librariesMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
|
||||
}.build()
|
||||
|
||||
// Server continue Listening cat
|
||||
libraryCategories.find { it.id == "continue-listening" }?.let { continueListeningCategory ->
|
||||
val continueListeningMediaMetadata = continueListeningCategory.entities.map { liw ->
|
||||
val 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
|
||||
}
|
||||
}
|
||||
|
||||
if (libraries.isNotEmpty()) {
|
||||
rootList += librariesMetadata
|
||||
|
||||
libraries.forEach { library ->
|
||||
val libraryMediaMetadata = library.getMediaMetadata()
|
||||
val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
|
||||
children += libraryMediaMetadata
|
||||
mediaIdToChildren[LIBRARIES_ROOT] = children
|
||||
}
|
||||
}
|
||||
|
||||
rootList += downloadsMetadata
|
||||
libraryCategories.find { it.id == "local-books" }?.let { localBooksCat ->
|
||||
localBooksCat.entities.forEach { libc ->
|
||||
val libraryItem = libc as LocalLibraryItem
|
||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
children += libraryItem.getMediaMetadata(context)
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
||||
}
|
||||
}
|
||||
|
||||
libraryCategories.find { it.id == "local-podcasts" }?.let { localPodcastsCat ->
|
||||
localPodcastsCat.entities.forEach { libc ->
|
||||
val libraryItem = libc as LocalLibraryItem
|
||||
val children = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
children += libraryItem.getMediaMetadata(context)
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = children
|
||||
}
|
||||
}
|
||||
|
||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||
}
|
||||
|
||||
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
|
||||
}
|
||||
|
||||
const val AUTO_BROWSE_ROOT = "/"
|
||||
const val CONTINUE_ROOT = "__CONTINUE__"
|
||||
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||
const val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||
@@ -1,32 +1,31 @@
|
||||
package com.audiobookshelf.app
|
||||
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.CastPlayer
|
||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
||||
import com.google.android.gms.cast.Cast
|
||||
import com.google.android.gms.cast.CastDevice
|
||||
import com.google.android.gms.cast.CastMediaControlIntent
|
||||
import com.google.android.gms.cast.*
|
||||
import com.google.android.gms.cast.framework.*
|
||||
import org.json.JSONObject
|
||||
import java.util.ArrayList
|
||||
|
||||
class CastManager constructor(playerNotificationService:PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
class CastManager constructor(val mainActivity:Activity) {
|
||||
private val tag = "CastManager"
|
||||
|
||||
private var newConnectionListener:SessionListener? = null
|
||||
private var mainActivity:Activity? = null
|
||||
private var playerNotificationService:PlayerNotificationService? = null
|
||||
private var newConnectionListener: SessionListener? = null
|
||||
|
||||
private fun switchToPlayer(useCastPlayer:Boolean) {
|
||||
playerNotificationService.switchToPlayer(useCastPlayer)
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService?.switchToPlayer(useCastPlayer)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class CastSessionAvailabilityListener : SessionAvailabilityListener {
|
||||
@@ -36,6 +35,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
* remote Cast receiver rather than play audio locally.
|
||||
*/
|
||||
override fun onCastSessionAvailable() {
|
||||
Log.d(tag, "SessionAvailabilityListener: onCastSessionAvailable")
|
||||
switchToPlayer(true)
|
||||
}
|
||||
|
||||
@@ -48,42 +48,39 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSession(mainActivity: Activity, callback: RequestSessionCallback) {
|
||||
this.mainActivity = mainActivity
|
||||
fun requestSession(playerNotificationService: PlayerNotificationService, callback: RequestSessionCallback) {
|
||||
this.playerNotificationService = playerNotificationService
|
||||
|
||||
mainActivity.runOnUiThread(object : Runnable {
|
||||
override fun run() {
|
||||
Log.d(tag, "CAST RUNNING ON MAIN THREAD")
|
||||
mainActivity.runOnUiThread {
|
||||
val session: CastSession? = getSession()
|
||||
if (session == null) {
|
||||
// show the "choose a connection" dialog
|
||||
// Add the connection listener callback
|
||||
listenForConnection(callback)
|
||||
|
||||
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 {
|
||||
getSessionManager()!!.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
callback.onCancel()
|
||||
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)
|
||||
}
|
||||
builder.show()
|
||||
} else {
|
||||
// We are are already connected, so show the "connection options" Dialog
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
if (session.castDevice != null) {
|
||||
builder.setTitle(session.castDevice.friendlyName)
|
||||
}
|
||||
builder.setOnDismissListener { callback.onCancel() }
|
||||
builder.setPositiveButton("Stop Casting") { dialog, which -> endSession(true, null) }
|
||||
builder.show()
|
||||
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 {
|
||||
@@ -103,12 +100,10 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) {
|
||||
|
||||
getSessionManager()!!.addSessionManagerListener(object : SessionListener() {
|
||||
override fun onSessionEnded(castSession: CastSession?, error: Int) {
|
||||
override fun onSessionEnded(castSession: CastSession, error: Int) {
|
||||
getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java)
|
||||
Log.d(tag, "CAST END SESSION")
|
||||
// media.setSession(null)
|
||||
pluginCall?.resolve()
|
||||
// listener.onSessionEnd(ChromecastUtilities.createSessionObject(castSession, if (stopCasting) "stopped" else "disconnected"))
|
||||
}
|
||||
}, CastSession::class.java)
|
||||
getSessionManager()!!.endCurrentSession(stopCasting)
|
||||
@@ -116,78 +111,47 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
|
||||
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) {}
|
||||
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) {}
|
||||
}
|
||||
|
||||
private fun startRouteScan() {
|
||||
var connListener = object: ChromecastListener() {
|
||||
override fun onReceiverAvailableUpdate(available: Boolean) {
|
||||
Log.d(tag, "CAST RECEIVER UPDATE AVAILABLE $available")
|
||||
}
|
||||
|
||||
override fun onSessionRejoin(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionRejoin")
|
||||
}
|
||||
|
||||
override fun onMediaLoaded(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "CAST onMediaLoaded")
|
||||
}
|
||||
|
||||
override fun onMediaUpdate(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "CAST onMediaUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionUpdate(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionEnd(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionEnd")
|
||||
}
|
||||
|
||||
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
|
||||
Log.d(tag, "CAST onMessageReceived")
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
stopRouteScan(this, null)
|
||||
// Let the client know a receiver is available
|
||||
connListener.onReceiverAvailableUpdate(true);
|
||||
connListener.onReceiverAvailableUpdate(true)
|
||||
// Since we have a receiver we may also have an active session
|
||||
var session = getSessionManager()?.currentCastSession;
|
||||
var session = getSessionManager()?.currentCastSession
|
||||
// If we do have a session
|
||||
if (session != null) {
|
||||
// Let the client know
|
||||
Log.d(tag, "LET SESSION KNOW ABOUT")
|
||||
// media.setSession(session);
|
||||
// connListener.onSessionRejoin(ChromecastUtilities.createSessionObject(session));
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "No cast devices available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
callback.setMediaRouter(getMediaRouter())
|
||||
|
||||
callback.onFilteredRouteUpdate();
|
||||
callback.onFilteredRouteUpdate()
|
||||
|
||||
getMediaRouter()!!.addCallback(MediaRouteSelector.Builder()
|
||||
getMediaRouter()?.addCallback(MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||
.build(),
|
||||
callback,
|
||||
@@ -201,7 +165,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
fun onSessionEnd(jsonSession: JSONObject?)
|
||||
}
|
||||
|
||||
internal abstract class ChromecastListener : CastStateListener, CastListener {
|
||||
abstract class ChromecastListener : CastStateListener, CastListener {
|
||||
abstract fun onReceiverAvailableUpdate(available: Boolean)
|
||||
abstract fun onSessionRejoin(jsonSession: JSONObject?)
|
||||
|
||||
@@ -212,15 +176,19 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
|
||||
fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) {
|
||||
Log.d(tag, "stopRouteScan")
|
||||
if (callback == null) {
|
||||
completionCallback!!.run()
|
||||
completionCallback?.run()
|
||||
return
|
||||
}
|
||||
// ctx.runOnUiThread(Runnable {
|
||||
callback.stop()
|
||||
getMediaRouter()!!.removeCallback(callback)
|
||||
completionCallback?.run()
|
||||
// })
|
||||
|
||||
// mainActivity.runOnUiThread {
|
||||
Log.d(tag, "Removing callback on media router")
|
||||
callback.stop()
|
||||
getMediaRouter()?.removeCallback(callback)
|
||||
completionCallback?.run()
|
||||
// }
|
||||
|
||||
}
|
||||
|
||||
abstract class ScanCallback : MediaRouter.Callback() {
|
||||
@@ -271,7 +239,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
if (!route.isDefault
|
||||
&& !route.description.equals("Google Cast Multizone Member")
|
||||
&& route.playbackType === MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
|
||||
&& route.playbackType == MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
|
||||
outRoutes.add(route)
|
||||
}
|
||||
}
|
||||
@@ -293,46 +261,48 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
|
||||
private fun listenForConnection(callback: ConnectionCallback) {
|
||||
// We should only ever have one of these listeners active at a time, so remove previous
|
||||
getSessionManager()?.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
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}")
|
||||
override fun onSessionStarted(castSession: CastSession, sessionId: String) {
|
||||
Log.d(tag, "CAST SESSION STARTED ${castSession.castDevice?.friendlyName}")
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
|
||||
try {
|
||||
val castContext = CastContext.getSharedInstance(mainActivity)
|
||||
playerNotificationService.castPlayer = CastPlayer(castContext).apply {
|
||||
setSessionAvailabilityListener(CastSessionAvailabilityListener())
|
||||
addListener(playerNotificationService.getPlayerListener())
|
||||
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")
|
||||
}
|
||||
Log.d(tag, "CAST Cast Player Applied")
|
||||
switchToPlayer(true)
|
||||
} catch (e: Exception) {
|
||||
Log.i(tag, "Cast is not available on this device. " +
|
||||
"Exception thrown when attempting to obtain CastContext. " + e.message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// media.setSession(castSession)
|
||||
// callback.onJoin(ChromecastUtilities.createSessionObject(castSession))
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(castSession: CastSession?, errCode: Int) {
|
||||
if (callback.onSessionStartFailed(errCode)) {
|
||||
override fun onSessionStartFailed(castSession: CastSession, error: Int) {
|
||||
if (callback.onSessionStartFailed(error)) {
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSessionEnded(castSession: CastSession?, errCode: Int) {
|
||||
if (callback.onSessionEndedBeforeStart(errCode)) {
|
||||
override fun onSessionEnded(castSession: CastSession, error: Int) {
|
||||
if (callback.onSessionEndedBeforeStart(error)) {
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSessionManager()?.addSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
newConnectionListener?.let {
|
||||
Log.d(tag, "Add session manager listener")
|
||||
getSessionManager()?.addSessionManagerListener(it, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getContext(): CastContext {
|
||||
@@ -344,7 +314,7 @@ class CastManager constructor(playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
|
||||
private fun getMediaRouter(): MediaRouter? {
|
||||
return mainActivity?.let { MediaRouter.getInstance(it) }
|
||||
return MediaRouter.getInstance(mainActivity)
|
||||
}
|
||||
|
||||
private fun getSession(): CastSession? {
|
||||
@@ -0,0 +1,978 @@
|
||||
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 = C.INDEX_UNSET
|
||||
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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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.MediaProgress
|
||||
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(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||
private val tag = "MediaProgressSync"
|
||||
|
||||
private var listeningTimerTask: TimerTask? = null
|
||||
var listeningTimerRunning:Boolean = false
|
||||
|
||||
private var lastSyncTime:Long = 0
|
||||
private var failedSyncs:Int = 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
|
||||
failedSyncs = 0
|
||||
} 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) {
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
sync(currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!listeningTimerRunning) return
|
||||
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
sync(currentTime)
|
||||
reset()
|
||||
}
|
||||
|
||||
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||
currentPlaybackSession?.let {
|
||||
it.updatedAt = mediaProgress.lastUpdate
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(currentTime:Double) {
|
||||
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
||||
if (diffSinceLastSync < 1000L) {
|
||||
return
|
||||
}
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
|
||||
|
||||
currentPlaybackSession?.syncData(syncData)
|
||||
if (currentIsLocal) {
|
||||
// Save local progress sync
|
||||
currentPlaybackSession?.let {
|
||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||
saveLocalProgress(it)
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
if (!it.libraryItemId.isNullOrEmpty()) {
|
||||
// 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) {
|
||||
if (it) {
|
||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||
failedSyncs = 0
|
||||
} else {
|
||||
failedSyncs++
|
||||
if (failedSyncs == 2) {
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
Log.d(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveLocalProgress(playbackSession:PlaybackSession) {
|
||||
if (currentLocalMediaProgress == null) {
|
||||
val 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
|
||||
failedSyncs = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
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.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
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, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it,true,null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
|
||||
if (mediaId.isNullOrEmpty()) {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
|
||||
} else if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode?.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
|
||||
if (libraryItemWrapper == null) {
|
||||
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||
}
|
||||
}
|
||||
|
||||
libraryItemWrapper?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
||||
return handleCallMediaButton(mediaButtonEvent)
|
||||
}
|
||||
|
||||
fun handleCallMediaButton(intent: Intent): Boolean {
|
||||
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
||||
val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
|
||||
|
||||
if (keyEvent?.action == KeyEvent.ACTION_UP) {
|
||||
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
|
||||
when (keyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_HEADSETHOOK -> {
|
||||
Log.d(tag, "handleCallMediaButton: Headset Hook")
|
||||
if (0 == mediaButtonClickCount) {
|
||||
if (playerNotificationService.mPlayer.isPlaying)
|
||||
playerNotificationService.pause()
|
||||
else
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Play")
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||
Log.d(tag, "handleCallMediaButton: 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 -> {
|
||||
Log.d(tag, "handleCallMediaButton: 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.keyCode}")
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
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.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
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, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
||||
|
||||
var libraryItemWrapper: LibraryItemWrapper? = null
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
|
||||
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||
libraryItemWrapper = libraryItemWithEpisode?.libraryItemWrapper
|
||||
podcastEpisode = libraryItemWithEpisode?.episode
|
||||
} else {
|
||||
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||
}
|
||||
|
||||
libraryItemWrapper?.let { li ->
|
||||
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
|
||||
Log.d(tag, "ON PREPARE FROM URI $uri")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.PlayerState
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
|
||||
const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds
|
||||
|
||||
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) {
|
||||
val 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 ${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) {
|
||||
Log.d(tag, "SeekBackTime: Player is playing")
|
||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||
if (onSeekBack) onSeekBack = false
|
||||
else {
|
||||
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
|
||||
var backTime = calcPauseSeekBackTime()
|
||||
if (backTime > 0) {
|
||||
// Current chapter is used so that seek back does not go back to the previous chapter
|
||||
val currentChapter = playerNotificationService.getCurrentBookChapter()
|
||||
val minSeekBackTime = currentChapter?.startMs ?: 0
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTime()
|
||||
val newTime = currentTime - backTime
|
||||
if (newTime < minSeekBackTime) {
|
||||
backTime = currentTime - minSeekBackTime
|
||||
}
|
||||
Log.d(tag, "SeekBackTime $backTime")
|
||||
onSeekBack = true
|
||||
playerNotificationService.seekBackward(backTime)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if playback session still exists or sync media progress if updated
|
||||
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress()
|
||||
if (!shouldCarryOn) return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
|
||||
lastPauseTime = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
// Start/stop progress sync interval
|
||||
Log.d(tag, "Playing ${playerNotificationService.getCurrentBookTitle()}")
|
||||
if (player.isPlaying) {
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
playerNotificationService.mediaProgressSyncer.start()
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.stop()
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calcPauseSeekBackTime() : Long {
|
||||
if (lastPauseTime <= 0) return 0
|
||||
val time: Long = System.currentTimeMillis() - lastPauseTime
|
||||
val seekback: Long
|
||||
if (time < 10000) seekback = 0 // 10s or less = no seekback
|
||||
else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s
|
||||
else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s
|
||||
else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s
|
||||
else seekback = 29500 // 30m and up = jump back 30s
|
||||
return seekback
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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.isClosed = false
|
||||
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")
|
||||
|
||||
// When stop button is pressed on the notification I guess it isn't considered "dismissedByUser" so we need to close playback ourselves
|
||||
if (!PlayerNotificationService.isClosed) {
|
||||
Log.d(tag, "PNS is not closed - closing it now")
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,867 @@
|
||||
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.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.BuildConfig
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.data.DeviceInfo
|
||||
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 java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||
|
||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
companion object {
|
||||
var isStarted = false
|
||||
var isClosed = false
|
||||
}
|
||||
|
||||
interface ClientEventEmitter {
|
||||
fun onPlaybackSession(playbackSession:PlaybackSession)
|
||||
fun onPlaybackClosed()
|
||||
fun onPlayingUpdate(isPlaying: Boolean)
|
||||
fun onMetadata(metadata: PlaybackMetadata)
|
||||
fun onSleepTimerEnded(currentPosition: Long)
|
||||
fun onSleepTimerSet(sleepTimeRemaining: Int)
|
||||
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
|
||||
fun onPlaybackFailed(errorMessage:String)
|
||||
fun onMediaPlayerChanged(mediaPlayer:String)
|
||||
fun onProgressSyncFailing()
|
||||
}
|
||||
|
||||
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
|
||||
private var initialPlaybackRate:Float? = null
|
||||
|
||||
private 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
|
||||
}
|
||||
|
||||
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() {
|
||||
Log.d(tag, "onCreate")
|
||||
super.onCreate()
|
||||
ctx = this
|
||||
|
||||
DbManager.initialize(ctx)
|
||||
|
||||
// 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, PendingIntent.FLAG_IMMUTABLE)
|
||||
}
|
||||
|
||||
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.setUseStopAction(true)
|
||||
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 {
|
||||
if (currentPlaybackSession == null) {
|
||||
Log.e(tag,"Playback session is not set - returning blank MediaDescriptionCompat")
|
||||
return MediaDescriptionCompat.Builder().build()
|
||||
}
|
||||
|
||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
||||
|
||||
// Fix for local images crashing on Android 11 for specific devices
|
||||
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
||||
ctx.grantUriPermission(
|
||||
"com.android.systemui",
|
||||
coverUri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(currentPlaybackSession!!.id)
|
||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||
.setSubtitle(currentPlaybackSession!!.displayAuthor)
|
||||
.setIconUri(coverUri).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))
|
||||
|
||||
mediaSession.setCallback(MediaSessionCallback(this))
|
||||
|
||||
initializeMPlayer()
|
||||
currentPlayer = mPlayer
|
||||
}
|
||||
|
||||
private fun initializeMPlayer() {
|
||||
val 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()
|
||||
|
||||
val seekBackTime = DeviceManager.deviceData.deviceSettings?.jumpBackwardsTimeMs ?: 10000
|
||||
val seekForwardTime = DeviceManager.deviceData.deviceSettings?.jumpForwardTimeMs ?: 10000
|
||||
Log.d(tag, "Seek Back Time $seekBackTime")
|
||||
Log.d(tag, "Seek Forward Time $seekForwardTime")
|
||||
|
||||
mPlayer = ExoPlayer.Builder(this)
|
||||
.setLoadControl(customLoadControl)
|
||||
.setSeekBackIncrementMs(seekBackTime)
|
||||
.setSeekForwardIncrementMs(seekForwardTime)
|
||||
.build()
|
||||
mPlayer.setHandleAudioBecomingNoisy(true)
|
||||
mPlayer.addListener(PlayerListener(this))
|
||||
val audioAttributes:AudioAttributes = AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.CONTENT_TYPE_SPEECH).build()
|
||||
mPlayer.setAudioAttributes(audioAttributes, true)
|
||||
|
||||
//attach player to playerNotificationManager
|
||||
playerNotificationManager.setPlayer(mPlayer)
|
||||
|
||||
mediaSessionConnector.setPlayer(mPlayer)
|
||||
}
|
||||
|
||||
/*
|
||||
User callable methods
|
||||
*/
|
||||
fun preparePlayer(playbackSession: PlaybackSession, playWhenReady:Boolean, playbackRate:Float?) {
|
||||
isClosed = false
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
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)
|
||||
|
||||
val metadata = playbackSession.getMediaMetadataCompat()
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
|
||||
if (mediaItems.isEmpty()) {
|
||||
Log.e(tag, "Invalid playback session no media items to play")
|
||||
currentPlaybackSession = null
|
||||
return
|
||||
}
|
||||
|
||||
if (mPlayer == currentPlayer) {
|
||||
val mediaSource:MediaSource
|
||||
|
||||
if (playbackSession.isLocal) {
|
||||
Log.d(tag, "Playing Local Item")
|
||||
val dataSourceFactory = DefaultDataSource.Factory(ctx)
|
||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
||||
} else if (!playbackSession.isHLS) {
|
||||
Log.d(tag, "Direct Playing Item")
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
dataSourceFactory.setUserAgent(channelId)
|
||||
mediaSource = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0])
|
||||
} else {
|
||||
Log.d(tag, "Playing HLS Item")
|
||||
val dataSourceFactory = DefaultHttpDataSource.Factory()
|
||||
dataSourceFactory.setUserAgent(channelId)
|
||||
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}")
|
||||
|
||||
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
||||
val 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(playbackRateToUse)
|
||||
|
||||
currentPlayer.prepare()
|
||||
|
||||
} else if (castPlayer != null) {
|
||||
val currentTrackIndex = playbackSession.getCurrentTrackIndex()
|
||||
val currentTrackTime = playbackSession.getCurrentTrackTimeMs()
|
||||
val mediaType = playbackSession.mediaType
|
||||
Log.d(tag, "Loading cast player $currentTrackIndex $currentTrackTime $mediaType")
|
||||
|
||||
castPlayer?.load(mediaItems, currentTrackIndex, currentTrackTime, playWhenReady, playbackRateToUse, mediaType)
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePlayerPlaybackError(errorMessage:String) {
|
||||
// On error and was attempting to direct play - fallback to transcode
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
if (playbackSession.isDirectPlay) {
|
||||
val playItemRequestPayload = getPlayItemRequestPayload(true)
|
||||
Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer")
|
||||
|
||||
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||
val episodeId = playbackSession.episodeId
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) { // Play request failed
|
||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||
closePlayback()
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||
closePlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startNewPlaybackSession() {
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
val forceTranscode = playbackSession.isHLS // If already HLS then force
|
||||
val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode)
|
||||
|
||||
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||
val episodeId = playbackSession.episodeId
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to start new playback session")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun switchToPlayer(useCastPlayer: Boolean) {
|
||||
val 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, null)
|
||||
}
|
||||
}
|
||||
|
||||
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 currentPlaybackSession?.totalDurationMs ?: 0L
|
||||
}
|
||||
|
||||
fun getCurrentBookTitle() : String? {
|
||||
return currentPlaybackSession?.displayTitle
|
||||
}
|
||||
|
||||
fun getCurrentPlaybackSessionCopy() :PlaybackSession? {
|
||||
return currentPlaybackSession?.clone()
|
||||
}
|
||||
|
||||
fun getCurrentPlaybackSessionId() :String? {
|
||||
return currentPlaybackSession?.id
|
||||
}
|
||||
|
||||
fun getCurrentBookChapter():BookChapter? {
|
||||
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
|
||||
}
|
||||
|
||||
// Called from PlayerListener play event
|
||||
// check with server if progress has updated since last play and sync progress update
|
||||
fun checkCurrentSessionProgress():Boolean {
|
||||
if (currentPlaybackSession == null) return true
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
|
||||
return true // carry on
|
||||
}
|
||||
|
||||
if (playbackSession.isLocal) {
|
||||
|
||||
// Make sure this connection config exists
|
||||
val serverConnectionConfig = DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId)
|
||||
if (serverConnectionConfig == null) {
|
||||
Log.d(tag, "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}")
|
||||
return true // carry on
|
||||
}
|
||||
|
||||
// Local playback session check if server has updated media progress
|
||||
Log.d(tag, "checkCurrentSessionProgress: Checking if local media progress was updated on server")
|
||||
apiHandler.getMediaProgress(playbackSession.libraryItemId!!, playbackSession.episodeId, serverConnectionConfig) { mediaProgress ->
|
||||
|
||||
if (mediaProgress != null && mediaProgress.lastUpdate > playbackSession.updatedAt && mediaProgress.currentTime != playbackSession.currentTime) {
|
||||
Log.d(tag, "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}")
|
||||
mediaProgressSyncer.syncFromServerProgress(mediaProgress)
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
seekPlayer(playbackSession.currentTimeMs)
|
||||
}
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
// Should already be playing
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
mediaProgressSyncer.start()
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Streaming from server so check if playback session still exists on server
|
||||
Log.d(
|
||||
tag,
|
||||
"checkCurrentSessionProgress: Checking if playback session for server stream is still available"
|
||||
)
|
||||
apiHandler.getPlaybackSession(playbackSession.id) {
|
||||
if (it == null) {
|
||||
Log.d(
|
||||
tag,
|
||||
"checkCurrentSessionProgress: Playback session does not exist on server - start new playback session"
|
||||
)
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
currentPlayer.pause()
|
||||
startNewPlaybackSession()
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
mediaProgressSyncer.start()
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
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
|
||||
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
|
||||
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
|
||||
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
|
||||
} else {
|
||||
currentPlayer.seekTo(time)
|
||||
}
|
||||
}
|
||||
|
||||
fun seekForward(amount: Long) {
|
||||
seekPlayer(getCurrentTime() + amount)
|
||||
}
|
||||
|
||||
fun seekBackward(amount: Long) {
|
||||
seekPlayer(getCurrentTime() - amount)
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
currentPlayer.setPlaybackSpeed(speed)
|
||||
}
|
||||
|
||||
fun closePlayback() {
|
||||
Log.d(tag, "closePlayback")
|
||||
try {
|
||||
currentPlayer.stop()
|
||||
currentPlayer.clearMediaItems()
|
||||
} catch(e:Exception) {
|
||||
Log.e(tag, "Exception clearing exoplayer $e")
|
||||
}
|
||||
|
||||
currentPlaybackSession = null
|
||||
clientEventEmitter?.onPlaybackClosed()
|
||||
PlayerListener.lastPauseTime = 0
|
||||
isClosed = true
|
||||
stopForeground(true)
|
||||
}
|
||||
|
||||
fun sendClientMetadata(playerState: PlayerState) {
|
||||
val duration = currentPlaybackSession?.getTotalDuration() ?: 0.0
|
||||
clientEventEmitter?.onMetadata(PlaybackMetadata(duration, getCurrentTimeSeconds(), playerState))
|
||||
}
|
||||
|
||||
fun getMediaPlayer():String {
|
||||
return if(currentPlayer == castPlayer) "cast-player" else "exo-player"
|
||||
}
|
||||
|
||||
fun getDeviceInfo(): DeviceInfo {
|
||||
/* EXAMPLE
|
||||
manufacturer: Google
|
||||
model: Pixel 6
|
||||
brand: google
|
||||
sdkVersion: 32
|
||||
appVersion: 0.9.46-beta
|
||||
*/
|
||||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
||||
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||
}
|
||||
|
||||
fun getContext():Context {
|
||||
return ctx
|
||||
}
|
||||
|
||||
fun alertSyncFailing() {
|
||||
clientEventEmitter?.onProgressSyncFailing()
|
||||
}
|
||||
|
||||
//
|
||||
// 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 val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||
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 {
|
||||
isStarted = true
|
||||
mediaManager.checkResetServerItems() // Reset any server items if no longer connected to server
|
||||
|
||||
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 || parentMediaId == LIBRARIES_ROOT) MediaBrowserCompat.MediaItem.FLAG_BROWSABLE else MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
|
||||
result.detach()
|
||||
|
||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||
Log.d(tag, "Loading podcast episodes")
|
||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
|
||||
result.sendResult(it)
|
||||
}
|
||||
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||
|
||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||
val children = libraryItems.map { libraryItem ->
|
||||
val libraryItemMediaMetadata = libraryItem.getMediaMetadata()
|
||||
|
||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||
flag = MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
}
|
||||
|
||||
MediaBrowserCompat.MediaItem(libraryItemMediaMetadata.description, flag)
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
} else if (parentMediaId == "__DOWNLOADS__") { // Load downloads
|
||||
|
||||
val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book")
|
||||
val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast")
|
||||
val localBrowseItems:MutableList<MediaBrowserCompat.MediaItem> = mutableListOf()
|
||||
|
||||
localBooks.forEach { localLibraryItem ->
|
||||
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
localPodcasts.forEach { localLibraryItem ->
|
||||
val mediaMetadata = localLibraryItem.getMediaMetadata(ctx)
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaMetadata.description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
result.sendResult(localBrowseItems)
|
||||
|
||||
} else { // Load categories
|
||||
|
||||
mediaManager.loadAndroidAutoItems() { libraryCategories ->
|
||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
||||
|
||||
val children = browseTree[parentMediaId]?.map { item ->
|
||||
Log.d(tag, "Loading Browser Media Item ${item.description.title} $flag")
|
||||
|
||||
MediaBrowserCompat.MediaItem(item.description, flag)
|
||||
}
|
||||
result.sendResult(children as MutableList<MediaBrowserCompat.MediaItem>?)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSearch(query: String, extras: Bundle?, result: Result<MutableList<MediaBrowserCompat.MediaItem>>) {
|
||||
result.detach()
|
||||
mediaManager.loadAndroidAutoItems() { libraryCategories ->
|
||||
browseTree = BrowseTree(this, libraryCategories, mediaManager.serverLibraries)
|
||||
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}")
|
||||
val 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,10 +1,9 @@
|
||||
package com.audiobookshelf.app
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import java.lang.Math.sqrt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class ShakeDetector : SensorEventListener {
|
||||
@@ -0,0 +1,251 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.content.Context
|
||||
import android.os.*
|
||||
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(val playerNotificationService:PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
|
||||
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()
|
||||
|
||||
val 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
|
||||
|
||||
val 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
|
||||
val 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)
|
||||
}
|
||||
|
||||
// Vibrate when extending sleep timer by shaking
|
||||
private fun vibrate() {
|
||||
val context = playerNotificationService.getContext()
|
||||
val vibrator:Vibrator
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vibratorManager =
|
||||
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||
vibrator = vibratorManager.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||
}
|
||||
|
||||
vibrator.let {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150),-1)
|
||||
it.vibrate(vibrationEffect)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
it.vibrate(10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
val 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
|
||||
}
|
||||
|
||||
val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
|
||||
vibrate()
|
||||
setSleepTimer(newSleepTime, false)
|
||||
play()
|
||||
return
|
||||
}
|
||||
// Only extend if within 30 seconds of finishing
|
||||
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
if (sleepTimeRemaining <= 30) {
|
||||
vibrate()
|
||||
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 {
|
||||
val 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 {
|
||||
val newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
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 com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import org.json.JSONObject
|
||||
|
||||
@CapacitorPlugin(name = "AbsAudioPlayer")
|
||||
class AbsAudioPlayer : Plugin() {
|
||||
private val tag = "AbsAudioPlayer"
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
private lateinit var mainActivity: MainActivity
|
||||
private lateinit var apiHandler:ApiHandler
|
||||
var castManager:CastManager? = null
|
||||
|
||||
lateinit var playerNotificationService: PlayerNotificationService
|
||||
|
||||
private var isCastAvailable:Boolean = false
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
|
||||
initCastManager()
|
||||
|
||||
val 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 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)
|
||||
}
|
||||
|
||||
override fun onProgressSyncFailing() {
|
||||
emit("onProgressSyncFailing", "")
|
||||
}
|
||||
})
|
||||
}
|
||||
mainActivity.pluginCallback = foregroundServiceReady
|
||||
}
|
||||
|
||||
fun emit(evtName: String, value: Any) {
|
||||
val ret = JSObject()
|
||||
ret.put("value", value)
|
||||
notifyListeners(evtName, ret)
|
||||
}
|
||||
|
||||
private fun initCastManager() {
|
||||
val googleApi = GoogleApiAvailability.getInstance()
|
||||
val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity)
|
||||
|
||||
if (statusCode != ConnectionResult.SUCCESS) {
|
||||
if (statusCode == ConnectionResult.SERVICE_MISSING) {
|
||||
Log.w(tag, "initCastManager: Google Api Missing")
|
||||
} else if (statusCode == ConnectionResult.SERVICE_DISABLED) {
|
||||
Log.w(tag, "initCastManager: Google Api Disabled")
|
||||
} else if (statusCode == ConnectionResult.SERVICE_INVALID) {
|
||||
Log.w(tag, "initCastManager: Google Api Invalid")
|
||||
} else if (statusCode == ConnectionResult.SERVICE_UPDATING) {
|
||||
Log.w(tag, "initCastManager: Google Api Updating")
|
||||
} else if (statusCode == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED) {
|
||||
Log.w(tag, "initCastManager: Google Api Update Required")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val 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)
|
||||
}
|
||||
}
|
||||
|
||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
val episodeId = call.getString("episodeId", "").toString()
|
||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
return call.resolve(JSObject("{\"error\":\"Invalid request\"}"))
|
||||
}
|
||||
|
||||
if (libraryItemId.startsWith("local")) { // Play local media item
|
||||
DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let {
|
||||
var episode: PodcastEpisode? = null
|
||||
if (episodeId.isNotEmpty()) {
|
||||
val 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("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||
val playbackSession = it.getPlaybackSession(episode)
|
||||
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
|
||||
}
|
||||
return call.resolve(JSObject())
|
||||
}
|
||||
} else { // Play library item from server
|
||||
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
|
||||
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "Preparing Player TEST ${jacksonMapper.writeValueAsString(it)}")
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getCurrentTime(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
val 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 {
|
||||
val playing = playerNotificationService.playPause()
|
||||
call.resolve(JSObject("{\"playing\":$playing}"))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seek(call: PluginCall) {
|
||||
val 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) {
|
||||
val 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) {
|
||||
val 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) {
|
||||
val 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) {
|
||||
val time:Long = call.getString("time", "360000")!!.toLong()
|
||||
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getSleepTimerTime(call: PluginCall) {
|
||||
val time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
|
||||
val ret = JSObject()
|
||||
ret.put("value", time)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun increaseSleepTime(call: PluginCall) {
|
||||
val 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) {
|
||||
val 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()
|
||||
if (castManager == null) {
|
||||
Log.e(tag, "Cast Manager not initialized")
|
||||
return
|
||||
}
|
||||
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) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("value", isCastAvailable)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
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.fasterxml.jackson.module.kotlin.readValue
|
||||
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>)
|
||||
data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, val token:String, val address:String?, val customHeaders:Map<String,String>?)
|
||||
|
||||
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) {
|
||||
val deviceData = DeviceManager.dbManager.getDeviceData()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData)))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalFolders(call:PluginCall) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val folders = DeviceManager.dbManager.getAllLocalFolders()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalFolder(call:PluginCall) {
|
||||
val folderId = call.getString("folderId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
DeviceManager.dbManager.getLocalFolder(folderId)?.let {
|
||||
val folderObj = jacksonMapper.writeValueAsString(it)
|
||||
call.resolve(JSObject(folderObj))
|
||||
} ?: call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItem(call:PluginCall) {
|
||||
val id = call.getString("id", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItemByLLId(call:PluginCall) {
|
||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLLId(libraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItems(call:PluginCall) {
|
||||
val mediaType = call.getString("mediaType", "").toString()
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalLibraryItemsInFolder(call:PluginCall) {
|
||||
val folderId = call.getString("folderId", "").toString()
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setCurrentServerConnectionConfig(call:PluginCall) {
|
||||
Log.d(tag, "setCurrentServerConnectionConfig ${call.data}")
|
||||
val serverConfigPayload = jacksonMapper.readValue<ServerConnConfigPayload>(call.data.toString())
|
||||
var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id }
|
||||
|
||||
val userId = serverConfigPayload.userId
|
||||
val username = serverConfigPayload.username
|
||||
val token = serverConfigPayload.token
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
if (serverConnectionConfig == null) { // New Server Connection
|
||||
val serverAddress = call.getString("address", "").toString()
|
||||
|
||||
// Create new server connection config
|
||||
val sscId = DeviceManager.getBase64Id("$serverAddress@$username")
|
||||
val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size
|
||||
serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, userId, username, token, serverConfigPayload.customHeaders)
|
||||
|
||||
// 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 != serverConfigPayload.id) {
|
||||
DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id
|
||||
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) {
|
||||
val 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) {
|
||||
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress))))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeLocalMediaProgress(call:PluginCall) {
|
||||
val 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)))
|
||||
}
|
||||
}
|
||||
|
||||
// Updates received via web socket
|
||||
// This function doesn't need to sync with the server also because this data is coming from the server
|
||||
// If sending the localMediaProgressId then update existing media progress
|
||||
// If sending localLibraryItemId then save new local media progress
|
||||
@PluginMethod
|
||||
fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) {
|
||||
val serverMediaProgress = call.getObject("mediaProgress").toString()
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
|
||||
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
|
||||
var localMediaProgressId = call.getString("localMediaProgressId") ?: ""
|
||||
|
||||
val mediaProgress = jacksonMapper.readValue<MediaProgress>(serverMediaProgress)
|
||||
|
||||
if (localMediaProgressId == "") {
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem != null) {
|
||||
localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
|
||||
val localMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = mediaProgress.duration,
|
||||
progress = mediaProgress.progress,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
isFinished = mediaProgress.isFinished,
|
||||
lastUpdate = mediaProgress.lastUpdate,
|
||||
startedAt = mediaProgress.startedAt,
|
||||
finishedAt = mediaProgress.finishedAt,
|
||||
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||
serverAddress = localLibraryItem.serverAddress,
|
||||
serverUserId = localLibraryItem.serverUserId,
|
||||
libraryItemId = localLibraryItem.libraryItemId,
|
||||
episodeId = mediaProgress.episodeId)
|
||||
|
||||
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress")
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
} else {
|
||||
Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found")
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId")
|
||||
val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||
|
||||
if (localMediaProgress == null) {
|
||||
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
|
||||
call.resolve()
|
||||
} else {
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalMediaProgressFinished(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
|
||||
var localEpisodeId:String? = call.getString("localEpisodeId", "").toString()
|
||||
if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null
|
||||
|
||||
val localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
val isFinished = call.getBoolean("isFinished", false) == true
|
||||
|
||||
Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished")
|
||||
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
|
||||
|
||||
if (localMediaProgress == null) { // Create new local media progress if does not exist
|
||||
Log.d(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId - Creating new")
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
|
||||
if (localLibraryItem == null) {
|
||||
return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
|
||||
}
|
||||
if (localLibraryItem.mediaType != "podcast" && !localEpisodeId.isNullOrEmpty()) {
|
||||
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
}
|
||||
|
||||
var duration = 0.0
|
||||
var podcastEpisode:PodcastEpisode? = null
|
||||
if (!localEpisodeId.isNullOrEmpty()) {
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { episode ->
|
||||
episode.id == localEpisodeId
|
||||
}
|
||||
if (podcastEpisode == null) {
|
||||
return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}"))
|
||||
}
|
||||
duration = podcastEpisode.duration ?: 0.0
|
||||
} else {
|
||||
val book = localLibraryItem.media as Book
|
||||
duration = book.duration ?: 0.0
|
||||
}
|
||||
|
||||
val currentTime = System.currentTimeMillis()
|
||||
localMediaProgress = LocalMediaProgress(
|
||||
id = localMediaProgressId,
|
||||
localLibraryItemId = localLibraryItemId,
|
||||
localEpisodeId = localEpisodeId,
|
||||
duration = duration,
|
||||
progress = if (isFinished) 1.0 else 0.0,
|
||||
currentTime = 0.0,
|
||||
isFinished = isFinished,
|
||||
lastUpdate = currentTime,
|
||||
startedAt = if (isFinished) currentTime else 0L,
|
||||
finishedAt = if (isFinished) currentTime else null,
|
||||
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
|
||||
serverAddress = localLibraryItem.serverAddress,
|
||||
serverUserId = localLibraryItem.serverUserId,
|
||||
libraryItemId = localLibraryItem.libraryItemId,
|
||||
episodeId = podcastEpisode?.serverEpisodeId)
|
||||
} else {
|
||||
localMediaProgress.updateIsFinished(isFinished)
|
||||
}
|
||||
|
||||
// Save local media progress locally
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
|
||||
val 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) {
|
||||
val libraryItemId = localMediaProgress.libraryItemId ?: ""
|
||||
val episodeId = localMediaProgress.episodeId ?: ""
|
||||
val updatePayload = JSObject()
|
||||
updatePayload.put("isFinished", isFinished)
|
||||
apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) {
|
||||
Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server")
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("local", true)
|
||||
jsobj.put("server", true)
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("local", true)
|
||||
jsobj.put("server", false)
|
||||
jsobj.put("localMediaProgress", JSObject(lmpstring))
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateLocalTrackOrder(call:PluginCall) {
|
||||
val localLibraryItemId = call.getString("localLibraryItemId", "") ?: ""
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
call.resolve()
|
||||
return
|
||||
}
|
||||
|
||||
val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList
|
||||
|
||||
val tracks:JSArray = call.getArray("tracks") ?: JSArray()
|
||||
Log.d(tag, "updateLocalTrackOrder $tracks")
|
||||
|
||||
var index = 1
|
||||
var hasUpdates = false
|
||||
for (i in 0 until tracks.length()) {
|
||||
val track = tracks.getJSONObject(i)
|
||||
val localFileId = track.getString("localFileId")
|
||||
|
||||
val 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()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun updateDeviceSettings(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "updateDeviceSettings ${call.data}")
|
||||
val newDeviceSettings = jacksonMapper.readValue<DeviceSettings>(call.data.toString())
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
DeviceManager.deviceData.deviceSettings = newDeviceSettings
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
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 finalDestinationPath:String,
|
||||
val itemTitle: String,
|
||||
val serverPath: String,
|
||||
val localFolderName: String,
|
||||
val localFolderUrl: String,
|
||||
val localFolderId: String,
|
||||
val audioTrack: AudioTrack?,
|
||||
val episode:PodcastEpisode?,
|
||||
var completed:Boolean,
|
||||
var moved:Boolean,
|
||||
var failed:Boolean,
|
||||
@JsonIgnore val uri: Uri,
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
@JsonIgnore val finalDestinationUri: Uri,
|
||||
var downloadId: Long?,
|
||||
var progress: Long
|
||||
) {
|
||||
companion object {
|
||||
fun make(filename:String, destinationFile:File, finalDestinationFile:File, itemTitle:String, serverPath:String, localFolder:LocalFolder, audioTrack:AudioTrack?, episode:PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
var downloadUrl = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
|
||||
if (serverPath.endsWith("/cover")) downloadUrl += "&format=jpeg" // For cover images force to jpeg
|
||||
val downloadUri = Uri.parse(downloadUrl)
|
||||
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Download URI $downloadUri")
|
||||
return DownloadItemPart(
|
||||
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||
filename = filename, finalDestinationFile.absolutePath,
|
||||
itemTitle = itemTitle,
|
||||
serverPath = serverPath,
|
||||
localFolderName = localFolder.name,
|
||||
localFolderUrl = localFolder.contentUrl,
|
||||
localFolderId = localFolder.id,
|
||||
audioTrack = audioTrack,
|
||||
episode = episode,
|
||||
completed = false,
|
||||
moved = false,
|
||||
failed = false,
|
||||
uri = downloadUri,
|
||||
destinationUri = destinationUri,
|
||||
finalDestinationUri = finalDestinationUri,
|
||||
downloadId = null,
|
||||
progress = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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 userMediaProgress:MediaProgress?,
|
||||
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 = ""
|
||||
val localFolderId = call.data.getString("localFolderId").toString()
|
||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||
|
||||
val downloadId = if (episodeId.isEmpty()) 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.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem ->
|
||||
if (libraryItem == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server request failed\"}"))
|
||||
} else {
|
||||
Log.d(tag, "Got library item from server ${libraryItem.id}")
|
||||
|
||||
val localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
|
||||
if (localFolder != null) {
|
||||
|
||||
if (episodeId.isNotEmpty() && 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.isNotEmpty()) {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val 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
|
||||
private fun cleanRelPath(relPath: String): String {
|
||||
val 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 sub-folders, this will make them unique
|
||||
private fun getFilenameFromRelPath(relPath: String): String {
|
||||
var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_")
|
||||
cleanedRelPath = cleanStringForFileSystem(cleanedRelPath)
|
||||
return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath
|
||||
}
|
||||
|
||||
// Replace characters that cant be used in the file system
|
||||
// Reserved characters: ?:\"*|/\\<>
|
||||
private fun cleanStringForFileSystem(str:String):String {
|
||||
val reservedCharacters = listOf("?", "\"", "*", "|", "/", "\\", "<", ">")
|
||||
var newTitle = str
|
||||
newTitle = newTitle.replace(":", " -") // Special case replace : with -
|
||||
|
||||
reservedCharacters.forEach {
|
||||
newTitle = newTitle.replace(it, "")
|
||||
}
|
||||
return newTitle
|
||||
}
|
||||
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
|
||||
if (libraryItem.mediaType == "book") {
|
||||
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
|
||||
val tracks = libraryItem.media.getAudioTracks()
|
||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||
val itemFolderPath = localFolder.absolutePath + "/" + bookTitle
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,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 ->
|
||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
|
||||
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val 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) {
|
||||
val serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
val destinationFilename = "cover.jpg"
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
}
|
||||
|
||||
downloadQueue.add(downloadItem)
|
||||
startWatchingDownloads(downloadItem)
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
}
|
||||
} else {
|
||||
// Podcast episode download
|
||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
|
||||
val audioTrack = episode?.audioTrack
|
||||
Log.d(tag, "Starting podcast episode download")
|
||||
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
||||
val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, 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("$tempFolderPath/$destinationFilename")
|
||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||
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"
|
||||
destinationFilename = "cover.jpg"
|
||||
|
||||
destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
dlRequest = downloadItemPart.getDownloadRequest()
|
||||
downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
}
|
||||
}
|
||||
|
||||
downloadQueue.add(downloadItem)
|
||||
startWatchingDownloads(downloadItem)
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startWatchingDownloads(downloadItem: DownloadItem) {
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
while (downloadItem.downloadItemParts.find { !it.moved && !it.failed } != null) { // While some item is not completed
|
||||
val 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)
|
||||
}
|
||||
|
||||
// Remove download notifications
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
downloadItemPart.downloadId?.let {
|
||||
downloadManager.remove(it)
|
||||
}
|
||||
}
|
||||
|
||||
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
downloadQueue.remove(downloadItem)
|
||||
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id} | Items remaining in Queue ${downloadQueue.size}")
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
notifyListeners("onItemDownloadComplete", jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloads(downloadItem: DownloadItem) {
|
||||
val itemParts = downloadItem.downloadItemParts.map { it }
|
||||
for (downloadItemPart in itemParts) {
|
||||
if (downloadItemPart.downloadId != null) {
|
||||
val dlid = downloadItemPart.downloadId!!
|
||||
val query = DownloadManager.Query().setFilterById(dlid)
|
||||
downloadManager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
|
||||
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
|
||||
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
|
||||
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getInt(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
||||
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
// Once file download is complete move the file to the final destination
|
||||
if (!downloadItemPart.completed) {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Done")
|
||||
downloadItemPart.completed = true
|
||||
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: Attempt move for file at destination ${downloadItemPart.destinationUri} | ${file?.getBasePath(mainActivity)}")
|
||||
|
||||
val fcb = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode:ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
file?.delete()
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO ${resultDocFile.getAbsolutePath(mainActivity)}")
|
||||
downloadItemPart.moved = true
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
|
||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
} else {
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
|
||||
file?.moveFileTo(mainActivity,localFolderFile,fileDescription,fcb)
|
||||
}
|
||||
} else {
|
||||
// Why is kotlin requiring an else here..
|
||||
}
|
||||
} else if (downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
|
||||
downloadItem.downloadItemParts.remove(downloadItemPart)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
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
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ApiHandler(var ctx:Context) {
|
||||
val tag = "ApiHandler"
|
||||
|
||||
private var defaultClient = OkHttpClient()
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
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, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
|
||||
val request = Request.Builder()
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
makeRequest(request, httpClient, 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, null, 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, null, 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, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||
val client = httpClient ?: defaultClient
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Failed to connect")
|
||||
cb(jsobj)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!it.isSuccessful) {
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("error", "Unexpected code $response")
|
||||
cb(jsobj)
|
||||
return
|
||||
}
|
||||
|
||||
val bodyString = it.body!!.string()
|
||||
if (bodyString == "OK") {
|
||||
cb(JSObject())
|
||||
} else {
|
||||
var jsonObj = JSObject()
|
||||
if (bodyString.startsWith("[")) {
|
||||
val 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", null,null) {
|
||||
val libraries = mutableListOf<Library>()
|
||||
if (it.has("value")) {
|
||||
val 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", null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getLibraryItem Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val libraryItem = jacksonMapper.readValue<LibraryItem>(it.toString())
|
||||
cb(libraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) {
|
||||
var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress"
|
||||
if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId"
|
||||
getRequest(requestUrl, null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
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", null, null) {
|
||||
val items = mutableListOf<LibraryItem>()
|
||||
if (it.has("results")) {
|
||||
val 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", null, null) {
|
||||
val items = mutableListOf<LibraryCategory>()
|
||||
if (it.has("value")) {
|
||||
val array = it.getJSONArray("value")
|
||||
for (i in 0 until array.length()) {
|
||||
val jsobj = array.get(i) as JSONObject
|
||||
|
||||
val 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?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload))
|
||||
|
||||
val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId"
|
||||
postRequest(endpoint, payload) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "Play Library Item Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
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: (Boolean) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
||||
|
||||
postRequest("/api/session/$sessionId/sync", payload) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false)
|
||||
} else {
|
||||
cb(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: () -> Unit) {
|
||||
val 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
|
||||
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
|
||||
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
|
||||
}
|
||||
|
||||
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
|
||||
|
||||
if (localMediaProgress.isNotEmpty()) {
|
||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
||||
postRequest("/api/me/sync-local-progress", payload) {
|
||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
|
||||
|
||||
if (it.toString() == "{}") {
|
||||
Log.e(tag, "Progress sync received empty object")
|
||||
} else if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "Progress sync error")
|
||||
} 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")
|
||||
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
||||
patchRequest(endpoint,updatePayload) {
|
||||
Log.d(tag, "updateMediaProgress patched progress")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) {
|
||||
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
||||
|
||||
// TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response
|
||||
getRequest(endpoint, pingClient, serverConnectionConfig) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, "getMediaProgress: Failed to get progress")
|
||||
cb(null)
|
||||
} else {
|
||||
val progress = jacksonMapper.readValue<MediaProgress>(it.toString())
|
||||
cb(progress)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
|
||||
val endpoint = "/api/session/$playbackSessionId"
|
||||
getRequest(endpoint, null, null) {
|
||||
val err = it.getString("error")
|
||||
if (!err.isNullOrEmpty()) {
|
||||
cb(null)
|
||||
} else {
|
||||
cb(jacksonMapper.readValue<PlaybackSession>(it.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) {
|
||||
Log.d(tag, "pingServer: Pinging ${config.address}")
|
||||
getRequest("/ping", pingClient, config) {
|
||||
val success = it.getString("success")
|
||||
if (success.isNullOrEmpty()) {
|
||||
Log.d(tag, "pingServer: Ping ${config.address} Failed")
|
||||
cb(false)
|
||||
} else {
|
||||
Log.d(tag, "pingServer: Ping ${config.address} Successful")
|
||||
cb(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,5 @@
|
||||
<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>
|
||||
|
||||
@@ -11,11 +11,5 @@
|
||||
<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>
|
||||
|
||||
@@ -10,9 +10,6 @@
|
||||
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"/>
|
||||
android:pathData="M10,4H4c-1.1,0 -1.99,0.9 -1.99,2L2,18c0,1.1 0.9,2 2,2h16c1.1,0 2,-0.9 2,-2V8c0,-1.1 -0.9,-2 -2,-2h-8l-2,-2z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
Before Width: | Height: | Size: 276 B |
|
After Width: | Height: | Size: 216 B |
|
Before Width: | Height: | Size: 199 B |
|
After Width: | Height: | Size: 164 B |
|
Before Width: | Height: | Size: 309 B |
|
After Width: | Height: | Size: 254 B |
|
Before Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 466 B |
@@ -2,6 +2,4 @@
|
||||
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>
|
||||
|
||||
@@ -2,6 +2,4 @@
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
<string name="app_name">AudioBookshelf</string>
|
||||
<string name="title_activity_main">AudioBookshelf</string>
|
||||
<string name="app_name">audiobookshelf</string>
|
||||
<string name="title_activity_main">audiobookshelf</string>
|
||||
<string name="package_name">com.audiobookshelf.app</string>
|
||||
<string name="custom_url_scheme">com.audiobookshelf.app</string>
|
||||
</resources>
|
||||
|
||||
@@ -5,11 +5,11 @@ buildscript {
|
||||
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:4.0.1'
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.google.gms:google-services:4.3.5'
|
||||
classpath 'com.android.tools.build:gradle:7.2.0-beta04'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
@@ -22,7 +22,7 @@ apply from: "variables.gradle"
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':capacitor-community-sqlite'
|
||||
project(':capacitor-community-sqlite').projectDir = new File('../node_modules/@capacitor-community/sqlite/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-dialog'
|
||||
project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android')
|
||||
|
||||
include ':capacitor-haptics'
|
||||
project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android')
|
||||
|
||||
include ':capacitor-network'
|
||||
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
|
||||
|
||||
@@ -19,9 +19,3 @@ project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacit
|
||||
|
||||
include ':capacitor-storage'
|
||||
project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android')
|
||||
|
||||
include ':robingenz-capacitor-app-update'
|
||||
project(':robingenz-capacitor-app-update').projectDir = new File('../node_modules/@robingenz/capacitor-app-update/android')
|
||||
|
||||
include ':capacitor-data-storage-sqlite'
|
||||
project(':capacitor-data-storage-sqlite').projectDir = new File('../node_modules/capacitor-data-storage-sqlite/android')
|
||||
|
||||
@@ -9,7 +9,15 @@
|
||||
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
#org.gradle.jvmargs=-Xmx1536m
|
||||
org.gradle.jvmargs=-Dfile.encoding=UTF-8 \
|
||||
--add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \
|
||||
--add-opens jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \
|
||||
--add-opens jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \
|
||||
--add-opens jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \
|
||||
--add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \
|
||||
--add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \
|
||||
--add-opens jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
|
||||
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
@@ -22,3 +30,5 @@ org.gradle.jvmargs=-Xmx1536m
|
||||
android.useAndroidX=true
|
||||
# Automatically convert third-party libraries to use AndroidX
|
||||
android.enableJetifier=true
|
||||
|
||||
kapt.use.worker.api=false
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#Sun Apr 17 13:28:55 CDT 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,34 +1,33 @@
|
||||
ext {
|
||||
minSdkVersion = 23
|
||||
compileSdkVersion = 30
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 31
|
||||
targetSdkVersion = 30
|
||||
androidxActivityVersion = '1.2.0'
|
||||
androidxAppCompatVersion = '1.2.0'
|
||||
androidxCoordinatorLayoutVersion = '1.1.0'
|
||||
androidxAppCompatVersion = '1.4.1'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.6.0'
|
||||
androidPlayCore = '1.9.0'
|
||||
androidxFragmentVersion = '1.3.0'
|
||||
junitVersion = '4.13.1'
|
||||
androidxJunitVersion = '1.1.2'
|
||||
androidxEspressoCoreVersion = '3.3.0'
|
||||
cordovaAndroidVersion = '7.0.0'
|
||||
androidx_app_compat_version = '1.2.0'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_car_version = '1.0.0-alpha7'
|
||||
androidx_core_ktx_version = '1.6.0'
|
||||
androidx_media_version = '1.0.1'
|
||||
androidx_core_ktx_version = '1.7.0'
|
||||
androidx_media_version = '1.5.0'
|
||||
androidx_preference_version = '1.1.1'
|
||||
androidx_test_runner_version = '1.3.0'
|
||||
arch_lifecycle_version = '2.2.0'
|
||||
constraint_layout_version = '2.0.1'
|
||||
espresso_version = '3.3.0'
|
||||
exoplayer_version = '2.15.0'
|
||||
exoplayer_version = '2.17.0'
|
||||
fragment_version = '1.2.5'
|
||||
glide_version = '4.11.0'
|
||||
gms_strict_version_matcher_version = '1.0.3'
|
||||
gradle_version = '3.1.4'
|
||||
gson_version = '2.8.5'
|
||||
junit_version = '4.13'
|
||||
kotlin_version = '1.4.32'
|
||||
kotlin_version = '1.5.30'
|
||||
kotlin_coroutines_version = '1.1.0'
|
||||
multidex_version = '1.0.3'
|
||||
play_services_auth_version = '18.1.0'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
@import "./fonts.css";
|
||||
@import './defaultStyles.css';
|
||||
|
||||
body {
|
||||
background-color: #262626;
|
||||
@@ -16,6 +17,7 @@ body {
|
||||
min-height: calc(100% - 64px);
|
||||
max-height: calc(100% - 64px);
|
||||
}
|
||||
|
||||
#content.playerOpen {
|
||||
height: calc(100% - 164px);
|
||||
min-height: calc(100% - 164px);
|
||||
@@ -46,6 +48,7 @@ body {
|
||||
.box-shadow-book {
|
||||
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
|
||||
}
|
||||
|
||||
.shadow-height {
|
||||
height: calc(100% - 4px);
|
||||
}
|
||||
@@ -53,6 +56,7 @@ body {
|
||||
.bookshelfRow {
|
||||
background-image: url(/wood_panels.jpg);
|
||||
}
|
||||
|
||||
.bookshelfDivider {
|
||||
background: rgb(149, 119, 90);
|
||||
background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
|
||||
@@ -63,9 +67,9 @@ body {
|
||||
Bookshelf Label
|
||||
*/
|
||||
.categoryPlacard {
|
||||
background-image: url(https://image.freepik.com/free-photo/brown-wooden-textured-flooring-background_53876-128537.jpg);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.shinyBlack {
|
||||
background-color: #2d3436;
|
||||
background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
|
||||
This is for setting regular html styles for places where embedding HTML will be
|
||||
like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup.
|
||||
|
||||
*/
|
||||
|
||||
.default-style p {
|
||||
display: block;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
}
|
||||
|
||||
.default-style a {
|
||||
text-decoration: none;
|
||||
color: #5985ff;
|
||||
}
|
||||
|
||||
.default-style ul {
|
||||
display: block;
|
||||
list-style: circle;
|
||||
list-style-type: disc;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.default-style ol {
|
||||
display: block;
|
||||
list-style: decimal;
|
||||
list-style-type: decimal;
|
||||
margin-block-start: 1em;
|
||||
margin-block-end: 1em;
|
||||
margin-inline-start: 0px;
|
||||
margin-inline-end: 0px;
|
||||
padding-inline-start: 40px;
|
||||
}
|
||||
|
||||
.default-style li {
|
||||
display: list-item;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
.default-style li::marker {
|
||||
unicode-bidi: isolate;
|
||||
font-variant-numeric: tabular-nums;
|
||||
text-transform: none;
|
||||
text-indent: 0px !important;
|
||||
text-align: start !important;
|
||||
text-align-last: start !important;
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
@@ -12,43 +12,6 @@
|
||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||
}
|
||||
|
||||
/* .material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
font-size: 1.5rem;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
display: inline-block;
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.material-icons.text-icon {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.material-icons.text-lg {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.material-icons.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.material-icons.text-3xl {
|
||||
font-size: 1.875rem;
|
||||
}
|
||||
.material-icons.text-4xl {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
.material-icons.text-5xl {
|
||||
font-size: 3rem;
|
||||
}
|
||||
.material-icons.text-base {
|
||||
font-size: 1rem;
|
||||
} */
|
||||
|
||||
.material-icons {
|
||||
font-family: 'Material Icons';
|
||||
font-weight: normal;
|
||||
@@ -60,9 +23,9 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -78,9 +41,9 @@
|
||||
white-space: nowrap;
|
||||
word-wrap: normal;
|
||||
direction: ltr;
|
||||
-webkit-font-feature-settings: 'liga';
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.material-icons-outlined:not(.text-xs):not(.text-sm):not(.text-md):not(.text-base):not(.text-lg):not(.text-xl):not(.text-2xl):not(.text-3xl):not(.text-4xl):not(.text-5xl):not(.text-6xl):not(.text-7xl):not(.text-8xl) {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -93,6 +56,7 @@
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
@@ -101,4 +65,275 @@
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* vietnamese */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Source Sans Pro';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
|
||||
}
|
||||
|
||||
/* cyrillic */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
|
||||
}
|
||||
|
||||
/* greek-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+1F00-1FFF;
|
||||
}
|
||||
|
||||
/* greek */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0370-03FF;
|
||||
}
|
||||
|
||||
/* latin-ext */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Ubuntu Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -7,17 +7,22 @@
|
||||
<a v-if="showBack" @click="back" class="rounded-full h-10 w-10 flex items-center justify-center hover:bg-white hover:bg-opacity-10 mr-2 cursor-pointer">
|
||||
<span class="material-icons text-3xl text-white">arrow_back</span>
|
||||
</a>
|
||||
<div v-if="socketConnected">
|
||||
<div class="px-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
<p class="text-lg font-book leading-4 ml-2">{{ currentLibraryName }}</p>
|
||||
<div v-if="user && currentLibrary">
|
||||
<div class="pl-3 pr-4 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<widgets-library-icon :icon="currentLibraryIcon" :size="4" />
|
||||
<p class="text-base font-book leading-4 ml-2 mt-0.5">{{ currentLibraryName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<nuxt-link class="h-7 mx-2" to="/search">
|
||||
<widgets-download-progress-indicator />
|
||||
|
||||
<!-- Must be connected to a server to cast, only supports media items on server -->
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer mt-1.5">
|
||||
<span class="material-icons" :class="isCasting ? 'text-success' : ''" style="font-size: 1.6rem" @click="castClick">cast</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="user" class="h-7 mx-2" to="/search">
|
||||
<span class="material-icons" style="font-size: 1.75rem">search</span>
|
||||
</nuxt-link>
|
||||
|
||||
@@ -29,21 +34,34 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
onCastAvailableUpdateListener: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
isCastAvailable: {
|
||||
get() {
|
||||
return this.$store.state.isCastAvailable
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setCastAvailable', val)
|
||||
}
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.currentLibrary ? this.currentLibrary.name : 'Main'
|
||||
return this.currentLibrary ? this.currentLibrary.name : ''
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
},
|
||||
showBack() {
|
||||
if (!this.$route.name) return true
|
||||
return this.$route.name !== 'index' && !this.$route.name.startsWith('bookshelf')
|
||||
},
|
||||
user() {
|
||||
@@ -51,9 +69,19 @@ export default {
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : 'err'
|
||||
},
|
||||
isCasting() {
|
||||
return this.$store.state.isCasting
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
castClick() {
|
||||
if (this.$store.state.playerIsLocal) {
|
||||
this.$eventBus.$emit('cast-local-item')
|
||||
return
|
||||
}
|
||||
AbsAudioPlayer.requestSession()
|
||||
},
|
||||
clickShowSideDrawer() {
|
||||
this.$store.commit('setShowSideDrawer', true)
|
||||
},
|
||||
@@ -62,9 +90,20 @@ export default {
|
||||
},
|
||||
back() {
|
||||
window.history.back()
|
||||
},
|
||||
onCastAvailableUpdate(data) {
|
||||
this.isCastAvailable = data && data.value
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
AbsAudioPlayer.getIsCastAvailable().then((data) => {
|
||||
this.isCastAvailable = data && data.value
|
||||
})
|
||||
this.onCastAvailableUpdateListener = AbsAudioPlayer.addListener('onCastAvailableUpdate', this.onCastAvailableUpdate)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onCastAvailableUpdateListener) this.onCastAvailableUpdateListener.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
<template>
|
||||
<div class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="showFullscreen ? 'fullscreen' : ''">
|
||||
<div v-if="playbackSession" id="streamContainer" class="playerContainer fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
|
||||
<div class="top-2 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
|
||||
</div>
|
||||
<div v-show="showCastBtn" class="top-3.5 right-20 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" @click="castClick">cast</span>
|
||||
<div v-show="showCastBtn" class="top-4 right-16 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||
</div>
|
||||
<div class="top-4 right-4 absolute cursor-pointer">
|
||||
<ui-dropdown-menu ref="dropdownMenu" :items="menuItems" @action="clickMenuAction">
|
||||
<span class="material-icons text-3xl">more_vert</span>
|
||||
</ui-dropdown-menu>
|
||||
</div>
|
||||
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="useChapterTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
|
||||
@@ -29,21 +30,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" :class="bookCoverAspectRatio === 1 ? 'square-cover' : ''" @click="clickContainer">
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
|
||||
<covers-book-cover :audiobook="audiobook" :download-cover="downloadedCover" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden">
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||
<p class="title-text font-book truncate">{{ title }}</p>
|
||||
<p class="author-text text-white text-opacity-75 truncate">by {{ authorFL }}</p>
|
||||
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
|
||||
</div>
|
||||
|
||||
<div id="streamContainer" class="w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
|
||||
<div id="playerContent" class="playerContainer w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<span v-if="!isPodcast && !isLocalPlayMethod" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||
|
||||
<span class="font-mono text-white text-opacity-75 cursor-pointer" style="font-size: 1.35rem" @click="$emit('selectPlaybackSpeed')">{{ currentPlaybackRate }}x</span>
|
||||
<svg v-if="!sleepTimerRunning" xmlns="http://www.w3.org/2000/svg" class="h-7 w-7 text-white text-opacity-75 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor" @click.stop="$emit('showSleepTimer')">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
||||
@@ -52,30 +56,31 @@
|
||||
<p class="text-xl font-mono text-success">{{ sleepTimeRemainingPretty }}</p>
|
||||
</div>
|
||||
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="$emit('selectChapter')">format_list_bulleted</span>
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="backward10">replay_10</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm bg-accent flex items-center justify-center rounded-full text-primary mx-4" :class="seekLoading ? 'animate-spin' : ''" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<span v-if="!loading" class="material-icons">{{ seekLoading ? 'autorenew' : isPaused ? 'play_arrow' : 'pause' }}</span>
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4" :class="{ 'animate-spin': seekLoading, 'bg-accent': !isLocalPlayMethod, 'bg-success': isLocalPlayMethod }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="loading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="forward10">forward_10</span>
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !loading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
<span class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
<span v-show="showFullscreen" class="material-icons next-icon text-white cursor-pointer" :class="nextChapter && !isLoading ? 'text-opacity-75' : 'text-opacity-10'" @click.stop="jumpNextChapter">last_page</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
||||
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="loading ? 'animate-pulse' : ''" @click="clickTrack">
|
||||
<div ref="track" class="h-2 w-full bg-gray-500 bg-opacity-50 relative" :class="isLoading ? 'animate-pulse' : ''" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||
</div>
|
||||
<div class="flex pt-0.5">
|
||||
<div id="timestamp-row" class="flex pt-0.5">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<div class="flex-grow" />
|
||||
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
|
||||
@@ -84,74 +89,62 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MyNativeAudio from '@/plugins/my-native-audio'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
playing: Boolean,
|
||||
audiobook: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
download: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
bookmarks: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
loading: Boolean,
|
||||
sleepTimerRunning: Boolean,
|
||||
sleepTimerEndTime: Number
|
||||
sleepTimeRemaining: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showCastBtn: false,
|
||||
windowHeight: 0,
|
||||
playbackSession: null,
|
||||
showChapterModal: false,
|
||||
showFullscreen: false,
|
||||
totalDuration: 0,
|
||||
currentPlaybackRate: 1,
|
||||
currentTime: 0,
|
||||
bufferedTime: 0,
|
||||
isResetting: false,
|
||||
initObject: null,
|
||||
streamId: null,
|
||||
audiobookId: null,
|
||||
stateName: 'idle',
|
||||
playInterval: null,
|
||||
trackWidth: 0,
|
||||
isPaused: true,
|
||||
src: null,
|
||||
isPlaying: false,
|
||||
isEnded: false,
|
||||
volume: 0.5,
|
||||
readyTrackWidth: 0,
|
||||
playedTrackWidth: 0,
|
||||
seekedTime: 0,
|
||||
seekLoading: false,
|
||||
onPlaybackSessionListener: null,
|
||||
onPlaybackClosedListener: null,
|
||||
onPlayingUpdateListener: null,
|
||||
onMetadataListener: null,
|
||||
// noSyncUpdateTime: false,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
listenTimeInterval: null,
|
||||
listeningTimeSinceLastUpdate: 0,
|
||||
totalListeningTimeInSession: 0,
|
||||
useChapterTrack: false
|
||||
useChapterTrack: false,
|
||||
isLoading: false,
|
||||
touchTrackStart: false,
|
||||
dragPercent: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showFullscreen(val) {
|
||||
this.updateScreenSize()
|
||||
this.$store.commit('setPlayerFullscreen', !!val)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isPlaying: {
|
||||
get() {
|
||||
return this.playing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:playing', val)
|
||||
}
|
||||
},
|
||||
menuItems() {
|
||||
var items = []
|
||||
items.push({
|
||||
@@ -166,34 +159,91 @@ export default {
|
||||
})
|
||||
return items
|
||||
},
|
||||
jumpForwardIcon() {
|
||||
return this.$store.getters['globals/getJumpForwardIcon'](this.jumpForwardTime)
|
||||
},
|
||||
jumpBackwardsIcon() {
|
||||
return this.$store.getters['globals/getJumpBackwardsIcon'](this.jumpBackwardsTime)
|
||||
},
|
||||
jumpForwardTime() {
|
||||
return this.$store.getters['getJumpForwardTime']
|
||||
},
|
||||
jumpBackwardsTime() {
|
||||
return this.$store.getters['getJumpBackwardsTime']
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
bookCoverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) {
|
||||
return this.showFullscreen ? 260 : 60
|
||||
}
|
||||
return this.showFullscreen ? 200 : 60
|
||||
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
||||
return 60
|
||||
},
|
||||
book() {
|
||||
return this.audiobook.book || {}
|
||||
fullscreenBookCoverWidth() {
|
||||
var heightScale = (this.windowHeight - 200) / 651
|
||||
if (this.bookCoverAspectRatio === 1) {
|
||||
return 260 * heightScale
|
||||
}
|
||||
return 200 * heightScale
|
||||
},
|
||||
showCastBtn() {
|
||||
return this.$store.state.isCastAvailable
|
||||
},
|
||||
isCasting() {
|
||||
return this.mediaPlayer === 'cast-player'
|
||||
},
|
||||
mediaPlayer() {
|
||||
return this.playbackSession ? this.playbackSession.mediaPlayer : null
|
||||
},
|
||||
mediaType() {
|
||||
return this.playbackSession ? this.playbackSession.mediaType : null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.playbackSession ? this.playbackSession.mediaMetadata : null
|
||||
},
|
||||
libraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.libraryItem || null : null
|
||||
},
|
||||
localLibraryItem() {
|
||||
return this.playbackSession ? this.playbackSession.localLibraryItem || null : null
|
||||
},
|
||||
localLibraryItemCoverSrc() {
|
||||
var localItemCover = this.localLibraryItem ? this.localLibraryItem.coverContentUrl : null
|
||||
if (localItemCover) return Capacitor.convertFileSrc(localItemCover)
|
||||
return null
|
||||
},
|
||||
playMethod() {
|
||||
return this.playbackSession ? this.playbackSession.playMethod : null
|
||||
},
|
||||
isLocalPlayMethod() {
|
||||
return this.playMethod == this.$constants.PlayMethod.LOCAL
|
||||
},
|
||||
isDirectPlayMethod() {
|
||||
return this.playMethod == this.$constants.PlayMethod.DIRECTPLAY
|
||||
},
|
||||
title() {
|
||||
return this.book.title
|
||||
if (this.playbackSession) return this.playbackSession.displayTitle
|
||||
return this.mediaMetadata ? this.mediaMetadata.title : 'Title'
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL
|
||||
authorName() {
|
||||
if (this.playbackSession) return this.playbackSession.displayAuthor
|
||||
return this.mediaMetadata ? this.mediaMetadata.authorName : 'Author'
|
||||
},
|
||||
chapters() {
|
||||
return this.audiobook ? this.audiobook.chapters || [] : []
|
||||
if (this.playbackSession && this.playbackSession.chapters) {
|
||||
return this.playbackSession.chapters
|
||||
}
|
||||
return []
|
||||
},
|
||||
currentChapter() {
|
||||
if (!this.audiobook || !this.chapters.length) return null
|
||||
return this.chapters.find((ch) => Number(ch.start.toFixed(2)) <= this.currentTime && Number(ch.end.toFixed(2)) > this.currentTime)
|
||||
if (!this.chapters.length) return null
|
||||
return this.chapters.find((ch) => Number(Number(ch.start).toFixed(2)) <= this.currentTime && Number(Number(ch.end).toFixed(2)) > this.currentTime)
|
||||
},
|
||||
nextChapter() {
|
||||
if (!this.chapters.length) return
|
||||
return this.chapters.find((c) => Number(c.start.toFixed(2)) > this.currentTime)
|
||||
return this.chapters.find((c) => Number(Number(c.start).toFixed(2)) > this.currentTime)
|
||||
},
|
||||
currentChapterTitle() {
|
||||
return this.currentChapter ? this.currentChapter.title : ''
|
||||
@@ -201,9 +251,6 @@ export default {
|
||||
currentChapterDuration() {
|
||||
return this.currentChapter ? this.currentChapter.end - this.currentChapter.start : this.totalDuration
|
||||
},
|
||||
downloadedCover() {
|
||||
return this.download ? this.download.cover : null
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
@@ -236,10 +283,6 @@ export default {
|
||||
if (!this.currentChapter) return 0
|
||||
return this.currentChapter.end - this.currentTime
|
||||
},
|
||||
sleepTimeRemaining() {
|
||||
if (!this.sleepTimerEndTime) return 0
|
||||
return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
|
||||
},
|
||||
sleepTimeRemainingPretty() {
|
||||
if (!this.sleepTimeRemaining) return '0s'
|
||||
var secondsRemaining = Math.round(this.sleepTimeRemaining)
|
||||
@@ -251,65 +294,28 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
castClick() {
|
||||
console.log('Cast Btn Click')
|
||||
MyNativeAudio.requestSession()
|
||||
},
|
||||
sendStreamSync(timeListened = 0) {
|
||||
var syncData = {
|
||||
timeListened,
|
||||
currentTime: this.currentTime,
|
||||
streamId: this.streamId,
|
||||
audiobookId: this.audiobookId,
|
||||
totalDuration: this.totalDuration
|
||||
clickTitleAndAuthor() {
|
||||
if (!this.showFullscreen) return
|
||||
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
|
||||
if (llid) {
|
||||
this.$router.push(`/item/${llid}`)
|
||||
this.showFullscreen = false
|
||||
}
|
||||
this.$emit('sync', syncData)
|
||||
},
|
||||
sendAddListeningTime() {
|
||||
var listeningTimeToAdd = Math.floor(this.listeningTimeSinceLastUpdate)
|
||||
this.listeningTimeSinceLastUpdate = Math.max(0, this.listeningTimeSinceLastUpdate - listeningTimeToAdd)
|
||||
this.sendStreamSync(listeningTimeToAdd)
|
||||
touchstartTrack(e) {
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen) return
|
||||
this.touchTrackStart = true
|
||||
},
|
||||
cancelListenTimeInterval() {
|
||||
this.sendAddListeningTime()
|
||||
clearInterval(this.listenTimeInterval)
|
||||
this.listenTimeInterval = null
|
||||
selectChapter(chapter) {
|
||||
this.seek(chapter.start)
|
||||
this.showChapterModal = false
|
||||
},
|
||||
startListenTimeInterval() {
|
||||
clearInterval(this.listenTimeInterval)
|
||||
var lastTime = this.currentTime
|
||||
var lastTick = Date.now()
|
||||
var noProgressCount = 0
|
||||
this.listenTimeInterval = setInterval(() => {
|
||||
var timeSinceLastTick = Date.now() - lastTick
|
||||
lastTick = Date.now()
|
||||
|
||||
var expectedAudioTime = lastTime + timeSinceLastTick / 1000
|
||||
var currentTime = this.currentTime
|
||||
var differenceFromExpected = expectedAudioTime - currentTime
|
||||
if (currentTime === lastTime) {
|
||||
noProgressCount++
|
||||
if (noProgressCount > 3) {
|
||||
console.error('Audio current time has not increased - cancel interval and pause player')
|
||||
this.pause()
|
||||
}
|
||||
} else if (Math.abs(differenceFromExpected) > 0.1) {
|
||||
noProgressCount = 0
|
||||
console.warn('Invalid time between interval - resync last', differenceFromExpected)
|
||||
lastTime = currentTime
|
||||
} else {
|
||||
noProgressCount = 0
|
||||
var exactPlayTimeDifference = currentTime - lastTime
|
||||
// console.log('Difference from expected', differenceFromExpected, 'Exact play time diff', exactPlayTimeDifference)
|
||||
lastTime = currentTime
|
||||
this.listeningTimeSinceLastUpdate += exactPlayTimeDifference
|
||||
this.totalListeningTimeInSession += exactPlayTimeDifference
|
||||
// console.log('Time since last update:', this.listeningTimeSinceLastUpdate, 'Session listening time:', this.totalListeningTimeInSession)
|
||||
if (this.listeningTimeSinceLastUpdate > 5) {
|
||||
this.sendAddListeningTime()
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
castClick() {
|
||||
if (this.isLocalPlayMethod) {
|
||||
this.$eventBus.$emit('cast-local-item')
|
||||
return
|
||||
}
|
||||
AbsAudioPlayer.requestSession()
|
||||
},
|
||||
clickContainer() {
|
||||
this.showFullscreen = true
|
||||
@@ -324,19 +330,19 @@ export default {
|
||||
this.forceCloseDropdownMenu()
|
||||
},
|
||||
jumpNextChapter() {
|
||||
if (this.loading) return
|
||||
if (this.isLoading) return
|
||||
if (!this.nextChapter) return
|
||||
this.seek(this.nextChapter.start)
|
||||
},
|
||||
jumpChapterStart() {
|
||||
if (this.loading) return
|
||||
if (this.isLoading) return
|
||||
if (!this.currentChapter) {
|
||||
return this.restart()
|
||||
}
|
||||
|
||||
// If 1 second or less into current chapter, then go to previous
|
||||
if (this.currentTime - this.currentChapter.start <= 1) {
|
||||
var currChapterIndex = this.chapters.findIndex((ch) => ch.start <= this.currentTime && ch.end >= this.currentTime)
|
||||
// If 4 seconds or less into current chapter, then go to previous
|
||||
if (this.currentTime - this.currentChapter.start <= 4) {
|
||||
var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
|
||||
if (currChapterIndex > 0) {
|
||||
var prevChapter = this.chapters[currChapterIndex - 1]
|
||||
this.seek(prevChapter.start)
|
||||
@@ -351,18 +357,18 @@ export default {
|
||||
setPlaybackSpeed(speed) {
|
||||
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
|
||||
this.currentPlaybackRate = speed
|
||||
MyNativeAudio.setPlaybackSpeed({ speed: speed })
|
||||
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
|
||||
},
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
backward10() {
|
||||
if (this.loading) return
|
||||
MyNativeAudio.seekBackward({ amount: '10000' })
|
||||
jumpBackwards() {
|
||||
if (this.isLoading) return
|
||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||
},
|
||||
forward10() {
|
||||
if (this.loading) return
|
||||
MyNativeAudio.seekForward({ amount: '10000' })
|
||||
jumpForward() {
|
||||
if (this.isLoading) return
|
||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||
},
|
||||
setStreamReady() {
|
||||
this.readyTrackWidth = this.trackWidth
|
||||
@@ -432,6 +438,7 @@ export default {
|
||||
this.updateTrack()
|
||||
},
|
||||
updateTrack() {
|
||||
// Update progress track UI
|
||||
var percentDone = this.currentTime / this.totalDuration
|
||||
var totalPercentDone = percentDone
|
||||
var bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
@@ -443,12 +450,8 @@ export default {
|
||||
bufferedPercent = (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration
|
||||
}
|
||||
var ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
if (this.playedTrackWidth === ptWidth) {
|
||||
return
|
||||
}
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
|
||||
if (this.useChapterTrack) {
|
||||
if (this.$refs.totalPlayedTrack) this.$refs.totalPlayedTrack.style.width = Math.round(totalPercentDone * this.trackWidth) + 'px'
|
||||
@@ -456,7 +459,7 @@ export default {
|
||||
}
|
||||
},
|
||||
seek(time) {
|
||||
if (this.loading) return
|
||||
if (this.isLoading) return
|
||||
if (this.seekLoading) {
|
||||
console.error('Already seek loading', this.seekedTime)
|
||||
return
|
||||
@@ -464,20 +467,20 @@ export default {
|
||||
|
||||
this.seekedTime = time
|
||||
this.seekLoading = true
|
||||
MyNativeAudio.seekPlayer({ timeMs: String(Math.floor(time * 1000)) })
|
||||
|
||||
AbsAudioPlayer.seek({ value: Math.floor(time) })
|
||||
|
||||
if (this.$refs.playedTrack) {
|
||||
var perc = time / this.totalDuration
|
||||
var ptWidth = Math.round(perc * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.playedTrackWidth = ptWidth
|
||||
|
||||
this.$refs.playedTrack.classList.remove('bg-gray-200')
|
||||
this.$refs.playedTrack.classList.add('bg-yellow-300')
|
||||
}
|
||||
},
|
||||
clickTrack(e) {
|
||||
if (this.loading) return
|
||||
if (this.isLoading) return
|
||||
if (!this.showFullscreen) {
|
||||
// Track not clickable on mini-player
|
||||
return
|
||||
@@ -498,174 +501,37 @@ export default {
|
||||
}
|
||||
this.seek(time)
|
||||
},
|
||||
playPauseClick() {
|
||||
if (this.loading) return
|
||||
if (this.isPaused) {
|
||||
console.log('playPause PLAY')
|
||||
this.play()
|
||||
} else {
|
||||
console.log('playPause PAUSE')
|
||||
this.pause()
|
||||
}
|
||||
},
|
||||
calcSeekBackTime(lastUpdate) {
|
||||
var time = Date.now() - lastUpdate
|
||||
var seekback = 0
|
||||
if (time < 3000) seekback = 0
|
||||
else if (time < 60000) seekback = time / 6
|
||||
else if (time < 300000) seekback = 15000
|
||||
else if (time < 1800000) seekback = 20000
|
||||
else if (time < 3600000) seekback = 25000
|
||||
else seekback = 29500
|
||||
return seekback
|
||||
},
|
||||
async set(audiobookStreamData, stream, fromAppDestroy) {
|
||||
this.isResetting = false
|
||||
this.bufferedTime = 0
|
||||
this.streamId = stream ? stream.id : null
|
||||
this.audiobookId = audiobookStreamData.audiobookId
|
||||
this.initObject = { ...audiobookStreamData }
|
||||
console.log('[AudioPlayer] Set Audio Player', !!stream)
|
||||
async playPauseClick() {
|
||||
if (this.isLoading) return
|
||||
|
||||
var init = true
|
||||
if (!!stream) {
|
||||
//console.log(JSON.stringify(stream))
|
||||
var data = await MyNativeAudio.getStreamSyncData()
|
||||
console.log('getStreamSyncData', JSON.stringify(data))
|
||||
console.log('lastUpdate', stream.lastUpdate || 0)
|
||||
//Same audiobook
|
||||
if (data.id == stream.id && (data.isPlaying || data.lastPauseTime >= (stream.lastUpdate || 0))) {
|
||||
console.log('Same audiobook')
|
||||
this.isPaused = !data.isPlaying
|
||||
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
|
||||
this.totalDuration = Number((data.duration / 1000).toFixed(2))
|
||||
this.$emit('setTotalDuration', this.totalDuration)
|
||||
this.timeupdate()
|
||||
if (data.isPlaying) {
|
||||
console.log('playing - continue')
|
||||
if (fromAppDestroy) this.startPlayInterval()
|
||||
} else console.log('paused and newer')
|
||||
if (!fromAppDestroy) return
|
||||
init = false
|
||||
this.initObject.startTime = String(Math.floor(this.currentTime * 1000))
|
||||
}
|
||||
//new audiobook stream or sync from other client
|
||||
else if (stream.clientCurrentTime > 0) {
|
||||
console.log('new audiobook stream or sync from other client')
|
||||
if (!!stream.lastUpdate) {
|
||||
var backTime = this.calcSeekBackTime(stream.lastUpdate)
|
||||
var currentTime = Math.floor(stream.clientCurrentTime * 1000)
|
||||
if (backTime >= currentTime) backTime = currentTime - 500
|
||||
console.log('SeekBackTime', backTime)
|
||||
this.initObject.startTime = String(Math.floor(currentTime - backTime))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPlaybackRate = this.initObject.playbackSpeed
|
||||
console.log(`[AudioPlayer] Set Stream Playback Rate: ${this.currentPlaybackRate}`)
|
||||
|
||||
if (init)
|
||||
MyNativeAudio.initPlayer(this.initObject).then((res) => {
|
||||
if (res && res.success) {
|
||||
console.log('Success init audio player')
|
||||
} else {
|
||||
console.error('Failed to init audio player')
|
||||
}
|
||||
})
|
||||
|
||||
if (audiobookStreamData.isLocal) {
|
||||
this.setStreamReady()
|
||||
}
|
||||
},
|
||||
setFromObj() {
|
||||
if (!this.initObject) {
|
||||
console.error('Cannot set from obj')
|
||||
return
|
||||
}
|
||||
this.isResetting = false
|
||||
MyNativeAudio.initPlayer(this.initObject).then((res) => {
|
||||
if (res && res.success) {
|
||||
console.log('Success init audio player')
|
||||
} else {
|
||||
console.error('Failed to init audio player')
|
||||
}
|
||||
})
|
||||
|
||||
if (audiobookStreamData.isLocal) {
|
||||
this.setStreamReady()
|
||||
}
|
||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||
this.isEnded = false
|
||||
},
|
||||
play() {
|
||||
MyNativeAudio.playPlayer()
|
||||
AbsAudioPlayer.playPlayer()
|
||||
this.startPlayInterval()
|
||||
this.isPlaying = true
|
||||
},
|
||||
pause() {
|
||||
MyNativeAudio.pausePlayer()
|
||||
AbsAudioPlayer.pausePlayer()
|
||||
this.stopPlayInterval()
|
||||
this.isPlaying = false
|
||||
},
|
||||
startPlayInterval() {
|
||||
this.startListenTimeInterval()
|
||||
|
||||
clearInterval(this.playInterval)
|
||||
this.playInterval = setInterval(async () => {
|
||||
var data = await MyNativeAudio.getCurrentTime()
|
||||
this.currentTime = Number((data.value / 1000).toFixed(2))
|
||||
this.bufferedTime = Number((data.bufferedTime / 1000).toFixed(2))
|
||||
var data = await AbsAudioPlayer.getCurrentTime()
|
||||
this.currentTime = Number(data.value.toFixed(2))
|
||||
this.bufferedTime = Number(data.bufferedTime.toFixed(2))
|
||||
console.log('[AudioPlayer] Got Current Time', this.currentTime)
|
||||
this.timeupdate()
|
||||
}, 1000)
|
||||
},
|
||||
stopPlayInterval() {
|
||||
this.cancelListenTimeInterval()
|
||||
clearInterval(this.playInterval)
|
||||
},
|
||||
resetStream(startTime) {
|
||||
var _time = String(Math.floor(startTime * 1000))
|
||||
if (!this.initObject) {
|
||||
console.error('Terminate stream when no init object is set...')
|
||||
return
|
||||
}
|
||||
this.isResetting = true
|
||||
this.initObject.currentTime = _time
|
||||
this.terminateStream()
|
||||
},
|
||||
terminateStream() {
|
||||
MyNativeAudio.terminateStream()
|
||||
},
|
||||
onPlayingUpdate(data) {
|
||||
this.isPaused = !data.value
|
||||
if (!this.isPaused) {
|
||||
this.startPlayInterval()
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
},
|
||||
onMetadata(data) {
|
||||
this.totalDuration = Number((data.duration / 1000).toFixed(2))
|
||||
this.$emit('setTotalDuration', this.totalDuration)
|
||||
this.currentTime = Number((data.currentTime / 1000).toFixed(2))
|
||||
this.stateName = data.stateName
|
||||
|
||||
if (this.stateName === 'ended' && this.isResetting) {
|
||||
this.setFromObj()
|
||||
}
|
||||
|
||||
this.timeupdate()
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
|
||||
this.onPlayingUpdateListener = MyNativeAudio.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
||||
this.onMetadataListener = MyNativeAudio.addListener('onMetadata', this.onMetadata)
|
||||
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
this.closePlayback()
|
||||
},
|
||||
handleGesture() {
|
||||
var touchDistance = this.touchEndY - this.touchStartY
|
||||
@@ -684,15 +550,60 @@ export default {
|
||||
this.touchStartTime = Date.now()
|
||||
},
|
||||
touchend(e) {
|
||||
if (!this.showFullscreen || !e.changedTouches) return
|
||||
if (!e.changedTouches) return
|
||||
|
||||
this.touchEndY = e.changedTouches[0].screenY
|
||||
var touchDuration = Date.now() - this.touchStartTime
|
||||
if (touchDuration > 1200) {
|
||||
// console.log('touch too long', touchDuration)
|
||||
return
|
||||
if (this.touchTrackStart) {
|
||||
var touch = e.changedTouches[0]
|
||||
const touchOnTrackPos = touch.pageX - 12
|
||||
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
|
||||
|
||||
var seekToTime = 0
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = dragPercent * this.currentChapterDuration
|
||||
seekToTime = this.currentChapter.start + currChapTime
|
||||
} else {
|
||||
seekToTime = dragPercent * this.totalDuration
|
||||
}
|
||||
this.seek(seekToTime)
|
||||
|
||||
if (this.$refs.draggingTrack) {
|
||||
this.$refs.draggingTrack.style.width = '0px'
|
||||
}
|
||||
this.touchTrackStart = false
|
||||
} else if (this.showFullscreen) {
|
||||
this.touchEndY = e.changedTouches[0].screenY
|
||||
var touchDuration = Date.now() - this.touchStartTime
|
||||
if (touchDuration > 1200) {
|
||||
// console.log('touch too long', touchDuration)
|
||||
return
|
||||
}
|
||||
this.handleGesture()
|
||||
}
|
||||
},
|
||||
touchmove(e) {
|
||||
if (!this.touchTrackStart) return
|
||||
|
||||
var touch = e.touches[0]
|
||||
const touchOnTrackPos = touch.pageX - 12
|
||||
const dragPercent = Math.max(0, Math.min(1, touchOnTrackPos / this.trackWidth))
|
||||
this.dragPercent = dragPercent
|
||||
|
||||
if (this.$refs.draggingTrack) {
|
||||
this.$refs.draggingTrack.style.width = this.dragPercent * this.trackWidth + 'px'
|
||||
}
|
||||
|
||||
var ts = this.$refs.currentTimestamp
|
||||
if (ts) {
|
||||
var currTimeStr = ''
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
const currChapTime = dragPercent * this.currentChapterDuration
|
||||
currTimeStr = this.$secondsToTimestamp(currChapTime)
|
||||
} else {
|
||||
const dragTime = dragPercent * this.totalDuration
|
||||
currTimeStr = this.$secondsToTimestamp(dragTime)
|
||||
}
|
||||
ts.innerText = currTimeStr
|
||||
}
|
||||
this.handleGesture()
|
||||
},
|
||||
clickMenuAction(action) {
|
||||
if (action === 'chapter_track') {
|
||||
@@ -705,44 +616,166 @@ export default {
|
||||
})
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
} else if (action === 'close') {
|
||||
this.$emit('close')
|
||||
this.closePlayback()
|
||||
}
|
||||
},
|
||||
forceCloseDropdownMenu() {
|
||||
if (this.$refs.dropdownMenu && this.$refs.dropdownMenu.closeMenu) {
|
||||
this.$refs.dropdownMenu.closeMenu()
|
||||
}
|
||||
},
|
||||
closePlayback() {
|
||||
this.endPlayback()
|
||||
AbsAudioPlayer.closePlayback()
|
||||
},
|
||||
endPlayback() {
|
||||
this.$store.commit('setPlayerItem', null)
|
||||
this.showFullscreen = false
|
||||
this.isEnded = false
|
||||
this.isLoading = false
|
||||
this.playbackSession = null
|
||||
},
|
||||
//
|
||||
// Listeners from audio AbsAudioPlayer
|
||||
//
|
||||
onPlayingUpdate(data) {
|
||||
console.log('onPlayingUpdate', JSON.stringify(data))
|
||||
this.isPlaying = !!data.value
|
||||
this.$store.commit('setPlayerPlaying', this.isPlaying)
|
||||
if (this.isPlaying) {
|
||||
this.startPlayInterval()
|
||||
} else {
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
},
|
||||
onMetadata(data) {
|
||||
console.log('onMetadata', JSON.stringify(data))
|
||||
this.totalDuration = Number(data.duration.toFixed(2))
|
||||
this.currentTime = Number(data.currentTime.toFixed(2))
|
||||
|
||||
// Done loading
|
||||
if (data.playerState !== 'BUFFERING' && data.playerState !== 'IDLE') {
|
||||
this.isLoading = false
|
||||
}
|
||||
|
||||
if (data.playerState === 'ENDED') {
|
||||
console.log('[AudioPlayer] Playback ended')
|
||||
}
|
||||
this.isEnded = data.playerState === 'ENDED'
|
||||
|
||||
console.log('received metadata update', data)
|
||||
|
||||
if (data.currentRate && data.currentRate > 0) this.playbackSpeed = data.currentRate
|
||||
|
||||
this.timeupdate()
|
||||
},
|
||||
// When a playback session is started the native android/ios will send the session
|
||||
onPlaybackSession(playbackSession) {
|
||||
console.log('onPlaybackSession received', JSON.stringify(playbackSession))
|
||||
this.playbackSession = playbackSession
|
||||
|
||||
this.isEnded = false
|
||||
this.isLoading = true
|
||||
this.$store.commit('setPlayerItem', this.playbackSession)
|
||||
|
||||
// Set track width
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
} else {
|
||||
console.error('Track not loaded', this.$refs)
|
||||
}
|
||||
})
|
||||
},
|
||||
onPlaybackClosed() {
|
||||
this.endPlayback()
|
||||
},
|
||||
onPlaybackFailed(data) {
|
||||
console.log('Received onPlaybackFailed evt')
|
||||
var errorMessage = data.value || 'Unknown Error'
|
||||
this.$toast.error(`Playback Failed: ${errorMessage}`)
|
||||
this.endPlayback()
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
|
||||
this.onPlaybackSessionListener = AbsAudioPlayer.addListener('onPlaybackSession', this.onPlaybackSession)
|
||||
this.onPlaybackClosedListener = AbsAudioPlayer.addListener('onPlaybackClosed', this.onPlaybackClosed)
|
||||
this.onPlaybackFailedListener = AbsAudioPlayer.addListener('onPlaybackFailed', this.onPlaybackFailed)
|
||||
this.onPlayingUpdateListener = AbsAudioPlayer.addListener('onPlayingUpdate', this.onPlayingUpdate)
|
||||
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(this.updateScreenSize, 50)
|
||||
},
|
||||
updateScreenSize() {
|
||||
this.windowHeight = window.innerHeight
|
||||
var coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
console.log('Minimize Player Evt')
|
||||
this.showFullscreen = false
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateScreenSize()
|
||||
if (screen.orientation) {
|
||||
screen.orientation.addEventListener('change', this.screenOrientationChange)
|
||||
}
|
||||
|
||||
this.$eventBus.$on('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.addEventListener('touchstart', this.touchstart)
|
||||
document.body.addEventListener('touchend', this.touchend)
|
||||
|
||||
document.body.addEventListener('touchmove', this.touchmove)
|
||||
this.$nextTick(this.init)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (screen.orientation) {
|
||||
screen.orientation.removeEventListener('change', this.screenOrientationChange)
|
||||
}
|
||||
|
||||
if (this.playbackSession) {
|
||||
console.log('[AudioPlayer] Before destroy closing playback')
|
||||
this.closePlayback()
|
||||
}
|
||||
|
||||
this.forceCloseDropdownMenu()
|
||||
this.$eventBus.$off('minimize-player', this.minimizePlayerEvt)
|
||||
document.body.removeEventListener('touchstart', this.touchstart)
|
||||
document.body.removeEventListener('touchend', this.touchend)
|
||||
document.body.removeEventListener('touchmove', this.touchmove)
|
||||
|
||||
if (this.onPlayingUpdateListener) this.onPlayingUpdateListener.remove()
|
||||
if (this.onMetadataListener) this.onMetadataListener.remove()
|
||||
if (this.onPlaybackSessionListener) this.onPlaybackSessionListener.remove()
|
||||
if (this.onPlaybackClosedListener) this.onPlaybackClosedListener.remove()
|
||||
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
|
||||
clearInterval(this.playInterval)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--cover-image-width: 0px;
|
||||
--cover-image-height: 0px;
|
||||
--cover-image-width-collapsed: 60px;
|
||||
--cover-image-height-collapsed: 60px;
|
||||
}
|
||||
.bookCoverWrapper {
|
||||
box-shadow: 3px -2px 5px #00000066;
|
||||
}
|
||||
#streamContainer {
|
||||
box-shadow: 0px -8px 8px #11111155;
|
||||
.playerContainer {
|
||||
height: 100px;
|
||||
}
|
||||
.fullscreen #streamContainer {
|
||||
.fullscreen .playerContainer {
|
||||
height: 200px;
|
||||
}
|
||||
#playerContent {
|
||||
box-shadow: 0px -8px 8px #11111155;
|
||||
}
|
||||
|
||||
#playerTrack {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
@@ -753,18 +786,20 @@ export default {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.ios-player #timestamp-row {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
bottom: 44px;
|
||||
left: 12px;
|
||||
height: 96px;
|
||||
width: 60px;
|
||||
height: var(--cover-image-height-collapsed);
|
||||
width: var(--cover-image-width-collapsed);
|
||||
transition: all 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: left, bottom, width, height;
|
||||
transform-origin: left bottom;
|
||||
}
|
||||
.cover-wrapper.square-cover {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.total-track {
|
||||
bottom: 215px;
|
||||
@@ -796,16 +831,18 @@ export default {
|
||||
}
|
||||
|
||||
.fullscreen .title-author-texts {
|
||||
bottom: 36%;
|
||||
bottom: calc(50% - var(--cover-image-height) / 2 + 50px);
|
||||
width: 80%;
|
||||
left: 10%;
|
||||
text-align: center;
|
||||
padding-bottom: calc(((260px - var(--cover-image-height)) / 260) * 40);
|
||||
pointer-events: auto;
|
||||
}
|
||||
.fullscreen .title-author-texts .title-text {
|
||||
font-size: 1.2rem;
|
||||
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.2rem);
|
||||
}
|
||||
.fullscreen .title-author-texts .author-text {
|
||||
font-size: 1rem;
|
||||
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
|
||||
}
|
||||
|
||||
#playerControls {
|
||||
@@ -843,16 +880,11 @@ export default {
|
||||
}
|
||||
|
||||
.fullscreen .cover-wrapper {
|
||||
bottom: 46%;
|
||||
left: calc(50% - 100px);
|
||||
margin: 0 auto;
|
||||
height: 320px;
|
||||
width: 200px;
|
||||
}
|
||||
.fullscreen .cover-wrapper.square-cover {
|
||||
height: 260px;
|
||||
width: 260px;
|
||||
left: calc(50% - 130px);
|
||||
height: var(--cover-image-height);
|
||||
width: var(--cover-image-width);
|
||||
left: calc(50% - (calc(var(--cover-image-width)) / 2));
|
||||
bottom: calc(50% + 120px - (calc(var(--cover-image-height)) / 2));
|
||||
}
|
||||
|
||||
.fullscreen #playerControls {
|
||||
|
||||
@@ -1,59 +1,40 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="audiobook" id="streamContainer">
|
||||
<app-audio-player
|
||||
ref="audioPlayer"
|
||||
:playing.sync="isPlaying"
|
||||
:audiobook="audiobook"
|
||||
:download="download"
|
||||
:loading="isLoading"
|
||||
:bookmarks="bookmarks"
|
||||
:sleep-timer-running="isSleepTimerRunning"
|
||||
:sleep-timer-end-time="sleepTimerEndTime"
|
||||
@close="cancelStream"
|
||||
@sync="sync"
|
||||
@setTotalDuration="setTotalDuration"
|
||||
@selectPlaybackSpeed="showPlaybackSpeedModal = true"
|
||||
@selectChapter="clickChapterBtn"
|
||||
@updateTime="(t) => (currentTime = t)"
|
||||
@showSleepTimer="showSleepTimer"
|
||||
@showBookmarks="showBookmarks"
|
||||
@hook:mounted="audioPlayerMounted"
|
||||
/>
|
||||
</div>
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
|
||||
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :audiobook-id="audiobookId" :bookmarks="bookmarks" :current-time="currentTime" @select="selectBookmark" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="currentTime" :library-item-id="serverLibraryItemId" @select="selectBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import MyNativeAudio from '@/plugins/my-native-audio'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
isPlaying: false,
|
||||
audioPlayerReady: false,
|
||||
stream: null,
|
||||
download: null,
|
||||
lastProgressTimeUpdate: 0,
|
||||
showPlaybackSpeedModal: false,
|
||||
showBookmarksModal: false,
|
||||
showSleepTimerModal: false,
|
||||
playbackSpeed: 1,
|
||||
showChapterModal: false,
|
||||
currentTime: 0,
|
||||
isSleepTimerRunning: false,
|
||||
sleepTimerEndTime: 0,
|
||||
sleepTimeRemaining: 0,
|
||||
onLocalMediaProgressUpdateListener: null,
|
||||
onSleepTimerEndedListener: null,
|
||||
onSleepTimerSetListener: null,
|
||||
onMediaPlayerChangedListener: null,
|
||||
onProgressSyncFailing: null,
|
||||
sleepInterval: null,
|
||||
currentEndOfChapterTime: 0,
|
||||
totalDuration: 0
|
||||
serverLibraryItemId: null,
|
||||
syncFailedToast: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -65,94 +46,12 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userAudiobook() {
|
||||
if (!this.audiobookId) return
|
||||
return this.$store.getters['user/getUserAudiobookData'](this.audiobookId)
|
||||
},
|
||||
bookmarks() {
|
||||
if (!this.userAudiobook) return []
|
||||
return this.userAudiobook.bookmarks || []
|
||||
},
|
||||
currentChapter() {
|
||||
if (!this.audiobook || !this.chapters.length) return null
|
||||
return this.chapters.find((ch) => ch.start <= this.currentTime && ch.end > this.currentTime)
|
||||
if (!this.serverLibraryItemId) return []
|
||||
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
isLoading() {
|
||||
if (this.playingDownload) return false
|
||||
if (!this.streamAudiobook) return false
|
||||
return !this.stream || this.streamAudiobook.id !== this.stream.audiobook.id
|
||||
},
|
||||
playingDownload() {
|
||||
return this.$store.state.playingDownload
|
||||
},
|
||||
audiobook() {
|
||||
if (this.playingDownload) return this.playingDownload.audiobook
|
||||
return this.streamAudiobook
|
||||
},
|
||||
audiobookId() {
|
||||
return this.audiobook ? this.audiobook.id : null
|
||||
},
|
||||
streamAudiobook() {
|
||||
return this.$store.state.streamAudiobook
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
},
|
||||
title() {
|
||||
return this.book ? this.book.title : ''
|
||||
},
|
||||
author() {
|
||||
return this.book ? this.book.author : ''
|
||||
},
|
||||
cover() {
|
||||
return this.book ? this.book.cover : ''
|
||||
},
|
||||
series() {
|
||||
return this.book ? this.book.series : ''
|
||||
},
|
||||
chapters() {
|
||||
return this.audiobook ? this.audiobook.chapters || [] : []
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book ? this.book.volumeNumber : ''
|
||||
},
|
||||
seriesTxt() {
|
||||
if (!this.series) return ''
|
||||
if (!this.volumeNumber) return this.series
|
||||
return `${this.series} #${this.volumeNumber}`
|
||||
},
|
||||
duration() {
|
||||
return this.audiobook ? this.audiobook.duration || 0 : 0
|
||||
},
|
||||
coverForNative() {
|
||||
if (!this.cover) {
|
||||
return `${this.$store.state.serverUrl}/Logo.png`
|
||||
}
|
||||
if (this.cover.startsWith('http')) return this.cover
|
||||
var coverSrc = this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook)
|
||||
return coverSrc
|
||||
},
|
||||
tracksForCast() {
|
||||
if (!this.audiobook || !this.audiobook.tracks) {
|
||||
return []
|
||||
}
|
||||
var abpath = this.audiobook.path
|
||||
var tracks = this.audiobook.tracks.map((t) => {
|
||||
var trelpath = t.path.replace(abpath, '')
|
||||
if (trelpath.startsWith('/')) trelpath = trelpath.substr(1)
|
||||
return `${this.$store.state.serverUrl}/s/book/${this.audiobook.id}/${trelpath}?token=${this.userToken}`
|
||||
})
|
||||
return tracks
|
||||
},
|
||||
sleepTimeRemaining() {
|
||||
if (!this.sleepTimerEndTime) return 0
|
||||
return Math.max(0, this.sleepTimerEndTime / 1000 - this.currentTime)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -172,23 +71,23 @@ export default {
|
||||
if (currentPosition) {
|
||||
console.log('Sleep Timer Ended Current Position: ' + currentPosition)
|
||||
var currentTime = Math.floor(currentPosition / 1000)
|
||||
this.updateTime(currentTime)
|
||||
// TODO: Was syncing to the server here before
|
||||
}
|
||||
},
|
||||
onSleepTimerSet({ value: sleepTimerEndTime }) {
|
||||
console.log('SLEEP TIMER SET', sleepTimerEndTime)
|
||||
if (sleepTimerEndTime === 0) {
|
||||
onSleepTimerSet({ value: sleepTimeRemaining }) {
|
||||
console.log('SLEEP TIMER SET', sleepTimeRemaining)
|
||||
if (sleepTimeRemaining === 0) {
|
||||
console.log('Sleep timer canceled')
|
||||
this.isSleepTimerRunning = false
|
||||
} else {
|
||||
this.isSleepTimerRunning = true
|
||||
}
|
||||
|
||||
this.sleepTimerEndTime = sleepTimerEndTime
|
||||
this.sleepTimeRemaining = sleepTimeRemaining
|
||||
},
|
||||
showSleepTimer() {
|
||||
if (this.currentChapter) {
|
||||
this.currentEndOfChapterTime = Math.floor(this.currentChapter.end)
|
||||
if (this.$refs.audioPlayer && this.$refs.audioPlayer.currentChapter) {
|
||||
this.currentEndOfChapterTime = Math.floor(this.$refs.audioPlayer.currentChapter.end)
|
||||
} else {
|
||||
this.currentEndOfChapterTime = 0
|
||||
}
|
||||
@@ -196,101 +95,24 @@ export default {
|
||||
},
|
||||
async selectSleepTimeout({ time, isChapterTime }) {
|
||||
console.log('Setting sleep timer', time, isChapterTime)
|
||||
var res = await MyNativeAudio.setSleepTimer({ time: String(time), isChapterTime })
|
||||
var res = await AbsAudioPlayer.setSleepTimer({ time: String(time), isChapterTime })
|
||||
if (!res.success) {
|
||||
return this.$toast.error('Sleep timer did not set, invalid time')
|
||||
}
|
||||
},
|
||||
increaseSleepTimer() {
|
||||
// Default time to increase = 5 min
|
||||
MyNativeAudio.increaseSleepTime({ time: '300000' })
|
||||
AbsAudioPlayer.increaseSleepTime({ time: '300000' })
|
||||
},
|
||||
decreaseSleepTimer() {
|
||||
MyNativeAudio.decreaseSleepTime({ time: '300000' })
|
||||
AbsAudioPlayer.decreaseSleepTime({ time: '300000' })
|
||||
},
|
||||
async cancelSleepTimer() {
|
||||
console.log('Canceling sleep timer')
|
||||
await MyNativeAudio.cancelSleepTimer()
|
||||
await AbsAudioPlayer.cancelSleepTimer()
|
||||
},
|
||||
clickChapterBtn() {
|
||||
if (!this.chapters.length) return
|
||||
this.showChapterModal = true
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.seek(chapter.start)
|
||||
}
|
||||
this.showChapterModal = false
|
||||
},
|
||||
async cancelStream() {
|
||||
this.currentTime = 0
|
||||
|
||||
if (this.download) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.terminateStream()
|
||||
}
|
||||
this.download = null
|
||||
this.$store.commit('setPlayingDownload', null)
|
||||
|
||||
this.$localStore.setCurrent(null)
|
||||
} else {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Cancel this stream?'
|
||||
})
|
||||
if (value) {
|
||||
this.$server.socket.emit('close_stream')
|
||||
this.$store.commit('setStreamAudiobook', null)
|
||||
this.$server.stream = null
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.terminateStream()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
sync(syncData) {
|
||||
var diff = syncData.currentTime - this.lastServerUpdateSentSeconds
|
||||
if (Math.abs(diff) < 1 && !syncData.timeListened) {
|
||||
// No need to sync
|
||||
return
|
||||
}
|
||||
|
||||
if (this.stream) {
|
||||
this.$server.socket.emit('stream_sync', syncData)
|
||||
} else {
|
||||
var progressUpdate = {
|
||||
audiobookId: syncData.audiobookId,
|
||||
currentTime: syncData.currentTime,
|
||||
totalDuration: syncData.totalDuration,
|
||||
progress: syncData.totalDuration ? Number((syncData.currentTime / syncData.totalDuration).toFixed(3)) : 0,
|
||||
lastUpdate: Date.now(),
|
||||
isRead: false
|
||||
}
|
||||
|
||||
if (this.$server.connected) {
|
||||
this.$server.socket.emit('progress_update', progressUpdate)
|
||||
} else {
|
||||
this.$store.dispatch('user/updateUserAudiobookData', progressUpdate)
|
||||
}
|
||||
}
|
||||
},
|
||||
updateTime(currentTime) {
|
||||
this.sync({
|
||||
currentTime,
|
||||
audiobookId: this.audiobookId,
|
||||
streamId: this.stream ? this.stream.id : null,
|
||||
timeListened: 0,
|
||||
totalDuration: this.totalDuration || 0
|
||||
})
|
||||
},
|
||||
setTotalDuration(duration) {
|
||||
this.totalDuration = duration
|
||||
},
|
||||
streamClosed(audiobookId) {
|
||||
streamClosed() {
|
||||
console.log('Stream Closed')
|
||||
if (this.stream.audiobook.id === audiobookId || audiobookId === 'n/a') {
|
||||
this.$store.commit('setStreamAudiobook', null)
|
||||
}
|
||||
},
|
||||
streamProgress(data) {
|
||||
if (!data.numSegments) return
|
||||
@@ -306,131 +128,13 @@ export default {
|
||||
}
|
||||
},
|
||||
streamReset({ streamId, startTime }) {
|
||||
console.log('received stream reset', streamId, startTime)
|
||||
if (this.$refs.audioPlayer) {
|
||||
if (this.stream && this.stream.id === streamId) {
|
||||
this.$refs.audioPlayer.resetStream(startTime)
|
||||
}
|
||||
}
|
||||
},
|
||||
async getDownloadStartTime() {
|
||||
var userAudiobook = this.$store.getters['user/getUserAudiobookData'](this.audiobookId)
|
||||
if (!userAudiobook) {
|
||||
console.log('[StreamContainer] getDownloadStartTime no user audiobook record found')
|
||||
return 0
|
||||
}
|
||||
return userAudiobook.currentTime
|
||||
},
|
||||
async playDownload() {
|
||||
if (this.stream) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.terminateStream()
|
||||
}
|
||||
this.stream = null
|
||||
}
|
||||
|
||||
this.lastProgressTimeUpdate = 0
|
||||
console.log('[StreamContainer] Playing local', this.playingDownload)
|
||||
if (!this.$refs.audioPlayer) {
|
||||
console.error('No Audio Player Mini')
|
||||
return
|
||||
}
|
||||
|
||||
var playOnLoad = this.$store.state.playOnLoad
|
||||
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
|
||||
|
||||
var currentTime = await this.getDownloadStartTime()
|
||||
if (isNaN(currentTime) || currentTime === null) currentTime = 0
|
||||
this.currentTime = currentTime
|
||||
|
||||
// Update local current time
|
||||
this.$localStore.setCurrent({
|
||||
audiobookId: this.download.id,
|
||||
lastUpdate: Date.now()
|
||||
})
|
||||
|
||||
var audiobookStreamData = {
|
||||
id: 'download',
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
playWhenReady: !!playOnLoad,
|
||||
startTime: String(Math.floor(currentTime * 1000)),
|
||||
playbackSpeed: this.playbackSpeed || 1,
|
||||
cover: this.download.coverUrl || null,
|
||||
duration: String(Math.floor(this.duration * 1000)),
|
||||
series: this.seriesTxt,
|
||||
token: this.userToken,
|
||||
contentUrl: this.playingDownload.contentUrl,
|
||||
isLocal: true,
|
||||
audiobookId: this.download.id
|
||||
}
|
||||
|
||||
this.$refs.audioPlayer.set(audiobookStreamData, null, false)
|
||||
},
|
||||
streamOpen(stream) {
|
||||
if (this.download) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.terminateStream()
|
||||
}
|
||||
this.download = null
|
||||
}
|
||||
|
||||
this.lastProgressTimeUpdate = 0
|
||||
console.log('[StreamContainer] Stream Open: ' + this.title)
|
||||
|
||||
if (!this.$refs.audioPlayer) {
|
||||
console.error('[StreamContainer] No Audio Player Mini')
|
||||
return
|
||||
}
|
||||
|
||||
// Update local remove current
|
||||
this.$localStore.setCurrent(null)
|
||||
|
||||
var playlistUrl = stream.clientPlaylistUri
|
||||
var currentTime = stream.clientCurrentTime || 0
|
||||
this.currentTime = currentTime
|
||||
var playOnLoad = this.$store.state.playOnLoad
|
||||
if (playOnLoad) this.$store.commit('setPlayOnLoad', false)
|
||||
|
||||
var audiobookStreamData = {
|
||||
id: stream.id,
|
||||
title: this.title,
|
||||
author: this.author,
|
||||
playWhenReady: !!playOnLoad,
|
||||
startTime: String(Math.floor(currentTime * 1000)),
|
||||
playbackSpeed: this.playbackSpeed || 1,
|
||||
cover: this.coverForNative,
|
||||
duration: String(Math.floor(this.duration * 1000)),
|
||||
series: this.seriesTxt,
|
||||
playlistUrl: this.$server.url + playlistUrl,
|
||||
token: this.userToken,
|
||||
audiobookId: this.audiobookId,
|
||||
tracks: this.tracksForCast
|
||||
}
|
||||
console.log('[StreamContainer] Set Audio Player', JSON.stringify(audiobookStreamData))
|
||||
if (!this.$refs.audioPlayer) {
|
||||
console.error('[StreamContainer] Invalid no audio player')
|
||||
} else {
|
||||
console.log('[StreamContainer] Has Audio Player Ref')
|
||||
}
|
||||
this.$refs.audioPlayer.set(audiobookStreamData, stream, !this.stream)
|
||||
|
||||
this.stream = stream
|
||||
},
|
||||
audioPlayerMounted() {
|
||||
console.log('Audio Player Mounted', this.$server.stream)
|
||||
this.audioPlayerReady = true
|
||||
|
||||
if (this.playingDownload) {
|
||||
console.log('[StreamContainer] Play download on audio mount')
|
||||
if (!this.download) {
|
||||
this.download = { ...this.playingDownload }
|
||||
}
|
||||
this.playDownload()
|
||||
} else if (this.$server.stream) {
|
||||
console.log('[StreamContainer] Open stream on audio mount')
|
||||
this.streamOpen(this.$server.stream)
|
||||
}
|
||||
},
|
||||
updatePlaybackSpeed(speed) {
|
||||
if (this.$refs.audioPlayer) {
|
||||
console.log(`[AudioPlayerContainer] Update Playback Speed: ${speed}`)
|
||||
@@ -449,66 +153,150 @@ export default {
|
||||
this.$refs.audioPlayer.setPlaybackSpeed(this.playbackSpeed)
|
||||
}
|
||||
},
|
||||
streamUpdated(type, data) {
|
||||
if (type === 'download') {
|
||||
if (data) {
|
||||
this.download = { ...data }
|
||||
if (this.audioPlayerReady) {
|
||||
this.playDownload()
|
||||
}
|
||||
} else if (this.download) {
|
||||
this.cancelStream()
|
||||
}
|
||||
}
|
||||
},
|
||||
setListeners() {
|
||||
if (!this.$server.socket) {
|
||||
console.error('Invalid server socket not set')
|
||||
return
|
||||
}
|
||||
this.$server.socket.on('stream_open', this.streamOpen)
|
||||
this.$server.socket.on('stream_closed', this.streamClosed)
|
||||
this.$server.socket.on('stream_progress', this.streamProgress)
|
||||
this.$server.socket.on('stream_ready', this.streamReady)
|
||||
this.$server.socket.on('stream_reset', this.streamReset)
|
||||
// if (!this.$server.socket) {
|
||||
// console.error('Invalid server socket not set')
|
||||
// return
|
||||
// }
|
||||
// this.$server.socket.on('stream_open', this.streamOpen)
|
||||
// this.$server.socket.on('stream_closed', this.streamClosed)
|
||||
// this.$server.socket.on('stream_progress', this.streamProgress)
|
||||
// this.$server.socket.on('stream_ready', this.streamReady)
|
||||
// this.$server.socket.on('stream_reset', this.streamReset)
|
||||
},
|
||||
closeStreamOnly() {
|
||||
// If user logs out or disconnects from server, close audio if streaming
|
||||
if (!this.download) {
|
||||
this.$store.commit('setStreamAudiobook', null)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.terminateStream()
|
||||
// If user logs out or disconnects from server and not playing local
|
||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
this.$refs.audioPlayer.closePlayback()
|
||||
}
|
||||
},
|
||||
castLocalItem() {
|
||||
if (!this.serverLibraryItemId) {
|
||||
this.$toast.error(`Cannot cast locally downloaded media`)
|
||||
} else {
|
||||
// Change to server library item
|
||||
this.playServerLibraryItemAndCast(this.serverLibraryItemId)
|
||||
}
|
||||
},
|
||||
playServerLibraryItemAndCast(libraryItemId) {
|
||||
var playbackRate = 1
|
||||
if (this.$refs.audioPlayer) {
|
||||
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
||||
}
|
||||
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId: null, playWhenReady: false, playbackRate })
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
const errorMsg = data.error || 'Failed to play'
|
||||
this.$toast.error(errorMsg)
|
||||
} else {
|
||||
console.log('Library item play response', JSON.stringify(data))
|
||||
AbsAudioPlayer.requestSession()
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to play')
|
||||
})
|
||||
},
|
||||
async playLibraryItem(payload) {
|
||||
var libraryItemId = payload.libraryItemId
|
||||
var episodeId = payload.episodeId
|
||||
|
||||
// When playing local library item and can also play this item from the server
|
||||
// then store the server library item id so it can be used if a cast is made
|
||||
var serverLibraryItemId = payload.serverLibraryItemId || null
|
||||
|
||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Warning',
|
||||
message: `Cannot cast downloaded media items. Confirm to close cast and play on your device.`
|
||||
})
|
||||
if (!value) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.serverLibraryItemId = null
|
||||
|
||||
var playbackRate = 1
|
||||
if (this.$refs.audioPlayer) {
|
||||
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
||||
}
|
||||
|
||||
console.log('Called playLibraryItem', libraryItemId)
|
||||
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate })
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
const errorMsg = data.error || 'Failed to play'
|
||||
this.$toast.error(errorMsg)
|
||||
} else {
|
||||
console.log('Library item play response', JSON.stringify(data))
|
||||
if (!libraryItemId.startsWith('local')) {
|
||||
this.serverLibraryItemId = libraryItemId
|
||||
} else {
|
||||
this.serverLibraryItemId = serverLibraryItemId
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to play')
|
||||
})
|
||||
},
|
||||
pauseItem() {
|
||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isPaused) {
|
||||
this.$refs.audioPlayer.pause()
|
||||
}
|
||||
},
|
||||
onLocalMediaProgressUpdate(localMediaProgress) {
|
||||
console.log('Got local media progress update', localMediaProgress.progress, JSON.stringify(localMediaProgress))
|
||||
this.$store.commit('globals/updateLocalMediaProgress', localMediaProgress)
|
||||
},
|
||||
onMediaPlayerChanged(data) {
|
||||
var mediaPlayer = data.value
|
||||
this.$store.commit('setMediaPlayer', mediaPlayer)
|
||||
},
|
||||
showProgressSyncIsFailing() {
|
||||
if (!isNaN(this.syncFailedToast)) this.$toast.dismiss(this.syncFailedToast)
|
||||
this.syncFailedToast = this.$toast('Progress is not being synced', { timeout: false, type: 'error' })
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.onSleepTimerEndedListener = MyNativeAudio.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
||||
this.onSleepTimerSetListener = MyNativeAudio.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
||||
this.onLocalMediaProgressUpdateListener = AbsAudioPlayer.addListener('onLocalMediaProgressUpdate', this.onLocalMediaProgressUpdate)
|
||||
this.onSleepTimerEndedListener = AbsAudioPlayer.addListener('onSleepTimerEnded', this.onSleepTimerEnded)
|
||||
this.onSleepTimerSetListener = AbsAudioPlayer.addListener('onSleepTimerSet', this.onSleepTimerSet)
|
||||
this.onMediaPlayerChangedListener = AbsAudioPlayer.addListener('onMediaPlayerChanged', this.onMediaPlayerChanged)
|
||||
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
||||
|
||||
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||
|
||||
this.setListeners()
|
||||
this.$eventBus.$on('close_stream', this.closeStreamOnly)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'streamContainer', meth: this.settingsUpdated })
|
||||
this.$store.commit('setStreamListener', this.streamUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||
if (this.onSleepTimerEndedListener) this.onSleepTimerEndedListener.remove()
|
||||
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
||||
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
||||
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
|
||||
|
||||
if (this.$server.socket) {
|
||||
this.$server.socket.off('stream_open', this.streamOpen)
|
||||
this.$server.socket.off('stream_closed', this.streamClosed)
|
||||
this.$server.socket.off('stream_progress', this.streamProgress)
|
||||
this.$server.socket.off('stream_ready', this.streamReady)
|
||||
this.$server.socket.off('stream_reset', this.streamReset)
|
||||
}
|
||||
|
||||
this.$eventBus.$off('close_stream', this.closeStreamOnly)
|
||||
// if (this.$server.socket) {
|
||||
// this.$server.socket.off('stream_open', this.streamOpen)
|
||||
// this.$server.socket.off('stream_closed', this.streamClosed)
|
||||
// this.$server.socket.off('stream_progress', this.streamProgress)
|
||||
// this.$server.socket.off('stream_ready', this.streamReady)
|
||||
// this.$server.socket.off('stream_reset', this.streamReset)
|
||||
// }
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
||||
this.$store.commit('user/removeSettingsListener', 'streamContainer')
|
||||
this.$store.commit('removeStreamListener')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -3,25 +3,31 @@
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-black transition-opacity duration-200" :class="show ? 'bg-opacity-60 pointer-events-auto' : 'bg-opacity-0'" @click="clickBackground" />
|
||||
<div class="absolute top-0 right-0 w-64 h-full bg-primary transform transition-transform py-6 pointer-events-auto" :class="show ? '' : 'translate-x-64'" @click.stop>
|
||||
<div class="px-6 mb-4">
|
||||
<p v-if="socketConnected" class="text-base">
|
||||
<p v-if="user" class="text-base">
|
||||
Welcome,
|
||||
<strong>{{ username }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="w-full overflow-y-auto">
|
||||
<template v-for="item in navItems">
|
||||
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300">
|
||||
<nuxt-link :to="item.to" :key="item.text" class="w-full hover:bg-bg hover:bg-opacity-60 flex items-center py-3 px-6 text-gray-300" :class="currentRoutePath.startsWith(item.to) ? 'bg-bg bg-opacity-60' : ''">
|
||||
<span class="text-lg" :class="item.iconOutlined ? 'material-icons-outlined' : 'material-icons'">{{ item.icon }}</span>
|
||||
<p class="pl-4">{{ item.text }}</p>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full flex items-center py-6 px-6 text-gray-300">
|
||||
<p class="text-xs">{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
<div v-if="socketConnected" class="flex items-center" @click="logout">
|
||||
<p class="text-xs pr-2">Logout</p>
|
||||
<span class="material-icons text-sm">logout</span>
|
||||
<div class="absolute bottom-0 left-0 w-full py-6 px-6 text-gray-300">
|
||||
<div v-if="serverConnectionConfig" class="mb-4 flex justify-center">
|
||||
<p class="text-xs" style="word-break: break-word">{{ serverConnectionConfig.address }} (v{{ serverSettings.version }})</p>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<p class="text-xs">{{ $config.version }}</p>
|
||||
<div class="flex-grow" />
|
||||
<div v-if="user" class="flex items-center" @click="logout">
|
||||
<p class="text-xs pr-2">Logout</p>
|
||||
<span class="material-icons text-sm">logout</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -62,37 +68,24 @@ export default {
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
serverConnectionConfig() {
|
||||
return this.$store.state.user.serverConnectionConfig
|
||||
},
|
||||
serverSettings() {
|
||||
return this.$store.state.serverSettings || {}
|
||||
},
|
||||
username() {
|
||||
return this.user ? this.user.username : ''
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
},
|
||||
navItems() {
|
||||
var items = [
|
||||
{
|
||||
icon: 'home',
|
||||
text: 'Home',
|
||||
to: '/bookshelf'
|
||||
},
|
||||
{
|
||||
icon: 'person',
|
||||
text: 'Account',
|
||||
to: '/account'
|
||||
},
|
||||
{
|
||||
icon: 'folder',
|
||||
iconOutlined: true,
|
||||
text: 'Downloads',
|
||||
to: '/downloads'
|
||||
}
|
||||
// {
|
||||
// icon: 'settings',
|
||||
// text: 'Settings',
|
||||
// to: '/config'
|
||||
// }
|
||||
]
|
||||
if (!this.socketConnected) {
|
||||
if (!this.serverConnectionConfig) {
|
||||
items = [
|
||||
{
|
||||
icon: 'cloud_off',
|
||||
@@ -100,8 +93,31 @@ export default {
|
||||
to: '/connect'
|
||||
}
|
||||
].concat(items)
|
||||
} else {
|
||||
items.push({
|
||||
icon: 'person',
|
||||
text: 'Account',
|
||||
to: '/account'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.$platform !== 'ios') {
|
||||
items.push({
|
||||
icon: 'folder',
|
||||
iconOutlined: true,
|
||||
text: 'Local Media',
|
||||
to: '/localMedia/folders'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
icon: 'settings',
|
||||
text: 'Settings',
|
||||
to: '/settings'
|
||||
})
|
||||
return items
|
||||
},
|
||||
currentRoutePath() {
|
||||
return this.$route.path
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -109,10 +125,16 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
async logout() {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
this.$server.logout()
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
}
|
||||
|
||||
this.$socket.logout()
|
||||
await this.$db.logout()
|
||||
this.$localStore.removeLastLibraryId()
|
||||
this.$store.commit('user/logout')
|
||||
this.$router.push('/connect')
|
||||
},
|
||||
touchstart(e) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div id="bookshelf" class="w-full max-w-full h-full">
|
||||
<template v-for="shelf in totalShelves">
|
||||
<div :key="shelf" class="w-full px-2 bookshelfRow relative" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
|
||||
<div class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
|
||||
<div :key="shelf" class="w-full px-2 relative" :class="showBookshelfListView ? '' : 'bookshelfRow'" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
|
||||
<div v-if="!showBookshelfListView" class="bookshelfDivider w-full absolute bottom-0 left-0 z-30" style="min-height: 16px" :class="`h-${shelfDividerHeightIndex}`" />
|
||||
<div v-else class="flex border-t border-white border-opacity-10" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -24,13 +25,13 @@ export default {
|
||||
mixins: [bookshelfCardsHelpers],
|
||||
data() {
|
||||
return {
|
||||
routeFullPath: null,
|
||||
entitiesPerShelf: 2,
|
||||
bookshelfHeight: 0,
|
||||
bookshelfWidth: 0,
|
||||
bookshelfMarginLeft: 0,
|
||||
shelvesPerPage: 0,
|
||||
entitiesPerShelf: 8,
|
||||
currentPage: 0,
|
||||
currentBookWidth: 0,
|
||||
booksPerFetch: 20,
|
||||
initialized: false,
|
||||
currentSFQueryString: null,
|
||||
@@ -42,12 +43,18 @@ export default {
|
||||
entityIndexesMounted: [],
|
||||
pagesLoaded: {},
|
||||
isFirstInit: false,
|
||||
pendingReset: false
|
||||
pendingReset: false,
|
||||
localLibraryItems: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showBookshelfListView(newVal) {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isSocketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
isBookEntity() {
|
||||
return this.entityName === 'books' || this.entityName === 'series-books'
|
||||
@@ -56,21 +63,19 @@ export default {
|
||||
if (this.isBookEntity) return 4
|
||||
return 6
|
||||
},
|
||||
bookshelfListView() {
|
||||
return this.$store.state.globals.bookshelfListView
|
||||
},
|
||||
showBookshelfListView() {
|
||||
return this.isBookEntity && this.bookshelfListView
|
||||
},
|
||||
entityName() {
|
||||
return this.page
|
||||
},
|
||||
bookshelfView() {
|
||||
return this.$store.state.bookshelfView
|
||||
},
|
||||
hasFilter() {
|
||||
if (this.page === 'series' || this.page === 'collections') return false
|
||||
return this.filterBy !== 'all'
|
||||
},
|
||||
isListView() {
|
||||
return this.bookshelfView === 'list'
|
||||
},
|
||||
books() {
|
||||
return this.$store.getters['downloads/getAudiobooks']
|
||||
},
|
||||
orderBy() {
|
||||
return this.$store.getters['user/getUserSetting']('mobileOrderBy')
|
||||
},
|
||||
@@ -80,14 +85,11 @@ export default {
|
||||
filterBy() {
|
||||
return this.$store.getters['user/getUserSetting']('mobileFilterBy')
|
||||
},
|
||||
coverAspectRatio() {
|
||||
return this.$store.getters['getServerSetting']('coverAspectRatio')
|
||||
},
|
||||
isCoverSquareAspectRatio() {
|
||||
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.isCoverSquareAspectRatio ? 1 : 1.6
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
bookWidth() {
|
||||
var coverSize = 100
|
||||
@@ -101,33 +103,28 @@ export default {
|
||||
return this.bookWidth * 1.6
|
||||
},
|
||||
entityWidth() {
|
||||
if (this.showBookshelfListView) return this.bookshelfWidth - 16
|
||||
if (this.isBookEntity) return this.bookWidth
|
||||
return this.bookWidth * 2
|
||||
},
|
||||
entityHeight() {
|
||||
if (this.showBookshelfListView) return 88
|
||||
return this.bookHeight
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
currentLibraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
shelfHeight() {
|
||||
if (this.showBookshelfListView) return this.entityHeight + 16
|
||||
return this.entityHeight + 40
|
||||
},
|
||||
totalEntityCardWidth() {
|
||||
if (this.showBookshelfListView) return this.entityWidth
|
||||
// Includes margin
|
||||
return this.entityWidth + 24
|
||||
},
|
||||
downloads() {
|
||||
return this.$store.getters['downloads/getDownloads']
|
||||
},
|
||||
downloadedBooks() {
|
||||
return this.downloads.map((dl) => {
|
||||
var download = { ...dl }
|
||||
var ab = { ...download.audiobook }
|
||||
delete download.audiobook
|
||||
ab.download = download
|
||||
return ab
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -144,19 +141,16 @@ export default {
|
||||
if (!this.initialized) {
|
||||
this.currentSFQueryString = this.buildSearchParams()
|
||||
}
|
||||
var entityPath = this.entityName === 'books' ? `books/all` : this.entityName
|
||||
|
||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
var queryString = `?${sfQueryString}&limit=${this.booksPerFetch}&page=${page}`
|
||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||
|
||||
if (this.entityName === 'series-books') {
|
||||
entityPath = `series/${this.seriesId}`
|
||||
queryString = ''
|
||||
}
|
||||
|
||||
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${queryString}`).catch((error) => {
|
||||
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
console.error('failed to fetch books', error)
|
||||
return null
|
||||
})
|
||||
|
||||
this.isFetchingEntities = false
|
||||
if (this.pendingReset) {
|
||||
this.pendingReset = false
|
||||
@@ -174,25 +168,26 @@ export default {
|
||||
}
|
||||
|
||||
for (let i = 0; i < payload.results.length; i++) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
// Check if has download and append download obj
|
||||
var download = this.downloads.find((dl) => dl.id === payload.results[i].id)
|
||||
if (download) {
|
||||
var dl = { ...download }
|
||||
delete dl.audiobook
|
||||
payload.results[i].download = dl
|
||||
}
|
||||
}
|
||||
|
||||
var index = i + startIndex
|
||||
this.entities[index] = payload.results[i]
|
||||
if (this.entityComponentRefs[index]) {
|
||||
this.entityComponentRefs[index].setEntity(this.entities[index])
|
||||
|
||||
if (this.isBookEntity) {
|
||||
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id)
|
||||
if (localLibraryItem) {
|
||||
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async loadPage(page) {
|
||||
if (!this.currentLibraryId) {
|
||||
console.error('[LazyBookshelf] loadPage current library id not set')
|
||||
return
|
||||
}
|
||||
this.pagesLoaded[page] = true
|
||||
await this.fetchEntities(page)
|
||||
},
|
||||
@@ -224,6 +219,7 @@ export default {
|
||||
this.loadPage(lastBookPage)
|
||||
}
|
||||
|
||||
// Remove entities out of view
|
||||
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
|
||||
if (_index < firstBookIndex || _index >= lastBookIndex) {
|
||||
var el = document.getElementById(`book-card-${_index}`)
|
||||
@@ -243,7 +239,7 @@ export default {
|
||||
},
|
||||
setDownloads() {
|
||||
if (this.entityName === 'books') {
|
||||
this.entities = this.downloadedBooks
|
||||
this.entities = []
|
||||
// TOOD: Sort and filter here
|
||||
this.totalEntities = this.entities.length
|
||||
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
|
||||
@@ -271,14 +267,12 @@ export default {
|
||||
this.initialized = false
|
||||
|
||||
this.initSizeData()
|
||||
if (this.isSocketConnected) {
|
||||
if (this.user) {
|
||||
await this.loadPage(0)
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||
this.mountEntites(0, lastBookIndex)
|
||||
} else {
|
||||
this.setDownloads()
|
||||
|
||||
this.mountEntites(0, this.totalEntities - 1)
|
||||
// Local only
|
||||
}
|
||||
},
|
||||
remountEntities() {
|
||||
@@ -304,12 +298,13 @@ export default {
|
||||
var { clientHeight, clientWidth } = bookshelf
|
||||
this.bookshelfHeight = clientHeight
|
||||
this.bookshelfWidth = clientWidth
|
||||
this.entitiesPerShelf = Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
|
||||
|
||||
this.entitiesPerShelf = this.showBookshelfListView ? 1 : Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
|
||||
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
||||
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
||||
|
||||
this.currentBookWidth = this.bookWidth
|
||||
const entitiesPerPage = this.shelvesPerPage * this.entitiesPerShelf
|
||||
this.booksPerFetch = Math.ceil(entitiesPerPage / 20) * 20 // Round up to the nearest 20
|
||||
|
||||
if (this.totalEntities) {
|
||||
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
|
||||
}
|
||||
@@ -317,43 +312,52 @@ export default {
|
||||
},
|
||||
async init() {
|
||||
if (this.isFirstInit) return
|
||||
|
||||
this.localLibraryItems = await this.$db.getLocalLibraryItems(this.currentLibraryMediaType)
|
||||
console.log('Local library items loaded for lazy bookshelf', this.localLibraryItems.length)
|
||||
|
||||
this.isFirstInit = true
|
||||
this.initSizeData()
|
||||
|
||||
await this.loadPage(0)
|
||||
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
|
||||
this.mountEntites(0, lastBookIndex)
|
||||
},
|
||||
initDownloads() {
|
||||
this.initSizeData()
|
||||
this.setDownloads()
|
||||
this.$nextTick(() => {
|
||||
console.log('Mounting downloads', this.totalEntities, 'total shelves', this.totalShelves)
|
||||
this.mountEntites(0, this.totalEntities)
|
||||
})
|
||||
|
||||
// Set last scroll position for this bookshelf page
|
||||
if (this.$store.state.lastBookshelfScrollData[this.page] && window['bookshelf-wrapper']) {
|
||||
const { path, scrollTop } = this.$store.state.lastBookshelfScrollData[this.page]
|
||||
if (path === this.routeFullPath) {
|
||||
// Exact path match with query so use scroll position
|
||||
window['bookshelf-wrapper'].scrollTop = scrollTop
|
||||
}
|
||||
}
|
||||
},
|
||||
scroll(e) {
|
||||
if (!e || !e.target) return
|
||||
if (!this.isSocketConnected) return // Offline books are all mounted at once
|
||||
if (!this.user) return
|
||||
var { scrollTop } = e.target
|
||||
this.handleScroll(scrollTop)
|
||||
},
|
||||
socketInit(isConnected) {
|
||||
if (isConnected) {
|
||||
this.init()
|
||||
} else {
|
||||
this.isFirstInit = false
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
buildSearchParams() {
|
||||
let searchParams = new URLSearchParams()
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
if (this.page === 'search' || this.page === 'series' || this.page === 'collections') {
|
||||
return ''
|
||||
}
|
||||
if (this.orderBy) {
|
||||
searchParams.set('sort', this.orderBy)
|
||||
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
||||
|
||||
let searchParams = new URLSearchParams()
|
||||
if (this.page === 'series-books') {
|
||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||
searchParams.set('sort', 'book.volumeNumber')
|
||||
searchParams.set('desc', 0)
|
||||
} else {
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
}
|
||||
if (this.orderBy) {
|
||||
searchParams.set('sort', this.orderBy)
|
||||
searchParams.set('desc', this.orderDesc ? 1 : 0)
|
||||
}
|
||||
if (this.collapseSeries) {
|
||||
searchParams.set('collapseseries', 1)
|
||||
}
|
||||
}
|
||||
return searchParams.toString()
|
||||
},
|
||||
@@ -368,6 +372,8 @@ export default {
|
||||
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||
window.history.replaceState({ path: newurl }, '', newurl)
|
||||
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -379,54 +385,56 @@ export default {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
libraryChanged(libid) {
|
||||
libraryChanged() {
|
||||
if (this.hasFilter) {
|
||||
this.clearFilter()
|
||||
} else {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
downloadsLoaded() {
|
||||
if (!this.isSocketConnected) {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
audiobookAdded(audiobook) {
|
||||
console.log('Audiobook added', audiobook)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
libraryItemAdded(libraryItem) {
|
||||
console.log('libraryItem added', libraryItem)
|
||||
// TODO: Check if item would be on this shelf
|
||||
this.resetEntities()
|
||||
},
|
||||
audiobookUpdated(audiobook) {
|
||||
console.log('Audiobook updated', audiobook)
|
||||
libraryItemUpdated(libraryItem) {
|
||||
console.log('Item updated', libraryItem)
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities[indexOf] = audiobook
|
||||
this.entities[indexOf] = libraryItem
|
||||
if (this.entityComponentRefs[indexOf]) {
|
||||
this.entityComponentRefs[indexOf].setEntity(audiobook)
|
||||
this.entityComponentRefs[indexOf].setEntity(libraryItem)
|
||||
|
||||
if (this.isBookEntity) {
|
||||
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == libraryItem.id)
|
||||
if (localLibraryItem) {
|
||||
this.entityComponentRefs[indexOf].setLocalLibraryItem(localLibraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
audiobookRemoved(audiobook) {
|
||||
libraryItemRemoved(libraryItem) {
|
||||
if (this.entityName === 'books' || this.entityName === 'series-books') {
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === audiobook.id)
|
||||
var indexOf = this.entities.findIndex((ent) => ent && ent.id === libraryItem.id)
|
||||
if (indexOf >= 0) {
|
||||
this.entities = this.entities.filter((ent) => ent.id !== audiobook.id)
|
||||
this.entities = this.entities.filter((ent) => ent.id !== libraryItem.id)
|
||||
this.totalEntities = this.entities.length
|
||||
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
|
||||
this.remountEntities()
|
||||
this.executeRebuild()
|
||||
}
|
||||
}
|
||||
},
|
||||
audiobooksAdded(audiobooks) {
|
||||
console.log('audiobooks added', audiobooks)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('items added', libraryItems)
|
||||
// TODO: Check if item would be on this shelf
|
||||
this.resetEntities()
|
||||
},
|
||||
audiobooksUpdated(audiobooks) {
|
||||
audiobooks.forEach((ab) => {
|
||||
this.audiobookUpdated(ab)
|
||||
libraryItemsUpdated(libraryItems) {
|
||||
libraryItems.forEach((ab) => {
|
||||
this.libraryItemUpdated(ab)
|
||||
})
|
||||
},
|
||||
initListeners() {
|
||||
@@ -434,55 +442,48 @@ export default {
|
||||
if (bookshelf) {
|
||||
bookshelf.addEventListener('scroll', this.scroll)
|
||||
}
|
||||
// this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
|
||||
// this.$eventBus.$on('bookshelf-select-all', this.selectAllEntities)
|
||||
// this.$eventBus.$on('bookshelf-keyword-filter', this.updateKeywordFilter)
|
||||
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
this.$eventBus.$on('downloads-loaded', this.downloadsLoaded)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
||||
|
||||
if (this.$server.socket) {
|
||||
this.$server.socket.on('audiobook_updated', this.audiobookUpdated)
|
||||
this.$server.socket.on('audiobook_added', this.audiobookAdded)
|
||||
this.$server.socket.on('audiobook_removed', this.audiobookRemoved)
|
||||
this.$server.socket.on('audiobooks_updated', this.audiobooksUpdated)
|
||||
this.$server.socket.on('audiobooks_added', this.audiobooksAdded)
|
||||
} else {
|
||||
console.error('Bookshelf - Socket not initialized')
|
||||
}
|
||||
this.$socket.$on('item_updated', this.libraryItemUpdated)
|
||||
this.$socket.$on('item_added', this.libraryItemAdded)
|
||||
this.$socket.$on('item_removed', this.libraryItemRemoved)
|
||||
this.$socket.$on('items_updated', this.libraryItemsUpdated)
|
||||
this.$socket.$on('items_added', this.libraryItemsAdded)
|
||||
},
|
||||
removeListeners() {
|
||||
var bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
if (bookshelf) {
|
||||
bookshelf.removeEventListener('scroll', this.scroll)
|
||||
}
|
||||
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
this.$eventBus.$off('downloads-loaded', this.downloadsLoaded)
|
||||
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
||||
|
||||
if (this.$server.socket) {
|
||||
this.$server.socket.off('audiobook_updated', this.audiobookUpdated)
|
||||
this.$server.socket.off('audiobook_added', this.audiobookAdded)
|
||||
this.$server.socket.off('audiobook_removed', this.audiobookRemoved)
|
||||
this.$server.socket.off('audiobooks_updated', this.audiobooksUpdated)
|
||||
this.$server.socket.off('audiobooks_added', this.audiobooksAdded)
|
||||
} else {
|
||||
console.error('Bookshelf - Socket not initialized')
|
||||
}
|
||||
this.$socket.$off('item_updated', this.libraryItemUpdated)
|
||||
this.$socket.$off('item_added', this.libraryItemAdded)
|
||||
this.$socket.$off('item_removed', this.libraryItemRemoved)
|
||||
this.$socket.$off('items_updated', this.libraryItemsUpdated)
|
||||
this.$socket.$off('items_added', this.libraryItemsAdded)
|
||||
}
|
||||
},
|
||||
updated() {
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||
},
|
||||
mounted() {
|
||||
if (this.$server.initialized) {
|
||||
this.init()
|
||||
} else {
|
||||
this.initDownloads()
|
||||
}
|
||||
this.$server.on('initialized', this.socketInit)
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '')
|
||||
|
||||
this.init()
|
||||
this.initListeners()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$server.off('initialized', this.socketInit)
|
||||
this.removeListeners()
|
||||
|
||||
// Set bookshelf scroll position for specific bookshelf page and query
|
||||
if (window['bookshelf-wrapper']) {
|
||||
this.$store.commit('setLastBookshelfScrollData', { scrollTop: window['bookshelf-wrapper'].scrollTop || 0, path: this.routeFullPath, name: this.page })
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
<div class="w-full relative">
|
||||
<div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
|
||||
<template v-for="(entity, index) in entities">
|
||||
<cards-lazy-book-card v-if="type === 'books'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
|
||||
<cards-lazy-book-card v-if="type === 'book' || type === 'podcast'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
|
||||
<cards-lazy-book-card v-if="type === 'episode'" :key="entity.recentEpisode.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
|
||||
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
|
||||
<cards-author-card v-else-if="type === 'authors'" :key="entity.id" :width="bookWidth / 1.25" :height="bookWidth" :author="entity" :size-multiplier="1" class="mx-2" />
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
<covers-author-image :author="author" />
|
||||
|
||||
<!-- Author name & num books overlay -->
|
||||
<div v-show="!searching && !nameBelow" class="absolute bottom-0 left-0 w-full py-1 bg-black bg-opacity-60 px-2">
|
||||
<p class="text-center font-semibold truncate" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
<p class="text-center text-gray-200" :style="{ fontSize: sizeMultiplier * 0.65 + 'rem' }">{{ numBooks }} Book{{ numBooks === 1 ? '' : 's' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading spinner -->
|
||||
<div v-show="searching" class="absolute top-0 left-0 z-10 w-full h-full bg-black bg-opacity-50 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="nameBelow" class="w-full py-1 px-2">
|
||||
<p class="text-center font-semibold truncate text-gray-200" :style="{ fontSize: sizeMultiplier * 0.75 + 'rem' }">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
sizeMultiplier: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
nameBelow: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
searching: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
authorId() {
|
||||
return this._author.id
|
||||
},
|
||||
name() {
|
||||
return this._author.name || ''
|
||||
},
|
||||
numBooks() {
|
||||
return this._author.numBooks || 0
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async searchAuthor() {
|
||||
this.searching = true
|
||||
var response = await this.$axios.$post(`/api/authors/${this.authorId}/match`, { q: this.name }).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return null
|
||||
})
|
||||
if (!response) {
|
||||
this.$toast.error('Author not found')
|
||||
} else if (response.updated) {
|
||||
if (response.author.imagePath) this.$toast.success('Author was updated')
|
||||
else this.$toast.success('Author was updated (no image found)')
|
||||
} else {
|
||||
this.$toast.info('No updates were made for Author')
|
||||
}
|
||||
this.searching = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,6 +1,13 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden shadow-sm">
|
||||
<!-- <img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" /> -->
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<div class="overflow-hidden bg-primary rounded" style="height: 50px; width: 40px">
|
||||
<covers-author-image :author="author" />
|
||||
</div>
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="flex h-full px-1 overflow-hidden shadow-sm">
|
||||
<div style="max-height: 48px; max-width: 48px" class="w-12 h-12 bg-primary overflow-hidden rounded">
|
||||
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||
@@ -19,18 +26,25 @@
|
||||
<div class="flex-grow px-2 authorSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ author }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: String
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
name() {
|
||||
return this.author.name
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 h-full audiobookSearchCardContent">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 audiobookSearchCardContent">
|
||||
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
|
||||
<p v-else class="truncate text-sm" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
|
||||
|
||||
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags'" class="m-0 p-0 truncate" v-html="matchHtml" />
|
||||
<div v-if="matchKey === 'series' || matchKey === 'tags' || matchKey === 'isbn' || matchKey === 'asin'" class="m-0 p-0 truncate text-xs" v-html="matchHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
@@ -37,17 +37,27 @@ export default {
|
||||
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
|
||||
return 50
|
||||
},
|
||||
book() {
|
||||
return this.audiobook ? this.audiobook.book || {} : {}
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
title() {
|
||||
return this.book ? this.book.title : 'No Title'
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
subtitle() {
|
||||
return this.book ? this.book.subtitle : ''
|
||||
return this.mediaMetadata.subtitle
|
||||
},
|
||||
authorFL() {
|
||||
return this.book ? this.book.authorFL : 'Unknown'
|
||||
authorName() {
|
||||
if (this.isPodcast) return this.mediaMetadata.author
|
||||
return this.mediaMetadata.authorName
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
@@ -69,7 +79,9 @@ export default {
|
||||
html += lastPart
|
||||
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
|
||||
if (this.matchKey === 'authorFL') return `by ${html}`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
|
||||
return `${html}`
|
||||
}
|
||||
@@ -1,16 +1,27 @@
|
||||
<template>
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ width: width + 'px', minWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
|
||||
<!-- When cover image does not fill -->
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<!-- Alternative bookshelf title/author/sort -->
|
||||
<!-- <div v-if="isAlternativeBookshelfView" class="absolute left-0 z-50 w-full" :style="{ bottom: `-${titleDisplayBottomOffset}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: 0.9 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
</div> -->
|
||||
|
||||
<div v-if="booksInSeries" class="absolute z-20 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ booksInSeries }}</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="hasCover ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
|
||||
<!-- Placeholder Cover Title & Author -->
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
@@ -23,25 +34,43 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Downloaded indicator icon -->
|
||||
<div v-if="hasDownload" class="absolute z-10" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons text-success" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">download_done</span>
|
||||
<!-- Play/pause button for podcast episode -->
|
||||
<div v-if="recentEpisode" class="absolute z-10 top-0 left-0 bottom-0 right-0 m-auto flex items-center justify-center w-12 h-12 rounded-full bg-white bg-opacity-70">
|
||||
<span class="material-icons text-6xl text-black text-opacity-80">{{ streamIsPlaying ? 'pause_circle' : 'play_circle_filled' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Progress bar -->
|
||||
<div class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
<!-- No progress shown for collapsed series in library -->
|
||||
<div v-if="!collapsedSeries && (!isPodcast || recentEpisode)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
|
||||
|
||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Error widget -->
|
||||
<div v-if="showError" :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
|
||||
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
|
||||
</div>
|
||||
|
||||
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
|
||||
<!-- Series sequence -->
|
||||
<div v-if="seriesSequence && showSequence && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ seriesSequence }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Episode # -->
|
||||
<div v-if="recentEpisodeNumber && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="numEpisodes && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
@@ -54,119 +83,225 @@ export default {
|
||||
default: 192
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
showVolumeNumber: Boolean,
|
||||
showSequence: Boolean,
|
||||
bookshelfView: Number,
|
||||
bookMount: {
|
||||
// Book can be passed as prop or set with setEntity()
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
orderBy: String,
|
||||
filterBy: String,
|
||||
sortingIgnorePrefix: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isHovering: false,
|
||||
isMoreMenuOpen: false,
|
||||
isProcessingReadUpdate: false,
|
||||
audiobook: null,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
showCoverBg: false
|
||||
showCoverBg: false,
|
||||
localLibraryItem: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
bookMount: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.libraryItem = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_audiobook() {
|
||||
return this.audiobook || {}
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
isLocal() {
|
||||
return !!this._libraryItem.isLocal
|
||||
},
|
||||
isLocalOnly() {
|
||||
// Local item with no server match
|
||||
return this.isLocal && !this._libraryItem.libraryItemId
|
||||
},
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this._libraryItem.mediaType
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
hasDownload() {
|
||||
return !!this._audiobook.download
|
||||
},
|
||||
downloadedCover() {
|
||||
if (!this._audiobook.download) return null
|
||||
return this._audiobook.download.cover
|
||||
},
|
||||
bookCoverSrc() {
|
||||
if (this.downloadedCover) return this.downloadedCover
|
||||
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
|
||||
if (this.isLocal) {
|
||||
if (this.libraryItem.coverContentUrl) return Capacitor.convertFileSrc(this.libraryItem.coverContentUrl)
|
||||
return this.placeholderUrl
|
||||
}
|
||||
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||
},
|
||||
audiobookId() {
|
||||
return this._audiobook.id
|
||||
libraryItemId() {
|
||||
return this._libraryItem.id
|
||||
},
|
||||
libraryId() {
|
||||
return this._libraryItem.libraryId
|
||||
},
|
||||
hasEbook() {
|
||||
return this._audiobook.numEbooks
|
||||
return this.media.ebookFile
|
||||
},
|
||||
hasTracks() {
|
||||
return this._audiobook.numTracks
|
||||
numTracks() {
|
||||
return this.media.numTracks
|
||||
},
|
||||
book() {
|
||||
return this._audiobook.book || {}
|
||||
numEpisodes() {
|
||||
return this.media.numEpisodes
|
||||
},
|
||||
processingBatch() {
|
||||
return this.store.state.processingBatch
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to audiobook object when collapseSeries is enabled
|
||||
return this._libraryItem.booksInSeries
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.book.cover
|
||||
return !!this.media.coverPath
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.squareAspectRatio ? 160 : 100
|
||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||
return this.width / baseSize
|
||||
},
|
||||
title() {
|
||||
return this.book.title || ''
|
||||
return this.mediaMetadata.title || ''
|
||||
},
|
||||
playIconFontSize() {
|
||||
return Math.max(2, 3 * this.sizeMultiplier)
|
||||
},
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
author() {
|
||||
return this.book.author
|
||||
},
|
||||
authorFL() {
|
||||
return this.book.authorFL || this.author
|
||||
if (this.isPodcast) return this.mediaMetadata.author
|
||||
return this.mediaMetadata.authorName
|
||||
},
|
||||
authorLF() {
|
||||
return this.book.authorLF || this.author
|
||||
return this.mediaMetadata.authorNameLF
|
||||
},
|
||||
volumeNumber() {
|
||||
return this.book.volumeNumber || null
|
||||
series() {
|
||||
// Only included when filtering by series or collapse series
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
},
|
||||
recentEpisode() {
|
||||
// Only added to item when getting currently listening podcasts
|
||||
return this._libraryItem.recentEpisode
|
||||
},
|
||||
recentEpisodeNumber() {
|
||||
if (!this.recentEpisode) return null
|
||||
if (this.recentEpisode.episode) {
|
||||
return this.recentEpisode.episode.replace(/^#/, '')
|
||||
}
|
||||
return this.recentEpisode.index
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this._libraryItem.collapsedSeries
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
||||
return this.title.substr(4) + ', The'
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
displayAuthor() {
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
if (this.orderBy === 'duration') return 'Duration: ' + this.$elapsedPrettyExtended(this.media.duration, false)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
return null
|
||||
},
|
||||
episodeProgress() {
|
||||
// Only used on home page currently listening podcast shelf
|
||||
if (!this.recentEpisode) return null
|
||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.recentEpisode.id)
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.recentEpisode.id)
|
||||
},
|
||||
userProgress() {
|
||||
return this.store.getters['user/getUserAudiobook'](this.audiobookId)
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
if (this.isLocal) return this.store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
},
|
||||
userIsRead() {
|
||||
return this.userProgress ? !!this.userProgress.isRead : false
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
||||
return this.numMissingParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
playerIsLocal() {
|
||||
return !!this.$store.state.playerIsLocal
|
||||
},
|
||||
localLibraryItemId() {
|
||||
if (this.isLocal) return this.libraryItemId
|
||||
return this.localLibraryItem ? this.localLibraryItem.id : null
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
|
||||
if (this.isPodcast) {
|
||||
if (this.playerIsLocal) {
|
||||
// Check is streaming local version of this episode
|
||||
return false // episode cards not implemented for local yet
|
||||
}
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.recentEpisode.id)
|
||||
} else {
|
||||
return false // not yet necessary for books
|
||||
}
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
},
|
||||
isMissing() {
|
||||
return this._audiobook.isMissing
|
||||
return this._libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this._audiobook.isInvalid
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this._audiobook.hasMissingParts
|
||||
numMissingParts() {
|
||||
if (this.isPodcast) return 0
|
||||
return this.media.numMissingParts
|
||||
},
|
||||
hasInvalidParts() {
|
||||
return this._audiobook.hasInvalidParts
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Audiobook directory is missing!'
|
||||
else if (this.isInvalid) return 'Audiobook has no audio tracks & ebook'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||
else classes.push('bg-opacity-40')
|
||||
if (this.selected) {
|
||||
classes.push('border-2 border-yellow-400')
|
||||
}
|
||||
if (this.hasInvalidParts) {
|
||||
if (this.hasMissingParts) txt += ' '
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
return classes
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
@@ -206,11 +341,11 @@ export default {
|
||||
return this.title
|
||||
},
|
||||
authorCleaned() {
|
||||
if (!this.authorFL) return ''
|
||||
if (this.authorFL.length > 30) {
|
||||
return this.authorFL.slice(0, 27) + '...'
|
||||
if (!this.author) return ''
|
||||
if (this.author.length > 30) {
|
||||
return this.author.slice(0, 27) + '...'
|
||||
}
|
||||
return this.authorFL
|
||||
return this.author
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -218,22 +353,125 @@ export default {
|
||||
this.isSelectionMode = val
|
||||
if (!val) this.selected = false
|
||||
},
|
||||
setEntity(audiobook) {
|
||||
this.audiobook = audiobook
|
||||
setEntity(_libraryItem) {
|
||||
var libraryItem = _libraryItem
|
||||
|
||||
// this code block is only necessary when showing a selected series with sequence #
|
||||
// it will update the selected series so we get realtime updates for series sequence changes
|
||||
if (this.series) {
|
||||
// i know.. but the libraryItem passed to this func cannot be modified so we need to create a copy
|
||||
libraryItem = {
|
||||
..._libraryItem,
|
||||
media: {
|
||||
..._libraryItem.media,
|
||||
metadata: {
|
||||
..._libraryItem.media.metadata
|
||||
}
|
||||
}
|
||||
}
|
||||
var mediaMetadata = libraryItem.media.metadata
|
||||
if (mediaMetadata.series) {
|
||||
var newSeries = mediaMetadata.series.find((se) => se.id === this.series.id)
|
||||
if (newSeries) {
|
||||
// update selected series
|
||||
libraryItem.media.metadata.series = newSeries
|
||||
this.libraryItem = libraryItem
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.libraryItem = libraryItem
|
||||
},
|
||||
setLocalLibraryItem(localLibraryItem) {
|
||||
// Server books may have a local library item
|
||||
this.localLibraryItem = localLibraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
} else if (this.recentEpisode) {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) eventBus.$emit('pause-item')
|
||||
else eventBus.$emit('play-item', { libraryItemId: this.libraryItemId, episodeId: this.recentEpisode.id })
|
||||
} else {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
if (router) router.push(`/audiobook/${this.audiobookId}`)
|
||||
if (router) {
|
||||
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
var updatePayload = {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
var toast = this.$toast || this.$nuxt.$toast
|
||||
var axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete item was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete item was removed`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
showEditModalTracks() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||
},
|
||||
showEditModalMatch() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||
},
|
||||
showEditModalDownload() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'download' })
|
||||
},
|
||||
openCollections() {
|
||||
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.store.commit('globals/setShowUserCollectionsModal', true)
|
||||
},
|
||||
clickReadEBook() {
|
||||
this.store.commit('showEReader', this.libraryItem)
|
||||
},
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.selected = !this.selected
|
||||
this.$emit('select', this.audiobook)
|
||||
this.$emit('select', this.libraryItem)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
@@ -272,6 +510,10 @@ export default {
|
||||
mounted() {
|
||||
if (this.bookMount) {
|
||||
this.setEntity(this.bookMount)
|
||||
|
||||
if (this.bookMount.localLibraryItem) {
|
||||
this.setLocalLibraryItem(this.bookMount.localLibraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer py-1" @click="clickCard">
|
||||
<div class="h-full flex relative">
|
||||
<div class="w-20 h-20 relative" style="min-width: 80px; max-width: 80px">
|
||||
<!-- When cover image does not fill -->
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 bg-primary">
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series or podcasts in library -->
|
||||
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 80 * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
{{ displayTitle }}<span v-if="seriesSequence"> #{{ seriesSequence }}</span>
|
||||
</p>
|
||||
<p class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">by {{ displayAuthor }}</p>
|
||||
<p v-if="displaySortLine" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displaySortLine }}</p>
|
||||
<p v-if="duration" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ $elapsedPretty(duration) }}</p>
|
||||
<p v-if="episodes" class="truncate text-gray-400" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ episodes }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="localLibraryItem || isLocal" class="absolute top-0 right-0 z-20" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
|
||||
<span class="material-icons text-2xl text-success">{{ isLocalOnly ? 'task' : 'download_done' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
height: {
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
showSequence: Boolean,
|
||||
bookshelfView: Number,
|
||||
bookMount: {
|
||||
// Book can be passed as prop or set with setEntity()
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
orderBy: String,
|
||||
filterBy: String,
|
||||
sortingIgnorePrefix: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
libraryItem: null,
|
||||
imageReady: false,
|
||||
rescanning: false,
|
||||
selected: false,
|
||||
isSelectionMode: false,
|
||||
showCoverBg: false,
|
||||
localLibraryItem: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
bookMount: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.libraryItem = newVal
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
isLocal() {
|
||||
return !!this._libraryItem.isLocal
|
||||
},
|
||||
isLocalOnly() {
|
||||
// Local item with no server match
|
||||
return this.isLocal && !this._libraryItem.libraryItemId
|
||||
},
|
||||
media() {
|
||||
return this._libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
mediaType() {
|
||||
return this._libraryItem.mediaType
|
||||
},
|
||||
duration() {
|
||||
return this.media.duration || null
|
||||
},
|
||||
isPodcast() {
|
||||
return this.mediaType === 'podcast'
|
||||
},
|
||||
episodes() {
|
||||
if (this.isPodcast) {
|
||||
if (this.media.numEpisodes==1) {
|
||||
return "1 episode"
|
||||
} else {
|
||||
return this.media.numEpisodes + ' episodes'
|
||||
}
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
placeholderUrl() {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
bookCoverSrc() {
|
||||
if (this.isLocal) {
|
||||
if (this.libraryItem.coverContentUrl) return Capacitor.convertFileSrc(this.libraryItem.coverContentUrl)
|
||||
return this.placeholderUrl
|
||||
}
|
||||
return this.store.getters['globals/getLibraryItemCoverSrc'](this._libraryItem, this.placeholderUrl)
|
||||
},
|
||||
libraryItemId() {
|
||||
return this._libraryItem.id
|
||||
},
|
||||
series() {
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
libraryId() {
|
||||
return this._libraryItem.libraryId
|
||||
},
|
||||
hasEbook() {
|
||||
return this.media.ebookFile
|
||||
},
|
||||
numTracks() {
|
||||
return this.media.numTracks
|
||||
},
|
||||
processingBatch() {
|
||||
return this.store.state.processingBatch
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to audiobook object when collapseSeries is enabled
|
||||
return this._libraryItem.booksInSeries
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.media.coverPath
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return Math.min(1, this.width / 364)
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title || ''
|
||||
},
|
||||
playIconFontSize() {
|
||||
return Math.max(2, 3 * this.sizeMultiplier)
|
||||
},
|
||||
author() {
|
||||
if (this.isPodcast) return this.mediaMetadata.author || 'Unknown'
|
||||
return this.mediaMetadata.authorName || 'Unknown'
|
||||
},
|
||||
authorLF() {
|
||||
return this.mediaMetadata.authorNameLF || 'Unknown'
|
||||
},
|
||||
series() {
|
||||
// Only included when filtering by series or collapse series
|
||||
return this.mediaMetadata.series
|
||||
},
|
||||
seriesSequence() {
|
||||
return this.series ? this.series.sequence : null
|
||||
},
|
||||
collapsedSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this._libraryItem.collapsedSeries
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
},
|
||||
displayTitle() {
|
||||
if (this.orderBy === 'media.metadata.title' && this.sortingIgnorePrefix && this.title.toLowerCase().startsWith('the ')) {
|
||||
return this.title.substr(4) + ', The'
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
displayAuthor() {
|
||||
if (this.isPodcast) return this.author
|
||||
if (this.orderBy === 'media.metadata.authorNameLF') return this.authorLF
|
||||
return this.author
|
||||
},
|
||||
displaySortLine() {
|
||||
if (this.orderBy === 'mtimeMs') return 'Modified ' + this.$formatDate(this._libraryItem.mtimeMs)
|
||||
if (this.orderBy === 'birthtimeMs') return 'Born ' + this.$formatDate(this._libraryItem.birthtimeMs)
|
||||
if (this.orderBy === 'addedAt') return 'Added ' + this.$formatDate(this._libraryItem.addedAt)
|
||||
if (this.orderBy === 'size') return 'Size: ' + this.$bytesPretty(this._libraryItem.size)
|
||||
return null
|
||||
},
|
||||
userProgress() {
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
userProgressPercent() {
|
||||
return this.userProgress ? this.userProgress.progress || 0 : 0
|
||||
},
|
||||
itemIsFinished() {
|
||||
return this.userProgress ? !!this.userProgress.isFinished : false
|
||||
},
|
||||
showError() {
|
||||
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.store.getters['getlibraryItemIdStreaming'] === this.libraryItemId
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && this.numTracks && !this.isStreaming
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this._libraryItem.isInvalid
|
||||
},
|
||||
hasMissingParts() {
|
||||
return this._libraryItem.hasMissingParts
|
||||
},
|
||||
hasInvalidParts() {
|
||||
return this._libraryItem.hasInvalidParts
|
||||
},
|
||||
errorText() {
|
||||
if (this.isMissing) return 'Item directory is missing!'
|
||||
else if (this.isInvalid) return 'Item has no media files'
|
||||
var txt = ''
|
||||
if (this.hasMissingParts) {
|
||||
txt = `${this.hasMissingParts} missing parts.`
|
||||
}
|
||||
if (this.hasInvalidParts) {
|
||||
if (this.hasMissingParts) txt += ' '
|
||||
txt += `${this.hasInvalidParts} invalid parts.`
|
||||
}
|
||||
return txt || 'Unknown Error'
|
||||
},
|
||||
overlayWrapperClasslist() {
|
||||
var classes = []
|
||||
if (this.isSelectionMode) classes.push('bg-opacity-60')
|
||||
else classes.push('bg-opacity-40')
|
||||
if (this.selected) {
|
||||
classes.push('border-2 border-yellow-400')
|
||||
}
|
||||
return classes
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
_socket() {
|
||||
return this.$root.socket || this.$nuxt.$root.socket
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
authorFontSize() {
|
||||
return 0.6 * this.sizeMultiplier
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
titleCleaned() {
|
||||
if (!this.title) return ''
|
||||
if (this.title.length > 60) {
|
||||
return this.title.slice(0, 57) + '...'
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
authorCleaned() {
|
||||
if (!this.author) return ''
|
||||
if (this.author.length > 30) {
|
||||
return this.author.slice(0, 27) + '...'
|
||||
}
|
||||
return this.author
|
||||
},
|
||||
isAlternativeBookshelfView() {
|
||||
return false
|
||||
// var constants = this.$constants || this.$nuxt.$constants
|
||||
// return this.bookshelfView === constants.BookshelfView.TITLES
|
||||
},
|
||||
titleDisplayBottomOffset() {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setSelectionMode(val) {
|
||||
this.isSelectionMode = val
|
||||
if (!val) this.selected = false
|
||||
},
|
||||
setEntity(libraryItem) {
|
||||
this.libraryItem = libraryItem
|
||||
},
|
||||
setLocalLibraryItem(localLibraryItem) {
|
||||
// Server books may have a local library item
|
||||
this.localLibraryItem = localLibraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
} else {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
if (router) {
|
||||
if (this.collapsedSeries) router.push(`/library/${this.libraryId}/series/${this.collapsedSeries.id}`)
|
||||
else router.push(`/item/${this.libraryItemId}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
editClick() {
|
||||
this.$emit('edit', this.libraryItem)
|
||||
},
|
||||
toggleFinished() {
|
||||
var updatePayload = {
|
||||
isFinished: !this.itemIsFinished
|
||||
}
|
||||
this.isProcessingReadUpdate = true
|
||||
var toast = this.$toast || this.$nuxt.$toast
|
||||
var axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.isProcessingReadUpdate = false
|
||||
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
rescan() {
|
||||
this.rescanning = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/scan`)
|
||||
.then((data) => {
|
||||
this.rescanning = false
|
||||
var result = data.result
|
||||
if (!result) {
|
||||
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
|
||||
} else if (result === 'UPDATED') {
|
||||
this.$toast.success(`Re-Scan complete item was updated`)
|
||||
} else if (result === 'UPTODATE') {
|
||||
this.$toast.success(`Re-Scan complete item was up to date`)
|
||||
} else if (result === 'REMOVED') {
|
||||
this.$toast.error(`Re-Scan complete item was removed`)
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to scan library item', error)
|
||||
this.$toast.error('Failed to scan library item')
|
||||
this.rescanning = false
|
||||
})
|
||||
},
|
||||
showEditModalTracks() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'tracks' })
|
||||
},
|
||||
showEditModalMatch() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||
},
|
||||
showEditModalDownload() {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'download' })
|
||||
},
|
||||
openCollections() {
|
||||
this.store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.store.commit('globals/setShowUserCollectionsModal', true)
|
||||
},
|
||||
clickReadEBook() {
|
||||
this.store.commit('showEReader', this.libraryItem)
|
||||
},
|
||||
selectBtnClick() {
|
||||
if (this.processingBatch) return
|
||||
this.selected = !this.selected
|
||||
this.$emit('select', this.libraryItem)
|
||||
},
|
||||
play() {
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
eventBus.$emit('play-item', { libraryItemId: this.libraryItemId })
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
},
|
||||
setCoverBg() {
|
||||
if (this.$refs.coverBg) {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.bookCoverSrc}")`
|
||||
}
|
||||
},
|
||||
imageLoaded() {
|
||||
this.imageReady = true
|
||||
|
||||
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
this.$nextTick(this.setCoverBg)
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.bookMount) {
|
||||
this.setEntity(this.bookMount)
|
||||
|
||||
if (this.bookMount.localLibraryItem) {
|
||||
this.setLocalLibraryItem(this.bookMount.localLibraryItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -56,7 +56,7 @@ export default {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
},
|
||||
seriesId() {
|
||||
return this.series ? this.$encode(this.series.id) : null
|
||||
return this.series ? this.series.id : null
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="flex h-full px-1 overflow-hidden">
|
||||
<covers-group-cover :name="series" :book-items="bookItems" :width="80" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-group-cover :name="name" :book-items="bookItems" :width="80" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="flex-grow px-2 seriesSearchCardContent h-full">
|
||||
<p class="truncate text-sm">{{ series }}</p>
|
||||
<p class="truncate text-sm">{{ name }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,10 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
series: String,
|
||||
series: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
bookItems: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -22,6 +25,9 @@ export default {
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['getBookCoverAspectRatio']
|
||||
},
|
||||
name() {
|
||||
return this.series.name
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div class="w-full max-w-md mx-auto px-2 sm:px-4 lg:px-8 z-10">
|
||||
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg px-4 py-6 w-full">
|
||||
<template v-if="!showForm">
|
||||
<div v-for="config in serverConnectionConfigs" :key="config.id" class="flex items-center py-4 my-1 border-b border-white border-opacity-10 relative" @click="connectToServer(config)">
|
||||
<span class="material-icons-outlined text-xl text-gray-300">dns</span>
|
||||
<p class="pl-3 pr-6 text-base text-gray-200">{{ config.name }}</p>
|
||||
|
||||
<div class="absolute top-0 right-0 h-full px-4 flex items-center" @click.stop="editServerConfig(config)">
|
||||
<span class="material-icons text-lg text-gray-300">more_vert</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-1 py-4 w-full">
|
||||
<ui-btn class="w-full" @click="newServerConfigClick">Add New Server</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="w-full">
|
||||
<form v-show="!showAuth" @submit.prevent="submit" novalidate class="w-full">
|
||||
<h2 class="text-lg leading-7 mb-2">Server address</h2>
|
||||
<ui-text-input v-model="serverConfig.address" :disabled="processing || !networkConnected || !!serverConfig.id" placeholder="http://55.55.55.55:13378" type="url" class="w-full h-10" />
|
||||
<div class="flex justify-end items-center mt-6">
|
||||
<!-- <div class="relative flex">
|
||||
<button class="outline-none uppercase tracking-wide font-semibold text-xs text-gray-300" type="button" @click="addCustomHeaders">Add Custom Headers</button>
|
||||
<div v-if="numCustomHeaders" class="rounded-full h-5 w-5 flex items-center justify-center text-xs bg-success bg-opacity-40 leading-3 font-semibold font-mono ml-1">{{ numCustomHeaders }}</div>
|
||||
</div> -->
|
||||
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
<template v-if="showAuth">
|
||||
<div v-if="serverConfig.id" class="flex items-center mb-4" @click="showServerList">
|
||||
<span class="material-icons text-gray-300">arrow_back</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-gray-300">{{ serverConfig.address }}</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="!serverConfig.id" class="material-icons" style="font-size: 1.1rem" @click="editServerAddress">edit</span>
|
||||
</div>
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-2" />
|
||||
<form @submit.prevent="submitAuth" class="pt-3">
|
||||
<ui-text-input v-model="serverConfig.username" :disabled="processing" placeholder="username" class="w-full mb-2 text-lg" />
|
||||
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full mb-2 text-lg" />
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<ui-icon-btn v-if="serverConfig.id" small bg-color="error" icon="delete" @click="removeServerConfigClick" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-show="error" class="w-full rounded-lg bg-red-600 bg-opacity-10 border border-error border-opacity-50 py-3 px-2 flex items-center mt-4">
|
||||
<span class="material-icons mr-2 text-error" style="font-size: 1.1rem">warning</span>
|
||||
<p class="text-error">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="processing ? 'opacity-100' : 'opacity-0 pointer-events-none'" class="fixed w-full h-full top-0 left-0 bg-black bg-opacity-75 flex items-center justify-center z-30 transition-opacity duration-500">
|
||||
<div>
|
||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
||||
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
||||
</div>
|
||||
<svg class="animate-spin w-16 h-16" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M12,4V2A10,10 0 0,0 2,12H4A8,8 0 0,1 12,4Z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-custom-headers-modal v-model="showAddCustomHeaders" :custom-headers.sync="serverConfig.customHeaders" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
loggedIn: false,
|
||||
showAuth: false,
|
||||
processing: false,
|
||||
serverConfig: {
|
||||
address: null,
|
||||
username: null,
|
||||
customHeaders: null
|
||||
},
|
||||
password: null,
|
||||
error: null,
|
||||
showForm: false,
|
||||
showAddCustomHeaders: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
deviceData() {
|
||||
return this.$store.state.deviceData || {}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
serverConnectionConfigs() {
|
||||
return this.deviceData ? this.deviceData.serverConnectionConfigs || [] : []
|
||||
},
|
||||
lastServerConnectionConfigId() {
|
||||
return this.deviceData ? this.deviceData.lastServerConnectionConfigId : null
|
||||
},
|
||||
lastServerConnectionConfig() {
|
||||
if (!this.lastServerConnectionConfigId || !this.serverConnectionConfigs.length) return null
|
||||
return this.serverConnectionConfigs.find((s) => s.id == this.lastServerConnectionConfigId)
|
||||
},
|
||||
numCustomHeaders() {
|
||||
if (!this.serverConfig.customHeaders) return 0
|
||||
return Object.keys(this.serverConfig.customHeaders).length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addCustomHeaders() {
|
||||
this.showAddCustomHeaders = true
|
||||
},
|
||||
showServerList() {
|
||||
this.showForm = false
|
||||
this.showAuth = false
|
||||
this.error = null
|
||||
this.serverConfig = {
|
||||
address: null,
|
||||
userId: null,
|
||||
username: null
|
||||
}
|
||||
},
|
||||
async connectToServer(config) {
|
||||
console.log('[ServerConnectForm] connectToServer', config.address)
|
||||
this.processing = true
|
||||
this.serverConfig = {
|
||||
...config
|
||||
}
|
||||
this.showForm = true
|
||||
var success = await this.pingServerAddress(config.address)
|
||||
this.processing = false
|
||||
console.log(`[ServerConnectForm] pingServer result ${success}`)
|
||||
if (!success) {
|
||||
this.showForm = false
|
||||
this.showAuth = false
|
||||
console.log(`[ServerConnectForm] showForm ${this.showForm}`)
|
||||
return
|
||||
}
|
||||
|
||||
this.error = null
|
||||
var payload = await this.authenticateToken()
|
||||
|
||||
if (payload) {
|
||||
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
|
||||
} else {
|
||||
this.showAuth = true
|
||||
}
|
||||
},
|
||||
async removeServerConfigClick() {
|
||||
if (!this.serverConfig.id) return
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Remove this server config?`
|
||||
})
|
||||
if (value) {
|
||||
this.processing = true
|
||||
await this.$db.removeServerConnectionConfig(this.serverConfig.id)
|
||||
const updatedDeviceData = { ...this.deviceData }
|
||||
updatedDeviceData.serverConnectionConfigs = this.deviceData.serverConnectionConfigs.filter((scc) => scc.id != this.serverConfig.id)
|
||||
this.$store.commit('setDeviceData', updatedDeviceData)
|
||||
|
||||
this.serverConfig = {
|
||||
address: null,
|
||||
userId: null,
|
||||
username: null
|
||||
}
|
||||
this.password = null
|
||||
this.processing = false
|
||||
this.showAuth = false
|
||||
this.showForm = !this.serverConnectionConfigs.length
|
||||
}
|
||||
},
|
||||
editServerConfig(serverConfig) {
|
||||
this.serverConfig = {
|
||||
...serverConfig
|
||||
}
|
||||
this.showForm = true
|
||||
this.showAuth = true
|
||||
},
|
||||
newServerConfigClick() {
|
||||
this.serverConfig = {
|
||||
address: '',
|
||||
userId: '',
|
||||
username: ''
|
||||
}
|
||||
this.showForm = true
|
||||
this.showAuth = false
|
||||
this.error = null
|
||||
},
|
||||
editServerAddress() {
|
||||
this.error = null
|
||||
this.showAuth = false
|
||||
},
|
||||
validateServerUrl(url) {
|
||||
try {
|
||||
var urlObject = new URL(url)
|
||||
var address = `${urlObject.protocol}//${urlObject.hostname}`
|
||||
if (urlObject.port) address += ':' + urlObject.port
|
||||
return address
|
||||
} catch (error) {
|
||||
console.error('Invalid URL', error)
|
||||
return null
|
||||
}
|
||||
},
|
||||
pingServerAddress(address, customHeaders) {
|
||||
const options = { timeout: 3000 }
|
||||
if (customHeaders) {
|
||||
options.headers = customHeaders
|
||||
}
|
||||
return this.$axios
|
||||
.$get(`${address}/ping`, options)
|
||||
.then((data) => data.success)
|
||||
.catch((error) => {
|
||||
console.error('Server check failed', error)
|
||||
this.error = 'Failed to ping server'
|
||||
return false
|
||||
})
|
||||
},
|
||||
requestServerLogin() {
|
||||
const options = {}
|
||||
if (this.serverConfig.customHeaders) {
|
||||
options.headers = this.serverConfig.customHeaders
|
||||
}
|
||||
return this.$axios
|
||||
.$post(`${this.serverConfig.address}/login`, { username: this.serverConfig.username, password: this.password }, options)
|
||||
.then((data) => {
|
||||
if (!data.user) {
|
||||
console.error(data.error)
|
||||
this.error = data.error || 'Unknown Error'
|
||||
return false
|
||||
}
|
||||
return data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Server auth failed', error)
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.error = errorMsg
|
||||
return false
|
||||
})
|
||||
},
|
||||
async submit() {
|
||||
if (!this.networkConnected) return
|
||||
if (!this.serverConfig.address) return
|
||||
if (!this.serverConfig.address.startsWith('http')) {
|
||||
this.serverConfig.address = 'http://' + this.serverConfig.address
|
||||
}
|
||||
var validServerAddress = this.validateServerUrl(this.serverConfig.address)
|
||||
if (!validServerAddress) {
|
||||
this.error = 'Invalid server address'
|
||||
return
|
||||
}
|
||||
|
||||
this.serverConfig.address = validServerAddress
|
||||
this.processing = true
|
||||
this.error = null
|
||||
|
||||
var success = await this.pingServerAddress(this.serverConfig.address, this.serverConfig.customHeaders)
|
||||
this.processing = false
|
||||
if (success) this.showAuth = true
|
||||
},
|
||||
async submitAuth() {
|
||||
if (!this.networkConnected) return
|
||||
if (!this.serverConfig.username) {
|
||||
this.error = 'Invalid username'
|
||||
return
|
||||
}
|
||||
const duplicateConfig = this.serverConnectionConfigs.find((scc) => scc.address === this.serverConfig.address && scc.username === this.serverConfig.username)
|
||||
if (duplicateConfig) {
|
||||
this.error = 'Config already exists for this address and username'
|
||||
return
|
||||
}
|
||||
|
||||
this.error = null
|
||||
this.processing = true
|
||||
|
||||
var payload = await this.requestServerLogin()
|
||||
this.processing = false
|
||||
if (payload) {
|
||||
this.setUserAndConnection(payload.user, payload.userDefaultLibraryId)
|
||||
}
|
||||
},
|
||||
async setUserAndConnection(user, userDefaultLibraryId) {
|
||||
if (!user) return
|
||||
|
||||
console.log('Successfully logged in', JSON.stringify(user))
|
||||
|
||||
// Set library - Use last library if set and available fallback to default user library
|
||||
var lastLibraryId = await this.$localStore.getLastLibraryId()
|
||||
if (lastLibraryId && (!user.librariesAccessible.length || user.librariesAccessible.includes(lastLibraryId))) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', lastLibraryId)
|
||||
} else if (userDefaultLibraryId) {
|
||||
this.$store.commit('libraries/setCurrentLibrary', userDefaultLibraryId)
|
||||
}
|
||||
|
||||
this.serverConfig.userId = user.id
|
||||
this.serverConfig.token = user.token
|
||||
|
||||
var serverConnectionConfig = await this.$db.setServerConnectionConfig(this.serverConfig)
|
||||
|
||||
this.$store.commit('user/setUser', user)
|
||||
this.$store.commit('user/setServerConnectionConfig', serverConnectionConfig)
|
||||
|
||||
this.$socket.connect(this.serverConfig.address, this.serverConfig.token)
|
||||
this.$router.replace('/bookshelf')
|
||||
},
|
||||
async authenticateToken() {
|
||||
if (!this.networkConnected) return
|
||||
if (!this.serverConfig.token) {
|
||||
this.error = 'No token'
|
||||
return
|
||||
}
|
||||
|
||||
this.error = null
|
||||
this.processing = true
|
||||
var authRes = await this.$axios.$post(`${this.serverConfig.address}/api/authorize`, null, { headers: { Authorization: `Bearer ${this.serverConfig.token}` } }).catch((error) => {
|
||||
console.error('[Server] Server auth failed', error)
|
||||
var errorMsg = error.response ? error.response.data || 'Unknown Error' : 'Unknown Error'
|
||||
this.error = errorMsg
|
||||
return false
|
||||
})
|
||||
this.processing = false
|
||||
return authRes
|
||||
},
|
||||
async init() {
|
||||
if (this.lastServerConnectionConfig) {
|
||||
this.connectToServer(this.lastServerConnectionConfig)
|
||||
} else {
|
||||
this.showForm = !this.serverConnectionConfigs.length
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(authorId)}`" ref="wrapper" :class="`rounded-${rounded}`" class="w-full h-full bg-primary overflow-hidden">
|
||||
<svg v-if="!imagePath" width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
|
||||
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
|
||||
<path
|
||||
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path
|
||||
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
|
||||
fill="white"
|
||||
/>
|
||||
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
|
||||
</svg>
|
||||
<div v-else class="w-full h-full relative">
|
||||
<div v-if="showCoverBg" class="cover-bg absolute" :style="{ backgroundImage: `url(${imgSrc})` }" />
|
||||
<img ref="img" :src="imgSrc" @load="imageLoaded" class="absolute top-0 left-0 h-full w-full" :class="coverContain ? 'object-contain' : 'object-cover'" />
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
author: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
rounded: {
|
||||
type: String,
|
||||
default: 'lg'
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showCoverBg: false,
|
||||
coverContain: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
_author() {
|
||||
return this.author || {}
|
||||
},
|
||||
authorId() {
|
||||
return this._author.id
|
||||
},
|
||||
imagePath() {
|
||||
return this._author.imagePath
|
||||
},
|
||||
updatedAt() {
|
||||
return this._author.updatedAt
|
||||
},
|
||||
serverAddress() {
|
||||
return this.$store.getters['user/getServerAddress']
|
||||
},
|
||||
imgSrc() {
|
||||
if (!this.imagePath || !this.serverAddress) return null
|
||||
if (process.env.NODE_ENV !== 'production' && this.serverAddress.startsWith('http://192.168')) {
|
||||
// Testing
|
||||
return `http://localhost:3333/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
}
|
||||
return `${this.serverAddress}/api/authors/${this.authorId}/image?token=${this.userToken}&ts=${this.updatedAt}`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
imageLoaded() {
|
||||
var aspectRatio = 1.25
|
||||
if (this.$refs.wrapper) {
|
||||
aspectRatio = this.$refs.wrapper.clientHeight / this.$refs.wrapper.clientWidth
|
||||
}
|
||||
if (this.$refs.img) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.img
|
||||
var imgAr = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(imgAr - aspectRatio)
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
this.coverContain = false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -5,20 +5,12 @@
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||
<img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? 1 : 0 }" :class="showCoverBg && hasCover ? 'object-contain' : 'object-fill'" />
|
||||
|
||||
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<div class="absolute top-2 right-2">
|
||||
<div class="la-ball-spin-clockwise la-sm">
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,23 +24,24 @@
|
||||
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
<p class="text-center font-book truncate leading-none origin-center" style="color: rgb(247 223 187); font-size: 0.8rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
<p class="text-center font-book truncate leading-none origin-center" style="color: rgb(247 223 187); opacity: 0.75; font-size: 0.6rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
audiobook: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
authorOverride: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
@@ -70,18 +63,28 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isLocal() {
|
||||
if (!this.libraryItem) return false
|
||||
return this.libraryItem.isLocal
|
||||
},
|
||||
localCover() {
|
||||
return this.libraryItem ? this.libraryItem.coverContentUrl : null
|
||||
},
|
||||
squareAspectRatio() {
|
||||
return this.bookCoverAspectRatio === 1
|
||||
},
|
||||
height() {
|
||||
return this.width * this.bookCoverAspectRatio
|
||||
},
|
||||
book() {
|
||||
if (!this.audiobook) return {}
|
||||
return this.audiobook.book || {}
|
||||
media() {
|
||||
if (!this.libraryItem) return {}
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
title() {
|
||||
return this.book.title || 'No Title'
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
},
|
||||
titleCleaned() {
|
||||
if (this.title.length > 60) {
|
||||
@@ -89,9 +92,11 @@ export default {
|
||||
}
|
||||
return this.title
|
||||
},
|
||||
authors() {
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
author() {
|
||||
if (this.authorOverride) return this.authorOverride
|
||||
return this.book.author || 'Unknown'
|
||||
return this.authors.map((au) => au.name).join(', ')
|
||||
},
|
||||
authorCleaned() {
|
||||
if (this.author.length > 30) {
|
||||
@@ -103,19 +108,23 @@ export default {
|
||||
return '/book_placeholder.jpg'
|
||||
},
|
||||
fullCoverUrl() {
|
||||
if (this.isLocal) {
|
||||
if (this.localCover) return Capacitor.convertFileSrc(this.localCover)
|
||||
return this.placeholderUrl
|
||||
}
|
||||
if (this.downloadCover) return this.downloadCover
|
||||
if (!this.audiobook) return null
|
||||
if (!this.libraryItem) return null
|
||||
var store = this.$store || this.$nuxt.$store
|
||||
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||
},
|
||||
cover() {
|
||||
return this.book.cover || this.placeholderUrl
|
||||
return this.media.coverPath || this.placeholderUrl
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.book.cover
|
||||
return (!!this.media.coverPath && !this.isLocal) || this.localCover || this.downloadCover
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.squareAspectRatio ? 192 : 120
|
||||
var baseSize = this.squareAspectRatio ? 128 : 96
|
||||
return this.width / baseSize
|
||||
},
|
||||
titleFontSize() {
|
||||
@@ -125,7 +134,8 @@ export default {
|
||||
return 0.6 * this.sizeMultiplier
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
if (this.sizeMultiplier < 0.5) return 0
|
||||
return this.sizeMultiplier
|
||||
},
|
||||
authorBottom() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
@@ -140,7 +150,6 @@ export default {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
|
||||
}
|
||||
},
|
||||
hideCoverBg() {},
|
||||
imageLoaded() {
|
||||
this.loading = false
|
||||
this.$nextTick(() => {
|
||||
@@ -170,214 +179,3 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/*!
|
||||
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
|
||||
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
|
||||
* Licensed under MIT
|
||||
*/
|
||||
.la-ball-spin-clockwise,
|
||||
.la-ball-spin-clockwise > div {
|
||||
position: relative;
|
||||
-webkit-box-sizing: border-box;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.la-ball-spin-clockwise {
|
||||
display: block;
|
||||
font-size: 0;
|
||||
color: #fff;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-dark {
|
||||
color: #262626;
|
||||
}
|
||||
.la-ball-spin-clockwise > div {
|
||||
display: inline-block;
|
||||
float: none;
|
||||
background-color: currentColor;
|
||||
border: 0 solid currentColor;
|
||||
}
|
||||
.la-ball-spin-clockwise {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.la-ball-spin-clockwise > div {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
margin-left: -4px;
|
||||
border-radius: 100%;
|
||||
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
||||
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
||||
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
|
||||
animation: ball-spin-clockwise 1s infinite ease-in-out;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(1) {
|
||||
top: 5%;
|
||||
left: 50%;
|
||||
-webkit-animation-delay: -0.875s;
|
||||
-moz-animation-delay: -0.875s;
|
||||
-o-animation-delay: -0.875s;
|
||||
animation-delay: -0.875s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(2) {
|
||||
top: 18.1801948466%;
|
||||
left: 81.8198051534%;
|
||||
-webkit-animation-delay: -0.75s;
|
||||
-moz-animation-delay: -0.75s;
|
||||
-o-animation-delay: -0.75s;
|
||||
animation-delay: -0.75s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(3) {
|
||||
top: 50%;
|
||||
left: 95%;
|
||||
-webkit-animation-delay: -0.625s;
|
||||
-moz-animation-delay: -0.625s;
|
||||
-o-animation-delay: -0.625s;
|
||||
animation-delay: -0.625s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(4) {
|
||||
top: 81.8198051534%;
|
||||
left: 81.8198051534%;
|
||||
-webkit-animation-delay: -0.5s;
|
||||
-moz-animation-delay: -0.5s;
|
||||
-o-animation-delay: -0.5s;
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(5) {
|
||||
top: 94.9999999966%;
|
||||
left: 50.0000000005%;
|
||||
-webkit-animation-delay: -0.375s;
|
||||
-moz-animation-delay: -0.375s;
|
||||
-o-animation-delay: -0.375s;
|
||||
animation-delay: -0.375s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(6) {
|
||||
top: 81.8198046966%;
|
||||
left: 18.1801949248%;
|
||||
-webkit-animation-delay: -0.25s;
|
||||
-moz-animation-delay: -0.25s;
|
||||
-o-animation-delay: -0.25s;
|
||||
animation-delay: -0.25s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(7) {
|
||||
top: 49.9999750815%;
|
||||
left: 5.0000051215%;
|
||||
-webkit-animation-delay: -0.125s;
|
||||
-moz-animation-delay: -0.125s;
|
||||
-o-animation-delay: -0.125s;
|
||||
animation-delay: -0.125s;
|
||||
}
|
||||
.la-ball-spin-clockwise > div:nth-child(8) {
|
||||
top: 18.179464974%;
|
||||
left: 18.1803700518%;
|
||||
-webkit-animation-delay: 0s;
|
||||
-moz-animation-delay: 0s;
|
||||
-o-animation-delay: 0s;
|
||||
animation-delay: 0s;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-sm > div {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
margin-top: -2px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-2x {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-2x > div {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: -8px;
|
||||
margin-left: -8px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-3x {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-3x > div {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: -12px;
|
||||
margin-left: -12px;
|
||||
}
|
||||
/*
|
||||
* Animation
|
||||
*/
|
||||
@-webkit-keyframes ball-spin-clockwise {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 0;
|
||||
-webkit-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@-moz-keyframes ball-spin-clockwise {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
-moz-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 0;
|
||||
-moz-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@-o-keyframes ball-spin-clockwise {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
-o-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 0;
|
||||
-o-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
@keyframes ball-spin-clockwise {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
-webkit-transform: scale(1);
|
||||
-moz-transform: scale(1);
|
||||
-o-transform: scale(1);
|
||||
transform: scale(1);
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
80% {
|
||||
opacity: 0;
|
||||
-webkit-transform: scale(0);
|
||||
-moz-transform: scale(0);
|
||||
-o-transform: scale(0);
|
||||
transform: scale(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,8 @@
|
||||
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
|
||||
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover v-if="books.length > 1" :library-item="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
|
||||
@@ -17,6 +17,7 @@ export default {
|
||||
},
|
||||
width: Number,
|
||||
height: Number,
|
||||
groupTo: String,
|
||||
bookCoverAspectRatio: Number
|
||||
},
|
||||
data() {
|
||||
@@ -31,7 +32,6 @@ export default {
|
||||
isFannedOut: false,
|
||||
isDetached: false,
|
||||
isAttaching: false,
|
||||
windowWidth: 0,
|
||||
isInit: false
|
||||
}
|
||||
},
|
||||
@@ -48,8 +48,11 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (100 * 1.6 * 2)
|
||||
return this.width / 200
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
@@ -59,44 +62,8 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
detchCoverWrapper() {
|
||||
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
|
||||
|
||||
this.coverWrapperEl.remove()
|
||||
|
||||
this.isDetached = true
|
||||
document.body.appendChild(this.coverWrapperEl)
|
||||
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
|
||||
|
||||
this.coverWrapperEl.style.position = 'absolute'
|
||||
this.coverWrapperEl.style.zIndex = 40
|
||||
|
||||
this.updatePosition()
|
||||
},
|
||||
attachCoverWrapper() {
|
||||
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
|
||||
|
||||
this.coverWrapperEl.remove()
|
||||
this.coverWrapperEl.style.position = 'relative'
|
||||
this.coverWrapperEl.style.left = 'unset'
|
||||
this.coverWrapperEl.style.top = 'unset'
|
||||
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
|
||||
|
||||
this.$refs.wrapper.appendChild(this.coverWrapperEl)
|
||||
|
||||
this.isDetached = false
|
||||
},
|
||||
updatePosition() {
|
||||
var rect = this.$refs.wrapper.getBoundingClientRect()
|
||||
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
|
||||
|
||||
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
|
||||
|
||||
this.coverWrapperEl.style.height = rect.height + 'px'
|
||||
this.coverWrapperEl.style.width = rect.width + 'px'
|
||||
},
|
||||
getCoverUrl(book) {
|
||||
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
|
||||
return this.store.getters['globals/getLibraryItemCoverSrc'](book, '')
|
||||
},
|
||||
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
|
||||
var src = coverData.coverUrl
|
||||
@@ -156,6 +123,22 @@ export default {
|
||||
imgdiv.appendChild(img)
|
||||
return imgdiv
|
||||
},
|
||||
createSeriesNameCover(offsetLeft) {
|
||||
var imgdiv = document.createElement('div')
|
||||
imgdiv.style.height = this.height + 'px'
|
||||
imgdiv.style.width = this.height / this.bookCoverAspectRatio + 'px'
|
||||
imgdiv.style.left = offsetLeft + 'px'
|
||||
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform flex items-center justify-center'
|
||||
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
|
||||
imgdiv.style.backgroundColor = '#111'
|
||||
|
||||
var innerP = document.createElement('p')
|
||||
innerP.textContent = this.name
|
||||
innerP.className = 'text-sm font-book text-white'
|
||||
imgdiv.appendChild(innerP)
|
||||
|
||||
return imgdiv
|
||||
},
|
||||
async init() {
|
||||
if (this.isInit) return
|
||||
this.isInit = true
|
||||
@@ -168,7 +151,6 @@ export default {
|
||||
.map((bookItem) => {
|
||||
return {
|
||||
id: bookItem.id,
|
||||
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
|
||||
coverUrl: this.getCoverUrl(bookItem)
|
||||
}
|
||||
})
|
||||
@@ -179,6 +161,8 @@ export default {
|
||||
}
|
||||
this.noValidCovers = false
|
||||
|
||||
validCovers = validCovers.slice(0, 10)
|
||||
|
||||
var coverWidth = this.width
|
||||
var widthPer = this.width
|
||||
if (validCovers.length > 1) {
|
||||
@@ -189,7 +173,7 @@ export default {
|
||||
this.offsetIncrement = widthPer
|
||||
|
||||
var outerdiv = document.createElement('div')
|
||||
outerdiv.id = `group-cover-${this.id || this.$encode(this.name)}`
|
||||
outerdiv.id = `group-cover-${this.id}`
|
||||
this.coverWrapperEl = outerdiv
|
||||
outerdiv.className = 'w-full h-full relative box-shadow-book'
|
||||
|
||||
@@ -211,9 +195,7 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.windowWidth = window.innerWidth
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
if (this.coverWrapperEl) this.coverWrapperEl.remove()
|
||||
if (this.coverImageEls && this.coverImageEls.length) {
|
||||
@@ -222,4 +204,4 @@ export default {
|
||||
if (this.coverDiv) this.coverDiv.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-9 bg-bg relative">
|
||||
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
|
||||
<nuxt-link to="/bookshelf" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf' ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>Home</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link to="/bookshelf/library" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-library' ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>Library</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link to="/bookshelf/series" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-series' ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>Series</p>
|
||||
</nuxt-link>
|
||||
<nuxt-link to="/bookshelf/collections" class="w-1/4 h-full flex items-center justify-center" :class="routeName === 'bookshelf-collections' ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>Collections</p>
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex items-center justify-center" :style="{ width: isPodcast ? '50%' : '25%' }" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>{{ item.text }}</p>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -23,8 +14,52 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
if (this.isPodcast) {
|
||||
return [
|
||||
{
|
||||
to: '/bookshelf',
|
||||
routeName: 'bookshelf',
|
||||
text: 'Home'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
routeName: 'bookshelf-library',
|
||||
text: 'Library'
|
||||
}
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
to: '/bookshelf',
|
||||
routeName: 'bookshelf',
|
||||
text: 'Home'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
routeName: 'bookshelf-library',
|
||||
text: 'Library'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/series',
|
||||
routeName: 'bookshelf-series',
|
||||
text: 'Series'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
routeName: 'bookshelf-collections',
|
||||
text: 'Collections'
|
||||
}
|
||||
]
|
||||
},
|
||||
routeName() {
|
||||
return this.$route.name
|
||||
},
|
||||
isPodcast() {
|
||||
return this.libraryMediaType == 'podcast'
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<template v-if="page === 'library'">
|
||||
<!-- <span class="material-icons px-2" @click="changeView">{{ viewIcon }}</span> -->
|
||||
<div class="relative flex items-center px-2">
|
||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||
<div v-show="hasFilters" class="absolute top-0 right-2 w-2 h-2 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
|
||||
@@ -31,11 +31,19 @@ export default {
|
||||
showSortModal: false,
|
||||
showFilterModal: false,
|
||||
settings: {},
|
||||
isListView: false,
|
||||
totalEntities: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookshelfListView: {
|
||||
get() {
|
||||
return this.$store.state.globals.bookshelfListView
|
||||
},
|
||||
set(val) {
|
||||
this.$localStore.setBookshelfListView(val)
|
||||
this.$store.commit('globals/setBookshelfListView', val)
|
||||
}
|
||||
},
|
||||
hasFilters() {
|
||||
return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all'
|
||||
},
|
||||
@@ -43,10 +51,14 @@ export default {
|
||||
var routeName = this.$route.name || ''
|
||||
return routeName.split('-')[1]
|
||||
},
|
||||
seriesBookPage() {
|
||||
return this.$route.name == 'bookshelf-series-id'
|
||||
},
|
||||
routeQuery() {
|
||||
return this.$route.query || {}
|
||||
},
|
||||
entityTitle() {
|
||||
if (this.isPodcast) return 'Podcasts'
|
||||
if (this.page === 'library') return 'Books'
|
||||
else if (this.page === 'series') {
|
||||
return 'Series'
|
||||
@@ -56,23 +68,16 @@ export default {
|
||||
return ''
|
||||
},
|
||||
selectedSeriesName() {
|
||||
if (this.page === 'series' && this.$route.params.id) {
|
||||
return this.$decode(this.$route.params.id)
|
||||
if (this.page === 'series' && this.$route.params.id && this.$store.state.globals.series) {
|
||||
return this.$store.state.globals.series.name
|
||||
}
|
||||
return null
|
||||
},
|
||||
viewIcon() {
|
||||
return this.isListView ? 'grid_view' : 'view_stream'
|
||||
isPodcast() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType'] === 'podcast'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
changeView() {
|
||||
this.isListView = !this.isListView
|
||||
|
||||
var bookshelfView = this.isListView ? 'list' : 'grid'
|
||||
this.$localStore.setBookshelfView(bookshelfView)
|
||||
this.$store.commit('setBookshelfView', bookshelfView)
|
||||
},
|
||||
updateOrder() {
|
||||
this.saveSettings()
|
||||
},
|
||||
@@ -81,15 +86,12 @@ export default {
|
||||
},
|
||||
saveSettings() {
|
||||
this.$store.commit('user/setSettings', this.settings) // Immediate update
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings) // TODO: No need to update settings on server...
|
||||
},
|
||||
async init() {
|
||||
this.bookshelfListView = await this.$localStore.getBookshelfListView()
|
||||
this.settings = { ...this.$store.state.user.settings }
|
||||
|
||||
var bookshelfView = await this.$localStore.getBookshelfView()
|
||||
this.isListView = bookshelfView === 'list'
|
||||
this.bookshelfReady = true
|
||||
this.$store.commit('setBookshelfView', bookshelfView)
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
for (const key in settings) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<svg fill="currentColor" class="h-full w-full p-px" viewBox="0 0 1978.03 2349.44">
|
||||
<path
|
||||
d="M2519.5,1438.39c-12.13-10.1-31-25-56.57-42.62V1197.31c0-505.94-410.15-916.09-916.1-916.09h0c-505.94,0-916.09,410.15-916.09,916.09v198.46c-25.57,17.66-44.44,32.52-56.57,42.62a45.45,45.45,0,0,0-16.35,34.95v237.74a45.45,45.45,0,0,0,16.35,35c28.28,23.54,93.18,72.92,194.22,123.55v23.11c0,62.32,40.21,112.85,89.8,112.85h0c49.59,0,89.8-50.53,89.8-112.85V1322.51c0-62.33-40.21-112.86-89.8-112.86h0c-47.51,0-86.4,46.38-89.58,105.07l-.22.11V1197.31c0-429.92,348.52-778.43,778.44-778.43h0c429.92,0,778.44,348.51,778.44,778.43v117.52l-.22-.11c-3.18-58.69-42.06-105.07-89.58-105.07h0c-49.59,0-89.79,50.53-89.79,112.86v570.18c0,62.32,40.2,112.85,89.79,112.85h0c49.6,0,89.8-50.53,89.8-112.85v-23.11c101.05-50.63,165.95-100,194.23-123.55a45.48,45.48,0,0,0,16.35-35V1473.34A45.48,45.48,0,0,0,2519.5,1438.39Z"
|
||||
transform="translate(-557.82 -281.22)"
|
||||
/>
|
||||
<path d="M1227.4,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56A108.47,108.47,0,0,0,1227.4,998.08H1115.33a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1047.75,1289.38H1295v25.83H1047.75Z" transform="translate(-557.82 -281.22)" />
|
||||
<path d="M1602.87,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1490.8a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1423.22,1289.38h247.22v25.83H1423.22Z" transform="translate(-557.82 -281.22)" />
|
||||
<path d="M1978.34,2429.63a108.47,108.47,0,0,0,108.47-108.47V1106.56a108.47,108.47,0,0,0-108.47-108.48H1866.27a108.48,108.48,0,0,0-108.48,108.48v1214.6a108.47,108.47,0,0,0,108.48,108.47ZM1798.69,1289.38h247.22v25.83H1798.69Z" transform="translate(-557.82 -281.22)" />
|
||||
<rect x="180.05" y="2185.95" width="1617.93" height="163.49" rx="81.74" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M9 3V18H12V3H9M12 5L16 18L19 17L15 4L12 5M5 5V18H8V5H5M3 19V21H21V19H3Z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<svg class="p-px" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||