Compare commits

..
Author SHA1 Message Date
Mark Cooper 0fef23e47b init 2021-09-01 20:07:11 -05:00
562 changed files with 13552 additions and 67165 deletions
+6 -1
View File
@@ -1,8 +1,13 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
-88
View File
@@ -1,88 +0,0 @@
name: 🐞 ABS App Bug Report
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
title: "[Bug]: "
labels: ["bug", "triage"]
body:
- type: markdown
attributes:
value: "## App Bug Description"
- type: markdown
attributes:
value: "Thank you for filing a bug report! 🐛"
- type: markdown
attributes:
value: "Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug."
- type: textarea
id: what-happened
attributes:
label: What was the Problem?
description: Please help us understand the problem.
placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable.
validations:
required: true
- type: textarea
id: steps-to-reproduce
attributes:
label: Steps to Reproduce the Issue
description: Please help us understand how we can reliably reproduce the issue.
placeholder: "1. Go to the library page of a Podcast library and..."
validations:
required: true
- type: textarea
id: what-was-expected
attributes:
label: What was Expected?
description: Please help us understand what should have happened.
placeholder: Please help us visualize by including a doodle or screenshot.
validations:
required: true
- type: markdown
attributes:
value: "## Mobile Environment"
- type: input
id: phone-model
attributes:
label: Phone Model
description: What kind of phone are you using?
placeholder: e.g. Pixel 6, iPhone 14, Samusung Galaxy s23, etc
validations:
required: true
- type: input
id: phone-os
attributes:
label: Phone OS
description: What operating system? If Android, is it stock or a custom system?
placeholder: e.g. Android 14, iOS 17.4, etc
validations:
required: true
- type: dropdown
id: app-version
attributes:
label: Audiobookshelf App Version
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
multiple: true
options:
- Android App - 0.9.74
- iOS App - 0.9.74
- Android App - 0.9.73
- iOS App - 0.9.73
validations:
required: true
- type: dropdown
id: install-source
attributes:
label: Installation Source
multiple: true
options:
- Google Play Store
- Testflight
- SideStore
- Other (List in "Additional Notes")
validations:
required: true
- type: textarea
id: additional-notes
attributes:
label: Additional Notes
description: Anything else you want to add?
placeholder: "e.g. I have tried X, Y, and Z."
@@ -1,57 +0,0 @@
name: 🚀 App Feature Request
description: Request a feature/enhancement
title: "[Enhancement]: "
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: "## App Feature Request Description"
- type: markdown
attributes:
value: "Please first search in both issues & discussions for your enhancement and make sure your app is up to date."
- type: textarea
id: describe
attributes:
label: Describe the Feature/Enhancement
description: Please help us understand what you want.
placeholder: What is your vision?
validations:
required: true
- type: textarea
id: the-why
attributes:
label: Why would this be helpful?
description: Please help us understand why this would enhance your experience.
placeholder: Explain the "why" or "use case".
validations:
required: true
- type: textarea
id: image
attributes:
label: Future Implementation (Screenshot)
description: Please help us visualize by including a doodle or screenshot.
placeholder: How could this look or where could it go?
validations:
required: true
- type: markdown
attributes:
value: "## App Current Implementation"
- type: dropdown
id: version
attributes:
label: Audiobookshelf App Version
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
multiple: true
options:
- Android App - 0.9.74
- iOS App - 0.9.74
- Android App - 0.9.73
- iOS App - 0.9.73
validations:
required: true
- type: textarea
id: current-image
attributes:
label: Current Implementation (Screenshot)
description: What screen were you looking at when you thought of this enhancement?
placeholder: If an image is not applicable, please explain why.
-81
View File
@@ -1,81 +0,0 @@
<!DOCTYPE html>
<html lang=en>
<head>
<meta http-equiv=content-type content="text/html; charset=utf-8" />
<meta name=viewport content="width=device-width, initial-scale=1">
<title>Audiobookshelf :: Testers</title>
<style>
body {
background-color: rgb(55, 56, 56);
font-family: Sans;
margin: 40px auto;
max-width: 500px;
color: #ddd;
text-align: center;
padding: 5px;
}
img {
width: 100px;
}
h1 {
font-weight: 100;
}
a {
color: #fff;
}
#apk {
display: grid;
grid-template-columns: auto auto;
justify-content: center;
column-gap: 15px;
text-align: left;
font-family: monospace;
background-color: #222;
padding: 20px;
border-radius: 5px;
}
</style>
</head>
<body>
<a href="https://audiobookshelf.org">
<img src=logo.png />
</a>
<h1>Audiobookshelf :: Testers</h1>
<p>
Here you can download the latest version of Audiobookshelf for Android for <b>testing</b>.
The app is automatically built for every new patch added to the project.
</p>
<div id=apk>
<div>Built on</div>
<div>__DATE__</div>
<div>Git commit</div>
<a href="https://github.com/advplyr/audiobookshelf-app/commit/__COMMIT__">__COMMIT__</a>
<div>Download</div>
<a href="__APK__">__APK__</a>
</div>
<p>
Using this requires some technical knowledge to install the APK manually.
Regular users should use the releases published on the <a href="https://play.google.com/store/apps/details?id=com.audiobookshelf.app">Play Store</a>.
</p>
<hr />
<p>Report bugs, request features, provide feedback, and contribute on <a href="https://github.com/advplyr/audiobookshelf-app/issues">GitHub</a></p>
<p>
<a href="https://github.com/advplyr/audiobookshelf-app/issues">
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width=32 height=32 viewBox="0 0 24 24">
<path
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
/>
</svg>
</a>
</div>
</body>
</html>
-53
View File
@@ -1,53 +0,0 @@
name: Build APK
on:
push:
paths-ignore:
- 'ios/**'
- 'readme.md'
pull_request:
paths-ignore:
- 'ios/**'
- 'readme.md'
jobs:
main:
runs-on: ubuntu-latest
steps:
- name: checkout sources
uses: actions/checkout@v3
- name: use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 20
- name: Set up Java
uses: actions/setup-java@v2
with:
distribution: "temurin"
java-version: 17
- name: install dependencies
run: npm ci
- name: build Nuxt project
run: npm run generate
- name: copy to Android project
run: npx cap sync
- name: build Android app
run: ./android/gradlew assembleDebug -p android --no-daemon
- name: rename apk
working-directory: android/app/build/outputs/apk/debug/
run: |
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
name="audiobookshelf-${build}.apk"
mv -v app-debug.apk "${name}"
- name: upload app
uses: actions/upload-artifact@v3
with:
name: audiobookshelf-apk
path: android/app/build/outputs/apk/debug/*.apk
@@ -1,20 +0,0 @@
name: Close fixed issues on release.
on:
release:
types: [published]
permissions:
contents: read
issues: write
jobs:
comment:
runs-on: ubuntu-latest
steps:
- name: Close issues marked as fixed upon a release.
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
with:
label: 'awaiting release'
removeLabel: true
applyToAll: true
message: Fixed in [${releaseTag}](${releaseUrl}).
-82
View File
@@ -1,82 +0,0 @@
name: Publish Test App
on:
push:
branches:
- master
paths-ignore:
- 'ios/**'
- 'readme.md'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout sources
uses: actions/checkout@v3
- name: use Node.js 20.x
uses: actions/setup-node@v3
with:
node-version: 20
- name: Set up Java
uses: actions/setup-java@v2
with:
distribution: "temurin"
java-version: 17
- name: install dependencies
run: npm ci
- name: build Nuxt project
run: npm run generate
- name: copy to Android project
run: npx cap sync
- name: build Android app
run: ./android/gradlew assembleDebug -p android --no-daemon
- name: rename apk
working-directory: android/app/build/outputs/apk/debug/
run: |
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
name="audiobookshelf-${build}.apk"
mv -v app-debug.apk "${name}"
- name: prepare test page ressources
run: |
mkdir ghpages
cp android/app/build/outputs/apk/debug/*apk ghpages/
cp static/Logo.png ghpages/logo.png
cp .github/testing-page-template.html ghpages/index.html
- name: build test page
working-directory: ghpages
run: |
sed -i "s/__DATE__/$(date)/g" index.html
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
sed -i "s/__APK__/$(ls *apk)/g" index.html
- name: upload test page artifact
uses: actions/upload-pages-artifact@v1
with:
path: ./ghpages
deploy:
needs: build
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1
-28
View File
@@ -1,28 +0,0 @@
name: Verify all i18n files are alphabetized
on:
push:
pull_request:
paths:
- strings/** # Should only check if any strings changed
jobs:
update_translations:
runs-on: ubuntu-latest
steps:
# Check out the repository
- name: Checkout repository
uses: actions/checkout@v4
# Set up node to run the javascript
- name: Set up node
uses: actions/setup-node@v4
with:
node-version: '20'
# The only argument is the `directory`, which is where the i18n files are
# stored.
- name: Run Update JSON Files action
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
with:
directory: 'strings/' # Adjust the directory path as needed
+1 -2
View File
@@ -89,5 +89,4 @@ sw.*
# Vim swap files
*.swp
/resources/
/android/app/release/
/resources/
-17
View File
@@ -1,17 +0,0 @@
{
"semi": false,
"singleQuote": true,
"printWidth": 400,
"proseWrap": "never",
"trailingComma": "none",
"overrides": [
{
"files": ["*.html"],
"options": {
"singleQuote": false,
"wrapAttributes": false,
"sortAttributes": false
}
}
]
}
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"EditorConfig.EditorConfig",
"esbenp.prettier-vscode",
"octref.vetur"
]
}
-27
View File
@@ -1,27 +0,0 @@
{
"vetur.format.defaultFormatterOptions": {
"prettier": {
"semi": false,
"singleQuote": true,
"printWidth": 400,
"proseWrap": "never",
"trailingComma": "none"
},
"prettyhtml": {
"printWidth": 400,
"singleQuote": false,
"wrapAttributes": false,
"sortAttributes": false
}
},
"editor.formatOnSave": true,
"editor.detectIndentation": true,
"editor.tabSize": 2,
"javascript.format.semicolons": "remove",
"[javascript][json][jsonc]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[vue]": {
"editor.defaultFormatter": "octref.vetur"
}
}
-674
View File
@@ -1,674 +0,0 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Audiobookshelf Self-hosted audiobook server
Copyright (C) 2021 advplyr
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Audiobookshelf Copyright (C) 2021 advplyr
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+155
View File
@@ -0,0 +1,155 @@
import { io } from 'socket.io-client'
import axios from 'axios'
import EventEmitter from 'events'
class Server extends EventEmitter {
constructor(store) {
super()
this.store = store
this.url = null
this.socket = null
this.user = null
this.connected = false
this.stream = null
}
get token() {
return this.user ? this.user.token : null
}
getAxiosConfig() {
return { headers: { Authorization: `Bearer ${this.token}` } }
}
getServerUrl(url) {
var urlObject = new URL(url)
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
}
setUser(user) {
this.user = user
this.store.commit('user/setUser', user)
if (user) {
localStorage.setItem('userToken', user.token)
} else {
localStorage.removeItem('userToken')
}
}
setServerUrl(url) {
this.url = url
localStorage.setItem('serverUrl', url)
this.store.commit('setServerUrl', url)
}
async connect(url, token) {
var serverUrl = this.getServerUrl(url)
var res = await this.ping(serverUrl)
if (!res || !res.success) {
this.url = null
return false
}
var authRes = await this.authorize(serverUrl, token)
if (!authRes || !authRes.user) {
return false
}
this.setServerUrl(serverUrl)
console.warn('Connect setting auth user', authRes)
this.setUser(authRes.user)
this.connectSocket()
return true
}
async check(url) {
var serverUrl = this.getServerUrl(url)
var res = await this.ping(serverUrl)
if (!res || !res.success) {
return false
}
return serverUrl
}
async login(url, username, password) {
var serverUrl = this.getServerUrl(url)
var authUrl = serverUrl + '/login'
return axios.post(authUrl, { username, password }).then((res) => {
if (!res.data || !res.data.user) {
console.error(res.data.error)
return {
error: res.data.error || 'Unknown Error'
}
}
this.setServerUrl(serverUrl)
this.setUser(res.data.user)
this.connectSocket()
return {
user: res.data.user
}
}).catch(error => {
console.error('[Server] Server auth failed', error)
return {
error: 'Request Failed'
}
})
}
logout() {
this.setUser(null)
}
authorize(serverUrl, token) {
var authUrl = serverUrl + '/api/authorize'
return axios.post(authUrl, null, { headers: { Authorization: `Bearer ${token}` } }).then((res) => {
return res.data
}).catch(error => {
console.error('[Server] Server auth failed', error)
return false
})
}
ping(url) {
var pingUrl = url + '/ping'
console.log('[Server] Check server', pingUrl)
return axios.get(pingUrl).then((res) => {
return res.data
}).catch(error => {
console.error('Server check failed', error)
return false
})
}
connectSocket() {
console.log('[SERVER] Connect Socket', this.url)
this.socket = io(this.url)
this.socket.on('connect', () => {
console.log('[Server] Socket Connected')
// Authenticate socket with token
this.socket.emit('auth', this.token)
this.connected = true
this.emit('connected', true)
})
this.socket.on('disconnect', () => {
console.log('[Server] Socket Disconnected')
})
this.socket.on('init', (data) => {
console.log('[Server] Initial socket data received', data)
if (data.stream) {
this.stream = data.stream
this.store.commit('setStreamAudiobook', data.stream.audiobook)
this.emit('initialStream', data.stream)
}
})
}
}
export default Server
+26 -69
View File
@@ -1,44 +1,21 @@
//apply plugin: 'com.android.application'
//apply plugin: 'kotlin-android'
plugins {
id 'com.android.application'
id 'kotlin-android'
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 {
namespace 'com.audiobookshelf.app'
buildFeatures {
viewBinding true
}
kotlinOptions {
freeCompilerArgs = ['-Xjvm-default=all']
}
compileSdkVersion rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "com.audiobookshelf.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 107
versionName "0.9.76-beta"
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
manifestPlaceholders = [
"appAuthRedirectScheme": "com.audiobookshelf.app"
]
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
@@ -46,12 +23,6 @@ android {
}
}
buildTypes {
debug {
applicationIdSuffix ".debug"
versionNameSuffix "-debug"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
@@ -64,33 +35,22 @@ repositories {
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
mavenCentral()
// TODO: Temporarily using SNAPSHOT version of Simple Storage that resolves https://github.com/anggrayudi/SimpleStorage/issues/133
maven { url "https://oss.sonatype.org/content/repositories/snapshots" }
}
configurations.configureEach {
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("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")
}
}
dependencies {
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android')
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation project(':capacitor-android')
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
implementation project(':capacitor-cordova-android-plugins')
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:$androidx_core_ktx_version"
implementation "androidx.core:core-ktx:+"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
@@ -98,7 +58,6 @@ 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')
@@ -113,20 +72,18 @@ dependencies {
implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayer_version"
}
// Glide for images
implementation "com.github.bumptech.glide:glide:$glide_version"
// Paper NoSQL Db
implementation 'io.github.pilgr:paperdb:2.7.2'
// Simple Storage
implementation "com.anggrayudi:storage:1.5.5-SNAPSHOT"
// OK HTTP
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
// Jackson for JSON
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
kapt "com.github.bumptech.glide:compiler:$glide_version"
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.warn("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
+3 -10
View File
@@ -2,22 +2,15 @@
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':byteowls-capacitor-filesharer')
implementation project(':capacitor-app')
implementation project(':capacitor-browser')
implementation project(':capacitor-clipboard')
implementation project(':capacitor-dialog')
implementation project(':capacitor-haptics')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-status-bar')
implementation project(':capacitor-toast')
}
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 685 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

@@ -1,14 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<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">audiobookshelf</string>
<string name="add_widget">Add widget</string>
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
<string name="action_jump_forward">Jump Forward</string>
<string name="action_jump_backward">Jump Backward</string>
<string name="action_skip_forward">Skip Forward</string>
<string name="action_skip_backward">Skip Backward</string>
<string name="action_change_speed">Change Playback Speed</string>
</resources>
+24 -76
View File
@@ -1,108 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<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" >
xmlns:tools="http://schemas.android.com/tools"
package="com.audiobookshelf.app">
<!-- Permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="true"
android:largeHeap="true" >
<!-- Android app widget -->
<receiver
android:name="MediaPlayerWidget"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/media_player_widget_info" />
</receiver>
<!-- Used by Android Auto -->
<meta-data
android:name="com.google.android.gms.car.notification.SmallIcon"
android:resource="@drawable/icon" />
<meta-data
android:name="com.google.android.gms.car.application"
android:resource="@xml/automotive_app_desc" /> <!-- Support for Cast -->
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.audiobookshelf.app.CastOptionsProvider" />
android:usesCleartextTraffic="true">
<activity
android:name=".MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:exported="true"
android:name="com.audiobookshelf.app.MainActivity"
android:label="@string/title_activity_main"
android:launchMode="singleTask"
android:theme="@style/AppTheme.NoActionBarLaunch" >
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<!-- Register URL scheme -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
</activity>
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service
android:name=".player.PlayerNotificationService"
android:enabled="true"
android:exported="true"
android:foregroundServiceType="mediaPlayback">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<service
android:enabled="true"
android:name=".PlayerNotificationService">
</service>
</application>
</manifest>
@@ -2,10 +2,5 @@
"appId": "com.audiobookshelf.app",
"appName": "audiobookshelf-app",
"webDir": "dist",
"bundledWebRuntime": false,
"plugins": {
"CapacitorHttp": {
"enabled": false
}
}
"bundledWebRuntime": false
}
@@ -1,38 +1,10 @@
[
{
"pkg": "@byteowls/capacitor-filesharer",
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
},
{
"pkg": "@capacitor/app",
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
},
{
"pkg": "@capacitor/browser",
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
},
{
"pkg": "@capacitor/clipboard",
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
},
{
"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"
},
{
"pkg": "@capacitor/preferences",
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
},
{
"pkg": "@capacitor/status-bar",
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
"pkg": "@capacitor/toast",
"classpath": "com.capacitorjs.plugins.toast.ToastPlugin"
}
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

@@ -0,0 +1,38 @@
package com.audiobookshelf.app
import android.net.Uri
import com.getcapacitor.JSObject
class Audiobook {
var id:String = "audiobook"
var token:String = ""
var playlistUrl:String = ""
var title:String = "No Title"
var author:String = "Unknown"
var series:String = ""
var cover:String = ""
var playWhenReady:Boolean = false
var startTime:Long = 0
var duration:Long = 0
var hasPlayerLoaded:Boolean = false
val playlistUri:Uri
val coverUri:Uri
constructor(jsondata:JSObject) {
id = jsondata.getString("id", "audiobook").toString()
title = jsondata.getString("title", "No Title").toString()
token = jsondata.getString("token", "").toString()
author = jsondata.getString("author", "Unknown").toString()
series = jsondata.getString("series", "").toString()
cover = jsondata.getString("cover", "").toString()
playlistUrl = jsondata.getString("playlistUrl", "").toString()
playWhenReady = jsondata.getBoolean("playWhenReady", false) == true
startTime = jsondata.getString("startTime", "0")!!.toLong()
duration = jsondata.getString("duration", "0")!!.toLong()
playlistUri = Uri.parse(playlistUrl)
coverUri = Uri.parse(cover)
}
}
@@ -1,30 +0,0 @@
package com.audiobookshelf.app
import android.content.Context
import android.util.Log
import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider
import com.google.android.gms.cast.framework.media.CastMediaOptions
class CastOptionsProvider : OptionsProvider {
override fun getCastOptions(context: Context): CastOptions {
Log.d("CastOptionsProvider", "getCastOptions")
var appId = "FD1F76C5"
// var defaultId =CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
return CastOptions.Builder()
.setReceiverApplicationId(appId).setCastMediaOptions(
CastMediaOptions.Builder()
// We manage the media session and the notifications ourselves.
.setMediaSessionEnabled(false)
.setNotificationOptions(null)
.build()
)
.setStopReceiverApplicationWhenEndingSession(true).build()
}
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
return null
}
}
@@ -1,26 +1,15 @@
package com.audiobookshelf.app
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.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.plugins.AbsAudioPlayer
import com.audiobookshelf.app.plugins.AbsDatabase
import com.audiobookshelf.app.plugins.AbsDownloader
import com.audiobookshelf.app.plugins.AbsFileSystem
import com.example.myapp.MyNativeAudio
import com.getcapacitor.BridgeActivity
class MainActivity : BridgeActivity() {
private val tag = "MainActivity"
@@ -30,115 +19,47 @@ class MainActivity : BridgeActivity() {
lateinit var pluginCallback : () -> Unit
val storageHelper = SimpleStorageHelper(this)
val storage = SimpleStorage(this)
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())
DbManager.initialize(applicationContext)
registerPlugin(AbsAudioPlayer::class.java)
registerPlugin(AbsDownloader::class.java)
registerPlugin(AbsFileSystem::class.java)
registerPlugin(AbsDatabase::class.java)
super.onCreate(savedInstanceState)
Log.d(tag, "onCreate")
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
if (permission != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this,
PERMISSIONS_ALL,
REQUEST_PERMISSIONS)
}
}
override fun onDestroy() {
super.onDestroy()
registerPlugin(MyNativeAudio::class.java)
}
override fun onPostCreate(savedInstanceState: Bundle?) {
super.onPostCreate(savedInstanceState)
Log.d(tag, "onPostCreate MainActivity")
mConnection = object : ServiceConnection {
override fun onServiceDisconnected(name: ComponentName) {
Log.w(tag, "Service Disconnected $name")
Log.w(tag, "Service Disconnected")
mBounded = false
}
override fun onServiceConnected(name: ComponentName, service: IBinder) {
Log.d(tag, "Service Connected $name")
Log.d(tag, "Service Connected")
mBounded = true
val mLocalBinder = service as PlayerNotificationService.LocalBinder
foregroundService = mLocalBinder.getService()
// Let NativeAudio know foreground service is ready and setup event listener
pluginCallback()
// Let MyNativeAudio know foreground service is ready and setup event listener
if (pluginCallback != null) {
pluginCallback()
}
}
}
Intent(this, PlayerNotificationService::class.java).also { intent ->
Log.d(tag, "Binding PlayerNotificationService")
bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
}
val startIntent = Intent(this, PlayerNotificationService::class.java)
bindService(startIntent, mConnection as ServiceConnection, Context.BIND_AUTO_CREATE);
}
fun isPlayerNotificationServiceInitialized():Boolean {
return ::foregroundService.isInitialized
}
fun stopMyService() {
if (mBounded) {
mConnection.let { unbindService(it) };
mConnection?.let { unbindService(it) };
mBounded = false;
}
val stopIntent = Intent(this, PlayerNotificationService::class.java)
stopService(stopIntent)
}
override fun onSaveInstanceState(outState: Bundle) {
storageHelper.onSaveInstanceState(outState)
super.onSaveInstanceState(outState)
outState.clear()
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
storageHelper.onRestoreInstanceState(savedInstanceState)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
// Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.storage.onActivityResult(requestCode, resultCode, data)
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
Log.d(tag, "onRequestPermissionResult $requestCode")
permissions.forEach { Log.d(tag, "PERMISSION $it") }
grantResults.forEach { Log.d(tag, "GRANTREUSLTS $it") }
// Mandatory for Activity, but not for Fragment & ComponentActivity
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
}
}
@@ -1,103 +0,0 @@
package com.audiobookshelf.app
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.net.Uri
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import android.view.View
import android.widget.RemoteViews
import androidx.media.session.MediaButtonReceiver
import com.audiobookshelf.app.data.PlaybackSession
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.managers.DbManager
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.request.target.AppWidgetTarget
import com.bumptech.glide.request.transition.Transition
/**
* Implementation of App Widget functionality.
*/
class MediaPlayerWidget : AppWidgetProvider() {
val tag = "MediaPlayerWidget"
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
Log.d(tag, "onUpdate $appWidgetIds")
}
override fun onEnabled(context: Context) {
Log.i(tag, "onEnabled check context ${context.packageName}")
DbManager.initialize(context)
DeviceManager.deviceData.lastPlaybackSession?.let {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
Log.d(tag, "Setting initial widget state with last playback session ${it.displayTitle}")
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, it, false, true)
}
}
// Enter relevant functionality for when the first widget is created
DeviceManager.initializeWidgetUpdater(context)
}
}
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
val tag = "MediaPlayerWidget"
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
val wholeWidgetClickPI = PendingIntent.getActivity(
context,
System.currentTimeMillis().toInt(),
wholeWidgetClickI,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val playPausePI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
views.setOnClickPendingIntent(R.id.widgetPlayPauseButton, playPausePI)
val fastForwardPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_FAST_FORWARD)
views.setOnClickPendingIntent(R.id.widgetFastForwardButton, fastForwardPI)
val rewindPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_REWIND)
views.setOnClickPendingIntent(R.id.widgetRewindButton, rewindPI)
// Show/Hide button container
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
super.onResourceReady(resource, transition)
}
}
val artist = playbackSession?.displayAuthor ?: "Unknown"
views.setTextViewText(R.id.widgetArtistText, artist)
val title = playbackSession?.displayTitle ?: "Unknown"
views.setTextViewText(R.id.widgetMediaTitle, title)
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
// Instruct the widget manager to update the widget
appWidgetManager.updateAppWidget(appWidgetId, views)
}
@@ -0,0 +1,125 @@
package com.example.myapp
import android.content.Intent
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.core.content.ContextCompat
import com.audiobookshelf.app.Audiobook
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.PlayerNotificationService
import com.getcapacitor.JSObject
import com.getcapacitor.Plugin
import com.getcapacitor.PluginCall
import com.getcapacitor.PluginMethod
import com.getcapacitor.annotation.CapacitorPlugin
@CapacitorPlugin(name = "MyNativeAudio")
class MyNativeAudio : Plugin() {
private val tag = "MyNativeAudio"
lateinit var mainActivity:MainActivity
lateinit var playerNotificationService: PlayerNotificationService
override fun load() {
mainActivity = (activity as MainActivity)
var foregroundServiceReady : () -> Unit = {
playerNotificationService = mainActivity.foregroundService
playerNotificationService.setCustomObjectListener(object: PlayerNotificationService.MyCustomObjectListener {
override fun onPlayingUpdate(isPlaying:Boolean) {
emit("onPlayingUpdate", isPlaying)
}
override fun onMetadata(metadata:JSObject) {
notifyListeners("onMetadata", metadata)
}
})
}
mainActivity.pluginCallback = foregroundServiceReady
}
fun emit(evtName: String, value:Any) {
var ret:JSObject = JSObject()
ret.put("value", value)
notifyListeners(evtName, ret)
}
@PluginMethod
fun initPlayer(call: PluginCall) {
if (!PlayerNotificationService.isStarted) {
Log.w(tag, "Starting foreground service --")
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
ContextCompat.startForegroundService(mainActivity, intent)
}
} else {
Log.w(tag, "Service already started --")
}
var audiobook:Audiobook = Audiobook(call.data)
Handler(Looper.getMainLooper()).post() {
playerNotificationService.initPlayer(audiobook)
call.resolve()
}
}
@PluginMethod
fun getCurrentTime(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
var currentTime = playerNotificationService.getCurrentTime()
Log.d(tag, "Get Current Time $currentTime")
val ret = JSObject()
ret.put("value", currentTime)
call.resolve(ret)
}
}
@PluginMethod
fun pausePlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.pause()
call.resolve()
}
}
@PluginMethod
fun playPlayer(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.play()
call.resolve()
}
}
@PluginMethod
fun seekPlayer(call: PluginCall) {
var time:Long = call.getString("timeMs", "0")!!.toLong()
Handler(Looper.getMainLooper()).post() {
playerNotificationService.seekPlayer(time)
call.resolve()
}
}
@PluginMethod
fun seekForward10(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.seekForward10()
call.resolve()
}
}
@PluginMethod
fun seekBackward10(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.seekBackward10()
call.resolve()
}
}
@PluginMethod
fun terminateStream(call: PluginCall) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService.terminateStream()
call.resolve()
}
}
}
@@ -0,0 +1,422 @@
package com.audiobookshelf.app
import android.app.*
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.Color
import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.MediaSessionCompat
import android.util.Log
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import com.bumptech.glide.request.RequestOptions
import com.getcapacitor.JSObject
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator
import com.google.android.exoplayer2.source.hls.HlsMediaSource
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import com.google.android.exoplayer2.upstream.*
import kotlinx.coroutines.*
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
class PlayerNotificationService : Service() {
companion object {
var isStarted = false
}
interface MyCustomObjectListener {
fun onPlayingUpdate(isPlaying: Boolean)
fun onMetadata(metadata: JSObject)
}
private val tag = "PlayerService"
private lateinit var listener:MyCustomObjectListener
private lateinit var ctx:Context
private lateinit var mPlayer: SimpleExoPlayer
private lateinit var mediaSessionConnector: MediaSessionConnector
private lateinit var playerNotificationManager: PlayerNotificationManager
private lateinit var mediaSession: MediaSessionCompat
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
private val binder = LocalBinder()
private val glideOptions = RequestOptions()
.fallback(R.drawable.icon)
.diskCacheStrategy(DiskCacheStrategy.DATA)
private var notificationId = 10;
private var channelId = "audiobookshelf_channel"
private var channelName = "Audiobookshelf Channel"
private var currentAudiobook:Audiobook? = null
fun setCustomObjectListener(mylistener: MyCustomObjectListener) {
listener = mylistener
}
/*
Service related stuff
*/
override fun onBind(intent: Intent?): IBinder? {
Log.d(tag, "onBind")
return binder
}
inner class LocalBinder : Binder() {
// Return this instance of LocalService so clients can call public methods
fun getService(): PlayerNotificationService = this@PlayerNotificationService
}
fun stopService(context: Context) {
val stopIntent = Intent(context, PlayerNotificationService::class.java)
context.stopService(stopIntent)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.d(tag, "onStartCommand $startId")
isStarted = true
return START_STICKY
}
@RequiresApi(Build.VERSION_CODES.O)
private fun createNotificationChannel(channelId: String, channelName: String): String{
val chan = NotificationChannel(channelId,
channelName, NotificationManager.IMPORTANCE_HIGH)
chan.lightColor = Color.DKGRAY
chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
service.createNotificationChannel(chan)
return channelId
}
// detach player
override fun onDestroy() {
playerNotificationManager.setPlayer(null)
mPlayer.release()
mediaSession.release()
Log.d(tag, "onDestroy")
isStarted = false
super.onDestroy()
}
//removing service when user swipe out our app
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
Log.d(tag, "onTaskRemoved")
stopSelf()
}
override fun onCreate() {
super.onCreate()
ctx = this
channelId = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
createNotificationChannel(channelId, channelName)
} else ""
mPlayer = SimpleExoPlayer.Builder(this).build()
setPlayerListeners()
val sessionActivityPendingIntent =
packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent ->
PendingIntent.getActivity(this, 0, sessionIntent, 0)
}
mediaSession = MediaSessionCompat(this, tag)
.apply {
setSessionActivity(sessionActivityPendingIntent)
isActive = true
}
Log.d(tag, "Media Session Set")
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
val builder = PlayerNotificationManager.Builder(
ctx,
notificationId,
channelId)
builder.setMediaDescriptionAdapter(DescriptionAdapter(mediaController))
builder.setNotificationListener(object : PlayerNotificationManager.NotificationListener {
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
onGoing: Boolean) {
// Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
startForeground(notificationId, notification)
}
override fun onNotificationCancelled(
notificationId: Int,
dismissedByUser: Boolean
) {
if (dismissedByUser) {
Log.d(tag, "onNotificationCancelled dismissed by user")
stopSelf()
} else {
Log.d(tag, "onNotificationCancelled not dismissed by user")
}
}
})
playerNotificationManager = builder.build()
playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken)
playerNotificationManager.setUsePlayPauseActions(true)
playerNotificationManager.setUseNextAction(false)
playerNotificationManager.setUsePreviousAction(false)
playerNotificationManager.setUseChronometer(false)
playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
// Unknown action
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
// Color is set based on the art - cannot override
// playerNotificationManager.setColor(Color.RED)
// playerNotificationManager.setColorized(true)
// Icon needs to be black and white
// playerNotificationManager.setSmallIcon(R.drawable.icon_32)
mediaSessionConnector = MediaSessionConnector(mediaSession)
val queueNavigator: TimelineQueueNavigator = object : TimelineQueueNavigator(mediaSession) {
override fun getMediaDescription(player: Player, windowIndex: Int): MediaDescriptionCompat {
return MediaDescriptionCompat.Builder()
.setMediaId(currentAudiobook!!.id)
.setTitle(currentAudiobook!!.title)
.setSubtitle(currentAudiobook!!.author)
.setMediaUri(currentAudiobook!!.playlistUri)
.setIconUri(currentAudiobook!!.coverUri)
.build()
}
}
mediaSessionConnector.setQueueNavigator(queueNavigator)
mediaSessionConnector.setPlayer(mPlayer)
//attach player to playerNotificationManager
playerNotificationManager.setPlayer(mPlayer)
}
private inner class DescriptionAdapter(private val controller: MediaControllerCompat) :
PlayerNotificationManager.MediaDescriptionAdapter {
var currentIconUri: Uri? = null
var currentBitmap: Bitmap? = null
override fun createCurrentContentIntent(player: Player): PendingIntent? =
controller.sessionActivity
override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString()
override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString()
override fun getCurrentLargeIcon(
player: Player,
callback: PlayerNotificationManager.BitmapCallback
): Bitmap? {
val albumArtUri = controller.metadata.description.iconUri
return if (currentIconUri != albumArtUri || currentBitmap == null) {
// Cache the bitmap for the current audiobook so that successive calls to
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = albumArtUri
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
} else {
currentBitmap
}
}
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
// Block on downloading artwork.
Glide.with(ctx).applyDefaultRequestOptions(glideOptions)
.asBitmap()
.load(uri)
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
.get()
}
}
}
private fun setPlayerListeners() {
mPlayer.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
error.message?.let { Log.e(tag, it) }
error.localizedMessage?.let { Log.e(tag, it) }
}
override fun onEvents(player: Player, events: Player.Events) {
if (events.contains(Player.EVENT_TRACKS_CHANGED)) {
Log.d(tag, "EVENT_TRACKS_CHANGED")
}
if (events.contains(Player.EVENT_TIMELINE_CHANGED)) {
Log.d(tag, "EVENT_TIMELINE_CHANGED")
}
if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) {
Log.d(tag, "EVENT_POSITION_DISCONTINUITY")
}
if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) {
Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + mPlayer.isLoading.toString())
}
if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) {
if (mPlayer.playbackState == Player.STATE_READY) {
Log.d(tag, "STATE_READY : " + mPlayer.duration.toString())
if (!currentAudiobook!!.hasPlayerLoaded && currentAudiobook!!.startTime > 0) {
Log.d(tag, "Should seek to ${currentAudiobook!!.startTime}")
mPlayer.seekTo(currentAudiobook!!.startTime)
}
currentAudiobook!!.hasPlayerLoaded = true
sendClientMetadata("ready")
}
if (mPlayer.playbackState == Player.STATE_BUFFERING) {
Log.d(tag, "STATE_BUFFERING : " + mPlayer.currentPosition.toString())
sendClientMetadata("buffering")
}
if (mPlayer.playbackState == Player.STATE_ENDED) {
Log.d(tag, "STATE_ENDED")
sendClientMetadata("ended")
}
if (mPlayer.playbackState == Player.STATE_IDLE) {
Log.d(tag, "STATE_IDLE")
sendClientMetadata("idle")
}
}
if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) {
Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED")
}
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED")
}
if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) {
Log.d(tag, "EVENT IS PLAYING CHANGED")
if (listener != null) listener.onPlayingUpdate(player.isPlaying)
}
}
})
}
/*
User callable methods
*/
// fun initPlayer(token: String, playlistUri: String, playWhenReady: Boolean, currentTime: Long, title: String, artist: String, albumArt: String) {
fun initPlayer(audiobook: Audiobook) {
currentAudiobook = audiobook
Log.d(tag, "Init Player Audiobook ${currentAudiobook!!.playlistUrl} | ${currentAudiobook!!.title} | ${currentAudiobook!!.author}")
if (mPlayer.isPlaying) {
Log.d(tag, "Init Player audiobook already playing")
}
val metadata = MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, currentAudiobook!!.title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, currentAudiobook!!.title)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentAudiobook!!.author)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, currentAudiobook!!.series)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, currentAudiobook!!.cover)
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, currentAudiobook!!.cover)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, currentAudiobook!!.id)
.build()
mediaSession.setMetadata(metadata)
var mediaMetadata = MediaMetadata.Builder().build()
var mediaItem = MediaItem.Builder().setUri(currentAudiobook!!.playlistUri).setMediaMetadata(mediaMetadata).build()
var dataSourceFactory = DefaultHttpDataSource.Factory()
dataSourceFactory.setUserAgent(channelId)
dataSourceFactory.setDefaultRequestProperties(hashMapOf("Authorization" to "Bearer ${currentAudiobook!!.token}"))
var mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
mPlayer.setMediaSource(mediaSource, true)
mPlayer.prepare()
mPlayer.playWhenReady = currentAudiobook!!.playWhenReady
}
fun getCurrentTime() : Long {
return mPlayer.currentPosition
}
fun play() {
if (mPlayer.isPlaying) {
Log.d(tag, "Already playing")
return
}
mPlayer.play()
}
fun pause() {
mPlayer.pause()
}
fun seekPlayer(time: Long) {
mPlayer.seekTo(time)
}
fun seekForward10() {
mPlayer.seekTo(mPlayer.currentPosition + 10000)
}
fun seekBackward10() {
mPlayer.seekTo(mPlayer.currentPosition - 10000)
}
fun terminateStream() {
if (mPlayer.playbackState == Player.STATE_READY) {
mPlayer.clearMediaItems()
}
}
fun sendClientMetadata(stateName: String) {
var metadata = JSObject()
var duration = mPlayer.duration
if (duration < 0) duration = 0
metadata.put("duration", duration)
metadata.put("currentTime", mPlayer.currentPosition)
metadata.put("stateName", stateName)
if (listener != null) listener.onMetadata(metadata)
}
}
@@ -1,74 +0,0 @@
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() }
}
}
@@ -1,34 +0,0 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@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)
}
}
@@ -1,389 +0,0 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.media.MediaManager
import com.fasterxml.jackson.annotation.*
// 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("", false),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 localEpisodeId = "local_ep_" + at.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
episodes?.add(newEpisode)
}
}
var index = 1
episodes?.forEach {
it.index = index
index++
}
}
@JsonIgnore
override fun addAudioTrack(audioTrack:AudioTrack) {
val localEpisodeId = "local_ep_" + audioTrack.localFileId
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
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++
}
}
// 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)
}
@JsonIgnore
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
val localEpisodeId = "local_ep_" + episode.id
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,episode.size, episode.id, localEpisodeId)
episodes?.add(newEpisode)
var index = 1
episodes?.forEach {
it.index = index
index++
}
return newEpisode
}
@JsonIgnore
fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
val sortedEpisodes = episodes?.sortedByDescending { it.publishedAt }
val podcastEpisode = sortedEpisodes?.find { episode ->
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemId && it.episodeId == episode.id }
progress == null || !progress.isFinished
}
return podcastEpisode
}
}
@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 ebookFile: EBookFile?,
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 }
var totalDuration = 0.0
var currentStartOffset = 0.0
tracks?.forEach {
totalDuration += it.duration
it.startOffset = currentStartOffset
currentStartOffset += 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(), ebookFile, 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, var explicit:Boolean) {
@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?,
explicit:Boolean,
// In toJSONExpanded
var authorName:String?,
var authorNameLF:String?,
var narratorName:String?,
var seriesName:String?
) : MediaTypeMetadata(title, explicit) {
@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>,
explicit:Boolean
) : MediaTypeMetadata(title, explicit) {
@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 pubDate:String?,
var publishedAt:Long?,
var audioFile:AudioFile?,
var audioTrack:AudioTrack?,
var chapters:List<BookChapter>?,
var duration:Double?,
var size:Long?,
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
var localEpisodeId:String? // For Android Auto server episodes with local copy
) {
@JsonIgnore
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val coverUri = if (libraryItem is LocalLibraryItem) {
libraryItem.getCoverUri(ctx)
} else {
(libraryItem as LibraryItem).getCoverUri()
}
val extras = Bundle()
if (localEpisodeId != null) {
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
}
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
val mediaId = localEpisodeId ?: id
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(mediaId)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(libraryItemDescription.title)
.setExtras(extras)
libraryItemDescription.iconBitmap?.let {
mediaDescriptionBuilder.setIconBitmap(it)
}
return mediaDescriptionBuilder.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 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()
}
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
@JsonSubTypes(
JsonSubTypes.Type(MediaProgress::class),
JsonSubTypes.Type(LocalMediaProgress::class)
)
open class MediaProgressWrapper(var isFinished:Boolean, var currentTime:Double, var progress:Double) {
open val mediaItemId get() = ""
}
// Helper class
data class LibraryItemWithEpisode(
var libraryItemWrapper:LibraryItemWrapper,
var episode:PodcastEpisode
)
@@ -1,208 +0,0 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
enum class LockOrientationSetting {
NONE, PORTRAIT, LANDSCAPE
}
enum class HapticFeedbackSetting {
OFF, LIGHT, MEDIUM, HEAVY
}
enum class ShakeSensitivitySetting {
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
}
enum class DownloadUsingCellularSetting {
ASK, ALWAYS, NEVER
}
enum class StreamingUsingCellularSetting {
ASK, ALWAYS, NEVER
}
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>?
)
@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
}
@JsonIgnore
fun isEBookFile():Boolean {
return getEBookFormat() != null
}
@JsonIgnore
fun getEBookFormat():String? {
if (mimeType == "application/epub+zip") return "epub"
if (mimeType == "application/pdf") return "pdf"
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
return null
}
}
@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(var id:String) {
@JsonIgnore
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
}
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceInfo(
var deviceId:String,
var manufacturer:String,
var model: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
)
@JsonIgnoreProperties(ignoreUnknown = true)
data class DeviceSettings(
var disableAutoRewind:Boolean,
var enableAltView:Boolean,
var allowSeekingOnMediaControls:Boolean,
var jumpBackwardsTime:Int,
var jumpForwardTime:Int,
var enableMp3IndexSeeking:Boolean,
var disableShakeToResetSleepTimer:Boolean,
var shakeSensitivity: ShakeSensitivitySetting,
var lockOrientation: LockOrientationSetting,
var hapticFeedback: HapticFeedbackSetting,
var autoSleepTimer: Boolean,
var autoSleepTimerStartTime: String,
var autoSleepTimerEndTime: String,
var autoSleepTimerAutoRewind: Boolean,
var autoSleepTimerAutoRewindTime: Long, //Time in milliseconds
var sleepTimerLength: Long, // Time in milliseconds
var disableSleepTimerFadeOut: Boolean,
var disableSleepTimerResetFeedback: Boolean,
var languageCode: String,
var downloadUsingCellular: DownloadUsingCellularSetting,
var streamingUsingCellular: StreamingUsingCellularSetting
) {
companion object {
// Static method to get default device settings
fun default():DeviceSettings {
return DeviceSettings(
disableAutoRewind = false,
enableAltView = true,
allowSeekingOnMediaControls = false,
jumpBackwardsTime = 10,
jumpForwardTime = 10,
enableMp3IndexSeeking = false,
disableShakeToResetSleepTimer = false,
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
lockOrientation = LockOrientationSetting.NONE,
hapticFeedback = HapticFeedbackSetting.LIGHT,
autoSleepTimer = false,
autoSleepTimerStartTime = "22:00",
autoSleepTimerEndTime = "06:00",
sleepTimerLength = 900000L, // 15 minutes
autoSleepTimerAutoRewind = false,
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
disableSleepTimerFadeOut = false,
disableSleepTimerResetFeedback = false,
languageCode = "en-us",
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
)
}
}
@get:JsonIgnore
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
@get:JsonIgnore
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
@get:JsonIgnore
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
@get:JsonIgnore
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
@get:JsonIgnore
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
@get:JsonIgnore
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
@JsonIgnore
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
else {
Log.e("DeviceSetting", "Invalid ShakeSensitivitySetting $shakeSensitivity")
1.6f
}
}
}
data class DeviceData(
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
var lastServerConnectionConfigId:String?,
var deviceSettings: DeviceSettings?,
var lastPlaybackSession: PlaybackSession?
) {
@JsonIgnore
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
return lastServerConnectionConfigId?.let { lsccid ->
return serverConnectionConfigs.find { it.id == lsccid }
}
}
}
@@ -1,13 +0,0 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class EBookFile(
var ino:String,
var metadata:FileMetadata?,
var ebookFormat:String,
var isLocal:Boolean,
var localFileId:String?,
var contentUrl:String?
)
@@ -1,15 +0,0 @@
package com.audiobookshelf.app.data
data class FolderScanResult(
var itemsAdded:Int,
var itemsUpdated:Int,
var itemsRemoved:Int,
var itemsUpToDate:Int,
val localFolder:LocalFolder,
val localLibraryItems:List<LocalLibraryItem>,
)
data class LocalLibraryItemScanResult(
val updated:Boolean,
val localLibraryItem:LocalLibraryItem,
)
@@ -1,34 +0,0 @@
/*
Used in Android Auto to represent a podcast episode or an audiobook in progress
*/
package com.audiobookshelf.app.data
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 org.json.JSONObject
@JsonIgnoreProperties(ignoreUnknown = true)
data class ItemInProgress(
val libraryItemWrapper: LibraryItemWrapper,
val episode: PodcastEpisode?,
val progressLastUpdate: Long,
val isLocal: Boolean
) {
companion object {
fun makeFromServerObject(serverItem: JSONObject):ItemInProgress {
val jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
var episode:PodcastEpisode? = null
if (serverItem.has("recentEpisode")) {
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
}
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
}
}
}
@@ -1,12 +0,0 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class LibraryCategory(
var id:String,
var label:String,
var type:String,
var entities:List<LibraryItemWrapper>,
var isLocal:Boolean
)
@@ -1,106 +0,0 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.net.Uri
import android.os.Bundle
import android.support.v4.media.MediaDescriptionCompat
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class LibraryItem(
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)
var localLibraryItemId:String? // For Android Auto
) : LibraryItemWrapper(id) {
@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://${BuildConfig.APPLICATION_ID}/" + 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
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
val extras = Bundle()
if (localLibraryItemId != null) {
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
}
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else if (mediaType != "podcast") {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
if (media.metadata.explicit) {
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
}
val mediaId = localLibraryItemId ?: id
return MediaDescriptionCompat.Builder()
.setMediaId(mediaId)
.setTitle(title)
.setIconUri(getCoverUri())
.setSubtitle(authorName)
.setExtras(extras)
.build()
}
}
@@ -1,172 +0,0 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import android.support.v4.media.MediaDescriptionCompat
import android.util.Log
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.media.utils.MediaConstants
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
import java.util.*
@JsonIgnoreProperties(ignoreUnknown = true)
class LocalLibraryItem(
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(id) {
@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(ctx:Context): Uri {
if (coverContentUrl?.startsWith("file:") == true) {
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
}
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + 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?, deviceInfo:DeviceInfo):PlaybackSession {
val localEpisodeId = episode?.id
val sessionId = "${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
val mediaMetadata = media.metadata
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
val authorName = mediaMetadata.getAuthorDisplayName()
val displayTitle = episode?.title ?: mediaMetadata.title
var duration = getDuration()
if (episode != null) { // Get podcast episode audio track
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
chapters = episode.chapters
duration = episode.audioTrack?.duration ?: 0.0
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
}
val dateNow = System.currentTimeMillis()
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,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
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
val coverUri = getCoverUri(ctx)
var bitmap:Bitmap? = null
if (coverContentUrl != null) {
bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
}
val extras = Bundle()
extras.putLong(
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
MediaDescriptionCompat.STATUS_DOWNLOADED
)
if (progress != null) {
if (progress.isFinished) {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
)
} else {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
)
extras.putDouble(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
)
}
} else if (mediaType != "podcast") {
extras.putInt(
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
)
}
if (media.metadata.explicit) {
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
}
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
.setMediaId(id)
.setTitle(title)
.setIconUri(coverUri)
.setSubtitle(authorName)
.setExtras(extras)
bitmap?.let {
mediaDescriptionBuilder.setIconBitmap(bitmap)
}
return mediaDescriptionBuilder.build()
}
}
@@ -1,73 +0,0 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
/*
Used as a helper class to generate LocalLibraryItem from scan results
*/
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalMediaItem(
var id:String,
var name: String,
var mediaType:String,
var folderId:String,
var contentUrl:String,
var simplePath: String,
var basePath:String,
var absolutePath:String,
var audioTracks:MutableList<AudioTrack>,
var ebookFile:EBookFile?,
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(), false)
}
}
@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,ebookFile,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)
}
}
}
@@ -1,82 +0,0 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
import kotlin.math.roundToInt
@JsonIgnoreProperties(ignoreUnknown = true)
class LocalMediaProgress(
var id:String,
var localLibraryItemId:String,
var localEpisodeId:String?,
var duration:Double,
progress:Double, // 0 to 1
currentTime:Double,
isFinished:Boolean,
var ebookLocation:String?, // cfi tag
var ebookProgress:Double?, // 0 to 1
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?
) : MediaProgressWrapper(isFinished, currentTime, progress) {
@get:JsonIgnore
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
@get:JsonIgnore
override val mediaItemId get() = if (libraryItemId != null) {
if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
} else {
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
}
@JsonIgnore
fun isMatch(mediaProgress:MediaProgress):Boolean {
if (episodeId != null) return libraryItemId == mediaProgress.libraryItemId && episodeId == mediaProgress.episodeId
return libraryItemId == mediaProgress.libraryItemId
}
@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 = playbackSession.updatedAt
isFinished = playbackSession.progress >= 0.99
finishedAt = if (isFinished) lastUpdate else null
}
@JsonIgnore
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
lastUpdate = System.currentTimeMillis()
this.ebookProgress = ebookProgress
this.ebookLocation = ebookLocation
}
@JsonIgnore
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
isFinished = serverMediaProgress.isFinished
progress = serverMediaProgress.progress
currentTime = serverMediaProgress.currentTime
ebookProgress = serverMediaProgress.ebookProgress
ebookLocation = serverMediaProgress.ebookLocation
duration = serverMediaProgress.duration
lastUpdate = serverMediaProgress.lastUpdate
finishedAt = serverMediaProgress.finishedAt
startedAt = serverMediaProgress.startedAt
}
}
@@ -1,15 +0,0 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaItemEvent(
var name:String, // e.g. Play/Pause/Stop/Seek/Save
var type:String, // Playback/Info
var description:String?,
var currentTime:Number?, // Seconds
var serverSyncAttempted:Boolean?,
var serverSyncSuccess:Boolean?,
var serverSyncMessage:String?,
var timestamp: Long
)
@@ -1,17 +0,0 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaItemHistory(
var id: String, // media id
var mediaDisplayTitle: String,
var libraryItemId: String,
var episodeId: String?,
var isLocal:Boolean,
var serverConnectionConfigId:String?,
var serverAddress:String?,
var serverUserId:String?,
var createdAt: Long,
var events:MutableList<MediaItemEvent>,
)
@@ -1,24 +0,0 @@
package com.audiobookshelf.app.data
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
class MediaProgress(
var id:String,
var libraryItemId:String,
var episodeId:String?,
var duration:Double, // seconds
progress:Double, // 0 to 1
currentTime:Double,
isFinished:Boolean,
var ebookLocation:String?, // cfi tag
var ebookProgress:Double?, // 0 to 1
var lastUpdate:Long,
var startedAt:Long,
var finishedAt:Long?
) : MediaProgressWrapper(isFinished, currentTime, progress) {
@get:JsonIgnore
override val mediaItemId get() = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId"
}
@@ -1,11 +0,0 @@
package com.audiobookshelf.app.data
enum class PlayerState {
IDLE, BUFFERING, READY, ENDED
}
data class PlaybackMetadata(
val duration:Double,
val currentTime:Double,
val playerState:PlayerState
)
@@ -1,281 +0,0 @@
package com.audiobookshelf.app.data
import android.content.Context
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.MediaMetadataCompat
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.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
import com.audiobookshelf.app.player.*
@JsonIgnoreProperties(ignoreUnknown = true)
class PlaybackSession(
var id:String,
var userId:String?,
var libraryItemId:String?,
var episodeId:String?,
var mediaType:String,
var mediaMetadata:MediaTypeMetadata,
var deviceInfo:DeviceInfo,
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 isPodcastEpisode get() = mediaType == "podcast"
@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 (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
@get:JsonIgnore
val progress get() = currentTime / getTotalDuration()
@get:JsonIgnore
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
@get:JsonIgnore
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
@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 getNextTrackIndex():Int {
for (i in 0 until audioTracks.size) {
val track = audioTracks[i]
if (currentTimeMs < track.startOffsetMs) {
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 getCurrentTrackEndTime():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
return currentTrack.startOffsetMs + currentTrack.durationMs
}
@JsonIgnore
fun getNextChapterForTime(time:Long):BookChapter? {
if (chapters.isEmpty()) return null
return chapters.find { time < it.startMs } // First chapter where start time is > then time
}
@JsonIgnore
fun getNextTrackEndTime():Long {
val currentTrack = audioTracks[this.getNextTrackIndex()]
return currentTrack.startOffsetMs + currentTrack.durationMs
}
@JsonIgnore
fun getCurrentTrackTimeMs():Long {
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
val time = currentTime - currentTrack.startOffset
return (time * 1000L).toLong()
}
@JsonIgnore
fun getTrackStartOffsetMs(index:Int):Long {
if (index < 0 || index >= audioTracks.size) return 0L
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(ctx:Context): Uri {
if (localLibraryItem?.coverContentUrl != null) {
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
if (coverUri.toString().startsWith("file:")) {
coverUri = FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", coverUri.toFile())
}
return coverUri ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
}
if (coverPath == null) return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + 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(ctx: Context): MediaMetadataCompat {
val coverUri = getCoverUri(ctx)
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, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
// Local covers get bitmap
if (localLibraryItem?.coverContentUrl != null) {
val bitmap = if (Build.VERSION.SDK_INT < 28) {
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
ImageDecoder.decodeBitmap(source)
}
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
}
return metadataBuilder.build()
}
@JsonIgnore
fun getExoMediaMetadata(ctx:Context): MediaMetadata {
val coverUri = getCoverUri(ctx)
val metadataBuilder = MediaMetadata.Builder()
.setTitle(displayTitle)
.setDisplayTitle(displayTitle)
.setArtist(displayAuthor)
.setAlbumArtist(displayAuthor)
.setSubtitle(displayAuthor)
.setAlbumTitle(displayAuthor)
.setDescription(displayAuthor)
.setArtworkUri(coverUri)
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
return metadataBuilder.build()
}
@JsonIgnore
fun getMediaItems(ctx:Context):List<MediaItem> {
val mediaItems:MutableList<MediaItem> = mutableListOf()
for (audioTrack in audioTracks) {
val mediaMetadata = this.getExoMediaMetadata(ctx)
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_ALBUM_TITLE, 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,deviceInfo,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,null,null,updatedAt,startedAt,null,serverConnectionConfigId,serverAddress,userId,libraryItemId,episodeId)
}
}
@@ -1,124 +0,0 @@
package com.audiobookshelf.app.device
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.util.Log
import com.audiobookshelf.app.MediaPlayerWidget
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.managers.DbManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.updateAppWidget
interface WidgetEventEmitter {
fun onPlayerChanged(pns:PlayerNotificationService)
fun onPlayerClosed()
}
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
var widgetUpdater:WidgetEventEmitter? = null
init {
Log.d(tag, "Device Manager Singleton invoked")
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
deviceData.deviceSettings?.sleepTimerLength = 900000L
}
if (deviceData.deviceSettings?.shakeSensitivity == null) {
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
}
// Initialize auto sleep timer auto rewind added in v0.9.64
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
}
// Language added in v0.9.69
if (deviceData.deviceSettings?.languageCode == null) {
deviceData.deviceSettings?.languageCode = "en-us"
}
if (deviceData.deviceSettings?.downloadUsingCellular == null) {
deviceData.deviceSettings?.downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS
}
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
}
}
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 }
}
fun checkConnectivity(ctx:Context): 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 setLastPlaybackSession(playbackSession:PlaybackSession) {
deviceData.lastPlaybackSession = playbackSession
dbManager.saveDeviceData(deviceData)
}
fun initializeWidgetUpdater(context:Context) {
Log.d(tag, "Initializing widget updater")
widgetUpdater = (object : WidgetEventEmitter {
override fun onPlayerChanged(pns: PlayerNotificationService) {
val isPlaying = pns.currentPlayer.isPlaying
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
val playbackSession = pns.getCurrentPlaybackSessionCopy()
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, playbackSession, isPlaying, PlayerNotificationService.isClosed)
}
}
override fun onPlayerClosed() {
val appWidgetManager = AppWidgetManager.getInstance(context)
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
val ids = appWidgetManager.getAppWidgetIds(componentName)
for (widgetId in ids) {
updateAppWidget(context, appWidgetManager, widgetId, deviceData.lastPlaybackSession, false, PlayerNotificationService.isClosed)
}
}
})
}
}
@@ -1,368 +0,0 @@
package com.audiobookshelf.app.device
import android.content.Context
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.file.*
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.models.DownloadItem
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import java.io.File
class FolderScanner(var ctx: Context) {
private val tag = "FolderScanner"
private 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)
}
private fun scanInternalDownloadItem(downloadItem:DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
val localLibraryItemId = "local_${downloadItem.libraryItemId}"
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem?
if (downloadItem.mediaType == "book") {
localLibraryItem = LocalLibraryItem(localLibraryItemId, downloadItem.localFolder.id, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", 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, downloadItem.itemFolderPath, downloadItem.itemFolderPath, "", false, downloadItem.mediaType, downloadItem.media.getLocalCopy(), mutableListOf(), null, null, true,downloadItem.serverConnectionConfigId,downloadItem.serverAddress,downloadItem.serverUserId,downloadItem.libraryItemId)
}
}
val audioTracks:MutableList<AudioTrack> = mutableListOf()
var foundEBookFile = false
downloadItem.downloadItemParts.forEach { downloadItemPart ->
Log.d(tag, "Scan internal storage item with finalDestinationUri=${downloadItemPart.finalDestinationUri}")
val file = File(downloadItemPart.finalDestinationPath)
Log.d(tag, "Scan internal storage item created file ${file.name}")
if (file == null) {
Log.e(tag, "scanInternalDownloadItem: Null docFile for path ${downloadItemPart.finalDestinationPath}")
} else {
if (downloadItemPart.audioTrack != null) {
val audioTrackFromServer = downloadItemPart.audioTrack
Log.d(
tag,
"scanInternalDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}"
)
val localFileId = DeviceManager.getBase64Id(file.name)
Log.d(tag, "Scan internal file localFileId=$localFileId")
val localFile = LocalFile(
localFileId,
file.name,
downloadItemPart.finalDestinationUri.toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val trackFileMetadata = FileMetadata(file.name, file.extension, file.absolutePath, file.getBasePath(ctx), file.length())
// Create new audio track
val track = AudioTrack(
audioTrackFromServer.index,
audioTrackFromServer.startOffset,
audioTrackFromServer.duration,
localFile.filename ?: "",
localFile.contentUrl,
localFile.mimeType ?: "",
trackFileMetadata,
true,
localFileId,
null,
audioTrackFromServer.index
)
audioTracks.add(track)
Log.d(
tag,
"scanInternalDownloadItem: Created Audio Track with index ${track.index} from local file ${localFile.absolutePath}"
)
// Add podcast episodes to library
downloadItemPart.episode?.let { podcastEpisode ->
val podcast = localLibraryItem.media as Podcast
val newEpisode = podcast.addEpisode(track, podcastEpisode)
localEpisodeId = newEpisode.id
Log.d(
tag,
"scanInternalDownloadItem: Added episode to podcast ${podcastEpisode.title} ${track.title} | Track index: ${podcastEpisode.audioTrack?.index}"
)
}
} else if (downloadItemPart.ebookFile != null) {
foundEBookFile = true
Log.d(tag, "scanInternalDownloadItem: Ebook file found with mimetype=${file.mimeType}")
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(
localFileId,
file.name,
Uri.fromFile(file).toString(),
file.getBasePath(ctx),
file.absolutePath,
file.getSimplePath(ctx),
file.mimeType,
file.length()
)
localLibraryItem.localFiles.add(localFile)
val ebookFile = EBookFile(
downloadItemPart.ebookFile.ino,
downloadItemPart.ebookFile.metadata,
downloadItemPart.ebookFile.ebookFormat,
true,
localFileId,
localFile.contentUrl
)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanInternalDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} else {
val localFileId = DeviceManager.getBase64Id(file.name)
val localFile = LocalFile(localFileId,file.name,Uri.fromFile(file).toString(),file.getBasePath(ctx),file.absolutePath,file.getSimplePath(ctx),file.mimeType,file.length())
localLibraryItem.coverAbsolutePath = localFile.absolutePath
localLibraryItem.coverContentUrl = localFile.contentUrl
localLibraryItem.localFiles.add(localFile)
}
}
}
if (audioTracks.isEmpty() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(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 = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
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)
cb(downloadItemScanResult)
}
// Scan item after download and create local library item
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
// If downloading to internal storage handle separately
if (downloadItem.isInternalStorage) {
scanInternalDownloadItem(downloadItem, cb)
return
}
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
var itemFolderId = ""
var itemFolderUrl = ""
var itemFolderBasePath = ""
var itemFolderAbsolutePath = ""
foldersFound.forEach {
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
// and itemSubfolder is "Orson Scott Card/Enders Game"
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
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 cb(null)
}
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
if (df == null) {
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
return cb(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/*"))
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
var localEpisodeId:String? = null
var localLibraryItem:LocalLibraryItem?
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()
var foundEBookFile = false
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)
// Create new audio track
val trackFileMetadata = FileMetadata(docFile.name ?: "", docFile.extension ?: "", docFile.getAbsolutePath(ctx), docFile.getBasePath(ctx), docFile.length())
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", trackFileMetadata, true, localFileId, null, 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 if (itemPart.ebookFile != null) { // Ebook
foundEBookFile = true
Log.d(tag, "scanDownloadItem: Ebook file found with mimetype=${docFile.mimeType}")
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)
val ebookFile = EBookFile(itemPart.ebookFile.ino, itemPart.ebookFile.metadata, itemPart.ebookFile.ebookFormat, true, localFileId, localFile.contentUrl)
(localLibraryItem.media as Book).ebookFile = ebookFile
Log.d(tag, "scanDownloadItem: Ebook file added to lli ${localFile.contentUrl}")
} 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() && !foundEBookFile) {
Log.d(tag, "scanDownloadItem did not find any audio tracks or ebook file in folder for ${downloadItem.itemFolderPath}")
return cb(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 = mediaProgress.isFinished,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
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)
cb(downloadItemScanResult)
}
}
@@ -1,262 +0,0 @@
package com.audiobookshelf.app.managers
import android.content.Context
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.models.DownloadItem
import io.paperdb.Paper
import java.io.File
class DbManager {
val tag = "DbManager"
companion object {
private 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, DeviceSettings.default(), null)
}
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 getLocalLibraryItemByLId(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: DownloadItem) {
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
}
fun removeDownloadItem(downloadItemId:String) {
Paper.book("downloadItems").delete(downloadItemId)
}
fun getDownloadItems():List<DownloadItem> {
val downloadItems:MutableList<DownloadItem> = mutableListOf()
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
Paper.book("downloadItems").read<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 hasUpdates = 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}")
hasUpdates = 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}")
hasUpdates = 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}")
hasUpdates = 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
hasUpdates = true
}
}
if (hasUpdates) {
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 (!it.id.startsWith("local")) {
// A bug on the server when syncing local media progress was replacing the media progress id causing duplicate progress. Remove them.
Log.d(tag, "cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)")
Paper.book("localMediaProgress").delete(it.id)
} else 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 saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
}
fun getMediaItemHistory(id:String): MediaItemHistory? {
return Paper.book("mediaItemHistory").read(id)
}
fun savePlaybackSession(playbackSession: PlaybackSession) {
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
}
fun removePlaybackSession(playbackSessionId:String) {
Paper.book("playbackSession").delete(playbackSessionId)
}
fun getPlaybackSessions():List<PlaybackSession> {
val sessions:MutableList<PlaybackSession> = mutableListOf()
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
sessions.add(it)
}
}
return sessions
}
}
@@ -1,291 +0,0 @@
package com.audiobookshelf.app.managers
import android.app.DownloadManager
import android.net.Uri
import android.util.Log
import androidx.documentfile.provider.DocumentFile
import com.anggrayudi.storage.callback.FileCallback
import com.anggrayudi.storage.file.DocumentFileCompat
import com.anggrayudi.storage.file.MimeType
import com.anggrayudi.storage.file.getAbsolutePath
import com.anggrayudi.storage.file.moveFileTo
import com.anggrayudi.storage.media.FileDescription
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.JSObject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.util.*
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
val tag = "DownloadItemManager"
private val maxSimultaneousDownloads = 3
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
enum class DownloadCheckStatus {
InProgress,
Successful,
Failed
}
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
interface DownloadEventEmitter {
fun onDownloadItem(downloadItem:DownloadItem)
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
fun onDownloadItemComplete(jsobj:JSObject)
}
interface InternalProgressCallback {
fun onProgress(totalBytesWritten:Long, progress: Long)
fun onComplete(failed: Boolean)
}
companion object {
var isDownloading:Boolean = false
}
fun addDownloadItem(downloadItem:DownloadItem) {
DeviceManager.dbManager.saveDownloadItem(downloadItem)
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
downloadItemQueue.add(downloadItem)
clientEventEmitter.onDownloadItem(downloadItem)
checkUpdateDownloadQueue()
}
private fun checkUpdateDownloadQueue() {
for (downloadItem in downloadItemQueue) {
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
if (nextDownloadItemParts.size > 0) {
nextDownloadItemParts.forEach {
if (it.isInternalStorage) {
val file = File(it.finalDestinationPath)
file.parentFile?.mkdirs()
val fileOutputStream = FileOutputStream(it.finalDestinationPath)
val internalProgressCallback = (object : InternalProgressCallback {
override fun onProgress(totalBytesWritten:Long, progress: Long) {
it.bytesDownloaded = totalBytesWritten
it.progress = progress
}
override fun onComplete(failed:Boolean) {
it.failed = failed
it.completed = true
}
})
Log.d(tag, "Start internal download to destination path ${it.finalDestinationPath} from ${it.serverUrl}")
InternalDownloadManager(fileOutputStream, internalProgressCallback).download(it.serverUrl)
it.downloadId = 1
currentDownloadItemParts.add(it)
} else {
val dlRequest = it.getDownloadRequest()
val downloadId = downloadManager.enqueue(dlRequest)
it.downloadId = downloadId
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
currentDownloadItemParts.add(it)
}
}
}
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
break
}
}
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
}
private fun startWatchingDownloads() {
if (isDownloading) return // Already watching
GlobalScope.launch(Dispatchers.IO) {
Log.d(tag, "Starting watching downloads")
isDownloading = true
while (currentDownloadItemParts.size > 0) {
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
for (downloadItemPart in itemParts) {
if (downloadItemPart.isInternalStorage) {
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
if (downloadItemPart.completed) {
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
downloadItem?.let {
checkDownloadItemFinished(it)
}
currentDownloadItemParts.remove(downloadItemPart)
}
} else {
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
// Will move to final destination, remove current item parts, and check if download item is finished
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
}
}
delay(500)
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
checkUpdateDownloadQueue()
}
}
Log.d(tag, "Finished watching downloads")
isDownloading = false
}
}
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
val query = DownloadManager.Query().setFilterById(downloadId)
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.getLong(bytesDownloadedColumnIndex) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
return when (downloadStatus) {
DownloadManager.STATUS_SUCCESSFUL -> {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful")
downloadItemPart.completed = true
downloadItemPart.progress = 1
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
DownloadCheckStatus.Successful
}
DownloadManager.STATUS_FAILED -> {
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
downloadItemPart.completed = true
downloadItemPart.failed = true
DownloadCheckStatus.Failed
}
else -> {
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
downloadItemPart.progress = percentProgress
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
DownloadCheckStatus.InProgress
}
}
} else {
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
downloadItemPart.completed = true
downloadItemPart.failed = true
return DownloadCheckStatus.Failed
}
}
}
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
if (downloadItem == null) {
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
currentDownloadItemParts.remove(downloadItemPart)
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
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
downloadItemPart.isMoving = false
file?.delete()
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
override fun onCompleted(result:Any) {
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
val resultDocFile = result as DocumentFile
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
// Rename to fix appended .mp3 on m4b/m4a files
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
resultDocFile.renameTo(downloadItemPart.filename)
}
downloadItemPart.moved = true
downloadItemPart.isMoving = false
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
}
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
if (localFolderFile == null) {
// fAILED
downloadItemPart.failed = true
Log.e(tag, "Local Folder File from uri is null")
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
} else {
downloadItemPart.isMoving = true
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
}
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
checkDownloadItemFinished(downloadItem)
currentDownloadItemParts.remove(downloadItemPart)
}
}
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
if (downloadItem.isDownloadFinished) {
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
GlobalScope.launch(Dispatchers.IO) {
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
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)))
}
launch(Dispatchers.Main) {
clientEventEmitter.onDownloadItemComplete(jsobj)
downloadItemQueue.remove(downloadItem)
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
}
}
}
}
}
}
@@ -1,74 +0,0 @@
package com.audiobookshelf.app.managers
import android.util.Log
import com.google.common.net.HttpHeaders.CONTENT_LENGTH
import okhttp3.*
import java.io.*
import java.util.*
class InternalDownloadManager(outputStream:FileOutputStream, private val progressCallback: DownloadItemManager.InternalProgressCallback) : AutoCloseable {
private val tag = "InternalDownloadManager"
private val client: OkHttpClient = OkHttpClient()
private val writer = BinaryFileWriter(outputStream, progressCallback)
@Throws(IOException::class)
fun download(url:String) {
val request: Request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
Log.e(tag, "download URL $url FAILED")
progressCallback.onComplete(true)
}
override fun onResponse(call: Call, response: Response) {
val responseBody: ResponseBody = response.body
?: throw IllegalStateException("Response doesn't contain a file")
val length: Long = (response.header(CONTENT_LENGTH, "1") ?: "0").toLong()
writer.write(responseBody.byteStream(), length)
}
})
}
@Throws(Exception::class)
override fun close() {
writer.close()
}
}
class BinaryFileWriter(outputStream: OutputStream, progressCallback: DownloadItemManager.InternalProgressCallback) :
AutoCloseable {
private val outputStream: OutputStream
private val progressCallback: DownloadItemManager.InternalProgressCallback
init {
this.outputStream = outputStream
this.progressCallback = progressCallback
}
@Throws(IOException::class)
fun write(inputStream: InputStream?, length: Long): Long {
BufferedInputStream(inputStream).use { input ->
val dataBuffer = ByteArray(CHUNK_SIZE)
var readBytes: Int
var totalBytes: Long = 0
while (input.read(dataBuffer).also { readBytes = it } != -1) {
totalBytes += readBytes.toLong()
outputStream.write(dataBuffer, 0, readBytes)
progressCallback.onProgress(totalBytes, (totalBytes * 100L) / length)
}
progressCallback.onComplete(false)
return totalBytes
}
}
@Throws(IOException::class)
override fun close() {
outputStream.close()
}
companion object {
private const val CHUNK_SIZE = 1024
}
}
@@ -1,397 +0,0 @@
package com.audiobookshelf.app.managers
import android.content.Context
import android.media.metrics.PlaybackSession
import android.os.*
import android.util.Log
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
import java.text.SimpleDateFormat
import java.util.*
import kotlin.concurrent.schedule
import kotlin.math.roundToInt
class SleepTimerManager constructor(private 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 sleepTimerFinishedAt:Long = 0L
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
private var isFirstAutoSleepTimer: Boolean = true
private var sleepTimerSessionId:String = ""
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()
}
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
sleepTimerTask?.cancel()
sleepTimerRunning = true
sleepTimerFinishedAt = 0L
sleepTimerElapsed = 0L
setVolume(1f)
// 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
if (sleepTimerEndTime > getDuration()) {
sleepTimerEndTime = getDuration()
}
} else {
sleepTimerLength = time
sleepTimerEndTime = 0L
}
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
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, isAutoSleepTimer)
}
if (sleepTimeSecondsRemaining <= 0) {
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
pause()
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
clearSleepTimer()
sleepTimerFinishedAt = System.currentTimeMillis()
} else if (sleepTimeSecondsRemaining <= 60 && DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut != true) {
// Start fading out audio down to 10% volume
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
val volume = 1f - (percentToReduce * 0.9f)
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
setVolume(volume)
} else {
setVolume(1f)
}
}
}
}
return true
}
fun setManualSleepTimer(playbackSessionId:String, time: Long, isChapterTime:Boolean):Boolean {
sleepTimerSessionId = playbackSessionId
isAutoSleepTimer = false
return setSleepTimer(time, isChapterTime)
}
private fun clearSleepTimer() {
sleepTimerTask?.cancel()
sleepTimerTask = null
sleepTimerEndTime = 0
sleepTimerRunning = false
playerNotificationService.unregisterSensor()
setVolume(1f)
}
fun getSleepTimerTime():Long {
return sleepTimerEndTime
}
fun cancelSleepTimer() {
Log.d(tag, "Canceling Sleep Timer")
if (isAutoSleepTimer) {
Log.i(tag, "Disabling auto sleep timer")
DeviceManager.deviceData.deviceSettings?.autoSleepTimer = false
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
}
clearSleepTimer()
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
}
// Vibrate when resetting sleep timer
private fun vibrateFeedback() {
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
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)
}
}
}
// Get the chapter end time for use in End of Chapter timers
// if less than 2s remain in chapter then use the next chapter
private fun getChapterEndTime():Long? {
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
if (currentChapterEndTimeMs == null) {
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
return null
}
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
return if (timeLeftInChapter < 2000L) {
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
null
} else {
nextChapterEndTimeMs
}
} else {
currentChapterEndTimeMs
}
}
private fun resetChapterTimer() {
this.getChapterEndTime()?.let { chapterEndTime ->
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
vibrateFeedback()
setSleepTimer(chapterEndTime, true)
play()
}
}
private fun checkShouldResetSleepTimer() {
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
}
// Automatically Rewind in the book if settings is enabled
if (isAutoSleepTimer) {
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
}
isFirstAutoSleepTimer = false
}
}
// Set sleep timer
// When sleepTimerLength is 0 then use end of chapter/track time
if (sleepTimerLength == 0L) {
Log.d(tag, "Resetting stopped chapter sleep timer")
resetChapterTimer()
} else {
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
vibrateFeedback()
setSleepTimer(sleepTimerLength, false)
play()
}
return
}
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
vibrateFeedback()
setSleepTimer(sleepTimerLength, false)
} else if (sleepTimerLength == 0L) {
// When navigating to previous chapters make sure this is still the end of the current chapter
this.getChapterEndTime()?.let { chapterEndTime ->
if (chapterEndTime != sleepTimerEndTime) {
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
vibrateFeedback()
setSleepTimer(chapterEndTime, true)
play()
}
}
}
}
fun handleShake() {
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) {
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
Log.d(tag, "Shake to reset sleep timer is disabled")
return
}
checkShouldResetSleepTimer()
}
}
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(), isAutoSleepTimer)
}
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(), isAutoSleepTimer)
}
fun checkAutoSleepTimer() {
if (sleepTimerRunning) { // Sleep timer already running
return
}
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
if (!deviceSettings.autoSleepTimer) return // Check auto sleep timer is enabled
val startCalendar = Calendar.getInstance()
startCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerStartHour)
startCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerStartMinute)
val endCalendar = Calendar.getInstance()
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
val currentCalendar = Calendar.getInstance()
// In cases where end time is before start time then we shift the time window forward or backward based on the current time.
// e.g. start time 22:00 and end time 06:00.
// If current time is less than start time (e.g. 00:30) then start time will be the previous day.
// If current time is greater than start time (e.g. 23:00) then end time will be the next day.
if (endCalendar.before(startCalendar)) {
if (currentCalendar.before(startCalendar)) { // Shift start back a day
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
} else { // Shift end forward a day
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
}
}
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
// Automatically Rewind in the book if settings is enabled
if (deviceSettings.autoSleepTimerAutoRewind && !isFirstAutoSleepTimer) {
Log.i(tag, "Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms")
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
}
isFirstAutoSleepTimer = false
// Set sleep timer
// When sleepTimerLength is 0 then use end of chapter/track time
if (deviceSettings.sleepTimerLength == 0L) {
val chapterEndTimeMs = this.getChapterEndTime()
if (chapterEndTimeMs == null) {
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
} else {
isAutoSleepTimer = true
setSleepTimer(chapterEndTimeMs, true)
}
} else {
isAutoSleepTimer = true
setSleepTimer(deviceSettings.sleepTimerLength, false)
}
} else {
isFirstAutoSleepTimer = true
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
}
}
}
fun handleMediaPlayEvent(playbackSessionId:String) {
// Check if the playback session has changed
// If it hasn't changed OR the sleep timer is running then check reset the timer
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer will stay on and reset to 10 mins
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
checkShouldResetSleepTimer()
} else {
isFirstAutoSleepTimer = true
}
sleepTimerSessionId = playbackSessionId
checkAutoSleepTimer()
}
}
@@ -1,111 +0,0 @@
package com.audiobookshelf.app.media
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.player.PlayerNotificationService
object MediaEventManager {
const val tag = "MediaEventManager"
var clientEventEmitter: PlayerNotificationService.ClientEventEmitter? = null
fun playEvent(playbackSession: PlaybackSession) {
Log.i(tag, "Play Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Play", playbackSession, null)
}
fun pauseEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Pause Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Pause", playbackSession, syncResult)
}
fun stopEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Stop Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Stop", playbackSession, syncResult)
}
fun saveEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Save Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Save", playbackSession, syncResult)
}
fun finishedEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Finished Event for media \"${playbackSession.displayTitle}\"")
addPlaybackEvent("Finished", playbackSession, syncResult)
}
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
addPlaybackEvent("Seek", playbackSession, syncResult)
}
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
addSyncEvent("Sync", mediaProgress, description)
}
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
if (mediaItemHistory == null) {
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
return
}
val mediaItemEvent = MediaItemEvent(
name = eventName,
type = "Sync",
description = description,
currentTime = mediaProgress.currentTime,
serverSyncAttempted = false,
serverSyncSuccess = null,
serverSyncMessage = null,
timestamp = System.currentTimeMillis()
)
mediaItemHistory.events.add(mediaItemEvent)
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
}
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
val mediaItemEvent = MediaItemEvent(
name = eventName,
type = "Playback",
description = "",
currentTime = playbackSession.currentTime,
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
serverSyncSuccess = syncResult?.serverSyncSuccess,
serverSyncMessage = syncResult?.serverSyncMessage,
timestamp = System.currentTimeMillis()
)
mediaItemHistory.events.add(mediaItemEvent)
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
}
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
}
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
return MediaItemHistory(
id = playbackSession.mediaItemId,
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
libraryItemId,
episodeId,
isLocalOnly,
playbackSession.serverConnectionConfigId,
playbackSession.serverAddress,
playbackSession.userId,
createdAt = System.currentTimeMillis(),
events = mutableListOf())
}
}
@@ -1,446 +0,0 @@
package com.audiobookshelf.app.media
import android.app.Activity
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 com.getcapacitor.JSObject
import java.util.*
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking
import org.json.JSONException
import org.json.JSONObject
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
val tag = "MediaManager"
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
private var selectedLibraryItems = mutableListOf<LibraryItem>()
private var selectedLibraryId = ""
private var selectedPodcast:Podcast? = null
private var selectedLibraryItemId:String? = null
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
private var serverLibraryCategories = listOf<LibraryCategory>()
private var serverConfigIdUsed:String? = null
private var serverConfigLastPing:Long = 0L
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
var serverItemsInProgress = listOf<ItemInProgress>()
var serverLibraries = listOf<Library>()
var userSettingsPlaybackRate:Float? = null
fun getIsLibrary(id:String) : Boolean {
return serverLibraries.find { it.id == id } != null
}
fun getSavedPlaybackRate():Float {
if (userSettingsPlaybackRate != null) {
return userSettingsPlaybackRate ?: 1f
}
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
if (sharedPrefs != null) {
val userSettingsPref = sharedPrefs.getString("userSettings", null)
if (userSettingsPref != null) {
try {
val userSettings = JSObject(userSettingsPref)
if (userSettings.has("playbackRate")) {
userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat()
return userSettingsPlaybackRate ?: 1f
}
} catch(je:JSONException) {
Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}")
}
}
}
return 1f
}
fun setSavedPlaybackRate(newRate: Float) {
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
val sharedPrefEditor = sharedPrefs.edit()
if (sharedPrefs != null) {
val userSettingsPref = sharedPrefs.getString("userSettings", null)
if (userSettingsPref != null) {
try {
val userSettings = JSObject(userSettingsPref)
// toString().toDouble() to prevent float conversion issues (ex 1.2f becomes 1.2000000476837158d)
userSettings.put("playbackRate", newRate.toString().toDouble())
sharedPrefEditor.putString("userSettings", userSettings.toString())
sharedPrefEditor.apply()
userSettingsPlaybackRate = newRate
Log.d(tag, "Saved userSettings JSON from Android Auto with playbackRate=$newRate")
} catch(je:JSONException) {
Log.e(tag, "Failed to save userSettings JSON ${je.localizedMessage}")
}
} else {
// Not sure if this is the best place for this, but if a user has not changed any user settings in the app
// the object will not exist yet, could be moved to a centralized place or created on first app load
val userSettings = JSONObject()
userSettings.put("playbackRate", newRate.toString().toDouble())
sharedPrefEditor.putString("userSettings", userSettings.toString())
userSettingsPlaybackRate = newRate
Log.d(tag, "Created and saved userSettings JSON from Android Auto with playbackRate=$newRate")
}
}
}
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 || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
podcastEpisodeLibraryItemMap = mutableMapOf()
serverLibraryCategories = listOf()
serverLibraries = listOf()
serverLibraryItems = mutableListOf()
selectedLibraryItems = mutableListOf()
selectedLibraryId = ""
}
}
private fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
if (serverItemsInProgress.isNotEmpty()) {
cb(serverItemsInProgress)
} else {
apiHandler.getAllItemsInProgress { itemsInProgress ->
serverItemsInProgress = itemsInProgress.filter {
val libraryItem = it.libraryItemWrapper as LibraryItem
libraryItem.checkHasTracks()
}
cb(serverItemsInProgress)
}
}
}
fun loadLibraryItemsWithAudio(libraryId:String, cb: (List<LibraryItem>) -> Unit) {
if (selectedLibraryItems.isNotEmpty() && selectedLibraryId == libraryId) {
cb(selectedLibraryItems)
} else {
apiHandler.getLibraryItems(libraryId) { libraryItems ->
val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() }
if (libraryItemsWithAudio.isNotEmpty()) {
selectedLibraryId = libraryId
}
selectedLibraryItems = mutableListOf()
libraryItemsWithAudio.forEach { libraryItem ->
selectedLibraryItems.add(libraryItem)
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
}
cb(libraryItemsWithAudio)
}
}
}
private 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, ctx:Context, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
libraryItemWrapper?.let {
if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes
if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) {
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(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()) {
cb(mutableListOf())
} else {
val podcast = libraryItemWrapper.media as Podcast
podcast.episodes?.forEach { podcastEpisode ->
podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode)
}
selectedLibraryItemId = libraryItemWrapper.id
selectedPodcast = podcast
val children = podcast.episodes?.map { podcastEpisode ->
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
// to show download icon
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemWrapper.id)
localLibraryItem?.let { lli ->
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == podcastEpisode.id }
podcastEpisode.localEpisodeId = localEpisode?.id
}
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
}
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
}
}
}
}
}
private fun loadLibraries(cb: (List<Library>) -> Unit) {
if (serverLibraries.isNotEmpty()) {
cb(serverLibraries)
} else {
apiHandler.getLibraries {
serverLibraries = it
cb(it)
}
}
}
private suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
var successfulPing = false
suspendCoroutine { cont ->
apiHandler.pingServer(config) {
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
successfulPing = it
cont.resume(it)
}
}
return successfulPing
}
private suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
suspendCoroutine { cont ->
apiHandler.authorize(config) {
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
mediaProgress = it
}
cont.resume(mediaProgress)
}
}
return mediaProgress
}
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}")
coroutineScope {
if (!DeviceManager.checkConnectivity(ctx)) {
serverUserMediaProgress = mutableListOf()
Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity")
cb(false)
} else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset
serverUserMediaProgress = mutableListOf()
Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config")
cb(false)
} else {
var hasValidConn = false
var lookupMediaProgress = true
if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) {
Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago")
hasValidConn = true
lookupMediaProgress = false
} else {
serverUserMediaProgress = mutableListOf()
}
if (!hasValidConn) {
// 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
Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}")
break
}
}
}
if (hasValidConn) {
serverConfigLastPing = System.currentTimeMillis()
if (lookupMediaProgress) {
Log.d(tag, "Has valid conn now get user media progress")
DeviceManager.serverConnectionConfig?.let {
serverUserMediaProgress = authorize(it)
}
}
}
cb(hasValidConn)
}
}
}
fun loadServerUserMediaProgress(cb: () -> Unit) {
Log.d(tag, "Loading server media progress")
if (DeviceManager.serverConnectionConfig == null) {
return cb()
}
DeviceManager.serverConnectionConfig?.let { config ->
apiHandler.authorize(config) {
Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it")
if (!it.isNullOrEmpty()) {
serverUserMediaProgress = it
}
cb()
}
}
}
fun loadAndroidAutoItems(cb: () -> Unit) {
Log.d(tag, "Load android auto items")
// Check if any valid server connection if not use locally downloaded books
checkSetValidServerConnectionConfig { isConnected ->
if (isConnected) {
serverConfigIdUsed = DeviceManager.serverConnectionConfigId
Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed")
loadLibraries { libraries ->
if (libraries.isEmpty()) {
Log.w(tag, "No libraries returned from server request")
cb()
} else {
val library = libraries[0]
Log.d(tag, "Loading categories for library ${library.name} - ${library.id} - ${library.mediaType}")
loadItemsInProgressForAllLibraries { itemsInProgress ->
itemsInProgress.forEach {
val libraryItem = it.libraryItemWrapper as LibraryItem
if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) {
serverLibraryItems.add(libraryItem)
}
if (it.episode != null) {
podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode)
}
}
cb() // Fully loaded
}
}
}
} else { // Not connected to server
Log.d(tag, "loadAndroidAutoItems: Not connected to server")
cb()
}
}
}
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? {
return if (id.startsWith("local")) {
DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
} else {
podcastEpisodeLibraryItemMap[id]
}
}
fun getById(id:String) : LibraryItemWrapper? {
return if (id.startsWith("local")) {
DeviceManager.dbManager.getLocalLibraryItem(id)
} else {
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) {
cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo))
} 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,322 +0,0 @@
package com.audiobookshelf.app.media
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.player.PlayerNotificationService
import com.audiobookshelf.app.server.ApiHandler
import java.util.*
import kotlin.concurrent.schedule
data class MediaProgressSyncData(
var timeListened:Long, // seconds
var duration:Double, // seconds
var currentTime:Double // seconds
)
data class SyncResult(
var serverSyncAttempted:Boolean,
var serverSyncSuccess:Boolean?,
var serverSyncMessage:String?
)
class MediaProgressSyncer(val playerNotificationService: PlayerNotificationService, private val apiHandler: ApiHandler) {
private val tag = "MediaProgressSync"
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
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
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
val currentSessionId get() = currentPlaybackSession?.id ?: ""
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
fun start(playbackSession:PlaybackSession) {
if (listeningTimerRunning) {
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
if (playbackSession.id != currentSessionId) {
Log.d(tag, "Playback session changed, reset timer")
currentLocalMediaProgress = null
listeningTimerTask?.cancel()
lastSyncTime = 0L
Log.d(tag, "start: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
} else {
return
}
} else if (playbackSession.id != currentSessionId) {
currentLocalMediaProgress = null
}
listeningTimerRunning = true
lastSyncTime = System.currentTimeMillis()
currentPlaybackSession = playbackSession.clone()
Log.d(tag, "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}")
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
Handler(Looper.getMainLooper()).post() {
if (playerNotificationService.currentPlayer.isPlaying) {
// Set auto sleep timer if enabled and within start/end time
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) {
sync(shouldSyncServer, currentTime) { syncResult ->
Log.d(tag, "Sync complete")
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.saveEvent(playbackSession, syncResult)
}
}
}
}
}
}
}
fun play(playbackSession:PlaybackSession) {
Log.d(tag, "play ${playbackSession.displayTitle}")
MediaEventManager.playEvent(playbackSession)
start(playbackSession)
}
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
if (!listeningTimerRunning) {
reset()
return cb()
}
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
if (currentTime > 0) { // Current time should always be > 0 on stop
sync(true, currentTime) { syncResult ->
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.stopEvent(playbackSession, syncResult)
}
reset()
cb()
}
} else {
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.stopEvent(playbackSession, null)
}
reset()
cb()
}
}
fun pause(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle")
Log.d(tag, "pause: Last sync time $lastSyncTime")
val currentTime = playerNotificationService.getCurrentTimeSeconds()
if (currentTime > 0) { // Current time should always be > 0 on pause
sync(true, currentTime) { syncResult ->
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.pauseEvent(playbackSession, syncResult)
}
cb()
}
} else {
lastSyncTime = 0L
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
failedSyncs = 0
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.pauseEvent(playbackSession, null)
}
cb()
}
}
fun finished(cb: () -> Unit) {
if (!listeningTimerRunning) return
listeningTimerTask?.cancel()
listeningTimerTask = null
listeningTimerRunning = false
Log.d(tag, "finished: Stopping listening for $currentDisplayTitle")
sync(true, currentPlaybackSession?.duration ?: 0.0) { syncResult ->
reset()
currentPlaybackSession?.let { playbackSession ->
MediaEventManager.finishedEvent(playbackSession, syncResult)
}
cb()
}
}
fun seek() {
currentPlaybackSession?.currentTime = playerNotificationService.getCurrentTimeSeconds()
Log.d(tag, "seek: $currentDisplayTitle, currentTime=${currentPlaybackSession?.currentTime}")
if (currentPlaybackSession == null) {
Log.e(tag, "seek: Playback session not set")
return
}
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
}
fun syncFromServerProgress(mediaProgress: MediaProgress) {
currentPlaybackSession?.let {
it.updatedAt = mediaProgress.lastUpdate
it.currentTime = mediaProgress.currentTime
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
saveLocalProgress(it)
}
}
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
if (lastSyncTime <= 0) {
Log.e(tag, "Last sync time is not set $lastSyncTime")
return cb(null)
}
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
if (diffSinceLastSync < 1000L) {
return cb(null)
}
val listeningTimeToAdd = diffSinceLastSync / 1000L
val syncData = MediaProgressSyncData(listeningTimeToAdd,currentPlaybackDuration,currentTime)
currentPlaybackSession?.syncData(syncData)
if (currentPlaybackSession?.progress?.isNaN() == true) {
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
return cb(null)
}
val hasNetworkConnection = DeviceManager.checkConnectivity(playerNotificationService)
// Save playback session to db (server linked sessions only)
// Sessions are removed once successfully synced with the server
currentPlaybackSession?.let {
if (!it.isLocalLibraryItemOnly) {
DeviceManager.dbManager.savePlaybackSession(it)
}
}
if (currentIsLocal) {
// Save local progress sync
currentPlaybackSession?.let {
saveLocalProgress(it)
lastSyncTime = System.currentTimeMillis()
Log.d(tag, "Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}")
// Local library item is linked to a server library item
// Send sync to server also if connected to this server and local item belongs to this server
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
if (syncSuccess) {
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${it.id}")
}
cb(SyncResult(true, syncSuccess, errorMsg))
}
} else {
cb(SyncResult(false, null, null))
}
}
} else if (hasNetworkConnection && shouldSyncServer) {
Log.d(tag, "sync: currentSessionId=$currentSessionId")
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
if (syncSuccess) {
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
failedSyncs = 0
playerNotificationService.alertSyncSuccess()
lastSyncTime = System.currentTimeMillis()
DeviceManager.dbManager.removePlaybackSession(currentSessionId) // Remove session from db
} else {
failedSyncs++
if (failedSyncs == 2) {
playerNotificationService.alertSyncFailing() // Show alert in client
failedSyncs = 0
}
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime with session id=${currentSessionId}")
}
cb(SyncResult(true, syncSuccess, errorMsg))
}
} else {
cb(SyncResult(false, null, null))
}
}
private fun saveLocalProgress(playbackSession:PlaybackSession) {
if (currentLocalMediaProgress == null) {
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
if (mediaProgress == null) {
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
} else {
currentLocalMediaProgress = mediaProgress
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
} else {
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
}
currentLocalMediaProgress?.let {
if (it.progress.isNaN()) {
Log.e(tag, "Invalid progress on local media progress")
} else {
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.progressPercent}%")
}
}
}
fun reset() {
currentPlaybackSession = null
currentLocalMediaProgress = null
lastSyncTime = 0L
Log.d(tag, "reset: Set last sync time 0 $lastSyncTime")
failedSyncs = 0
}
}
@@ -1,44 +0,0 @@
package com.audiobookshelf.app.models
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.MediaProgress
import com.audiobookshelf.app.data.MediaType
import com.fasterxml.jackson.annotation.JsonIgnore
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 itemSubfolder: String,
val media: MediaType,
val downloadItemParts: MutableList<DownloadItemPart>
) {
@get:JsonIgnore
val isInternalStorage get() = localFolder.id.startsWith("internal-")
@get:JsonIgnore
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
@JsonIgnore
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
val itemParts = mutableListOf<DownloadItemPart>()
if (limit == 0) return itemParts
for (it in downloadItemParts) {
if (!it.completed && it.downloadId == null) {
itemParts.add(it)
if (itemParts.size >= limit) break
}
}
return itemParts
}
}
@@ -1,91 +0,0 @@
package com.audiobookshelf.app.models
import android.app.DownloadManager
import android.net.Uri
import android.util.Log
import com.audiobookshelf.app.data.AudioTrack
import com.audiobookshelf.app.data.EBookFile
import com.audiobookshelf.app.data.LocalFolder
import com.audiobookshelf.app.data.PodcastEpisode
import com.audiobookshelf.app.device.DeviceManager
import com.fasterxml.jackson.annotation.JsonIgnore
import java.io.File
data class DownloadItemPart(
val id: String,
val downloadItemId: String,
val filename: String,
val fileSize: Long,
val finalDestinationPath:String,
val serverPath: String,
val localFolderName: String,
val localFolderUrl: String,
val localFolderId: String,
val ebookFile: EBookFile?,
val audioTrack: AudioTrack?,
val episode: PodcastEpisode?,
var completed:Boolean,
var moved:Boolean,
var isMoving:Boolean,
var failed:Boolean,
@JsonIgnore val uri: Uri,
@JsonIgnore val destinationUri: Uri,
@JsonIgnore val finalDestinationUri: Uri,
val finalDestinationSubfolder: String,
var downloadId: Long?,
var progress: Long,
var bytesDownloaded: Long
) {
companion object {
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, 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&raw=1" // 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),
downloadItemId,
filename = filename,
fileSize = fileSize,
finalDestinationPath = finalDestinationFile.absolutePath,
serverPath = serverPath,
localFolderName = localFolder.name,
localFolderUrl = localFolder.contentUrl,
localFolderId = localFolder.id,
ebookFile = ebookFile,
audioTrack = audioTrack,
episode = episode,
completed = false,
moved = false,
isMoving = false,
failed = false,
uri = downloadUri,
destinationUri = destinationUri,
finalDestinationUri = finalDestinationUri,
finalDestinationSubfolder = subfolder,
downloadId = null,
progress = 0,
bytesDownloaded = 0
)
}
}
@get:JsonIgnore
val isInternalStorage get() = localFolderId.startsWith("internal-")
@get:JsonIgnore
val serverUrl get() = "${DeviceManager.serverAddress}${serverPath}?token=${DeviceManager.token}"
@JsonIgnore
fun getDownloadRequest(): DownloadManager.Request {
val dlRequest = DownloadManager.Request(uri)
dlRequest.setTitle(filename)
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
dlRequest.setDestinationUri(destinationUri)
return dlRequest
}
}
@@ -1,11 +0,0 @@
package com.audiobookshelf.app.models
import com.audiobookshelf.app.data.MediaProgress
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
@JsonIgnoreProperties(ignoreUnknown = true)
data class User(
val id:String,
val username: String,
val mediaProgress:List<MediaProgress>
)
@@ -1,91 +0,0 @@
package com.audiobookshelf.app.player
import android.app.PendingIntent
import android.graphics.Bitmap
import android.graphics.ImageDecoder
import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import android.support.v4.media.session.MediaControllerCompat
import android.util.Log
import com.audiobookshelf.app.BuildConfig
import com.audiobookshelf.app.R
import com.bumptech.glide.Glide
import com.google.android.exoplayer2.Player
import com.google.android.exoplayer2.ui.PlayerNotificationManager
import kotlinx.coroutines.*
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
private val tag = "MediaDescriptionAdapter"
private var currentIconUri: Uri? = null
private var currentBitmap: Bitmap? = null
private val serviceJob = SupervisorJob()
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
override fun createCurrentContentIntent(player: Player): PendingIntent? =
controller.sessionActivity
override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString()
override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString()
override fun getCurrentLargeIcon(
player: Player,
callback: PlayerNotificationManager.BitmapCallback
): Bitmap? {
val albumArtUri = controller.metadata.description.iconUri
return if (currentIconUri != albumArtUri || currentBitmap == null) {
// Cache the bitmap for the current audiobook so that successive calls to
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
currentIconUri = albumArtUri
Log.d(tag, "ART $currentIconUri")
if (currentIconUri.toString().startsWith("content://")) {
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
@Suppress("DEPRECATION")
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
} else {
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
ImageDecoder.decodeBitmap(source)
}
currentBitmap
} else {
serviceScope.launch {
currentBitmap = albumArtUri?.let {
resolveUriAsBitmap(it)
}
currentBitmap?.let { callback.onBitmap(it) }
}
null
}
} else {
currentBitmap
}
}
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
return withContext(Dispatchers.IO) {
try {
Glide.with(playerNotificationService)
.asBitmap()
.load(uri)
.placeholder(R.drawable.icon)
.error(R.drawable.icon)
.submit()
.get()
} catch (e: Exception) {
e.printStackTrace()
Glide.with(playerNotificationService)
.asBitmap()
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
.submit()
.get()
}
}
}
}
@@ -1,77 +0,0 @@
package com.audiobookshelf.app.player
import android.content.ContentResolver
import android.content.Context
import android.net.Uri
import android.support.v4.media.MediaMetadataCompat
import androidx.annotation.AnyRes
import com.audiobookshelf.app.R
import com.audiobookshelf.app.data.*
class BrowseTree(
val context: Context,
itemsInProgress: List<ItemInProgress>,
libraries: List<Library>
) {
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
/**
* get uri to drawable or any other resource type if u wish
* @param drawableId - drawable res id
* @return - uri
*/
private fun getUriToDrawable(@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 continueListeningMetadata = 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(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(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(R.drawable.icon_library_folder).toString())
}.build()
if (itemsInProgress.isNotEmpty()) {
rootList += continueListeningMetadata
}
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
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,349 +0,0 @@
package com.audiobookshelf.app.player
import android.app.Activity
import android.app.AlertDialog
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.appcompat.R
import androidx.mediarouter.app.MediaRouteChooserDialog
import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import com.getcapacitor.PluginCall
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
import com.google.android.gms.cast.*
import com.google.android.gms.cast.framework.*
import org.json.JSONObject
class CastManager constructor(val mainActivity:Activity) {
private val tag = "CastManager"
private var playerNotificationService:PlayerNotificationService? = null
private var newConnectionListener: SessionListener? = null
private fun switchToPlayer(useCastPlayer:Boolean) {
Handler(Looper.getMainLooper()).post() {
playerNotificationService?.switchToPlayer(useCastPlayer)
}
}
private inner class CastSessionAvailabilityListener : SessionAvailabilityListener {
/**
* Called when a Cast session has started and the user wishes to control playback on a
* remote Cast receiver rather than play audio locally.
*/
override fun onCastSessionAvailable() {
Log.d(tag, "SessionAvailabilityListener: onCastSessionAvailable")
switchToPlayer(true)
}
/**
* Called when a Cast session has ended and the user wishes to control playback locally.
*/
override fun onCastSessionUnavailable() {
Log.d(tag, "onCastSessionUnavailable")
switchToPlayer(false)
}
}
fun requestSession(playerNotificationService: PlayerNotificationService, callback: RequestSessionCallback) {
this.playerNotificationService = playerNotificationService
mainActivity.runOnUiThread {
val session: CastSession? = getSession()
if (session == null) {
// show the "choose a connection" dialog
// Add the connection listener callback
listenForConnection(callback)
val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar)
builder.routeSelector = MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
.build()
builder.setCanceledOnTouchOutside(true)
builder.setOnCancelListener {
newConnectionListener?.let { ncl ->
getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java)
}
callback.onCancel()
}
builder.show()
} else {
// We are are already connected, so show the "connection options" Dialog
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
session.castDevice?.let {
builder.setTitle(it.friendlyName)
}
builder.setOnDismissListener { callback.onCancel() }
builder.setPositiveButton("Stop Casting") { _, _ -> endSession(true, null) }
builder.show()
}
}
}
abstract class RequestSessionCallback : ConnectionCallback {
abstract fun onError(errorCode: Int)
abstract fun onCancel()
override fun onSessionEndedBeforeStart(errorCode: Int): Boolean {
onSessionStartFailed(errorCode)
return true
}
override fun onSessionStartFailed(errorCode: Int): Boolean {
onError(errorCode)
return true
}
}
fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) {
getSessionManager()!!.addSessionManagerListener(object : SessionListener() {
override fun onSessionEnded(castSession: CastSession, error: Int) {
getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java)
Log.d(tag, "CAST END SESSION")
pluginCall?.resolve()
}
}, CastSession::class.java)
getSessionManager()!!.endCurrentSession(stopCasting)
}
open class SessionListener : SessionManagerListener<CastSession> {
override fun onSessionStarting(castSession: CastSession) {}
override fun onSessionStarted(castSession: CastSession, sessionId: String) {}
override fun onSessionStartFailed(castSession: CastSession, error: Int) {}
override fun onSessionEnding(castSession: CastSession) {}
override fun onSessionEnded(castSession: CastSession, error: Int) {}
override fun onSessionResuming(castSession: CastSession, sessionId: String) {}
override fun onSessionResumed(castSession: CastSession, wasSuspended: Boolean) {}
override fun onSessionResumeFailed(castSession: CastSession, error: Int) {}
override fun onSessionSuspended(castSession: CastSession, reason: Int) {}
}
fun startRouteScan(connListener:ChromecastListener) {
val callback = object : ScanCallback() {
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
// if the routes have changed, we may have an available device
// If there is at least one device available
if (getContext().castState != CastState.NO_DEVICES_AVAILABLE) {
routes?.forEach { Log.d(tag, "CAST ROUTE ${it.description} | ${it.deviceType} | ${it.isBluetooth} | ${it.name}") }
// Stop the scan
stopRouteScan(this, null)
// Let the client know a receiver is available
connListener.onReceiverAvailableUpdate(true)
// Since we have a receiver we may also have an active session
val session = getSessionManager()?.currentCastSession
// If we do have a session
if (session != null) {
// Let the client know
}
} else {
Log.d(tag, "No cast devices available")
}
}
}
callback.setMediaRouter(getMediaRouter())
callback.onFilteredRouteUpdate()
getMediaRouter()?.addCallback(MediaRouteSelector.Builder()
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
.build(),
callback,
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
}
internal interface CastListener : Cast.MessageReceivedCallback {
fun onMediaLoaded(jsonMedia: JSONObject?)
fun onMediaUpdate(jsonMedia: JSONObject?)
fun onSessionUpdate(jsonSession: JSONObject?)
fun onSessionEnd(jsonSession: JSONObject?)
}
abstract class ChromecastListener : CastStateListener, CastListener {
abstract fun onReceiverAvailableUpdate(available: Boolean)
abstract fun onSessionRejoin(jsonSession: JSONObject?)
/** CastStateListener functions. */
override fun onCastStateChanged(state: Int) {
onReceiverAvailableUpdate(state != CastState.NO_DEVICES_AVAILABLE)
}
}
fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) {
Log.d(tag, "stopRouteScan")
if (callback == null) {
completionCallback?.run()
return
}
// mainActivity.runOnUiThread {
Log.d(tag, "Removing callback on media router")
callback.stop()
getMediaRouter()?.removeCallback(callback)
completionCallback?.run()
// }
}
abstract class ScanCallback : MediaRouter.Callback() {
/**
* Called whenever a route is updated.
* @param routes the currently available routes
*/
abstract fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?)
/** records whether we have been stopped or not. */
private var stopped = false
/** Global mediaRouter object. */
private var mediaRouter: MediaRouter? = null
/**
* Sets the mediaRouter object.
* @param router mediaRouter object
*/
fun setMediaRouter(router: MediaRouter?) {
mediaRouter = router
}
/**
* Call this method when you wish to stop scanning.
* It is important that it is called, otherwise battery
* life will drain more quickly.
*/
fun stop() {
stopped = true
}
fun onFilteredRouteUpdate() {
if (stopped || mediaRouter == null) {
return
}
val outRoutes: MutableList<MediaRouter.RouteInfo> = ArrayList()
// Filter the routes
for (route in mediaRouter!!.routes) {
// We don't want default routes, or duplicate active routes
// or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32
val extras: Bundle? = route.extras
if (extras != null) {
CastDevice.getFromBundle(extras)
if (extras.getString("com.google.android.gms.cast.EXTRA_SESSION_ID") != null) {
continue
}
}
if (!route.isDefault
&& !route.description.equals("Google Cast Multizone Member")
&& route.playbackType == MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
outRoutes.add(route)
}
}
onRouteUpdate(outRoutes)
}
override fun onRouteAdded(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
override fun onRouteChanged(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
override fun onRouteRemoved(router: MediaRouter, route: MediaRouter.RouteInfo) {
onFilteredRouteUpdate()
}
}
private fun listenForConnection(callback: ConnectionCallback) {
// We should only ever have one of these listeners active at a time, so remove previous
newConnectionListener?.let { ncl ->
getSessionManager()?.removeSessionManagerListener(ncl, CastSession::class.java)
}
newConnectionListener = object : SessionListener() {
override fun onSessionStarted(castSession: CastSession, sessionId: String) {
Log.d(tag, "CAST SESSION STARTED ${castSession.castDevice?.friendlyName}")
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
val castContext = CastContext.getSharedInstance(mainActivity)
playerNotificationService?.let {
if (it.castPlayer == null) {
Log.d(tag, "Initializing castPlayer on session started - switch to cast player")
it.castPlayer = CastPlayer(castContext).apply {
addListener(PlayerListener(it))
setSessionAvailabilityListener(CastSessionAvailabilityListener())
}
switchToPlayer(true)
} else {
Log.d(tag, "castPlayer is already initialized on session started")
}
}
}
override fun onSessionStartFailed(castSession: CastSession, error: Int) {
if (callback.onSessionStartFailed(error)) {
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
}
}
override fun onSessionEnded(castSession: CastSession, error: Int) {
if (callback.onSessionEndedBeforeStart(error)) {
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
}
}
}
newConnectionListener?.let {
Log.d(tag, "Add session manager listener")
getSessionManager()?.addSessionManagerListener(it, CastSession::class.java)
}
}
private fun getContext(): CastContext {
return CastContext.getSharedInstance(mainActivity)
}
private fun getSessionManager(): SessionManager? {
return getContext().sessionManager
}
private fun getMediaRouter(): MediaRouter? {
return MediaRouter.getInstance(mainActivity)
}
private fun getSession(): CastSession? {
return getSessionManager()?.currentCastSession
}
internal interface ConnectionCallback {
/**
* Successfully joined a session on a route.
* @param jsonSession the session we joined
*/
fun onJoin(jsonSession: JSONObject?)
/**
* Called if we received an error.
* @param errorCode You can find the error meaning here:
* https://developers.google.com/android/reference/com/google/android/gms/cast/CastStatusCodes
* @return true if we are done listening for join, false, if we to keep listening
*/
fun onSessionStartFailed(errorCode: Int): Boolean
/**
* Called when we detect a session ended event before session started.
* See issues:
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/49
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
* @param errorCode error to output
* @return true if we are done listening for join, false, if we to keep listening
*/
fun onSessionEndedBeforeStart(errorCode: Int): Boolean
}
}
@@ -1,972 +0,0 @@
package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.os.Looper
import android.util.Log
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import com.google.android.exoplayer2.*
import com.google.android.exoplayer2.C.TrackType
import com.google.android.exoplayer2.MediaMetadata
import com.google.android.exoplayer2.Player.*
import com.google.android.exoplayer2.Tracks.Group
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.CueGroup
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: Tracks
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_TRACKS)
.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 = Tracks.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.onTracksChanged(currentTracks) }
}
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 = Tracks.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<Group>(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] = Group(trackGroups[i]!!, false, trackSupport, trackSelected)
}
val tg = trackGroups.filterNotNull().toTypedArray()
val ts = trackSelections.filterNotNull().toTypedArray()
val tgi = trackGroupInfos.filterNotNull().toMutableList()
val newTrackGroups = TrackGroupArray(*tg)
val newTrackSelections = TrackSelectionArray(*ts)
val newTracksInfo = Tracks(tgi)
if (newTracksInfo != currentTracks) {
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()
val 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, seekCommand: Int, isRepeatingCurrentItem: Boolean) {
Log.d(tag, "seekTo $mediaItemIndex position $positionMs")
val 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.
val 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")
val 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)
val 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 getCurrentTracks(): Tracks {
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 getSurfaceSize(): Size {
return Size.UNKNOWN
}
override fun getCurrentCues(): CueGroup {
return CueGroup.EMPTY_TIME_ZERO
}
override fun getDeviceInfo(): DeviceInfo {
return DeviceInfo.UNKNOWN
}
override fun getDeviceVolume(): Int {
return 0
}
override fun isDeviceMuted(): Boolean {
return false
}
override fun setDeviceVolume(volume: Int) {
}
override fun increaseDeviceVolume() {
}
override fun decreaseDeviceVolume() {
}
override fun setDeviceMuted(muted: Boolean) {
}
inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener<CastSession>, RemoteMediaClient.ProgressListener {
val TAG = "StatusListener"
// RemoteMediaClient.ProgressListener implementation.
override fun onProgressUpdated(progressMs: Long, unusedDurationMs: Long) {
lastReportedPositionMs = progressMs
}
// RemoteMediaClient.Callback implementation.
override fun onStatusUpdated() {
Log.d(TAG, "StatusListener status queue items " + remoteMediaClient?.mediaStatus?.queueItemCount)
if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_IDLE) {
Log.d(TAG, "StatusListener CastPlayer STATE_IDLE")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_BUFFERING) {
Log.d(TAG, "StatusListener CastPlayer BUFFERING")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_LOADING) {
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_LOADING")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PAUSED) {
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PAUSED")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING) {
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PLAYING")
} else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_UNKNOWN) {
Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_UNKNOWN")
}
updateInternalStateAndNotifyIfChanged()
}
override fun onMetadataUpdated() {}
override fun onQueueStatusUpdated() {
Log.d(TAG, "onQueueStatusUpdated")
updateTimelineAndNotifyIfChanged()
listeners.flushEvents()
}
override fun onPreloadStatusUpdated() {}
override fun onSendingRemoteMediaRequest() {}
override fun onAdBreakStatusUpdated() {}
// SessionManagerListener implementation.
override fun onSessionStarted(castSession: CastSession, s: String) {
Log.d(TAG, "StatusListener onSessionStarted")
setRemoteMediaClient(castSession.remoteMediaClient)
}
override fun onSessionResumed(castSession: CastSession, b: Boolean) {
setRemoteMediaClient(castSession.remoteMediaClient)
}
override fun onSessionEnded(castSession: CastSession, i: Int) {
Log.d(TAG, "StatusListener onSessionEnded")
setRemoteMediaClient(null)
}
override fun onSessionSuspended(castSession: CastSession, i: Int) {
setRemoteMediaClient(null)
}
override fun onSessionResumeFailed(castSession: CastSession, statusCode: Int) {
Log.e(
TAG,
"Session resume failed. Error code "
+ statusCode)
}
override fun onSessionStarting(castSession: CastSession) {
Log.d(TAG, "StatusListener onSessionStarting")
// Do nothing.
}
override fun onSessionStartFailed(castSession: CastSession, statusCode: Int) {
Log.e(
TAG,
("Session start failed. Error code "
+ statusCode))
}
override fun onSessionEnding(castSession: CastSession) {
// Do nothing.
}
override fun onSessionResuming(castSession: CastSession, s: String) {
// Do nothing.
}
}
}
@@ -1,162 +0,0 @@
/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.audiobookshelf.app.player
import android.net.Uri
import android.util.Log
import android.util.SparseArray
import android.util.SparseIntArray
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.Timeline
import java.util.*
/** A [Timeline] for Cast media queues. */ /* package */
class CastTimeline(itemIds: IntArray, itemIdToData: SparseArray<ItemData>) : Timeline() {
/** Holds [Timeline] related data for a Cast media item. */
class ItemData(
/** The duration of the item in microseconds, or [C.TIME_UNSET] if unknown. */
val durationUs: Long,
/**
* The default start position of the item in microseconds, or [C.TIME_UNSET] if unknown.
*/
var defaultPositionUs: Long, isLive: Boolean) {
/** Whether the item is live content, or `false` if unknown. */
val isLive: Boolean = isLive
/**
* Returns a copy of this instance with the given values.
*
* @param durationUs The duration in microseconds, or [C.TIME_UNSET] if unknown.
* @param defaultPositionUs The default start position in microseconds, or [C.TIME_UNSET]
* if unknown.
* @param isLive Whether the item is live, or `false` if unknown.
*/
fun copyWithNewValues(durationUs: Long, defaultPositionUs: Long, isLive: Boolean): ItemData {
return if (durationUs == this.durationUs && defaultPositionUs == this.defaultPositionUs && isLive == this.isLive) {
this
} else ItemData(durationUs, defaultPositionUs, isLive)
}
companion object {
/** Holds no media information. */
val EMPTY = ItemData( /* durationUs= */
C.TIME_UNSET, /* defaultPositionUs= */
C.TIME_UNSET, /* isLive= */
false)
}
}
private val idsToIndex: SparseIntArray
private val ids: IntArray
private val durationsUs: LongArray
private val defaultPositionsUs: LongArray
private val isLive: BooleanArray
// Timeline implementation.
override fun getWindowCount(): Int {
return ids.size
}
override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window {
val durationUs = durationsUs[windowIndex]
val isDynamic = durationUs == C.TIME_UNSET
val mediaItem = MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build()
return window.set( /* uid= */
ids[windowIndex], /* mediaItem= */
mediaItem, /* manifest= */
null, /* presentationStartTimeMs= */
C.TIME_UNSET, /* windowStartTimeMs= */
C.TIME_UNSET, /* elapsedRealtimeEpochOffsetMs= */
C.TIME_UNSET, /* isSeekable= */
!isDynamic,
isDynamic,
if (isLive[windowIndex]) mediaItem.liveConfiguration else null,
defaultPositionsUs[windowIndex],
durationUs, /* firstPeriodIndex= */
windowIndex, /* lastPeriodIndex= */
windowIndex, /* positionInFirstPeriodUs= */
0)
}
override fun getPeriodCount(): Int {
return ids.size
}
override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
val id = ids[periodIndex]
return period.set(id, id, periodIndex, durationsUs[periodIndex], 0)
}
override fun getIndexOfPeriod(uid: Any): Int {
return if (uid is Int) idsToIndex[uid, C.INDEX_UNSET] else C.INDEX_UNSET
}
override fun getUidOfPeriod(periodIndex: Int): Int {
return ids[periodIndex]
}
// equals and hashCode implementations.
override fun equals(other: Any?): Boolean {
if (this === other) {
return true
} else if (other !is CastTimeline) {
return false
}
val that = other
return (Arrays.equals(ids, that.ids)
&& Arrays.equals(durationsUs, that.durationsUs)
&& Arrays.equals(defaultPositionsUs, that.defaultPositionsUs)
&& Arrays.equals(isLive, that.isLive))
}
override fun hashCode(): Int {
var result = Arrays.hashCode(ids)
result = 31 * result + Arrays.hashCode(durationsUs)
result = 31 * result + Arrays.hashCode(defaultPositionsUs)
result = 31 * result + Arrays.hashCode(isLive)
return result
}
companion object {
/** [Timeline] for a cast queue that has no items. */
val EMPTY_CAST_TIMELINE = CastTimeline(IntArray(0), SparseArray())
}
/**
* Creates a Cast timeline from the given data.
*
* @param itemIds The ids of the items in the timeline.
* @param itemIdToData Maps item ids to [ItemData].
*/
init {
val itemCount = itemIds.size
idsToIndex = SparseIntArray(itemCount)
ids = itemIds.copyOf(itemCount)
durationsUs = LongArray(itemCount)
defaultPositionsUs = LongArray(itemCount)
isLive = BooleanArray(itemCount)
for (i in ids.indices) {
val id = ids[i]
idsToIndex.put(id, i)
val data = itemIdToData[id, ItemData.EMPTY]
durationsUs[i] = data.durationUs
defaultPositionsUs[i] = if (data.defaultPositionUs == C.TIME_UNSET) 0 else data.defaultPositionUs
isLive[i] = data.isLive
}
}
}
@@ -1,109 +0,0 @@
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.audiobookshelf.app.player
import android.util.SparseArray
import com.google.android.exoplayer2.C
import com.google.android.gms.cast.MediaInfo
import com.google.android.gms.cast.framework.media.RemoteMediaClient
/**
* Creates [CastTimelines][CastTimeline] from cast receiver app status updates.
*
*
* This class keeps track of the duration reported by the current item to fill any missing
* durations in the media queue items [See internal: b/65152553].
*/
/* package */
class CastTimelineTracker {
private val itemIdToData: SparseArray<CastTimeline.ItemData>
/**
* Returns a [CastTimeline] that represents the state of the given `remoteMediaClient`.
*
*
* Returned timelines may contain values obtained from `remoteMediaClient` in previous
* invocations of this method.
*
* @param remoteMediaClient The Cast media client.
* @return A [CastTimeline] that represents the given `remoteMediaClient` status.
*/
fun getCastTimeline(remoteMediaClient: RemoteMediaClient): CastTimeline {
val itemIds = remoteMediaClient.mediaQueue.itemIds
if (itemIds.size > 0) {
// Only remove unused items when there is something in the queue to avoid removing all entries
// if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
removeUnusedItemDataEntries(itemIds)
}
// TODO: Reset state when the app instance changes [Internal ref: b/129672468].
val mediaStatus = remoteMediaClient.mediaStatus
?: return CastTimeline.EMPTY_CAST_TIMELINE
val currentItemId = mediaStatus.currentItemId
updateItemData(
currentItemId, mediaStatus.mediaInfo, /* defaultPositionUs= */C.TIME_UNSET)
for (item in mediaStatus.queueItems) {
val defaultPositionUs = (item.startTime * C.MICROS_PER_SECOND).toLong()
updateItemData(item.itemId, item.media, defaultPositionUs)
}
return CastTimeline(itemIds, itemIdToData)
}
private fun updateItemData(itemId: Int, mediaInfo: MediaInfo?, defaultPositionUs: Long) {
var defaultPositionUs = defaultPositionUs
val previousData = itemIdToData[itemId, CastTimeline.ItemData.EMPTY]
var durationUs = getStreamDurationUs(mediaInfo)
if (durationUs == C.TIME_UNSET) {
durationUs = previousData.durationUs
}
val isLive = if (mediaInfo == null) previousData.isLive else mediaInfo.streamType == MediaInfo.STREAM_TYPE_LIVE
if (defaultPositionUs == C.TIME_UNSET) {
defaultPositionUs = previousData.defaultPositionUs
}
itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive))
}
private fun getStreamDurationUs(mediaInfo: MediaInfo?): Long {
if (mediaInfo == null) {
return C.TIME_UNSET
}
val durationMs = mediaInfo.streamDuration
return if (durationMs != MediaInfo.UNKNOWN_DURATION) msToUs(durationMs) else C.TIME_UNSET
}
fun msToUs(timeMs: Long): Long {
return if (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) timeMs else timeMs * 1000
}
private fun removeUnusedItemDataEntries(itemIds: IntArray) {
val scratchItemIds = HashSet<Int>( /* initialCapacity= */itemIds.size * 2)
for (id in itemIds) {
scratchItemIds.add(id)
}
var index = 0
while (index < itemIdToData.size()) {
if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
itemIdToData.removeAt(index)
} else {
index++
}
}
}
init {
itemIdToData = SparseArray()
}
}
@@ -1,79 +0,0 @@
/*
* Copyright (C) 2021 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.audiobookshelf.app.player
import com.google.android.exoplayer2.C
import com.google.android.exoplayer2.Format
import com.google.android.exoplayer2.source.TrackGroup
import com.google.android.exoplayer2.trackselection.TrackSelection
import com.google.android.exoplayer2.util.Assertions
/**
* [TrackSelection] that only selects the first track of the provided [TrackGroup].
*
*
* This relies on [CastPlayer] track groups only having one track.
*/
/* package */
internal class CastTrackSelection
/** @param trackGroup The [TrackGroup] from which the first track will only be selected.
*/(private val trackGroup: TrackGroup) : TrackSelection {
override fun getType(): Int {
return TrackSelection.TYPE_UNSET
}
override fun getTrackGroup(): TrackGroup {
return trackGroup
}
override fun length(): Int {
return 1
}
override fun getFormat(index: Int): Format {
Assertions.checkArgument(index == 0)
return trackGroup.getFormat(0)
}
override fun getIndexInTrackGroup(index: Int): Int {
return if (index == 0) 0 else C.INDEX_UNSET
}
override fun indexOf(format: Format): Int {
return if (format === trackGroup.getFormat(0)) 0 else C.INDEX_UNSET
}
override fun indexOf(indexInTrackGroup: Int): Int {
return if (indexInTrackGroup == 0) 0 else C.INDEX_UNSET
}
// Object overrides.
override fun hashCode(): Int {
return System.identityHashCode(trackGroup)
}
// Track groups are compared by identity not value, as distinct groups may have the same value.
override fun equals(obj: Any?): Boolean {
if (this === obj) {
return true
}
if (obj == null || javaClass != obj.javaClass) {
return false
}
val other = obj as CastTrackSelection
return trackGroup === other.trackGroup
}
}
@@ -1,287 +0,0 @@
package com.audiobookshelf.app.player
import android.annotation.SuppressLint
import android.content.Intent
import android.os.*
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 com.audiobookshelf.app.device.DeviceManager
import java.util.*
import kotlin.concurrent.schedule
class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() {
var tag = "MediaSessionCallback"
private var mediaButtonClickCount: Int = 0
private var mediaButtonClickTimeout: Long = 1000 //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 {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it,true, playbackRate)
}
}
}
}
}
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 {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
}
}
}
override fun onPause() {
Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT")
playerNotificationService.pause()
}
override fun onStop() {
playerNotificationService.pause()
}
override fun onSkipToPrevious() {
playerNotificationService.skipToPrevious()
}
override fun onSkipToNext() {
playerNotificationService.skipToNext()
}
override fun onFastForward() {
playerNotificationService.jumpForward()
}
override fun onRewind() {
playerNotificationService.jumpBackward()
}
override fun onSeekTo(pos: Long) {
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
}
private fun onChangeSpeed() {
// cycle to next speed, only contains preset android app options, as each increment needs it's own icon
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
val mediaManager = playerNotificationService.mediaManager
val newSpeed = when (mediaManager.getSavedPlaybackRate()) {
in 0.5f..0.7f -> 1.0f
in 0.8f..1.0f -> 1.2f
in 1.1f..1.2f -> 1.5f
in 1.3f..1.5f -> 2.0f
in 1.6f..2.0f -> 3.0f
in 2.1f..3.0f -> 0.5f
// anything set above 3 (can happen in the android app) will be reset to 1
else -> 1.0f
}
mediaManager.setSavedPlaybackRate(newSpeed)
playerNotificationService.setPlaybackSpeed(newSpeed)
playerNotificationService.clientEventEmitter?.onPlaybackSpeedChanged(newSpeed)
}
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null
if (mediaId.isNullOrEmpty()) {
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
} else {
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
if (libraryItemWithEpisode != null) {
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 {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, true, playbackRate)
}
}
}
}
}
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
return handleCallMediaButton(mediaButtonEvent)
}
private fun handleCallMediaButton(intent: Intent): Boolean {
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
val keyEvent = if (Build.VERSION.SDK_INT >= 33) {
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)
}
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
// Widget button intent is only sending the action down event
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}")
when (keyEvent.keyCode) {
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
// TODO: Play/pause event sent from widget when app is closed. Currently the service gets destroyed before anything can happen
// if (playerNotificationService.currentPlaybackSession == null && DeviceManager.deviceData.lastPlaybackSession != null) {
// Log.i(tag, "No playback session but had one in the db")
//
// val connectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == DeviceManager.deviceData.lastPlaybackSession?.serverConnectionConfigId }
// connectionConfig?.let {
// Log.i(tag, "Setting playback session from db $it")
// DeviceManager.serverConnectionConfig = it
//
// playerNotificationService.currentPlaybackSession = DeviceManager.deviceData.lastPlaybackSession
// playerNotificationService.startNewPlaybackSession()
// return true
// }
// }
if (playerNotificationService.mPlayer.isPlaying) {
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
} else {
if (0 == mediaButtonClickCount) {
playerNotificationService.play()
}
handleMediaButtonClickCount()
}
}
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
Log.d(tag, "handleCallMediaButton: Media Fast Forward")
playerNotificationService.jumpForward()
}
KeyEvent.KEYCODE_MEDIA_REWIND -> {
Log.d(tag, "handleCallMediaButton: Media Rewind")
playerNotificationService.jumpBackward()
}
}
}
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()
}
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
Log.d(tag, "handleCallMediaButton: Media Pause")
if (0 == mediaButtonClickCount) playerNotificationService.pause()
handleMediaButtonClickCount()
}
KeyEvent.KEYCODE_MEDIA_NEXT -> {
playerNotificationService.jumpForward()
}
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
playerNotificationService.jumpBackward()
}
KeyEvent.KEYCODE_MEDIA_STOP -> {
playerNotificationService.closePlayback()
}
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.jumpBackward()
playerNotificationService.play()
}
else if (msg.what >= 3) {
playerNotificationService.jumpForward()
playerNotificationService.play()
}
}
}
override fun onCustomAction(action: String?, extras: Bundle?) {
super.onCustomAction(action, extras)
when (action) {
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed()
}
}
}
@@ -1,93 +0,0 @@
package com.audiobookshelf.app.player
import android.net.Uri
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.ResultReceiver
import android.support.v4.media.session.PlaybackStateCompat
import android.util.Log
import com.audiobookshelf.app.data.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 {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
}
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
val libraryItemWrapper: LibraryItemWrapper?
var podcastEpisode: PodcastEpisode? = null
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
if (libraryItemWithEpisode != null) {
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 {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
}
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 {
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
Handler(Looper.getMainLooper()).post {
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
}
}
}
}
}
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
Log.d(tag, "ON PREPARE FROM URI $uri")
}
}
@@ -1,21 +0,0 @@
package com.audiobookshelf.app.player
const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward";
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
const val CUSTOM_ACTION_CHANGE_SPEED = "com.audiobookshelf.customAction.change_speed";
const val PLAYMETHOD_DIRECTPLAY = 0
const val PLAYMETHOD_DIRECTSTREAM = 1
const val PLAYMETHOD_TRANSCODE = 2
const val PLAYMETHOD_LOCAL = 3
//
// MEDIA BROWSER STUFF (ANDROID AUTO)
//
const val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
const val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
const val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
@@ -1,174 +0,0 @@
package com.audiobookshelf.app.player
import android.util.Log
import com.audiobookshelf.app.data.PlaybackSession
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
var lazyIsPlaying: 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 onPositionDiscontinuity(
oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo,
reason: Int
) {
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
// If playing set seeking flag
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying} reason=SEEK")
playerNotificationService.mediaProgressSyncer.seek()
lastPauseTime = 0 // When seeking while paused reset the auto-rewind
} else {
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying}, reason=$reason")
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
Log.d(tag, "onIsPlayingChanged to $isPlaying | ${playerNotificationService.getMediaPlayer()} | playbackState=${playerNotificationService.currentPlayer.playbackState}")
val player = playerNotificationService.currentPlayer
// Goal of these 2 if statements and the lazyIsPlaying is to ignore this event when it is triggered by a seek
// When a seek occurs the player is paused and buffering, then plays again right afterwards.
if (!isPlaying && player.playbackState == Player.STATE_BUFFERING) {
Log.d(tag, "onIsPlayingChanged: Pause event when buffering is ignored")
return
}
if (lazyIsPlaying == isPlaying) {
Log.d(tag, "onIsPlayingChanged: Lazy is playing $lazyIsPlaying is already set to this so ignoring")
return
}
lazyIsPlaying = isPlaying
// Update widget
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
if (isPlaying) {
Log.d(tag, "SeekBackTime: Player is playing")
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
var seekBackTime = calcPauseSeekBackTime()
if (seekBackTime > 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 - seekBackTime
if (newTime < minSeekBackTime) {
seekBackTime = currentTime - minSeekBackTime
}
Log.d(tag, "SeekBackTime $seekBackTime")
}
// TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress
// 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(seekBackTime)
// if (!shouldCarryOn) return
// }
if (seekBackTime > 0L) {
playerNotificationService.seekBackward(seekBackTime)
}
}
} else {
Log.d(tag, "SeekBackTime: Player not playing set last pause time | playbackState=${player.playbackState}")
lastPauseTime = System.currentTimeMillis()
}
// Start/stop progress sync interval
if (isPlaying) {
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
playbackSession?.let {
// Handles auto-starting sleep timer and resetting sleep timer
playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id)
player.volume = 1F // Volume on sleep timer might have decreased this
playerNotificationService.mediaProgressSyncer.play(it)
}
} else {
playerNotificationService.mediaProgressSyncer.pause {
Log.d(tag, "Media Progress Syncer paused and synced")
}
}
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
}
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)
playerNotificationService.handlePlaybackEnded()
}
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()}")
}
}
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
}
}
@@ -1,56 +0,0 @@
package com.audiobookshelf.app.player
import android.app.Notification
import android.content.pm.ServiceInfo
import android.os.Build
import android.util.Log
import com.google.android.exoplayer2.ui.PlayerNotificationManager
class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener {
var tag = "PlayerNotificationListener"
companion object {
var isForegroundService = false
}
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
onGoing: Boolean) {
if (onGoing && !isForegroundService) {
// Start foreground service
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
PlayerNotificationService.isClosed = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
playerNotificationService.startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
} else {
playerNotificationService.startForeground(notificationId, notification)
}
isForegroundService = true
} else {
Log.d(tag, "Notification posted $notificationId, not starting foreground - onGoing=$onGoing | isForegroundService=$isForegroundService")
}
}
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")
if (PlayerNotificationService.isSwitchingPlayer) {
// When switching from cast player to exo player and vice versa the notification is cancelled and posted again
// so we don't want to cancel the playback during this switch
Log.d(tag, "PNS is switching player")
PlayerNotificationService.isSwitchingPlayer = false
}
}
isForegroundService = false
}
}
@@ -1,68 +0,0 @@
package com.audiobookshelf.app.player
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager
import com.audiobookshelf.app.device.DeviceManager
import kotlin.math.sqrt
class ShakeDetector : SensorEventListener {
private var mListener: OnShakeListener? = null
private var mShakeTimestamp: Long = 0
private var mShakeCount = 0
fun setOnShakeListener(listener: OnShakeListener?) {
mListener = listener
}
interface OnShakeListener {
fun onShake(count: Int)
}
override fun onAccuracyChanged(
sensor: Sensor,
accuracy: Int
) { // ignore
}
override fun onSensorChanged(event: SensorEvent) {
if (mListener != null) {
val x = event.values[0]
val y = event.values[1]
val z = event.values[2]
val gX = x / SensorManager.GRAVITY_EARTH
val gY = y / SensorManager.GRAVITY_EARTH
val gZ = z / SensorManager.GRAVITY_EARTH
// gForce will be close to 1 when there is no movement.
val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ)
val shakeThreshold = DeviceManager.deviceData.deviceSettings?.getShakeThresholdGravity() ?: 1.6f
if (gForce > shakeThreshold) {
val now = System.currentTimeMillis()
// ignore shake events too close to each other (500ms)
if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) {
return
}
// reset the shake count after 3 seconds of no shakes
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
mShakeCount = 0
}
mShakeTimestamp = now
mShakeCount++
mListener!!.onShake(mShakeCount)
}
}
}
companion object {
/*
* The gForce that is necessary to register as shake.
* Must be greater than 1G (one earth gravity unit).
* You can install "G-Force", by Blake La Pierre
* from the Google Play Store and run it to see how
* many G's it takes to register a shake
*/
// var SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
private const val SHAKE_SLOP_TIME_MS = 500
private const val SHAKE_COUNT_RESET_TIME_MS = 3000
}
}
@@ -1,422 +0,0 @@
package com.audiobookshelf.app.plugins
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.player.CastManager
import com.audiobookshelf.app.player.PlayerListener
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"
private 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)
try {
initCastManager()
} catch(e:Exception) {
Log.e(tag, "initCastManager exception ${e.printStackTrace()}")
}
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, isAutoSleepTimer:Boolean) {
val ret = JSObject()
ret.put("value", sleepTimeRemaining)
ret.put("isAuto", isAutoSleepTimer)
notifyListeners("onSleepTimerSet", ret)
}
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", "")
}
override fun onProgressSyncSuccess() {
emit("onProgressSyncSuccess", "")
}
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
emit("onNetworkMeteredChanged", isUnmetered)
}
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
}
override fun onPlaybackSpeedChanged(playbackSpeed:Float) {
emit("onPlaybackSpeedChanged", playbackSpeed)
}
})
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
}
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) {
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
val startTimeOverride = call.getDouble("startTime")
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate")
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.getDeviceInfo())
if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
playbackSession.currentTime = startTimeOverride
}
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
playerNotificationService.mediaProgressSyncer.stop {
Log.d(tag, "Media progress syncer was already syncing - stopped")
PlayerListener.lazyIsPlaying = false
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
playerNotificationService.preparePlayer(
playbackSession,
playWhenReady,
playbackRate
)
}
}
} else {
playerNotificationService.mediaProgressSyncer.reset()
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
}
}
return call.resolve(JSObject())
}
} else { // Play library item from server
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
Handler(Looper.getMainLooper()).post {
playerNotificationService.mediaProgressSyncer.stop {
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
if (it == null) {
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
} else {
if (startTimeOverride != null) {
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
it.currentTime = startTimeOverride
}
Handler(Looper.getMainLooper()).post {
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
PlayerListener.lazyIsPlaying = false
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
Log.d(tag, "seek action to $time")
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 playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", 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) {
Handler(Looper.getMainLooper()).post {
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)
}
}
@@ -1,526 +0,0 @@
package com.audiobookshelf.app.plugins
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
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
@CapacitorPlugin(name = "AbsDatabase")
class AbsDatabase : Plugin() {
val tag = "AbsDatabase"
private 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 getLocalLibraryItemByLId(call:PluginCall) {
val libraryItemId = call.getString("libraryItemId", "").toString()
GlobalScope.launch(Dispatchers.IO) {
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(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 getLocalMediaProgressForServerItem(call:PluginCall) {
val libraryItemId = call.getString("libraryItemId", "").toString()
var episodeId:String? = call.getString("episodeId", "").toString()
if (episodeId == "") episodeId = null
GlobalScope.launch(Dispatchers.IO) {
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
val localMediaProgress = allLocalMediaProgress.find { libraryItemId == it.libraryItemId && (episodeId == null || it.episodeId == episodeId) }
if (localMediaProgress == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
}
}
}
@PluginMethod
fun removeLocalMediaProgress(call:PluginCall) {
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId)
call.resolve()
}
@PluginMethod
fun syncLocalSessionsWithServer(call:PluginCall) {
if (DeviceManager.serverConnectionConfig == null) {
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
return call.resolve()
}
apiHandler.syncLocalMediaProgressForUser {
Log.d(tag, "Finished syncing local media progress for user")
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
if (savedSessions.isNotEmpty()) {
apiHandler.sendSyncLocalSessions(savedSessions) { success, errorMsg ->
if (!success) {
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
} else {
// Remove all local sessions
savedSessions.forEach {
DeviceManager.dbManager.removePlaybackSession(it.id)
}
call.resolve()
}
}
} else {
call.resolve()
}
}
}
// 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,
ebookLocation = mediaProgress.ebookLocation,
ebookProgress = mediaProgress.ebookProgress,
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 {
MediaEventManager.syncEvent(mediaProgress, "Received from webhook event")
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\"}"))
}
val duration: Double
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,
ebookLocation = null,
ebookProgress = null,
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 updateLocalEbookProgress(call:PluginCall) {
val localLibraryItemId = call.getString("localLibraryItemId", "").toString()
val ebookLocation = call.getString("ebookLocation", "").toString()
val ebookProgress = call.getDouble("ebookProgress") ?: 0.0
val localMediaProgressId = localLibraryItemId
var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId)
if (localMediaProgress == null) {
Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new")
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}"))
val book = localLibraryItem.media as Book
localMediaProgress = LocalMediaProgress(
id = localMediaProgressId,
localLibraryItemId = localLibraryItemId,
localEpisodeId = null,
duration = book.duration ?: 0.0,
progress = 0.0,
currentTime = 0.0,
isFinished = false,
ebookLocation = ebookLocation,
ebookProgress = ebookProgress,
lastUpdate = System.currentTimeMillis(),
startedAt = 0L,
finishedAt = null,
serverConnectionConfigId = localLibraryItem.serverConnectionConfigId,
serverAddress = localLibraryItem.serverAddress,
serverUserId = localLibraryItem.serverUserId,
libraryItemId = localLibraryItem.libraryItemId,
episodeId = null)
} else {
localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress)
}
// Save local media progress locally
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress)
Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring")
val jsobj = JSObject()
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())
Handler(Looper.getMainLooper()).post {
DeviceManager.deviceData.deviceSettings = newDeviceSettings
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
// Updates playback actions for media notification (handles media control seek locking setting)
if (mainActivity.isPlayerNotificationServiceInitialized()) {
mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions()
}
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
}
}
@PluginMethod
fun getMediaItemHistory(call:PluginCall) { // Returns device data
Log.d(tag, "getMediaItemHistory ${call.data}")
val mediaId = call.getString("mediaId") ?: ""
GlobalScope.launch(Dispatchers.IO) {
val mediaItemHistory = DeviceManager.dbManager.getMediaItemHistory(mediaId)
if (mediaItemHistory == null) {
call.resolve()
} else {
call.resolve(JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
}
}
}
}
@@ -1,275 +0,0 @@
package com.audiobookshelf.app.plugins
import android.app.DownloadManager
import android.content.Context
import android.os.Environment
import android.util.Log
import com.audiobookshelf.app.MainActivity
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.device.FolderScanner
import com.audiobookshelf.app.models.DownloadItem
import com.audiobookshelf.app.models.DownloadItemPart
import com.audiobookshelf.app.server.ApiHandler
import com.audiobookshelf.app.managers.DownloadItemManager
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 java.io.File
@CapacitorPlugin(name = "AbsDownloader")
class AbsDownloader : Plugin() {
private val tag = "AbsDownloader"
private 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
lateinit var downloadItemManager: DownloadItemManager
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
override fun onDownloadItem(downloadItem:DownloadItem) {
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
}
override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) {
notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart)))
}
override fun onDownloadItemComplete(jsobj:JSObject) {
notifyListeners("onItemDownloadComplete", jsobj)
}
})
override fun load() {
mainActivity = (activity as MainActivity)
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
folderScanner = FolderScanner(mainActivity)
apiHandler = ApiHandler(mainActivity)
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
}
@PluginMethod
fun downloadLibraryItem(call: PluginCall) {
val libraryItemId = call.data.getString("libraryItemId").toString()
var episodeId = call.data.getString("episodeId").toString()
if (episodeId == "null") episodeId = ""
var localFolderId = call.data.getString("localFolderId", "").toString()
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
if (downloadItemManager.downloadItemQueue.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}")
if (localFolderId == "") {
localFolderId = "internal-${libraryItem.mediaType}"
}
var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId)
if (localFolder == null && localFolderId.startsWith("internal-")) {
Log.d(tag, "Creating new App Storage internal LocalFolder $localFolderId")
localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "", "internal", libraryItem.mediaType)
DeviceManager.dbManager.saveLocalFolder(localFolder)
}
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\"}"))
}
}
}
}
// 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 isInternal = localFolder.id.startsWith("internal-")
val tempFolderPath = if (isInternal) "${mainActivity.filesDir}/downloads/${libraryItem.id}" else mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
if (libraryItem.mediaType == "book") {
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
val tracks = libraryItem.media.getAudioTracks()
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
val itemSubfolder = "$bookAuthor/$bookTitle"
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${localFolder.absolutePath}/$itemSubfolder"
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
val book = libraryItem.media as Book
book.ebookFile?.let { ebookFile ->
val fileSize = ebookFile.metadata?.size ?: 0
val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download"
val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
val destinationFile = File("$tempFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP ebook file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "ebook file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
// Create download item part for each audio track
val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf()
tracks.forEach { audioTrack ->
val fileSize = audioTrack.metadata?.size ?: 0
// TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack
val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino
val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
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 (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
if (downloadItem.downloadItemParts.isNotEmpty()) {
// Add cover download item
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
val serverPath = "/api/items/${libraryItem.id}/cover"
val destinationFilename = "cover-${libraryItem.id}.jpg"
val destinationFile = File("$tempFolderPath/$destinationFilename")
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
if (destinationFile.exists()) {
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
destinationFile.delete()
}
if (finalDestinationFile.exists()) {
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
finalDestinationFile.delete()
}
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
downloadItemManager.addDownloadItem(downloadItem)
}
} else {
// Podcast episode download
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
val audioTrack = episode?.audioTrack
val audioFileIno = episode?.audioFile?.ino
val fileSize = audioTrack?.metadata?.size ?: 0
Log.d(tag, "Starting podcast episode download")
val itemFolderPath = if (isInternal) "$tempFolderPath" else "${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, podcastTitle, libraryItem.media, mutableListOf())
var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download"
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(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode)
downloadItem.downloadItemParts.add(downloadItemPart)
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
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(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null)
downloadItem.downloadItemParts.add(downloadItemPart)
}
}
downloadItemManager.addDownloadItem(downloadItem)
}
}
}
@@ -1,260 +0,0 @@
package com.audiobookshelf.app.plugins
import android.app.AlertDialog
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.device.DeviceManager
import com.fasterxml.jackson.core.json.JsonReadFeature
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.getcapacitor.*
import com.getcapacitor.annotation.CapacitorPlugin
import java.io.File
@CapacitorPlugin(name = "AbsFileSystem")
class AbsFileSystem : Plugin() {
private val TAG = "AbsFileSystem"
private val tag = "AbsFileSystem"
private 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) {
val mediaType = call.data.getString("mediaType", "book").toString()
val REQUEST_CODE_SELECT_FOLDER = 6
val REQUEST_CODE_SDCARD_ACCESS = 7
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
val absolutePath = folder.getAbsolutePath(activity)
val storageType = folder.getStorageType(activity)
val simplePath = folder.getSimplePath(activity)
val basePath = folder.getBasePath(activity)
val folderId = android.util.Base64.encodeToString(folder.id.toByteArray(), android.util.Base64.DEFAULT)
val 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,
storageId: String
) {
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
val jsobj = JSObject()
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
builder.setMessage(
"You have no write access to this storage, thus selecting this folder is useless." +
"\nWould you like to grant access to this folder?")
builder.setNegativeButton("Dont Allow") { _, _ ->
run {
jsobj.put("error", "User Canceled, Access Denied")
call.resolve(jsobj)
}
}
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
builder.show()
} else {
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
jsobj.put("error", "Access Denied")
call.resolve(jsobj)
}
}
override fun onStoragePermissionDenied(requestCode: Int) {
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
val jsobj = JSObject()
jsobj.put("error", "Permission Denied")
call.resolve(jsobj)
}
}
mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER)
}
@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) {
val 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
}
val jsobj = JSObject()
jsobj.put("value", res)
call.resolve(jsobj)
}
@PluginMethod
fun checkFolderPermissions(call: PluginCall) {
val folderUrl = call.data.getString("folderUrl", "").toString()
Log.d(TAG, "Check Folder Permissions for $folderUrl")
val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
val jsobj = JSObject()
jsobj.put("value", hasAccess)
call.resolve(jsobj)
}
@PluginMethod
fun getSDKVersion(call: PluginCall) {
val jsObject = JSObject()
jsObject.put("version", Build.VERSION.SDK_INT)
call.resolve(jsObject)
}
@PluginMethod
fun removeFolder(call: PluginCall) {
val folderId = call.data.getString("folderId", "").toString()
DeviceManager.dbManager.removeLocalFolder(folderId)
call.resolve()
}
@PluginMethod
fun removeLocalLibraryItem(call: PluginCall) {
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
call.resolve()
}
@PluginMethod
fun deleteItem(call: PluginCall) {
val localLibraryItemId = call.data.getString("id", "").toString()
val absolutePath = call.data.getString("absolutePath", "").toString()
val contentUrl = call.data.getString("contentUrl", "").toString()
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
// Check if should delete subfolder
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
val success: Boolean
// If internal library item use File to delete
if (localLibraryItem?.folderId?.startsWith("internal-") == true) {
Log.d(tag, "Deleting internal library item at absolutePath $absolutePath")
val file = File(absolutePath)
success = file.deleteRecursively()
} else {
var subfolderPathToDelete = ""
localLibraryItem?.folderId?.let { folderId ->
val folder = DeviceManager.dbManager.getLocalFolder(folderId)
folder?.absolutePath?.let { folderPath ->
val splitAbsolutePath = absolutePath.split("/")
val fullSubDir = splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/")
if (fullSubDir != folderPath) {
val subdirHasAnItem = DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem ->
if (_localLibraryItem.id == localLibraryItemId) {
false
} else {
_localLibraryItem.absolutePath.startsWith(fullSubDir)
}
}
subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir
}
}
}
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
success = docfile?.delete() == true
if (subfolderPathToDelete != "") {
Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete")
val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete)
docfilesub?.delete()
}
}
if (success) {
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
}
call.resolve(JSObject("{\"success\":$success}"))
}
@PluginMethod
fun deleteTrackFromItem(call: PluginCall) {
val localLibraryItemId = call.data.getString("id", "").toString()
val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
val contentUrl = call.data.getString("trackContentUrl", "").toString()
Log.d(tag, "deleteTrackFromItem $contentUrl")
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
if (localLibraryItem == null) {
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
return call.resolve(JSObject("{\"success\":false}"))
}
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
val success = docfile?.delete() == true
if (success) {
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
localLibraryItem.removeLocalFile(trackLocalFileId)
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
} else {
call.resolve(JSObject("{\"success\":false}"))
}
}
}
@@ -1,394 +0,0 @@
package com.audiobookshelf.app.server
import android.annotation.SuppressLint
import android.content.Context
import android.os.Build
import android.provider.Settings
import android.util.Log
import com.audiobookshelf.app.data.*
import com.audiobookshelf.app.device.DeviceManager
import com.audiobookshelf.app.media.MediaEventManager
import com.audiobookshelf.app.media.MediaProgressSyncData
import com.audiobookshelf.app.media.SyncResult
import com.audiobookshelf.app.models.User
import com.audiobookshelf.app.BuildConfig
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 okhttp3.internal.EMPTY_REQUEST
import org.json.JSONArray
import org.json.JSONException
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()
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>, val deviceInfo:DeviceInfo)
@JsonIgnoreProperties(ignoreUnknown = true)
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
private 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)
}
private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
val address = config?.address ?: DeviceManager.serverAddress
val token = config?.token ?: DeviceManager.token
val mediaType = "application/json; charset=utf-8".toMediaType()
val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST
val requestUrl = "${address}$endpoint"
Log.d(tag, "postRequest to $requestUrl")
val request = Request.Builder().post(requestBody)
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
.build()
makeRequest(request, null, cb)
}
private 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)
}
private 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 {
try {
var jsonObj = JSObject()
if (bodyString.startsWith("[")) {
val array = JSArray(bodyString)
jsonObj.put("value", array)
} else {
jsonObj = JSObject(bodyString)
}
cb(jsonObj)
} catch(je:JSONException) {
Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString")
val jsobj = JSObject()
jsobj.put("error", "Invalid response body")
cb(jsobj)
}
}
}
}
})
}
fun getCurrentUser(cb: (User?) -> Unit) {
getRequest("/api/me", null, null) {
if (it.has("error")) {
Log.e(tag, it.getString("error") ?: "getCurrentUser Failed")
cb(null)
} else {
val user = jacksonMapper.readValue<User>(it.toString())
cb(user)
}
}
}
fun getLibraries(cb: (List<Library>) -> Unit) {
val mapper = jacksonMapper
getRequest("/api/libraries", null,null) {
val libraries = mutableListOf<Library>()
var array = JSONArray()
if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
array = it.getJSONArray("libraries")
} else if (it.has("value")) {
array = it.getJSONArray("value")
}
for (i in 0 until array.length()) {
libraries.add(mapper.readValue(array.get(i).toString()))
}
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 getAllItemsInProgress(cb: (List<ItemInProgress>) -> Unit) {
getRequest("/api/me/items-in-progress", null, null) {
val items = mutableListOf<ItemInProgress>()
if (it.has("libraryItems")) {
val array = it.getJSONArray("libraryItems")
for (i in 0 until array.length()) {
val jsobj = array.get(i) as JSONObject
val itemInProgress = ItemInProgress.makeFromServerObject(jsobj)
items.add(itemInProgress)
}
}
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, null) {
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, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
postRequest("/api/session/$sessionId/sync", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false, it.getString("error"))
} else {
cb(true, null)
}
}
}
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
postRequest("/api/session/local", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
cb(false, it.getString("error"))
} else {
cb(true, null)
}
}
}
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) {
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
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)
}
}
}
fun closePlaybackSession(playbackSessionId:String, cb: (Boolean) -> Unit) {
Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId")
postRequest("/api/session/$playbackSessionId/close", null, null) {
cb(true)
}
}
fun authorize(config:ServerConnectionConfig, cb: (MutableList<MediaProgress>?) -> Unit) {
Log.d(tag, "authorize: Authorizing ${config.address}")
postRequest("/api/authorize", JSObject(), config) {
val error = it.getString("error")
if (!error.isNullOrEmpty()) {
Log.d(tag, "authorize: Authorize ${config.address} Failed: $error")
cb(null)
} else {
val mediaProgressList:MutableList<MediaProgress> = mutableListOf()
val user = it.getJSObject("user")
val mediaProgress = user?.getJSONArray("mediaProgress") ?: JSONArray()
for (i in 0 until mediaProgress.length()) {
val mediaProg = jacksonMapper.readValue<MediaProgress>(mediaProgress.getJSONObject(i).toString())
mediaProgressList.add(mediaProg)
}
Log.d(tag, "authorize: Authorize ${config.address} Successful")
cb(mediaProgressList)
}
}
}
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
@SuppressLint("HardwareIds")
val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID)
val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions, deviceInfo)))
Log.d(tag, "Sending ${playbackSessions.size} saved local playback sessions to server")
postRequest("/api/session/local-all", payload, null) {
if (!it.getString("error").isNullOrEmpty()) {
Log.e(tag, "Failed to sync local sessions")
cb(false, it.getString("error"))
} else {
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
response.results.forEach { localSessionSyncResult ->
Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}")
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
if (localSessionSyncResult.progressSynced == true) {
val syncResult = SyncResult(true, true, "Progress synced on server")
MediaEventManager.saveEvent(session, syncResult)
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
} else if (!localSessionSyncResult.success) {
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
}
}
}
cb(true, null)
}
}
}
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
// Get all local media progress for this server
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
if (allLocalMediaProgress.isEmpty()) {
Log.d(tag, "No local media progress to sync")
return cb()
}
getCurrentUser { _user ->
_user?.let { user->
// Compare server user progress with local progress
user.mediaProgress.forEach { mediaProgress ->
// Get matching local media progress
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
} else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) {
// Patch ebook progress to server
val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}"
val updatePayload = JSObject()
updatePayload.put("ebookLocation", localMediaProgress.ebookLocation)
updatePayload.put("ebookProgress", localMediaProgress.ebookProgress)
updatePayload.put("lastUpdate", localMediaProgress.lastUpdate)
patchRequest(endpoint,updatePayload) {
Log.d(tag, "syncLocalMediaProgressForUser patched ebook progress")
}
}
}
}
}
cb()
}
}
}
@@ -1,18 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="M20.13,5.41l-1.41,-1.41l-9.19,9.19l-4.25,-4.24l-1.41,1.41l5.66,5.66z"/>
<path
android:fillColor="@android:color/white"
android:pathData="M5,18h14v2h-14z"/>
</group>
</vector>
@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="M12,3v9.28c-0.47,-0.17 -0.97,-0.28 -1.5,-0.28C8.01,12 6,14.01 6,16.5S8.01,21 10.5,21c2.31,0 4.2,-1.75 4.45,-4H15V6h4V3h-7z"/>
</group>
</vector>
@@ -1,15 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="#FFFFFF">
<group android:scaleX="1.127451"
android:scaleY="1.127451"
android:translateX="-1.5294118"
android:translateY="-1.5294118">
<path
android:fillColor="@android:color/white"
android:pathData="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"/>
</group>
</vector>

Some files were not shown because too many files have changed in this diff Show More